Skip to content

Commit c950e05

Browse files
fix(logs): stop requiring a human subject on actorless runs
Scheduled, public-API, and subject-less webhook runs carry no user on their principal. Several use cases resolved one with requirePrincipalSubjectUserId where the user was only attribution, so those runs failed with an opaque 500. Authorization for an actorless caller comes from the workflow running a deployment, never from a userId; each site now treats the user as what it actually is.
1 parent 6818425 commit c950e05

41 files changed

Lines changed: 1112 additions & 90 deletions

Some content is hidden

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

apps/sim/lib/auth/principal.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
resolvePrincipalAttribution,
99
resolvePrincipalAuditAttribution,
1010
resolvePrincipalSubject,
11+
resolvePrincipalSubjectUserId,
1112
serializePrincipal,
1213
toPrincipalActor,
1314
} from '@sim/auth/principal'
@@ -43,6 +44,70 @@ describe('principal subject users', () => {
4344
).toBe('delegated-user')
4445
})
4546

47+
it('resolves the same subject without demanding one', () => {
48+
expect(
49+
resolvePrincipalSubjectUserId({
50+
kind: 'session',
51+
userId: 'session-user',
52+
sessionId: 'session-1',
53+
})
54+
).toBe('session-user')
55+
expect(
56+
resolvePrincipalSubjectUserId({
57+
kind: 'delegated',
58+
serviceId: 'executor',
59+
subjectUserId: 'delegated-user',
60+
workspaceId: 'workspace-1',
61+
delegationId: 'delegation-1',
62+
audience: 'sim:test',
63+
issuedAt: new Date('2026-01-01T00:00:00Z'),
64+
expiresAt: new Date('2026-01-01T00:05:00Z'),
65+
})
66+
).toBe('delegated-user')
67+
})
68+
69+
it('answers undefined for an actorless caller rather than throwing', () => {
70+
// The distinction the two helpers exist to make visible: a schedule, a webhook
71+
// with no external subject, and a workspace key are all authorized callers that
72+
// simply have no person. Attribution-only reads take this branch.
73+
expect(
74+
resolvePrincipalSubjectUserId({
75+
kind: 'system',
76+
serviceId: 'schedule',
77+
workspaceId: 'workspace-1',
78+
workflowId: 'workflow-1',
79+
})
80+
).toBeUndefined()
81+
expect(
82+
resolvePrincipalSubjectUserId({
83+
kind: 'delegated',
84+
serviceId: 'executor',
85+
workspaceId: 'workspace-1',
86+
delegationId: 'delegation-1',
87+
audience: 'sim:test',
88+
issuedAt: new Date('2026-01-01T00:00:00Z'),
89+
expiresAt: new Date('2026-01-01T00:05:00Z'),
90+
delegationContext: {
91+
kind: 'workflow_execution',
92+
workflowId: 'workflow-1',
93+
principal: {
94+
kind: 'system',
95+
serviceId: 'schedule',
96+
workspaceId: 'workspace-1',
97+
workflowId: 'workflow-1',
98+
},
99+
},
100+
})
101+
).toBeUndefined()
102+
expect(
103+
resolvePrincipalSubjectUserId({
104+
kind: 'workspace_api_key',
105+
keyId: 'key-1',
106+
workspaceId: 'workspace-1',
107+
})
108+
).toBeUndefined()
109+
})
110+
46111
it('fails fast instead of fabricating a workspace-key subject', () => {
47112
expect(() =>
48113
requirePrincipalSubjectUserId({

apps/sim/lib/credential-groups/application/create-invite-link.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,48 @@ describe('createCredentialGroupInviteLink', () => {
104104
expect(mocks.resolveGroup).not.toHaveBeenCalled()
105105
})
106106

107+
it('issues an unattributed link for an actorless run', async () => {
108+
// A schedule (or a webhook with no external subject) reaches this with a real
109+
// admin-scoped delegation and no person on it. The delegation is the authority;
110+
// the issuer is only recorded, and `created_by` is nullable — so this issues the
111+
// link with no issuer rather than refusing, which is what it did when the
112+
// subject was demanded here.
113+
const { subjectUserId: _subject, ...base } = executorPrincipal()
114+
// What actually authorizes an actorless caller: the delegation is running a
115+
// deployment. No user is consulted anywhere in that decision.
116+
const actorless = {
117+
...base,
118+
delegationContext: {
119+
kind: 'workflow_execution' as const,
120+
workflowId: 'workflow-1',
121+
principal: {
122+
kind: 'system' as const,
123+
serviceId: 'schedule' as const,
124+
workspaceId: 'workspace-1',
125+
workflowId: 'workflow-1',
126+
},
127+
currentWorkflow: {
128+
workflowId: 'workflow-1',
129+
mode: 'deployment' as const,
130+
deploymentVersionId: 'version-1',
131+
},
132+
},
133+
}
134+
135+
const result = await createCredentialGroupInviteLink.execute({
136+
principal: actorless,
137+
input: { credentialGroupId: 'group-1', email: 'person@example.com' },
138+
})
139+
140+
expect(result.invitationLink).toBe('https://sim.ai/credential-groups/enroll/token-1')
141+
expect(mocks.createInvitationLink).toHaveBeenCalledWith(
142+
'workspace-1',
143+
'group-1',
144+
undefined,
145+
'person@example.com'
146+
)
147+
})
148+
107149
it('rejects delegation scoped to another Credential Group', async () => {
108150
await expect(
109151
createCredentialGroupInviteLink.execute({

apps/sim/lib/credential-groups/application/create-invite-link.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AuditAction, AuditResourceType } from '@sim/audit'
2-
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
2+
import { resolvePrincipalSubjectUserId } from '@sim/auth/principal'
33
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
44
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
55
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -39,7 +39,9 @@ export const createCredentialGroupInviteLink = defineAuthorizedWorkspaceUseCase(
3939
return await createCredentialGroupInvitationLink(
4040
context.workspaceId,
4141
context.credentialGroupId,
42-
requirePrincipalSubjectUserId(principal),
42+
// Attribution, not authority: the delegation's admin-scoped Credential Group
43+
// grant is what permits this. An actorless run records no issuer.
44+
resolvePrincipalSubjectUserId(principal),
4345
email
4446
)
4547
} catch (error) {

apps/sim/lib/credential-groups/application/send-invite.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { AuditAction, AuditResourceType } from '@sim/audit'
2-
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
32
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
43
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
54
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -41,7 +40,7 @@ export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({
4140
}
4241
await requireCredentialGroupsAvailable(context.workspaceId)
4342

44-
const userId = requirePrincipalSubjectUserId(principal)
43+
const userId = requireCredentialGroupWorkflowSubject(principal)
4544
const inviter = await loadCredentialGroupInviterIdentity(userId)
4645
const inviterName = inviter?.name?.trim() || inviter?.email
4746
if (!inviterName) {

apps/sim/lib/credential-groups/enrollments.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,14 @@ async function getInvitationContext(
330330

331331
async function issueInvitation(
332332
context: InvitationContext,
333-
userId: string,
333+
/**
334+
* Who to record as the issuer, when there is someone. Attribution only — the
335+
* authority to invite comes from the delegation, so an actorless run (a schedule,
336+
* or a webhook with no external subject) issues an unattributed invitation rather
337+
* than none. `created_by` is nullable and `on delete set null`, so a row with no
338+
* issuer is a shape the schema already carries.
339+
*/
340+
userId: string | undefined,
334341
email: string,
335342
options: SendInvitationOptions
336343
): Promise<IssuedInvitation> {
@@ -387,7 +394,7 @@ async function issueInvitation(
387394
completedAt: preservesProgress ? current.completedAt : null,
388395
revokedAt: null,
389396
lastDeliveryError: null,
390-
createdBy: userId,
397+
createdBy: userId ?? null,
391398
updatedAt: now,
392399
}
393400
const [next] = current
@@ -664,7 +671,8 @@ export async function inviteCredentialGroupEnrollment(
664671
export async function createCredentialGroupInvitationLink(
665672
workspaceId: string,
666673
groupId: string,
667-
userId: string,
674+
/** See {@link issueInvitation}: the issuer is attribution, never the authority. */
675+
userId: string | undefined,
668676
email: string
669677
): Promise<CredentialGroupInvitationLink> {
670678
const context = await getInvitationContext(workspaceId, groupId)

apps/sim/lib/credentials/application/authorized-credential-use-case.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export function defineAuthorizedCredentialUseCase<
8888
async authorizeResource({ principal, context }) {
8989
const actor = await getCredentialActorContext(
9090
context.credential.id,
91+
// actorless-unsupported: credential access is decided per person; an actorless run has no credential grants
9192
requirePrincipalSubjectUserId(principal)
9293
)
9394
if (

apps/sim/lib/credentials/application/connection-target.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export async function resolveCredentialConnectionTarget(params: {
4646
}
4747

4848
if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID')
49+
// actorless-unsupported: reconnecting rebinds a person's own OAuth grant
4950
const userId = requirePrincipalSubjectUserId(principal)
5051
const targetCredentialId = credentialId
5152
const credential = await getWorkspaceCredential({

apps/sim/lib/custom-tools/application/use-cases.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ async function resolveAvailableToolContext(args: {
7070
const workspace = await resolveWorkspaceContext(args.workspaceId)
7171
const tool = await getCustomToolById({
7272
toolId: args.toolId,
73+
// actorless-unsupported: a custom tool is owned by one user; an actorless run has no library to look in
7374
userId: requirePrincipalSubjectUserId(args.principal),
7475
workspaceId: workspace.workspaceId,
7576
})
@@ -129,6 +130,7 @@ export const listAvailableCustomToolsUseCase = defineAuthorizedWorkspaceUseCase(
129130
authorizationOptions,
130131
async execute({ principal, context }) {
131132
const tools = await listCustomTools({
133+
// actorless-unsupported: the listing is the acting user's own tool library, which an actorless run does not have
132134
userId: requirePrincipalSubjectUserId(principal),
133135
workspaceId: context.workspaceId,
134136
})
@@ -353,6 +355,7 @@ export const updateAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase
353355
const tool = await updateCustomTool({
354356
workspaceId: context.workspaceId,
355357
toolId: context.tool.id,
358+
// actorless-unsupported: editing a tool is scoped to its owner; an actorless run owns none
356359
userId: requirePrincipalSubjectUserId(principal),
357360
title,
358361
schema: input.schema ?? context.tool.schema,
@@ -422,6 +425,7 @@ export const deleteAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase
422425
const deleted = await deleteCustomTool({
423426
workspaceId: context.workspaceId,
424427
toolId: context.tool.id,
428+
// actorless-unsupported: deleting a tool is scoped to its owner; an actorless run owns none
425429
userId: requirePrincipalSubjectUserId(principal),
426430
})
427431
if (!deleted) throw new OrchestrationError('not_found', 'Custom tool not found')

apps/sim/lib/internal/deployments/execute-tool.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { isPlainRecord } from '@sim/utils/object'
33
import type { ZodError, ZodType } from 'zod'
44
import { getValidationErrorMessage } from '@/lib/api/server'
55
import { concealCrossTenantResourceError } from '@/lib/api/server/routes'
6-
import { InvalidInternalDelegationBindingError } from '@/lib/auth/internal-delegation'
76
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
87
import {
98
deploymentsDeployBodySchema,
@@ -20,6 +19,11 @@ import {
2019
executeDeploymentsUndeploy,
2120
} from '@/lib/internal/deployments/operations'
2221
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
22+
import {
23+
classifyInternalToolIdentityFault,
24+
internalToolIdentityFaultMessage,
25+
internalToolIdentityFaultStatus,
26+
} from '@/lib/internal/tool-operations/identity-faults'
2327
import type {
2428
InternalToolOperationCall,
2529
InternalToolOperationHandler,
@@ -52,11 +56,12 @@ function parseInput<T>(schema: ZodType<T>, request: InternalToolOperationCall) {
5256
}
5357

5458
function errorResponse(request: InternalToolOperationCall, error: unknown): Response {
55-
if (
56-
error instanceof InvalidInternalDelegationBindingError ||
57-
(error instanceof Error && error.message === 'Authentication required')
58-
) {
59-
return Response.json({ success: false, error: 'Authentication required' }, { status: 401 })
59+
const identityFault = classifyInternalToolIdentityFault(error)
60+
if (identityFault) {
61+
return Response.json(
62+
{ success: false, error: internalToolIdentityFaultMessage(identityFault) },
63+
{ status: internalToolIdentityFaultStatus(identityFault) }
64+
)
6065
}
6166

6267
const classified = asOrchestrationError(

apps/sim/lib/internal/file/execute-tool.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import {
2-
PrincipalSubjectUserRequiredError,
3-
resolvePrincipalAttribution,
4-
resolvePrincipalSubject,
5-
} from '@sim/auth/principal'
1+
import { resolvePrincipalAttribution, resolvePrincipalSubject } from '@sim/auth/principal'
62
import { createLogger } from '@sim/logger'
73
import { getErrorMessage } from '@sim/utils/errors'
84
import { fileParseContract } from '@/lib/api/contracts/storage-transfer'
95
import { fileManageContract } from '@/lib/api/contracts/tools/file'
10-
import { InvalidInternalDelegationBindingError } from '@/lib/auth/internal-delegation'
116
import { executeFileManageOperation } from '@/lib/internal/file/operations'
127
import { executeFileParserOperation } from '@/lib/internal/file/parser'
138
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
9+
import {
10+
classifyInternalToolIdentityFault,
11+
internalToolIdentityFaultMessage,
12+
internalToolIdentityFaultStatus,
13+
} from '@/lib/internal/tool-operations/identity-faults'
1414
import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input'
1515
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
1616
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
@@ -104,12 +104,12 @@ export const executeFileTool: InternalToolOperationHandler = async (request) =>
104104
return response
105105
} catch (error) {
106106
request.signal?.throwIfAborted()
107-
if (
108-
error instanceof InvalidInternalDelegationBindingError ||
109-
error instanceof PrincipalSubjectUserRequiredError ||
110-
(error instanceof Error && error.message === 'Authentication required')
111-
) {
112-
return Response.json({ success: false, error: 'Authentication required' }, { status: 401 })
107+
const identityFault = classifyInternalToolIdentityFault(error)
108+
if (identityFault) {
109+
return Response.json(
110+
{ success: false, error: internalToolIdentityFaultMessage(identityFault) },
111+
{ status: internalToolIdentityFaultStatus(identityFault) }
112+
)
113113
}
114114
const message = getErrorMessage(error, 'Unknown error')
115115
logger.error('File operation dispatch failed', {

0 commit comments

Comments
 (0)