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
65 changes: 65 additions & 0 deletions apps/sim/lib/auth/principal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
resolvePrincipalAttribution,
resolvePrincipalAuditAttribution,
resolvePrincipalSubject,
resolvePrincipalSubjectUserId,
serializePrincipal,
toPrincipalActor,
} from '@sim/auth/principal'
Expand Down Expand Up @@ -43,6 +44,70 @@ describe('principal subject users', () => {
).toBe('delegated-user')
})

it('resolves the same subject without demanding one', () => {
expect(
resolvePrincipalSubjectUserId({
kind: 'session',
userId: 'session-user',
sessionId: 'session-1',
})
).toBe('session-user')
expect(
resolvePrincipalSubjectUserId({
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'delegated-user',
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'),
})
).toBe('delegated-user')
})

it('answers undefined for an actorless caller rather than throwing', () => {
// The distinction the two helpers exist to make visible: a schedule, a webhook
// with no external subject, and a workspace key are all authorized callers that
// simply have no person. Attribution-only reads take this branch.
expect(
resolvePrincipalSubjectUserId({
kind: 'system',
serviceId: 'schedule',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
})
).toBeUndefined()
expect(
resolvePrincipalSubjectUserId({
kind: 'delegated',
serviceId: 'executor',
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',
workflowId: 'workflow-1',
principal: {
kind: 'system',
serviceId: 'schedule',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
},
},
})
).toBeUndefined()
expect(
resolvePrincipalSubjectUserId({
kind: 'workspace_api_key',
keyId: 'key-1',
workspaceId: 'workspace-1',
})
).toBeUndefined()
})

it('fails fast instead of fabricating a workspace-key subject', () => {
expect(() =>
requirePrincipalSubjectUserId({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,48 @@ describe('createCredentialGroupInviteLink', () => {
expect(mocks.resolveGroup).not.toHaveBeenCalled()
})

it('issues an unattributed link for an actorless run', async () => {
// A schedule (or a webhook with no external subject) reaches this with a real
// admin-scoped delegation and no person on it. The delegation is the authority;
// the issuer is only recorded, and `created_by` is nullable — so this issues the
// link with no issuer rather than refusing, which is what it did when the
// subject was demanded here.
const { subjectUserId: _subject, ...base } = executorPrincipal()
// What actually authorizes an actorless caller: the delegation is running a
// deployment. No user is consulted anywhere in that decision.
const actorless = {
...base,
delegationContext: {
kind: 'workflow_execution' as const,
workflowId: 'workflow-1',
principal: {
kind: 'system' as const,
serviceId: 'schedule' as const,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
},
currentWorkflow: {
workflowId: 'workflow-1',
mode: 'deployment' as const,
deploymentVersionId: 'version-1',
},
},
}

const result = await createCredentialGroupInviteLink.execute({
principal: actorless,
input: { credentialGroupId: 'group-1', email: 'person@example.com' },
})

expect(result.invitationLink).toBe('https://sim.ai/credential-groups/enroll/token-1')
expect(mocks.createInvitationLink).toHaveBeenCalledWith(
'workspace-1',
'group-1',
undefined,
'person@example.com'
)
})

it('rejects delegation scoped to another Credential Group', async () => {
await expect(
createCredentialGroupInviteLink.execute({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { resolvePrincipalSubjectUserId } from '@sim/auth/principal'
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
Expand Down Expand Up @@ -39,7 +39,9 @@ export const createCredentialGroupInviteLink = defineAuthorizedWorkspaceUseCase(
return await createCredentialGroupInvitationLink(
context.workspaceId,
context.credentialGroupId,
requirePrincipalSubjectUserId(principal),
// Attribution, not authority: the delegation's admin-scoped Credential Group
// grant is what permits this. An actorless run records no issuer.
resolvePrincipalSubjectUserId(principal),
email
)
} catch (error) {
Expand Down
3 changes: 1 addition & 2 deletions apps/sim/lib/credential-groups/application/send-invite.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { AuditAction, AuditResourceType } from '@sim/audit'
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
Expand Down Expand Up @@ -41,7 +40,7 @@ export const sendCredentialGroupInvite = defineAuthorizedWorkspaceUseCase({
}
await requireCredentialGroupsAvailable(context.workspaceId)

const userId = requirePrincipalSubjectUserId(principal)
const userId = requireCredentialGroupWorkflowSubject(principal)
const inviter = await loadCredentialGroupInviterIdentity(userId)
const inviterName = inviter?.name?.trim() || inviter?.email
if (!inviterName) {
Expand Down
14 changes: 11 additions & 3 deletions apps/sim/lib/credential-groups/enrollments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,14 @@ async function getInvitationContext(

async function issueInvitation(
context: InvitationContext,
userId: string,
/**
* Who to record as the issuer, when there is someone. Attribution only — the
* authority to invite comes from the delegation, so an actorless run (a schedule,
* or a webhook with no external subject) issues an unattributed invitation rather
* than none. `created_by` is nullable and `on delete set null`, so a row with no
* issuer is a shape the schema already carries.
*/
userId: string | undefined,
email: string,
options: SendInvitationOptions
): Promise<IssuedInvitation> {
Expand Down Expand Up @@ -387,7 +394,7 @@ async function issueInvitation(
completedAt: preservesProgress ? current.completedAt : null,
revokedAt: null,
lastDeliveryError: null,
createdBy: userId,
createdBy: userId ?? null,
updatedAt: now,
}
const [next] = current
Expand Down Expand Up @@ -664,7 +671,8 @@ export async function inviteCredentialGroupEnrollment(
export async function createCredentialGroupInvitationLink(
workspaceId: string,
groupId: string,
userId: string,
/** See {@link issueInvitation}: the issuer is attribution, never the authority. */
userId: string | undefined,
email: string
): Promise<CredentialGroupInvitationLink> {
const context = await getInvitationContext(workspaceId, groupId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function defineAuthorizedCredentialUseCase<
async authorizeResource({ principal, context }) {
const actor = await getCredentialActorContext(
context.credential.id,
// actorless-unsupported: credential access is decided per person; an actorless run has no credential grants
requirePrincipalSubjectUserId(principal)
)
if (
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/credentials/application/connection-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export async function resolveCredentialConnectionTarget(params: {
}

if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID')
// actorless-unsupported: reconnecting rebinds a person's own OAuth grant
const userId = requirePrincipalSubjectUserId(principal)
const targetCredentialId = credentialId
const credential = await getWorkspaceCredential({
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/lib/custom-tools/application/use-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ async function resolveAvailableToolContext(args: {
const workspace = await resolveWorkspaceContext(args.workspaceId)
const tool = await getCustomToolById({
toolId: args.toolId,
// actorless-unsupported: a custom tool is owned by one user; an actorless run has no library to look in
userId: requirePrincipalSubjectUserId(args.principal),
workspaceId: workspace.workspaceId,
})
Expand Down Expand Up @@ -129,6 +130,7 @@ export const listAvailableCustomToolsUseCase = defineAuthorizedWorkspaceUseCase(
authorizationOptions,
async execute({ principal, context }) {
const tools = await listCustomTools({
// actorless-unsupported: the listing is the acting user's own tool library, which an actorless run does not have
userId: requirePrincipalSubjectUserId(principal),
workspaceId: context.workspaceId,
})
Expand Down Expand Up @@ -353,6 +355,7 @@ export const updateAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase
const tool = await updateCustomTool({
workspaceId: context.workspaceId,
toolId: context.tool.id,
// actorless-unsupported: editing a tool is scoped to its owner; an actorless run owns none
userId: requirePrincipalSubjectUserId(principal),
title,
schema: input.schema ?? context.tool.schema,
Expand Down Expand Up @@ -422,6 +425,7 @@ export const deleteAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase
const deleted = await deleteCustomTool({
workspaceId: context.workspaceId,
toolId: context.tool.id,
// actorless-unsupported: deleting a tool is scoped to its owner; an actorless run owns none
userId: requirePrincipalSubjectUserId(principal),
})
if (!deleted) throw new OrchestrationError('not_found', 'Custom tool not found')
Expand Down
17 changes: 11 additions & 6 deletions apps/sim/lib/internal/deployments/execute-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { isPlainRecord } from '@sim/utils/object'
import type { ZodError, ZodType } from 'zod'
import { getValidationErrorMessage } from '@/lib/api/server'
import { concealCrossTenantResourceError } from '@/lib/api/server/routes'
import { InvalidInternalDelegationBindingError } from '@/lib/auth/internal-delegation'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import {
deploymentsDeployBodySchema,
Expand All @@ -20,6 +19,11 @@ import {
executeDeploymentsUndeploy,
} from '@/lib/internal/deployments/operations'
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
import {
classifyInternalToolIdentityFault,
internalToolIdentityFaultMessage,
internalToolIdentityFaultStatus,
} from '@/lib/internal/tool-operations/identity-faults'
import type {
InternalToolOperationCall,
InternalToolOperationHandler,
Expand Down Expand Up @@ -52,11 +56,12 @@ function parseInput<T>(schema: ZodType<T>, request: InternalToolOperationCall) {
}

function errorResponse(request: InternalToolOperationCall, error: unknown): Response {
if (
error instanceof InvalidInternalDelegationBindingError ||
(error instanceof Error && error.message === 'Authentication required')
) {
return Response.json({ success: false, error: 'Authentication required' }, { status: 401 })
const identityFault = classifyInternalToolIdentityFault(error)
if (identityFault) {
return Response.json(
{ success: false, error: internalToolIdentityFaultMessage(identityFault) },
{ status: internalToolIdentityFaultStatus(identityFault) }
)
}

const classified = asOrchestrationError(
Expand Down
24 changes: 12 additions & 12 deletions apps/sim/lib/internal/file/execute-tool.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import {
PrincipalSubjectUserRequiredError,
resolvePrincipalAttribution,
resolvePrincipalSubject,
} from '@sim/auth/principal'
import { resolvePrincipalAttribution, resolvePrincipalSubject } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { fileParseContract } from '@/lib/api/contracts/storage-transfer'
import { fileManageContract } from '@/lib/api/contracts/tools/file'
import { InvalidInternalDelegationBindingError } from '@/lib/auth/internal-delegation'
import { executeFileManageOperation } from '@/lib/internal/file/operations'
import { executeFileParserOperation } from '@/lib/internal/file/parser'
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
import {
classifyInternalToolIdentityFault,
internalToolIdentityFaultMessage,
internalToolIdentityFaultStatus,
} from '@/lib/internal/tool-operations/identity-faults'
import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input'
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
Expand Down Expand Up @@ -104,12 +104,12 @@ export const executeFileTool: InternalToolOperationHandler = async (request) =>
return response
} catch (error) {
request.signal?.throwIfAborted()
if (
error instanceof InvalidInternalDelegationBindingError ||
error instanceof PrincipalSubjectUserRequiredError ||
(error instanceof Error && error.message === 'Authentication required')
) {
return Response.json({ success: false, error: 'Authentication required' }, { status: 401 })
const identityFault = classifyInternalToolIdentityFault(error)
if (identityFault) {
return Response.json(
{ success: false, error: internalToolIdentityFaultMessage(identityFault) },
{ status: internalToolIdentityFaultStatus(identityFault) }
)
}
const message = getErrorMessage(error, 'Unknown error')
logger.error('File operation dispatch failed', {
Expand Down
17 changes: 11 additions & 6 deletions apps/sim/lib/internal/knowledge/execute-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
upsertKnowledgeDocumentContract,
} from '@/lib/api/contracts/knowledge'
import type { JsonErrorResponseDescriptor } from '@/lib/api/server/routes/types'
import { InvalidInternalDelegationBindingError } from '@/lib/auth/internal-delegation'
import {
createChunkOperation,
createDocumentsOperation,
Expand All @@ -37,6 +36,11 @@ import {
upsertDocumentOperation,
} from '@/lib/internal/knowledge/operations'
import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor'
import {
classifyInternalToolIdentityFault,
internalToolIdentityFaultMessage,
internalToolIdentityFaultStatus,
} from '@/lib/internal/tool-operations/identity-faults'
import {
parseInternalContractInput,
parseInternalOperationInput,
Expand Down Expand Up @@ -125,11 +129,12 @@ export const executeKnowledgeTool: InternalToolOperationHandler = async (request
audience: KNOWLEDGE_DELEGATION_AUDIENCE,
})
} catch (error) {
if (
error instanceof InvalidInternalDelegationBindingError ||
(error instanceof Error && error.message === 'Authentication required')
) {
return Response.json({ error: 'Authentication required' }, { status: 401 })
const identityFault = classifyInternalToolIdentityFault(error)
if (identityFault) {
return Response.json(
{ error: internalToolIdentityFaultMessage(identityFault) },
{ status: internalToolIdentityFaultStatus(identityFault) }
)
}
throw error
}
Expand Down
Loading
Loading