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
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import type { ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'

const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
buildHeaders: vi.fn(),
createPrincipal: vi.fn(),
createInviteLink: vi.fn(),
enforceInviteRateLimit: vi.fn(),
listCredentials: vi.fn(),
Expand All @@ -22,10 +21,6 @@ vi.mock('@/lib/credential-groups/application/create-invite-link', () => ({
createCredentialGroupInviteLink: { execute: mocks.createInviteLink },
}))

vi.mock('@/lib/credential-groups/application/delegation', () => ({
authenticateCredentialGroupDelegation: mocks.authenticate,
}))

vi.mock('@/lib/credential-groups/application/list-credentials', () => ({
listCredentialGroupCredentials: { execute: mocks.listCredentials },
}))
Expand Down Expand Up @@ -53,8 +48,8 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({
enforceCredentialGroupInvitationExecutionRateLimit: mocks.enforceInviteRateLimit,
}))

vi.mock('@/executor/utils/http', () => ({
buildExecutorDelegationHeaders: mocks.buildHeaders,
vi.mock('@/lib/internal/principals/executor', () => ({
createExecutorPrincipalFromExecutionContext: mocks.createPrincipal,
}))

import { CredentialGroupBlockHandler } from '@/executor/handlers/credential-group/credential-group-handler'
Expand Down Expand Up @@ -92,8 +87,7 @@ const block = { metadata: { id: BlockType.CREDENTIAL_GROUP } } as SerializedBloc
describe('CredentialGroupBlockHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.buildHeaders.mockResolvedValue({ Authorization: 'Bearer executor-token' })
mocks.authenticate.mockResolvedValue(principal)
mocks.createPrincipal.mockResolvedValue(principal)
})

it('recognizes only Credential Group blocks', () => {
Expand Down Expand Up @@ -122,7 +116,11 @@ describe('CredentialGroupBlockHandler', () => {
cursor: ' credential-1 ',
})

expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1')
expect(mocks.createPrincipal).toHaveBeenCalledWith({
context,
audience: 'sim:credential-groups',
resourceScope: { credentialGroupId: 'group-1' },
})
expect(mocks.listCredentials).toHaveBeenCalledWith({
principal,
input: {
Expand All @@ -136,6 +134,73 @@ describe('CredentialGroupBlockHandler', () => {
expect(result).toEqual({ credentials: [], count: 0, hasMore: false, nextCursor: null })
})

it('lists credentials for an actorless workflow execution', async () => {
const executionPrincipal = {
kind: 'system' as const,
serviceId: 'schedule' as const,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
}
const actorlessPrincipal: WorkflowExecutionDelegatedPrincipal = {
kind: 'delegated',
serviceId: 'executor',
workspaceId: 'workspace-1',
delegationId: 'delegation-actorless',
audience: 'sim:credential-groups',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
resourceScope: { credentialGroupId: 'group-1' },
delegationContext: {
kind: 'workflow_execution',
workflowId: 'workflow-1',
principal: executionPrincipal,
currentWorkflow: {
workflowId: 'workflow-1',
mode: 'deployment',
deploymentVersionId: 'deployment-version-1',
},
},
}
const actorlessContext = {
...context,
userId: undefined,
principal: executionPrincipal,
executorDelegationOrigin: {
workflowId: 'workflow-1',
principal: executionPrincipal,
currentWorkflow: actorlessPrincipal.delegationContext.currentWorkflow,
},
} as ExecutionContext
mocks.createPrincipal.mockResolvedValueOnce(actorlessPrincipal)
mocks.listCredentials.mockResolvedValue({
credentials: [],
count: 0,
hasMore: false,
nextCursor: null,
})

await new CredentialGroupBlockHandler().execute(actorlessContext, block, {
operation: 'list_credentials',
credentialGroupId: 'group-1',
})

expect(mocks.createPrincipal).toHaveBeenCalledWith({
context: actorlessContext,
audience: 'sim:credential-groups',
resourceScope: { credentialGroupId: 'group-1' },
})
expect(mocks.listCredentials).toHaveBeenCalledWith({
principal: actorlessPrincipal,
input: {
credentialGroupId: 'group-1',
limit: 100,
cursor: undefined,
email: undefined,
credentialProviderIds: undefined,
},
})
})

it('lists groups under workspace-scoped delegation', async () => {
mocks.listGroups.mockResolvedValue({
credentialGroups: [],
Expand All @@ -149,7 +214,10 @@ describe('CredentialGroupBlockHandler', () => {
limit: 10,
})

expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', undefined)
expect(mocks.createPrincipal).toHaveBeenCalledWith({
context,
audience: 'sim:credential-groups',
})
expect(mocks.listGroups).toHaveBeenCalledWith({
principal,
input: { workspaceId: 'workspace-1', limit: 10, cursor: undefined },
Expand Down Expand Up @@ -201,7 +269,11 @@ describe('CredentialGroupBlockHandler', () => {
email: ' person@example.com ',
})

expect(mocks.authenticate).toHaveBeenCalledWith('Bearer executor-token', 'group-1')
expect(mocks.createPrincipal).toHaveBeenCalledWith({
context,
audience: 'sim:credential-groups',
resourceScope: { credentialGroupId: 'group-1' },
})
expect(mocks.enforceInviteRateLimit).toHaveBeenCalledWith('workspace-1')
expect(mocks.enforceInviteRateLimit.mock.invocationCallOrder[0]).toBeLessThan(
mocks.createInviteLink.mock.invocationCallOrder[0]!
Expand Down Expand Up @@ -236,7 +308,6 @@ describe('CredentialGroupBlockHandler', () => {
await expect(
new CredentialGroupBlockHandler().execute(context, block, { operation: 'unknown' })
).rejects.toThrow('Unsupported Credential Group operation: unknown')
expect(mocks.buildHeaders).not.toHaveBeenCalled()
expect(mocks.authenticate).not.toHaveBeenCalled()
expect(mocks.createPrincipal).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { CREDENTIAL_GROUP_DELEGATION_AUDIENCE } from '@/lib/credential-groups/application/authorization'
import { createCredentialGroupInviteLink } from '@/lib/credential-groups/application/create-invite-link'
import { authenticateCredentialGroupDelegation } from '@/lib/credential-groups/application/delegation'
import { listCredentialGroupCredentials } from '@/lib/credential-groups/application/list-credentials'
import { listCredentialGroupsForWorkflow } from '@/lib/credential-groups/application/list-groups'
import {
Expand All @@ -11,10 +11,10 @@ import { sendCredentialGroupInvite } from '@/lib/credential-groups/application/s
import { MAX_CREDENTIAL_GROUP_CREDENTIAL_PAGE_SIZE } from '@/lib/credential-groups/credentials'
import type { CredentialGroupEnrollmentStatus } from '@/lib/credential-groups/enrollments'
import { enforceCredentialGroupInvitationExecutionRateLimit } from '@/lib/credential-groups/rate-limit'
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
import type { BlockOutput } from '@/blocks/types'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext, ExecutorDelegationOrigin } from '@/executor/types'
import { buildExecutorDelegationHeaders } from '@/executor/utils/http'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import type { SerializedBlock } from '@/serializer/types'

const logger = createLogger('CredentialGroupBlockHandler')
Expand Down Expand Up @@ -84,13 +84,6 @@ function requireString(value: unknown, label: string): string {
return parsed
}

function delegationOrigin(ctx: ExecutionContext): ExecutorDelegationOrigin {
if (!ctx.executorDelegationOrigin) {
throw new Error('Credential Group operations require an authenticated workflow execution')
}
return ctx.executorDelegationOrigin
}

export class CredentialGroupBlockHandler implements BlockHandler {
canHandle(block: SerializedBlock): boolean {
return block.metadata?.id === BlockType.CREDENTIAL_GROUP
Expand All @@ -103,14 +96,18 @@ export class CredentialGroupBlockHandler implements BlockHandler {
): Promise<BlockOutput> {
if (!ctx.workspaceId) throw new Error('workspaceId is required for Credential Group operations')
const operation = parseOperation(inputs.operation)
if (!ctx.executorDelegationOrigin) {
throw new Error('Credential Group operations require an authenticated workflow execution')
}
const credentialGroupId =
operation === 'list_groups'
? undefined
: requireString(inputs.credentialGroupId, 'Credential Group')
const headers = await buildExecutorDelegationHeaders(delegationOrigin(ctx))
const authorization = headers.Authorization
if (!authorization) throw new Error('Executor delegation authorization is missing')
const principal = await authenticateCredentialGroupDelegation(authorization, credentialGroupId)
const principal = await createExecutorPrincipalFromExecutionContext({
context: ctx,
audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE,
...(credentialGroupId ? { resourceScope: { credentialGroupId } } : {}),
})

switch (operation) {
case 'list_credentials': {
Expand Down
45 changes: 45 additions & 0 deletions apps/sim/lib/auth/internal-delegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,41 @@ describe('bindInternalExecutorDelegation', () => {
})
})

it('binds the trusted legacy execution actor only for an actorless principal', async () => {
const principal = await bindInternalExecutorDelegation(
{
...claims,
subjectUserId: undefined,
principal: {
kind: 'system',
serviceId: 'schedule',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
},
},
{
audience: 'sim:workspace-files',
compatibilityActorUserId: 'execution-actor',
}
)

expect(principal.subjectUserId).toBeUndefined()
expect(principal.delegationContext.compatibilityActor).toEqual({
kind: 'legacy_execution_user',
userId: 'execution-actor',
})
})

it('rejects a compatibility actor when the delegation has a user subject', async () => {
await expect(
bindInternalExecutorDelegation(claims, {
audience: 'sim:workspace-files',
compatibilityActorUserId: 'execution-actor',
})
).rejects.toThrow('cannot bind a compatibility actor to a user subject')
expect(mockResolveWorkflow).not.toHaveBeenCalled()
})

it('binds deployed child authority to its exact historical deployment version', async () => {
const currentWorkflow = {
workflowId: 'child-workflow',
Expand Down Expand Up @@ -260,6 +295,16 @@ describe('bindInternalExecutorDelegation', () => {
expect(mockResolveWorkflow).not.toHaveBeenCalled()
})

it('fails before canonical loading when the compatibility actor is empty', async () => {
await expect(
bindInternalExecutorDelegation(claims, {
audience: 'sim:workspace-files',
compatibilityActorUserId: ' ',
})
).rejects.toThrow('Internal delegation execution actor must not be empty')
expect(mockResolveWorkflow).not.toHaveBeenCalled()
})

it('classifies a missing canonical execution as an invalid delegation binding', async () => {
mockResolveRun.mockRejectedValue(new OrchestrationError('not_found', 'Workflow run not found'))

Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/auth/internal-delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
export interface BindInternalExecutorDelegationOptions {
audience: string
resourceScope?: DelegatedPrincipal['resourceScope']
compatibilityActorUserId?: string
}

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

let context: ActiveWorkflowApplicationContext
let rootDeploymentVersionId: string | null | undefined
Expand Down Expand Up @@ -107,6 +114,14 @@ export async function bindInternalExecutorDelegation(
...(claims.executionId ? { executionId: claims.executionId } : {}),
...(claims.principal ? { principal: claims.principal } : {}),
...(claims.currentWorkflow ? { currentWorkflow: claims.currentWorkflow } : {}),
...(options.compatibilityActorUserId
? {
compatibilityActor: {
kind: 'legacy_execution_user',
userId: options.compatibilityActorUserId,
} as const,
}
: {}),
},
}
}
44 changes: 44 additions & 0 deletions apps/sim/lib/auth/principal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
requirePrincipalSubjectUserId,
resolvePrincipalAttribution,
resolvePrincipalAuditAttribution,
resolvePrincipalExecutionActorUserId,
resolvePrincipalSubject,
resolvePrincipalSubjectUserId,
serializePrincipal,
Expand Down Expand Up @@ -108,6 +109,49 @@ describe('principal subject users', () => {
).toBeUndefined()
})

it('resolves only a principal-bound compatibility actor for actorless execution', () => {
const principal = {
kind: 'delegated' as const,
serviceId: 'executor' as const,
workspaceId: 'workspace-1',
delegationId: 'delegation-1',
audience: 'sim:test',
issuedAt: new Date('2026-01-01T00:00:00Z'),
expiresAt: new Date('2026-01-01T00:05:00Z'),
delegationContext: {
kind: 'workflow_execution' as const,
workflowId: 'workflow-1',
currentWorkflow: {
workflowId: 'workflow-1',
mode: 'deployment' as const,
deploymentVersionId: 'deployment-1',
},
compatibilityActor: {
kind: 'legacy_execution_user' as const,
userId: 'execution-actor',
},
},
}

expect(resolvePrincipalSubjectUserId(principal)).toBeUndefined()
expect(resolvePrincipalExecutionActorUserId(principal)).toBe('execution-actor')
expect(
resolvePrincipalExecutionActorUserId({
...principal,
subjectUserId: 'authenticated-user',
})
).toBe('authenticated-user')
expect(
resolvePrincipalExecutionActorUserId({
...principal,
delegationContext: {
...principal.delegationContext,
currentWorkflow: { workflowId: 'workflow-1', mode: 'draft' },
},
})
).toBeUndefined()
})

it('fails fast instead of fabricating a workspace-key subject', () => {
expect(() =>
requirePrincipalSubjectUserId({
Expand Down
6 changes: 1 addition & 5 deletions apps/sim/lib/core/orchestration/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,7 @@ export function asOrchestrationError(error: unknown): OrchestrationError | null
return null
}

/**
* The slice of an HTTP request the audit log reads for client IP and user-agent
* capture. Optional on every orchestration function so the non-HTTP callers —
* copilot tools, background jobs — can omit what they do not have.
*/
/** Transport metadata available to an application operation for audit capture. */
export interface OrchestrationRequestContext {
headers: { get(name: string): string | null }
}
Loading
Loading