diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 8b4410acdbc..798ae4d6e4e 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -8359,7 +8359,7 @@ "operationId": "createWorkspaceInvitations", "summary": "Create Workspace Invitations", "description": "Invite people to a workspace or grant access immediately to existing organization members. Requires workspace administrator access and current invitation eligibility; organization administrator invitations also require organization administrator access. Recipients are processed independently: inspect failed even after HTTP 200, and inspect invitation status before retrying a delivery failure. Existing access is preserved. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "invitations.send_batch", + "x-sim-operation": "workspace_invitations.send_batch", "x-oauth-scope": "api:write", "tags": ["Workspaces"], "parameters": [ diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.test.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.test.ts index f0b60513f2c..55298f74ee7 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.test.ts @@ -12,7 +12,10 @@ const mocks = vi.hoisted(() => ({ send: vi.fn() })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) vi.mock('@/lib/invitations/application/send-invitation-batch', () => ({ - sendInvitationBatch: { operation: { id: 'invitations.send_batch' }, execute: mocks.send }, + sendWorkspaceInvitationBatch: { + operation: { id: 'workspace_invitations.send_batch' }, + execute: mocks.send, + }, })) import { NoWorkspaceAccessError } from '@/lib/core/application' diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.ts index a08ab25b0e2..a9ba0696b83 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.ts @@ -6,12 +6,12 @@ import { v2RateLimits, } from '@/lib/api/server/routes' import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations' -import { invitationOperations } from '@/lib/invitations/application/operations' -import { sendInvitationBatch } from '@/lib/invitations/application/send-invitation-batch' +import { workspaceInvitationOperations } from '@/lib/invitations/application/operations' +import { sendWorkspaceInvitationBatch } from '@/lib/invitations/application/send-invitation-batch' export const POST = defineV2JsonRoute({ contract: v2CreateWorkspaceInvitationsContract, - operation: invitationOperations.sendBatch, + operation: workspaceInvitationOperations.sendBatch, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: createV2ResourceConcealmentPolicy({ @@ -19,6 +19,6 @@ export const POST = defineV2JsonRoute({ render: v2OrganizationErrorPolicy.render, }), mapInput: ({ params, body }) => ({ ...body, workspaceIds: [params.workspaceId] }), - useCase: sendInvitationBatch, + useCase: sendWorkspaceInvitationBatch, present: (result) => ({ data: result }), }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/workspace-invitations.ts b/apps/sim/lib/api/contracts/v2/openapi/workspace-invitations.ts index d1bef02d0fc..1b7abdaa7df 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workspace-invitations.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workspace-invitations.ts @@ -6,13 +6,13 @@ import { } from '@/lib/api/contracts/v2/openapi/shared' import { v2CreateWorkspaceInvitationsContract } from '@/lib/api/contracts/v2/workspace-invitations' import { defineOpenApiRoute } from '@/lib/api/openapi/types' -import { invitationOperations } from '@/lib/invitations/application/operations' +import { workspaceInvitationOperations } from '@/lib/invitations/application/operations' export const workspaceInvitationOpenApiRoutes = [ defineOpenApiRoute( v2CreateWorkspaceInvitationsContract, { - applicationOperation: invitationOperations.sendBatch, + applicationOperation: workspaceInvitationOperations.sendBatch, operationId: 'createWorkspaceInvitations', summary: 'Create Workspace Invitations', description: `Invite people to a workspace or grant access immediately to existing organization members. Requires workspace administrator access and current invitation eligibility; organization administrator invitations also require organization administrator access. Recipients are processed independently: inspect failed even after HTTP 200, and inspect invitation status before retrying a delivery failure. Existing access is preserved. ${WORKSPACE_API_KEY_DENIED}`, diff --git a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts index 1f3a241a10c..1818f668fa4 100644 --- a/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts +++ b/apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts @@ -388,12 +388,6 @@ it('inventories private operation admission without executing route requests', a "operation": "access_requests.discover", "path": "/api/v2/workspaces/[workspaceId]/access-requests/discovery", }, - { - "audience": null, - "method": "POST", - "operation": "invitations.send_batch", - "path": "/api/v2/workspaces/[workspaceId]/invitations", - }, { "audience": null, "method": "GET", diff --git a/apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts b/apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts new file mode 100644 index 00000000000..86aa13e33db --- /dev/null +++ b/apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts @@ -0,0 +1,186 @@ +/** Chat's in-process CLI inviting teammates through the public workspace invitation command. */ + +import { db } from '@sim/db' +import { + auditLog, + invitation, + invitationWorkspaceGrant, + member, + organization, + permissions, + user, + workspace, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' +import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' +import '@/app/api/v2/workspaces/[workspaceId]/invitations/route' + +const ORIGIN = 'http://localhost:3000' +const organizationId = generateId() +const workspaceId = generateId() +const adminId = generateId() +const writerId = generateId() +const pendingInvitationId = generateId() +const fixtureDomain = `${generateId()}.invite.test` +const newInvitee = `new@${fixtureDomain}` +const pendingInvitee = `pending@${fixtureDomain}` +const writerInvitee = `writer-target@${fixtureDomain}` + +function inviteFromChat(userId: string, emails: string[]) { + const transport = createScopedCliTransport(ORIGIN, { userId, workspaceId, chatId: generateId() }) + return withWorkspaceInvocationScope({ workspaceId, organizationId }, () => + transport(`${ORIGIN}/api/v2/workspaces/${workspaceId}/invitations`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ emails, permission: 'write', membership: 'member' }), + }) + ) +} + +function pendingInvitationsFor(email: string) { + return db + .select({ id: invitation.id, inviterId: invitation.inviterId }) + .from(invitation) + .where(and(eq(invitation.email, email), eq(invitation.status, 'pending'))) +} + +function grantsOf(invitationId: string) { + return db + .select({ + workspaceId: invitationWorkspaceGrant.workspaceId, + permission: invitationWorkspaceGrant.permission, + }) + .from(invitationWorkspaceGrant) + .where(eq(invitationWorkspaceGrant.invitationId, invitationId)) +} + +describe('chat-delegated workspace invitations', () => { + beforeAll(async () => { + const now = new Date() + await db.insert(user).values( + [adminId, writerId].map((id) => ({ + id, + name: 'Invitation fixture', + email: `${id}@${fixtureDomain}`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Invitation fixture', + slug: organizationId, + createdAt: now, + updatedAt: now, + }) + await db.insert(member).values( + [adminId, writerId].map((userId) => ({ + id: generateId(), + userId, + organizationId, + role: 'member', + createdAt: now, + })) + ) + await db.insert(workspace).values({ + id: workspaceId, + name: 'Invitation fixture', + ownerId: adminId, + billedAccountUserId: adminId, + organizationId, + workspaceMode: 'organization', + }) + await db.insert(permissions).values([ + { + id: generateId(), + userId: adminId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }, + { + id: generateId(), + userId: writerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'write', + }, + ]) + await db.insert(invitation).values({ + id: pendingInvitationId, + kind: 'organization', + email: pendingInvitee, + inviterId: adminId, + organizationId, + membershipIntent: 'internal', + role: 'member', + status: 'pending', + token: generateId(), + expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), + createdAt: now, + updatedAt: now, + }) + }) + + afterAll(async () => { + await db + .delete(invitation) + .where(inArray(invitation.email, [newInvitee, pendingInvitee, writerInvitee])) + await db.delete(auditLog).where(eq(auditLog.workspaceId, workspaceId)) + await db.delete(workspace).where(eq(workspace.id, workspaceId)) + await db.delete(organization).where(eq(organization.id, organizationId)) + await db.delete(user).where(inArray(user.id, [adminId, writerId])) + }) + + it('lets a workspace admin invite a new teammate', async () => { + const response = await inviteFromChat(adminId, [newInvitee]) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: { success: true, successful: [newInvitee], failed: [] }, + }) + const [created] = await pendingInvitationsFor(newInvitee) + expect(created.inviterId).toBe(adminId) + expect(await grantsOf(created.id)).toEqual([{ workspaceId, permission: 'write' }]) + await vi.waitFor(async () => { + const [audit] = await db + .select({ actorId: auditLog.actorId, metadata: auditLog.metadata }) + .from(auditLog) + .where(and(eq(auditLog.workspaceId, workspaceId), eq(auditLog.resourceName, newInvitee))) + expect(audit).toMatchObject({ + actorId: adminId, + metadata: { + operation: 'workspace_invitations.send_batch', + actor: { kind: 'delegated', serviceId: 'copilot' }, + }, + }) + }) + }) + + it('adds the workspace to a pending organization invitation instead of creating another', async () => { + const response = await inviteFromChat(adminId, [pendingInvitee]) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + data: { success: true, successful: [pendingInvitee], failed: [] }, + }) + expect(await pendingInvitationsFor(pendingInvitee)).toEqual([ + { id: pendingInvitationId, inviterId: adminId }, + ]) + expect(await grantsOf(pendingInvitationId)).toEqual([{ workspaceId, permission: 'write' }]) + }) + + it('refuses a member without workspace admin rights', async () => { + const response = await inviteFromChat(writerId, [writerInvitee]) + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' } }, + }) + expect(await pendingInvitationsFor(writerInvitee)).toEqual([]) + }) +}) diff --git a/apps/sim/lib/invitations/application/operations.ts b/apps/sim/lib/invitations/application/operations.ts index a1a0d9ad2f9..435e6474ba4 100644 --- a/apps/sim/lib/invitations/application/operations.ts +++ b/apps/sim/lib/invitations/application/operations.ts @@ -50,3 +50,19 @@ export const invitationAuthorityOperations = { oauthScope: 'api:write', }), } as const + +/** + * One workspace's invitations. Direct callers and a Chat caller delegated by the + * same user share it, so both pass the same role and capability checks. + */ +export const workspaceInvitationOperations = { + sendBatch: defineWorkspaceOperation({ + id: 'workspace_invitations.send_batch', + minimumRole: 'admin', + capability: 'invitations.send', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], + delegatedServices: ['copilot'], + oauthScope: 'api:write', + }), +} as const diff --git a/apps/sim/lib/invitations/application/send-invitation-batch.test.ts b/apps/sim/lib/invitations/application/send-invitation-batch.test.ts index 83ee638b2a8..b05e2ae86c4 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.test.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.test.ts @@ -161,151 +161,156 @@ describe('invitation batch application boundary', () => { ).toBe(true) }) - it('rejects workspace API keys before any user or scope lookup', async () => { - await expect( - sendInvitationBatch.execute({ - principal: { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }, - input: orgInput, - }) - ).rejects.toMatchObject({ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) - expect(dbChainMockFns.select).not.toHaveBeenCalled() - expect(mocks.orgContext).not.toHaveBeenCalled() - }) + describe.each([ + ['the mixed organization and workspace batch', sendInvitationBatch], + ['the workspace batch shared with Chat', sendWorkspaceInvitationBatch], + ] as const)('direct credentials on %s', (_name, useCase) => { + it('rejects workspace API keys before any user or scope lookup', async () => { + await expect( + useCase.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace', keyId: 'key' }, + input: orgInput, + }) + ).rejects.toMatchObject({ detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(mocks.orgContext).not.toHaveBeenCalled() + }) - it('rejects read-only OAuth before any protected lookup', async () => { - await expect( - sendInvitationBatch.execute({ - principal: { ...oauth, scopes: ['api:read'] }, - input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, - }) - ).rejects.toMatchObject({ detailCode: 'INSUFFICIENT_SCOPE' }) - expect(mocks.canonicalWorkspace).not.toHaveBeenCalled() - expect(dbChainMockFns.select).not.toHaveBeenCalled() - }) + it('rejects read-only OAuth before any protected lookup', async () => { + await expect( + useCase.execute({ + principal: { ...oauth, scopes: ['api:read'] }, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + ).rejects.toMatchObject({ detailCode: 'INSUFFICIENT_SCOPE' }) + expect(mocks.canonicalWorkspace).not.toHaveBeenCalled() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) - it.each([null, 'write', 'read'])( - 'refuses workspace role %s before invitation preparation', - async (role) => { - mocks.workspaceRole.mockResolvedValue(role) + it.each([null, 'write', 'read'])( + 'refuses workspace role %s before invitation preparation', + async (role) => { + mocks.workspaceRole.mockResolvedValue(role) + await expect( + useCase.execute({ + principal: personal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + ).rejects.toThrow() + expect(mocks.workspaceContext).not.toHaveBeenCalled() + expect(mocks.workspaceSend).not.toHaveBeenCalled() + } + ) + + it('refuses a workspace that disabled personal keys before invitation preparation', async () => { + mocks.canonicalWorkspace.mockResolvedValue({ + ...canonicalWorkspace, + allowPersonalApiKeys: false, + }) await expect( - sendInvitationBatch.execute({ + useCase.execute({ principal: personal, input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, }) - ).rejects.toThrow() + ).rejects.toMatchObject({ detailCode: 'PERSONAL_API_KEYS_DISABLED' }) expect(mocks.workspaceContext).not.toHaveBeenCalled() - expect(mocks.workspaceSend).not.toHaveBeenCalled() - } - ) - - it('refuses a workspace that disabled personal keys before invitation preparation', async () => { - mocks.canonicalWorkspace.mockResolvedValue({ - ...canonicalWorkspace, - allowPersonalApiKeys: false, }) - await expect( - sendInvitationBatch.execute({ + + it('stops later recipients when permission-group invitation access is withdrawn', async () => { + mocks.workspaceSend.mockImplementationOnce(async () => { + mocks.config.mockResolvedValue({ + ...DEFAULT_PERMISSION_GROUP_CONFIG, + disableInvitations: true, + }) + return { ...invitation, workspaceIds: ['workspace'] } + }) + const result = await useCase.execute({ principal: personal, - input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + input: { workspaceIds: ['workspace'], emails: ['person@example.com', 'other@example.com'] }, }) - ).rejects.toMatchObject({ detailCode: 'PERSONAL_API_KEYS_DISABLED' }) - expect(mocks.workspaceContext).not.toHaveBeenCalled() - }) - - it('stops later recipients when permission-group invitation access is withdrawn', async () => { - mocks.workspaceSend.mockImplementationOnce(async () => { - mocks.config.mockResolvedValue({ - ...DEFAULT_PERMISSION_GROUP_CONFIG, - disableInvitations: true, + expect(result).toMatchObject({ + success: false, + successful: ['person@example.com'], + failed: [{ email: 'other@example.com', error: expect.stringMatching(/invitation/i) }], }) - return { ...invitation, workspaceIds: ['workspace'] } - }) - const result = await sendInvitationBatch.execute({ - principal: personal, - input: { workspaceIds: ['workspace'], emails: ['person@example.com', 'other@example.com'] }, - }) - expect(result).toMatchObject({ - success: false, - successful: ['person@example.com'], - failed: [{ email: 'other@example.com', error: expect.stringMatching(/invitation/i) }], + expect(mocks.workspaceSend).toHaveBeenCalledTimes(1) + expect(mocks.config).toHaveBeenLastCalledWith('admin-user', 'workspace', 'org-target', db) }) - expect(mocks.workspaceSend).toHaveBeenCalledTimes(1) - expect(mocks.config).toHaveBeenLastCalledWith('admin-user', 'workspace', 'org-target', db) - }) - it.each([ - [personal, { disableInvitations: true }], - [oauth, { disableInvitations: true }], - [personal, { disablePersonalApiKeys: true }], - [oauth, { disableOAuthAppAccess: true }], - [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }], - ] as const)( - 'rechecks $0.kind admission using the locked transaction after allowed preflight', - async (principal, restriction) => { + it.each([ + [personal, { disableInvitations: true }], + [oauth, { disableInvitations: true }], + [personal, { disablePersonalApiKeys: true }], + [oauth, { disableOAuthAppAccess: true }], + [{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }], + ] as const)( + 'rechecks $0.kind admission using the locked transaction after allowed preflight', + async (principal, restriction) => { + const tx = new Proxy(db, {}) + const write = vi.fn() + mocks.workspaceSend.mockImplementationOnce( + async (input: Parameters[0]) => { + mocks.config.mockImplementation(async (_user, _workspace, _organization, executor) => + executor === tx ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...restriction } : null + ) + expect(input.validateLockedWorkspace).toBeTypeOf('function') + await input.validateLockedWorkspace?.(tx, lockedWorkspace) + write() + return invitation + } + ) + const result = await useCase.execute({ + principal, + input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, + }) + expect(result).toMatchObject({ + success: false, + successful: [], + added: [], + invitations: [], + failed: [{ email: 'person@example.com', error: expect.any(String) }], + }) + expect(write).not.toHaveBeenCalled() + expect(mocks.workspaceRole).toHaveBeenLastCalledWith( + 'admin-user', + 'workspace', + 'org-target', + tx, + { forUpdate: true } + ) + expect(mocks.config).toHaveBeenLastCalledWith('admin-user', 'workspace', 'org-target', tx) + } + ) + + it('refuses a plan disabled after preflight using the locked billing context', async () => { const tx = new Proxy(db, {}) const write = vi.fn() + mocks.invitePolicy.mockResolvedValueOnce({ + allowed: false, + requiresSeat: false, + reason: 'Upgrade to invite teammates', + organizationId: 'org-target', + upgradeRequired: true, + }) mocks.workspaceSend.mockImplementationOnce( async (input: Parameters[0]) => { - mocks.config.mockImplementation(async (_user, _workspace, _organization, executor) => - executor === tx ? { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...restriction } : null - ) expect(input.validateLockedWorkspace).toBeTypeOf('function') await input.validateLockedWorkspace?.(tx, lockedWorkspace) write() return invitation } ) - const result = await sendInvitationBatch.execute({ - principal, + const result = await useCase.execute({ + principal: personal, input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, }) expect(result).toMatchObject({ success: false, - successful: [], - added: [], - invitations: [], - failed: [{ email: 'person@example.com', error: expect.any(String) }], + failed: [{ email: 'person@example.com', error: 'Upgrade to invite teammates' }], }) + expect(mocks.invitePolicy).toHaveBeenCalledExactlyOnceWith(lockedWorkspace, tx) expect(write).not.toHaveBeenCalled() - expect(mocks.workspaceRole).toHaveBeenLastCalledWith( - 'admin-user', - 'workspace', - 'org-target', - tx, - { forUpdate: true } - ) - expect(mocks.config).toHaveBeenLastCalledWith('admin-user', 'workspace', 'org-target', tx) - } - ) - - it('refuses a plan disabled after preflight using the locked billing context', async () => { - const tx = new Proxy(db, {}) - const write = vi.fn() - mocks.invitePolicy.mockResolvedValueOnce({ - allowed: false, - requiresSeat: false, - reason: 'Upgrade to invite teammates', - organizationId: 'org-target', - upgradeRequired: true, - }) - mocks.workspaceSend.mockImplementationOnce( - async (input: Parameters[0]) => { - expect(input.validateLockedWorkspace).toBeTypeOf('function') - await input.validateLockedWorkspace?.(tx, lockedWorkspace) - write() - return invitation - } - ) - const result = await sendInvitationBatch.execute({ - principal: personal, - input: { workspaceIds: ['workspace'], emails: ['person@example.com'] }, }) - expect(result).toMatchObject({ - success: false, - failed: [{ email: 'person@example.com', error: 'Upgrade to invite teammates' }], - }) - expect(mocks.invitePolicy).toHaveBeenCalledExactlyOnceWith(lockedWorkspace, tx) - expect(write).not.toHaveBeenCalled() }) it('deduplicates normalized addresses and preserves actionable per-email refusals', async () => { @@ -426,6 +431,20 @@ describe('workspace invitation delegation', () => { } ) + it('admits the delegating user while they hold current admin authority', async () => { + const result = await sendWorkspaceInvitationBatch.execute({ principal: actor, input }) + expect(result).toMatchObject({ success: true, successful: ['person@example.com'] }) + }) + + it('refuses an admin whose permission group disables invitations', async () => { + mocks.config.mockResolvedValue({ ...DEFAULT_PERMISSION_GROUP_CONFIG, disableInvitations: true }) + await expect( + sendWorkspaceInvitationBatch.execute({ principal: actor, input }) + ).rejects.toThrow() + expect(mocks.workspaceContext).not.toHaveBeenCalled() + expect(mocks.workspaceSend).not.toHaveBeenCalled() + }) + it('rechecks current admin authority before delivery', async () => { mocks.workspaceRole.mockResolvedValue('write') await expect( diff --git a/apps/sim/lib/invitations/application/send-invitation-batch.ts b/apps/sim/lib/invitations/application/send-invitation-batch.ts index b43d9846edd..fde30ee9262 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.ts @@ -10,6 +10,7 @@ import { createLogger } from '@sim/logger' import { normalizeEmail } from '@sim/utils/string' import { eq } from 'drizzle-orm' import { + type ApplicationOperation, assertOperationPrincipal, defineAuthorizedWorkspaceUseCase, ForbiddenOperationError, @@ -21,7 +22,6 @@ import { authorizeWorkspaceOperation, requireAllowedWorkspacePrincipal, } from '@/lib/core/application/workspace-authorization' -import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' import { OrchestrationError, type OrchestrationRequestContext, @@ -29,6 +29,7 @@ import { import { invitationAuthorityOperations, invitationOperations, + workspaceInvitationOperations, } from '@/lib/invitations/application/operations' import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' import { prepareOrganizationInvitationContext } from '@/lib/invitations/organization-invitations' @@ -78,13 +79,14 @@ export const sendInvitationBatch: OperationUseCase< async execute({ principal, input, request }) { requireAllowedWorkspacePrincipal(principal, invitationAuthorityOperations.workspace) assertOperationPrincipal(principal, invitationOperations.sendBatch) - return executeInvitationBatch(principal, input, request) + return executeInvitationBatch(principal, input, invitationOperations.sendBatch, request) }, } async function executeInvitationBatch( principal: Principal, input: SendInvitationBatchInput, + operation: Pick, request?: OrchestrationRequestContext ): Promise { const actorId = requirePrincipalSubjectUserId(principal) @@ -151,7 +153,7 @@ async function executeInvitationBatch( email: inviter.email, metadata: { actor: toPrincipalActor(principal), - operation: invitationOperations.sendBatch.id, + operation: operation.id, }, }, } @@ -307,18 +309,12 @@ async function executeInvitationBatch( return result } -export const workspaceInvitationSendOperation = defineWorkspaceOperation({ - id: 'workspace_invitations.send_batch', - minimumRole: 'admin', - capability: 'invitations.send', - workspaceApiKey: 'deny', - principalKinds: ['session', 'delegated'], - delegatedServices: ['copilot'], -}) - -/** Workspace batches retain per-target admission checks and per-email outcomes. */ +/** + * One workspace's batch, shared by the public v2 command and Chat. Every target + * keeps its admission checks and every email its own outcome. + */ export const sendWorkspaceInvitationBatch = defineAuthorizedWorkspaceUseCase({ - operation: workspaceInvitationSendOperation, + operation: workspaceInvitationOperations.sendBatch, resolveContext: ({ principal, input, @@ -339,7 +335,8 @@ export const sendWorkspaceInvitationBatch = defineAuthorizedWorkspaceUseCase({ return resolveActiveWorkspaceApplicationContext(input.workspaceIds[0]) }, authorizationOptions: { delegation: { audience: 'sim:settings', isWithinScope: () => true } }, - execute: ({ principal, input, request }) => executeInvitationBatch(principal, input, request), + execute: ({ principal, input, request }) => + executeInvitationBatch(principal, input, workspaceInvitationOperations.sendBatch, request), }) const organizationInvitationSendOperation = defineOrganizationOperation({ @@ -367,6 +364,6 @@ export const sendOrganizationInvitationBatch: OperationUseCase< await authorizeOrganizationOperation(principal, organizationInvitationSendOperation, { organizationId: input.organizationId, }) - return executeInvitationBatch(principal, input, request) + return executeInvitationBatch(principal, input, organizationInvitationSendOperation, request) }, } diff --git a/apps/sim/lib/settings/sections.ts b/apps/sim/lib/settings/sections.ts index bdb43534a6a..3cade5c6717 100644 --- a/apps/sim/lib/settings/sections.ts +++ b/apps/sim/lib/settings/sections.ts @@ -100,7 +100,12 @@ export const settingsSections: Record