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
11 changes: 9 additions & 2 deletions apps/sim/app/credential-groups/enroll/[token]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,15 @@ export default async function CredentialGroupEnrollmentPage({
Connect your accounts
</h1>
<p className='mt-4 max-w-[560px] text-pretty text-[var(--text-muted)] text-base leading-relaxed'>
<span className='font-medium text-[var(--text-body)]'>{enrollment.inviterName}</span>{' '}
invited you to connect accounts for{' '}
{enrollment.inviterName ? (
<>
<span className='font-medium text-[var(--text-body)]'>{enrollment.inviterName}</span>{' '}
invited you
</>
) : (
'You have been invited'
)}{' '}
to connect accounts for{' '}
<span className='font-medium text-[var(--text-body)]'>{enrollment.workspaceName}</span>.
</p>
</header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { getBrandConfig } from '@/ee/whitelabeling'

interface CredentialGroupInvitationEmailProps {
recipientEmail: string
inviterName: string
/** Absent when a workflow issued the invitation: there is no person to name. */
inviterName?: string
workspaceName: string
credentialGroupName: string
invitationLink: string
Expand All @@ -22,14 +23,26 @@ export function CredentialGroupInvitationEmail({

return (
<EmailLayout
preview={`${inviterName} invited you to connect accounts for ${workspaceName}`}
preview={
inviterName
? `${inviterName} invited you to connect accounts for ${workspaceName}`
: `You have been invited to connect accounts for ${workspaceName}`
}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited <strong>{recipientEmail}</strong> to connect accounts
for <strong>{credentialGroupName}</strong> in the <strong>{workspaceName}</strong> workspace
on {brand.name}.
{inviterName ? (
<>
<strong>{inviterName}</strong> invited <strong>{recipientEmail}</strong>
</>
) : (
<>
<strong>{recipientEmail}</strong> has been invited
</>
)}{' '}
to connect accounts for <strong>{credentialGroupName}</strong> in the{' '}
<strong>{workspaceName}</strong> workspace on {brand.name}.
</Text>

<Link href={invitationLink} style={{ textDecoration: 'none' }}>
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/components/emails/credential-groups/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CredentialGroupInvitationEmail } from '@/components/emails/credential-g

export async function renderCredentialGroupInvitationEmail(params: {
recipientEmail: string
inviterName: string
inviterName?: string
workspaceName: string
credentialGroupName: string
invitationLink: string
Expand Down
12 changes: 9 additions & 3 deletions apps/sim/components/emails/subjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,16 @@ export function getOtpSubject(resourceLabel: string): string {
return `Verification code for ${resourceLabel}`
}

/** Names both the inviter and workspace so an external recipient can identify the request. */
/**
* Names the workspace so an external recipient can identify the request, and the
* inviter when there is one — a workflow-issued invitation has no person to name.
*/
export function getCredentialGroupInvitationSubject(
inviterName: string,
inviterName: string | undefined,
workspaceName: string
): string {
return `${inviterName} invited you to connect accounts for ${workspaceName} on ${getBrandConfig().name}`
const brandName = getBrandConfig().name
return inviterName
? `${inviterName} invited you to connect accounts for ${workspaceName} on ${brandName}`
: `You have been invited to connect accounts for ${workspaceName} on ${brandName}`
}
62 changes: 61 additions & 1 deletion apps/sim/lib/credential-groups/application/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ vi.mock('@/lib/resource-policies/repository', () => ({
requireResourcePolicy: mocks.requirePolicy,
}))

import { requireCredentialGroupCredentialAccess } from '@/lib/credential-groups/application/authorization'
import {
requireCredentialGroupCredentialAccess,
requireCredentialGroupWorkflowActor,
} from '@/lib/credential-groups/application/authorization'

const context = {
workspaceId: 'workspace-1',
Expand Down Expand Up @@ -216,3 +219,60 @@ describe('requireCredentialGroupCredentialAccess', () => {
expect(mocks.loadEnrollmentAccess).not.toHaveBeenCalled()
})
})

describe('requireCredentialGroupWorkflowActor', () => {
it('returns the external subject a Slack-triggered run acts as', () => {
expect(requireCredentialGroupWorkflowActor(executorPrincipal())).toEqual({
kind: 'external_user',
provider: 'slack',
tenantId: 'T123',
subjectId: 'U123',
})
})

it('returns no subject for an actorless deployed run', () => {
const principal = executorPrincipal()
principal.delegationContext!.principal = {
kind: 'system',
serviceId: 'schedule',
workspaceId: 'workspace-1',
workflowId: 'root-workflow',
}

expect(requireCredentialGroupWorkflowActor(principal)).toBeNull()
})

it('returns the Sim subject a session-actor run acts as', () => {
const principal = executorPrincipal()
principal.subjectUserId = 'user-1'
principal.delegationContext!.principal = {
kind: 'session',
userId: 'user-1',
sessionId: 'session-1',
}

expect(requireCredentialGroupWorkflowActor(principal)).toEqual({
kind: 'sim_user',
userId: 'user-1',
})
})

it('rejects a delegation whose asserted subject contradicts its run', () => {
const invented = executorPrincipal()
invented.subjectUserId = 'invented-user'
expect(() => requireCredentialGroupWorkflowActor(invented)).toThrow(
'Credential Group actor access required'
)

const mismatched = executorPrincipal()
mismatched.subjectUserId = 'user-2'
mismatched.delegationContext!.principal = {
kind: 'session',
userId: 'user-1',
sessionId: 'session-1',
}
expect(() => requireCredentialGroupWorkflowActor(mismatched)).toThrow(
'Credential Group actor access required'
)
})
})
24 changes: 14 additions & 10 deletions apps/sim/lib/credential-groups/application/authorization.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type Principal,
type PrincipalSubject,
resolvePrincipalSubject,
type WorkflowExecutionAuthority,
type WorkflowExecutionPrincipal,
Expand Down Expand Up @@ -67,16 +68,19 @@ function requireConsistentWorkflowSubject(
return subject
}

export function requireCredentialGroupWorkflowSubject(principal: Principal): string {
const subject = resolvePrincipalSubject(requireWorkflowExecutionPrincipal(principal))
if (
subject?.kind !== 'sim_user' ||
principal.kind !== 'delegated' ||
principal.subjectUserId !== subject.userId
) {
throw new OrchestrationError('forbidden', 'Credential Group user access required')
}
return subject.userId
/**
* Asserts the delegation still names the subject its run was minted for, without
* requiring that subject to be a Sim user.
*
* A Slack-triggered run's subject is the external Slack user, and a scheduled,
* public-API, or subject-less webhook run has no subject at all. Neither is
* representable as a Sim user, and neither is what authorizes the call — for an
* actorless caller that is the deployment the workspace layer already checked.
* Whoever the run acts as is attribution only; an invitation issued with no Sim
* user simply records none.
*/
export function requireCredentialGroupWorkflowActor(principal: Principal): PrincipalSubject | null {
return requireConsistentWorkflowSubject(principal, requireWorkflowExecutionPrincipal(principal))
}

export async function requireCredentialGroupCredentialAccess(
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/credential-groups/application/list-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
credentialGroupWorkspaceDelegationPolicy,
requireCredentialGroupWorkflowSubject,
requireCredentialGroupWorkflowActor,
} from '@/lib/credential-groups/application/authorization'
import {
requireCredentialGroupsAvailable,
Expand Down Expand Up @@ -35,7 +35,7 @@ export const listCredentialGroupsForWorkflow = defineAuthorizedWorkspaceUseCase(
resolveCredentialGroupWorkspaceContext(input.workspaceId),
authorizationOptions: { delegation: credentialGroupWorkspaceDelegationPolicy },
authorizeResource({ principal }) {
requireCredentialGroupWorkflowSubject(principal)
requireCredentialGroupWorkflowActor(principal)
},
execute: async ({ input, context }): Promise<ListCredentialGroupsResult> => {
if (
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/credential-groups/application/list-people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
credentialGroupDelegationPolicy,
requireCredentialGroupWorkflowSubject,
requireCredentialGroupWorkflowActor,
} from '@/lib/credential-groups/application/authorization'
import {
requireCredentialGroupsAvailable,
Expand Down Expand Up @@ -38,7 +38,7 @@ export const listCredentialGroupPeople = defineAuthorizedWorkspaceUseCase({
resolveCredentialGroupContext(input.credentialGroupId),
authorizationOptions: { delegation: credentialGroupDelegationPolicy },
authorizeResource({ principal }) {
requireCredentialGroupWorkflowSubject(principal)
requireCredentialGroupWorkflowActor(principal)
},
execute: async ({ input, context }) => {
if (context.status !== 'active') {
Expand Down
Loading
Loading