Skip to content

Commit 53c24fc

Browse files
fix(auth): bind legacy execution actors to principals
1 parent 1aa7db8 commit 53c24fc

46 files changed

Lines changed: 358 additions & 200 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/internal-delegation.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,41 @@ describe('bindInternalExecutorDelegation', () => {
102102
})
103103
})
104104

105+
it('binds the trusted legacy execution actor only for an actorless principal', async () => {
106+
const principal = await bindInternalExecutorDelegation(
107+
{
108+
...claims,
109+
subjectUserId: undefined,
110+
principal: {
111+
kind: 'system',
112+
serviceId: 'schedule',
113+
workspaceId: 'workspace-1',
114+
workflowId: 'workflow-1',
115+
},
116+
},
117+
{
118+
audience: 'sim:workspace-files',
119+
compatibilityActorUserId: 'execution-actor',
120+
}
121+
)
122+
123+
expect(principal.subjectUserId).toBeUndefined()
124+
expect(principal.delegationContext.compatibilityActor).toEqual({
125+
kind: 'legacy_execution_user',
126+
userId: 'execution-actor',
127+
})
128+
})
129+
130+
it('rejects a compatibility actor when the delegation has a user subject', async () => {
131+
await expect(
132+
bindInternalExecutorDelegation(claims, {
133+
audience: 'sim:workspace-files',
134+
compatibilityActorUserId: 'execution-actor',
135+
})
136+
).rejects.toThrow('cannot bind a compatibility actor to a user subject')
137+
expect(mockResolveWorkflow).not.toHaveBeenCalled()
138+
})
139+
105140
it('binds deployed child authority to its exact historical deployment version', async () => {
106141
const currentWorkflow = {
107142
workflowId: 'child-workflow',
@@ -260,6 +295,16 @@ describe('bindInternalExecutorDelegation', () => {
260295
expect(mockResolveWorkflow).not.toHaveBeenCalled()
261296
})
262297

298+
it('fails before canonical loading when the compatibility actor is empty', async () => {
299+
await expect(
300+
bindInternalExecutorDelegation(claims, {
301+
audience: 'sim:workspace-files',
302+
compatibilityActorUserId: ' ',
303+
})
304+
).rejects.toThrow('Internal delegation execution actor must not be empty')
305+
expect(mockResolveWorkflow).not.toHaveBeenCalled()
306+
})
307+
263308
it('classifies a missing canonical execution as an invalid delegation binding', async () => {
264309
mockResolveRun.mockRejectedValue(new OrchestrationError('not_found', 'Workflow run not found'))
265310

apps/sim/lib/auth/internal-delegation.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
export interface BindInternalExecutorDelegationOptions {
1616
audience: string
1717
resourceScope?: DelegatedPrincipal['resourceScope']
18+
compatibilityActorUserId?: string
1819
}
1920

2021
export class InvalidInternalDelegationBindingError extends Error {
@@ -30,6 +31,12 @@ export async function bindInternalExecutorDelegation(
3031
options: BindInternalExecutorDelegationOptions
3132
): Promise<BoundWorkflowExecutionDelegatedPrincipal> {
3233
if (!options.audience.trim()) throw new Error('Internal delegation audience must not be empty')
34+
if (options.compatibilityActorUserId !== undefined && !options.compatibilityActorUserId.trim()) {
35+
throw new Error('Internal delegation execution actor must not be empty')
36+
}
37+
if (claims.subjectUserId && options.compatibilityActorUserId) {
38+
throw new Error('Internal delegation cannot bind a compatibility actor to a user subject')
39+
}
3340

3441
let context: ActiveWorkflowApplicationContext
3542
let rootDeploymentVersionId: string | null | undefined
@@ -107,6 +114,14 @@ export async function bindInternalExecutorDelegation(
107114
...(claims.executionId ? { executionId: claims.executionId } : {}),
108115
...(claims.principal ? { principal: claims.principal } : {}),
109116
...(claims.currentWorkflow ? { currentWorkflow: claims.currentWorkflow } : {}),
117+
...(options.compatibilityActorUserId
118+
? {
119+
compatibilityActor: {
120+
kind: 'legacy_execution_user',
121+
userId: options.compatibilityActorUserId,
122+
} as const,
123+
}
124+
: {}),
110125
},
111126
}
112127
}

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
requirePrincipalSubjectUserId,
88
resolvePrincipalAttribution,
99
resolvePrincipalAuditAttribution,
10+
resolvePrincipalExecutionActorUserId,
1011
resolvePrincipalSubject,
1112
resolvePrincipalSubjectUserId,
1213
serializePrincipal,
@@ -108,6 +109,49 @@ describe('principal subject users', () => {
108109
).toBeUndefined()
109110
})
110111

112+
it('resolves only a principal-bound compatibility actor for actorless execution', () => {
113+
const principal = {
114+
kind: 'delegated' as const,
115+
serviceId: 'executor' as const,
116+
workspaceId: 'workspace-1',
117+
delegationId: 'delegation-1',
118+
audience: 'sim:test',
119+
issuedAt: new Date('2026-01-01T00:00:00Z'),
120+
expiresAt: new Date('2026-01-01T00:05:00Z'),
121+
delegationContext: {
122+
kind: 'workflow_execution' as const,
123+
workflowId: 'workflow-1',
124+
currentWorkflow: {
125+
workflowId: 'workflow-1',
126+
mode: 'deployment' as const,
127+
deploymentVersionId: 'deployment-1',
128+
},
129+
compatibilityActor: {
130+
kind: 'legacy_execution_user' as const,
131+
userId: 'execution-actor',
132+
},
133+
},
134+
}
135+
136+
expect(resolvePrincipalSubjectUserId(principal)).toBeUndefined()
137+
expect(resolvePrincipalExecutionActorUserId(principal)).toBe('execution-actor')
138+
expect(
139+
resolvePrincipalExecutionActorUserId({
140+
...principal,
141+
subjectUserId: 'authenticated-user',
142+
})
143+
).toBe('authenticated-user')
144+
expect(
145+
resolvePrincipalExecutionActorUserId({
146+
...principal,
147+
delegationContext: {
148+
...principal.delegationContext,
149+
currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' },
150+
},
151+
})
152+
).toBeUndefined()
153+
})
154+
111155
it('fails fast instead of fabricating a workspace-key subject', () => {
112156
expect(() =>
113157
requirePrincipalSubjectUserId({

apps/sim/lib/copilot/tools/handlers/deployment/manage.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,6 @@ export async function executeLoadDeployment(
395395
workflowId,
396396
assertedWorkspaceId: context.workspaceId,
397397
version: target.version,
398-
executionActorUserId: context.userId,
399398
})
400399

401400
const label = target.version === 'active' ? 'the live deployment' : `version ${target.version}`

apps/sim/lib/copilot/tools/handlers/oauth.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ describe('executeOAuthGetAuthLink', () => {
5353
workspaceId: 'workspace-1',
5454
providerName: 'gmail',
5555
credentialId: undefined,
56-
executionActorUserId: 'user-1',
5756
})
5857
const url = new URL((result.output as { oauth_url: string }).oauth_url)
5958
expect(url.pathname).toBe('/api/auth/oauth2/authorize')

apps/sim/lib/copilot/tools/handlers/oauth.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ export async function executeOAuthGetAuthLink(
3939
workspaceId,
4040
providerName,
4141
credentialId,
42-
executionActorUserId: context.userId,
4342
})
4443
const callbackURL = context.workflowId
4544
? `${baseUrl}/workspace/${workspaceId}/w/${context.workflowId}`

apps/sim/lib/core/orchestration/types.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,7 @@ export function asOrchestrationError(error: unknown): OrchestrationError | null
109109
return null
110110
}
111111

112-
/**
113-
* Transport metadata available to an application operation. HTTP callers carry
114-
* headers for audit capture; executor adapters may also preserve the legacy
115-
* execution actor used by pre-application-boundary internal routes.
116-
*/
112+
/** Transport metadata available to an application operation for audit capture. */
117113
export interface OrchestrationRequestContext {
118114
headers: { get(name: string): string | null }
119-
executionActorUserId?: string
120115
}

apps/sim/lib/credentials/application/authorization.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type Principal, resolvePrincipalSubjectUserId } from '@sim/auth/principal'
1+
import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal'
22
import type { WorkspaceDelegationPolicy } from '@/lib/core/application'
33
import { OrchestrationError } from '@/lib/core/orchestration/types'
44
import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth'
@@ -26,15 +26,12 @@ export const managedOAuthCredentialDelegationPolicy = {
2626
/**
2727
* Resolves the user whose credential grants an operation evaluates.
2828
*
29-
* `executionActorUserId` is the user the legacy internal route authenticated as.
30-
* Workspace authorization remains principal-based, and a principal subject
31-
* always takes precedence over this compatibility value.
29+
* Actorless execution uses only the compatibility actor bound into the executor
30+
* principal by the trusted runtime. Workspace authorization remains
31+
* principal-based, and a principal subject always takes precedence.
3232
*/
33-
export function requireCredentialExecutionUserId(
34-
principal: Principal,
35-
executionActorUserId?: string
36-
): string {
37-
const userId = resolvePrincipalSubjectUserId(principal) ?? executionActorUserId
33+
export function requireCredentialExecutionUserId(principal: Principal): string {
34+
const userId = resolvePrincipalExecutionActorUserId(principal)
3835
if (!userId) {
3936
throw new OrchestrationError(
4037
'forbidden',

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,7 @@ type AuthorizedCredentialUseCaseDefinition<
7676
> = Omit<
7777
AuthorizedWorkspaceUseCaseDefinition<O, I, C, R>,
7878
'authorizationOptions' | 'authorizeResource'
79-
> & {
80-
resolveExecutionActorUserId?: (input: I) => string | undefined
81-
}
79+
>
8280

8381
export function defineAuthorizedCredentialUseCase<
8482
const O extends CredentialOperation,
@@ -89,10 +87,10 @@ export function defineAuthorizedCredentialUseCase<
8987
return defineAuthorizedWorkspaceUseCase({
9088
...definition,
9189
authorizationOptions: { delegation: credentialDelegationPolicy },
92-
async authorizeResource({ principal, input, context }) {
90+
async authorizeResource({ principal, context }) {
9391
const actor = await getCredentialActorContext(
9492
context.credential.id,
95-
requireCredentialExecutionUserId(principal, definition.resolveExecutionActorUserId?.(input))
93+
requireCredentialExecutionUserId(principal)
9694
)
9795
if (
9896
!actor.credential ||

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,13 +146,25 @@ describe('resolveCredentialConnectionTarget', () => {
146146
audience: 'sim:credentials',
147147
issuedAt: new Date('2026-08-28T00:00:00.000Z'),
148148
expiresAt: new Date('2099-08-28T00:00:00.000Z'),
149+
delegationContext: {
150+
kind: 'workflow_execution' as const,
151+
workflowId: 'workflow-1',
152+
currentWorkflow: {
153+
workflowId: 'workflow-1',
154+
mode: 'deployment' as const,
155+
deploymentVersionId: 'deployment-1',
156+
},
157+
compatibilityActor: {
158+
kind: 'legacy_execution_user' as const,
159+
userId: 'execution-actor',
160+
},
161+
},
149162
}
150163

151164
await resolveCredentialConnectionTarget({
152165
principal: actorlessPrincipal,
153166
context,
154167
credentialId: 'credential-1',
155-
executionActorUserId: 'execution-actor',
156168
})
157169

158170
expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'execution-actor')

0 commit comments

Comments
 (0)