From afd36e030b5b40e4a1ba0171c9e73fecf9118274 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 8 Oct 2026 19:33:05 -0700 Subject: [PATCH 1/2] fix(invitations): let Chat invite teammates through the workspace invitations command The public `workspaces invitations create` command bound a use case with no delegated admission, so every Chat call was refused before authorization even though Chat could already send the same invitation through workspace settings. The v2 route now binds the workspace invitation use case the settings tool already uses. Direct callers keep their checks, and a Chat caller passes the same workspace admin role, permission-group and invite-policy checks as the user it acts for. --- apps/docs/openapi-v2-resources.json | 2 +- .../[workspaceId]/invitations/route.test.ts | 5 +- .../[workspaceId]/invitations/route.ts | 8 +- .../v2/openapi/workspace-invitations.ts | 4 +- .../routes/copilot-route-inventory.test.ts | 6 - ...pilot-workspace-invitations.integration.ts | 171 ++++++++++++ .../lib/invitations/application/operations.ts | 16 ++ .../application/send-invitation-batch.test.ts | 252 ++++++++++-------- .../application/send-invitation-batch.ts | 18 +- apps/sim/lib/settings/sections.ts | 7 +- scripts/check-unused-exports.baseline.json | 1 - 11 files changed, 347 insertions(+), 143 deletions(-) create mode 100644 apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts 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..ebce3e55a7b --- /dev/null +++ b/apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts @@ -0,0 +1,171 @@ +/** Chat's in-process CLI inviting teammates through the public workspace invitation command. */ + +import { db } from '@sim/db' +import { + 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 } 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(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' }]) + }) + + 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..abdd989138c 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) + 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.each([null, 'write', 'read'])( - 'refuses workspace role %s before invitation preparation', - async (role) => { - mocks.workspaceRole.mockResolvedValue(role) + 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(mocks.workspaceSend).toHaveBeenCalledTimes(1) + expect(mocks.config).toHaveBeenLastCalledWith('admin-user', 'workspace', 'org-target', db) }) - 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) - }) - 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,23 @@ describe('workspace invitation delegation', () => { } ) + it('invites as 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'] }) + expect(mocks.workspaceContext).toHaveBeenCalledWith( + expect.objectContaining({ inviterId: 'admin-user', workspaceIds: ['workspace'] }) + ) + }) + + 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..3b88eb69eb2 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.ts @@ -21,7 +21,6 @@ import { authorizeWorkspaceOperation, requireAllowedWorkspacePrincipal, } from '@/lib/core/application/workspace-authorization' -import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' import { OrchestrationError, type OrchestrationRequestContext, @@ -29,6 +28,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' @@ -307,18 +307,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, 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 Date: Thu, 8 Oct 2026 20:26:03 -0700 Subject: [PATCH 2/2] fix(invitations): record the invoked operation in invitation audit metadata --- ...copilot-workspace-invitations.integration.ts | 17 ++++++++++++++++- .../application/send-invitation-batch.test.ts | 5 +---- .../application/send-invitation-batch.ts | 11 +++++++---- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts b/apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts index ebce3e55a7b..86aa13e33db 100644 --- a/apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts +++ b/apps/sim/lib/invitations/__integration__/copilot-workspace-invitations.integration.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { + auditLog, invitation, invitationWorkspaceGrant, member, @@ -12,7 +13,7 @@ import { } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { and, eq, inArray } from 'drizzle-orm' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +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' @@ -129,6 +130,7 @@ describe('chat-delegated workspace invitations', () => { 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])) @@ -144,6 +146,19 @@ describe('chat-delegated workspace invitations', () => { 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 () => { 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 abdd989138c..b05e2ae86c4 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.test.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.test.ts @@ -431,12 +431,9 @@ describe('workspace invitation delegation', () => { } ) - it('invites as the delegating user while they hold current admin authority', async () => { + 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'] }) - expect(mocks.workspaceContext).toHaveBeenCalledWith( - expect.objectContaining({ inviterId: 'admin-user', workspaceIds: ['workspace'] }) - ) }) it('refuses an admin whose permission group disables invitations', async () => { diff --git a/apps/sim/lib/invitations/application/send-invitation-batch.ts b/apps/sim/lib/invitations/application/send-invitation-batch.ts index 3b88eb69eb2..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, @@ -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, }, }, } @@ -333,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({ @@ -361,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) }, }