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
2 changes: 1 addition & 1 deletion apps/docs/content/docs/cli/usage-data.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ One event per command, after the command finishes:
| Duration | `1432` ms | From process start to completion |
| CLI, Node, OS, CPU | `2.1.2`, `22.14.0`, `darwin`, `arm64` | |
| Terminal and CI | `is_tty`, `is_ci` | Whether stdout is a terminal, whether a CI variable is set |
| Coding agent | `claude-code` | When the CLI runs inside an AI coding agent's shell |
| Coding agent | `claude-code`, or `none` | The AI coding agent whose shell the CLI runs in, if any |
| Deployment kind | `hosted` or `self_hosted` | Never the address |
| Device and session ids | random UUIDs | See below |

Expand Down
9 changes: 2 additions & 7 deletions apps/sim/app/api/v1/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import {
describePrincipalAuth,
type PersonalApiKeyPrincipal,
type WorkspaceApiKeyPrincipal,
} from '@sim/auth/principal'
import { createLogger, setRequestAuth } from '@sim/logger'
import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
Expand Down Expand Up @@ -76,7 +72,6 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
}

await updateApiKeyLastUsed(result.keyId)
setRequestAuth(describePrincipalAuth(principal))

return {
authenticated: true,
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/background/schedule-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
workflowExecutionLogs,
workflowSchedule,
} from '@sim/db'
import { createLogger, runWithRequestContext } from '@sim/logger'
import { createLogger, type RequestContext, runWithRequestContext } from '@sim/logger'
import { describeError, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { task, timeout } from '@trigger.dev/sdk'
Expand Down Expand Up @@ -822,7 +822,12 @@ export async function executeScheduleJob(
const scheduledFor = payload.scheduledFor ? new Date(payload.scheduledFor) : null

try {
return await runWithRequestContext({ requestId }, async () => {
/** A trigger, not a client, started this run. */
const requestContext: RequestContext = {
requestId,
client: { surface: 'schedule', source: 'trigger' },
}
return await runWithRequestContext(requestContext, async () => {
logger.info(`[${requestId}] Starting schedule execution`, {
scheduleId: payload.scheduleId,
workflowId: payload.workflowId,
Expand Down
9 changes: 7 additions & 2 deletions apps/sim/background/webhook-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@sim/auth/principal'
import { db } from '@sim/db'
import { account, webhook } from '@sim/db/schema'
import { createLogger, runWithRequestContext } from '@sim/logger'
import { createLogger, type RequestContext, runWithRequestContext } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { interruptibleSleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
Expand Down Expand Up @@ -556,7 +556,12 @@ export async function executeWebhookJob(
})
}

return await runWithRequestContext({ requestId }, async () => {
/** A trigger, not a client, started this run. */
const requestContext: RequestContext = {
requestId,
client: { surface: 'webhook', source: 'trigger' },
}
return await runWithRequestContext(requestContext, async () => {
logger.info(`[${requestId}] Starting webhook execution`, {
webhookId: authenticatedPayload.webhookId,
workflowId: authenticatedPayload.workflowId,
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/background/workflow-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getTimeoutErrorMessage,
RESERVATION_TTL_BUFFER_MS,
} from '@/lib/core/execution-limits'
import type { RequestAttribution } from '@/lib/core/utils/request-attribution'
import { preprocessExecution } from '@/lib/execution/preprocessing'
import { LoggingSession } from '@/lib/logs/execution/logging-session'
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
Expand Down Expand Up @@ -76,6 +77,8 @@ export type WorkflowExecutionPayload = {
correlation?: AsyncExecutionCorrelation
metadata?: Record<string, any>
callChain?: string[]
/** Who queued the run, restored into the job's context so its events stay attributed. */
attribution?: RequestAttribution
executionMode?: 'sync' | 'stream' | 'async'
/** Upstream preprocessing already consumed rate-limit quota and owns the usage reservation. */
admissionCompleted?: boolean
Expand Down Expand Up @@ -177,7 +180,7 @@ export async function executeWorkflowJob(
}
}

return await runWithRequestContext({ requestId }, async () => {
return await runWithRequestContext({ requestId, ...payload.attribution }, async () => {
logger.info(`[${requestId}] Starting workflow execution job: ${workflowId}`, {
userId: payload.userId,
triggerType: payload.triggerType,
Expand Down
22 changes: 22 additions & 0 deletions apps/sim/lib/api-key/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*
* @vitest-environment node
*/
import { setRequestAuth } from '@sim/logger'
import { dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -95,6 +96,27 @@ describe('authenticateApiKeyFromHeader', () => {
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})

it('records the key kind on the request without replacing a more specific principal', async () => {
dbChainMockFns.where.mockResolvedValueOnce([
personalKeyRecord({ type: 'workspace', workspaceId: 'workspace-1' }),
])

await authenticateApiKeyFromHeader('sk-sim-plain-key')

expect(vi.mocked(setRequestAuth)).toHaveBeenCalledWith(
{ kind: 'workspace_api_key' },
{ preserveExisting: true }
)
})

it('records nothing for a key that fails its checks', async () => {
dbChainMockFns.where.mockResolvedValueOnce([personalKeyRecord({ userId: 'other-user' })])

await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1' })

expect(vi.mocked(setRequestAuth)).not.toHaveBeenCalled()
})

it('returns invalid when the hash lookup finds a row that fails scope checks', async () => {
const record = personalKeyRecord({ userId: 'other-user' })
dbChainMockFns.where.mockResolvedValueOnce([record])
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/lib/api-key/service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db } from '@sim/db'
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { createLogger, setRequestAuth } from '@sim/logger'
import { and, eq, isNull, lt, or } from 'drizzle-orm'
import { hashApiKey } from '@/lib/api-key/crypto'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
Expand Down Expand Up @@ -132,6 +132,10 @@ export async function authenticateApiKeyFromHeader(
}

logger.debug('API key matched via hash lookup', { keyId: record.id, keyType })
setRequestAuth(
{ kind: keyType === 'personal' ? 'personal_api_key' : 'workspace_api_key' },
{ preserveExisting: true }
)

return {
success: true,
Expand Down
22 changes: 17 additions & 5 deletions apps/sim/lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { sso } from '@better-auth/sso'
import { stripe } from '@better-auth/stripe'
import { db } from '@sim/db'
import * as schema from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { createLogger, setRequestAuth } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { type BetterAuthOptions, betterAuth, type User } from 'better-auth'
import {
Expand Down Expand Up @@ -1758,13 +1758,25 @@ export const auth = betterAuth({
async function getSessionImpl() {
if (isAuthDisabled) {
await ensureAnonymousUserExists()
return createAnonymousSession()
return recordSessionAuth(createAnonymousSession())
}

const hdrs = await headers()
return await auth.api.getSession({
headers: hdrs,
})
return recordSessionAuth(
await auth.api.getSession({
headers: hdrs,
})
)
}

/**
* Records a resolved session as the request's auth kind. Stamped here, where
* every session is resolved, so the many routes that authenticate by calling
* `getSession` directly are attributed without each one remembering to.
*/
function recordSessionAuth<T extends { user?: { id?: string } } | null>(session: T): T {
if (session?.user?.id) setRequestAuth({ kind: 'session' }, { preserveExisting: true })
return session
}

export const getSession = cache(getSessionImpl)
43 changes: 5 additions & 38 deletions apps/sim/lib/auth/hybrid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describePrincipalAuth, type WorkflowExecutionPrincipal } from '@sim/auth/principal'
import { createLogger, setRequestAuth } from '@sim/logger'
import type { WorkflowExecutionPrincipal } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { API_KEY_HEADER, BEARER_PREFIX } from '@/lib/api/server/credential-headers'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
Expand Down Expand Up @@ -83,7 +83,7 @@ function resolveUserFromJwt(
* @param options - Optional configuration
* @param options.requireWorkflowId - Whether workflowId/userId is required (default: true)
*/
async function resolveInternalAuth(
export async function checkInternalAuth(
request: NextRequest,
options: { requireWorkflowId?: boolean } = {}
): Promise<AuthResult> {
Expand Down Expand Up @@ -131,7 +131,7 @@ async function resolveInternalAuth(
* @param options - Optional configuration
* @param options.requireWorkflowId - Whether workflowId/userId is required for JWT (default: true)
*/
async function resolveSessionOrInternalAuth(
export async function checkSessionOrInternalAuth(
request: NextRequest,
options: { requireWorkflowId?: boolean } = {}
): Promise<AuthResult> {
Expand Down Expand Up @@ -195,7 +195,7 @@ async function resolveSessionOrInternalAuth(
*
* For internal JWT calls, requires workflowId to determine user context
*/
async function resolveHybridAuth(
export async function checkHybridAuth(
request: NextRequest,
options: { requireWorkflowId?: boolean } = {}
): Promise<AuthResult> {
Expand Down Expand Up @@ -277,36 +277,3 @@ async function resolveHybridAuth(
}
}
}

type AuthCheck = (
request: NextRequest,
options?: { requireWorkflowId?: boolean }
) => Promise<AuthResult>

/**
* Records how a request authenticated on the request context, so the logs and
* analytics of a route that authenticates through these helpers rather than a
* route builder carry the same `auth` attribution. A principal describes
* itself; an internal JWT that produced none is recorded by its auth type.
*/
function recordingAuth(resolve: AuthCheck): AuthCheck {
return async (request, options) => {
const result = await resolve(request, options)
if (!result.success) return result
if (result.principal) {
setRequestAuth(describePrincipalAuth(result.principal))
} else if (result.authType) {
setRequestAuth({ kind: result.authType })
}
return result
}
}

/** Internal JWT authentication only. See {@link resolveInternalAuth}. */
export const checkInternalAuth = recordingAuth(resolveInternalAuth)

/** Session or internal JWT authentication, never an API key. See {@link resolveSessionOrInternalAuth}. */
export const checkSessionOrInternalAuth = recordingAuth(resolveSessionOrInternalAuth)

/** Any of the three supported credentials. See {@link resolveHybridAuth}. */
export const checkHybridAuth = recordingAuth(resolveHybridAuth)
14 changes: 14 additions & 0 deletions apps/sim/lib/auth/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { serializePrincipal } from '@sim/auth/principal'
import { setRequestAuth } from '@sim/logger'
import { resetEnvMock } from '@sim/testing'
import { decodeJwt, SignJWT } from 'jose'
import { afterAll, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -40,6 +41,19 @@ describe('internal JWT claims', () => {
})
})

it('records a verified internal token as the request auth kind, and a refused one not at all', async () => {
vi.mocked(setRequestAuth).mockClear()

await verifyInternalToken('not-a-jwt')
expect(vi.mocked(setRequestAuth)).not.toHaveBeenCalled()

await verifyInternalToken(await generateInternalToken('user-1'))
expect(vi.mocked(setRequestAuth)).toHaveBeenCalledWith(
{ kind: 'internal_jwt' },
{ preserveExisting: true }
)
})

it('rejects unknown sandbox profiles instead of falling back to another image', async () => {
const token = await generateInternalToken('user-1', {
sandboxProfile: 'unknown-profile' as never,
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/auth/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type WorkflowExecutionAuthority,
type WorkflowExecutionPrincipal,
} from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { createLogger, setRequestAuth } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { generateId } from '@sim/utils/id'
import { type JWTPayload, jwtVerify, SignJWT } from 'jose'
Expand Down Expand Up @@ -295,6 +295,7 @@ export async function verifyInternalToken(
if (payload.sandboxProfile !== undefined && payload.sandboxProfile !== 'mothership') {
return { valid: false }
}
setRequestAuth({ kind: 'internal_jwt' }, { preserveExisting: true })
return {
valid: true,
userId: typeof payload.userId === 'string' ? payload.userId : undefined,
Expand Down
6 changes: 6 additions & 0 deletions apps/sim/lib/auth/oauth-access-token.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* @vitest-environment node
*/
import { setRequestAuth } from '@sim/logger'
import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -85,12 +86,17 @@ describe('verifyOAuthAccessToken', () => {
})
expect(dbChainMockFns.where).toHaveBeenCalledOnce()
expect(JSON.stringify(dbChainMockFns.where.mock.calls[0])).toContain('hash:secret')
expect(vi.mocked(setRequestAuth)).toHaveBeenCalledWith(
{ kind: 'oauth_access_token', clientId: 'sim-cli' },
{ preserveExisting: true }
)
})

it('refuses a credential that is not one of ours without a database read', async () => {
expect(await reason('sim_abc')).toBe('malformed')
expect(await reason('sim_oat_')).toBe('malformed')
expect(dbChainMockFns.where).not.toHaveBeenCalled()
expect(vi.mocked(setRequestAuth)).not.toHaveBeenCalled()
})

it('refuses an unknown, expired, disabled-client, orphaned, or banned token', async () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/lib/auth/oauth-access-token.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { OAuthAccessTokenPrincipal } from '@sim/auth/principal'
import { db } from '@sim/db'
import { oauthAccessToken, oauthClient, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { createLogger, setRequestAuth } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { eq } from 'drizzle-orm'
import { isAccountBlocked } from '@/lib/auth/ban'
Expand Down Expand Up @@ -137,6 +137,7 @@ export async function verifyOAuthAccessToken(
}

logger.debug('Authenticated OAuth access token', { tokenId: row.id, clientId: row.clientId })
setRequestAuth({ kind: 'oauth_access_token', clientId: row.clientId }, { preserveExisting: true })
return {
kind: 'oauth_access_token',
userId: row.userId,
Expand Down
43 changes: 43 additions & 0 deletions apps/sim/lib/core/utils/request-attribution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { captureRequestAttribution } from '@/lib/core/utils/request-attribution'

describe('captureRequestAttribution', () => {
beforeEach(() => {
vi.mocked(loggerMock.getRequestContext).mockReset()
})

it('carries the client and auth of the request that queues the work', () => {
vi.mocked(loggerMock.getRequestContext).mockReturnValue({
requestId: 'req-1',
path: '/api/workflows/wf-1/execute',
client: { surface: 'cli', version: '2.1.2', agent: 'none', source: 'header' },
auth: { kind: 'oauth_access_token', clientId: 'sim-cli' },
})

expect(captureRequestAttribution()).toEqual({
client: { surface: 'cli', version: '2.1.2', agent: 'none', source: 'header' },
auth: { kind: 'oauth_access_token', clientId: 'sim-cli' },
})
})

it('omits what the request did not establish', () => {
vi.mocked(loggerMock.getRequestContext).mockReturnValue({
requestId: 'req-1',
client: { surface: 'api', source: 'credential' },
})

expect(captureRequestAttribution()).toEqual({
client: { surface: 'api', source: 'credential' },
})
})

it('carries nothing outside a request', () => {
vi.mocked(loggerMock.getRequestContext).mockReturnValue(undefined)

expect(captureRequestAttribution()).toBeUndefined()
})
})
Loading
Loading