Skip to content

Commit 0488311

Browse files
authored
fix(invitations): let Chat invite teammates through the workspace invitations command (#8834)
* 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. * fix(invitations): record the invoked operation in invitation audit metadata
1 parent dc39947 commit 0488311

11 files changed

Lines changed: 366 additions & 147 deletions

File tree

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8359,7 +8359,7 @@
83598359
"operationId": "createWorkspaceInvitations",
83608360
"summary": "Create Workspace Invitations",
83618361
"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`.",
8362-
"x-sim-operation": "invitations.send_batch",
8362+
"x-sim-operation": "workspace_invitations.send_batch",
83638363
"x-oauth-scope": "api:write",
83648364
"tags": ["Workspaces"],
83658365
"parameters": [

‎apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ const mocks = vi.hoisted(() => ({ send: vi.fn() }))
1212
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
1313
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
1414
vi.mock('@/lib/invitations/application/send-invitation-batch', () => ({
15-
sendInvitationBatch: { operation: { id: 'invitations.send_batch' }, execute: mocks.send },
15+
sendWorkspaceInvitationBatch: {
16+
operation: { id: 'workspace_invitations.send_batch' },
17+
execute: mocks.send,
18+
},
1619
}))
1720

1821
import { NoWorkspaceAccessError } from '@/lib/core/application'

‎apps/sim/app/api/v2/workspaces/[workspaceId]/invitations/route.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@ import {
66
v2RateLimits,
77
} from '@/lib/api/server/routes'
88
import { v2OrganizationErrorPolicy } from '@/lib/api/server/routes/organizations'
9-
import { invitationOperations } from '@/lib/invitations/application/operations'
10-
import { sendInvitationBatch } from '@/lib/invitations/application/send-invitation-batch'
9+
import { workspaceInvitationOperations } from '@/lib/invitations/application/operations'
10+
import { sendWorkspaceInvitationBatch } from '@/lib/invitations/application/send-invitation-batch'
1111

1212
export const POST = defineV2JsonRoute({
1313
contract: v2CreateWorkspaceInvitationsContract,
14-
operation: invitationOperations.sendBatch,
14+
operation: workspaceInvitationOperations.sendBatch,
1515
auth: v2ApiKeyAuth,
1616
rateLimit: v2RateLimits.publicApi,
1717
errorPolicy: createV2ResourceConcealmentPolicy({
1818
notFoundMessage: 'Workspace not found',
1919
render: v2OrganizationErrorPolicy.render,
2020
}),
2121
mapInput: ({ params, body }) => ({ ...body, workspaceIds: [params.workspaceId] }),
22-
useCase: sendInvitationBatch,
22+
useCase: sendWorkspaceInvitationBatch,
2323
present: (result) => ({ data: result }),
2424
})

‎apps/sim/lib/api/contracts/v2/openapi/workspace-invitations.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import {
66
} from '@/lib/api/contracts/v2/openapi/shared'
77
import { v2CreateWorkspaceInvitationsContract } from '@/lib/api/contracts/v2/workspace-invitations'
88
import { defineOpenApiRoute } from '@/lib/api/openapi/types'
9-
import { invitationOperations } from '@/lib/invitations/application/operations'
9+
import { workspaceInvitationOperations } from '@/lib/invitations/application/operations'
1010

1111
export const workspaceInvitationOpenApiRoutes = [
1212
defineOpenApiRoute(
1313
v2CreateWorkspaceInvitationsContract,
1414
{
15-
applicationOperation: invitationOperations.sendBatch,
15+
applicationOperation: workspaceInvitationOperations.sendBatch,
1616
operationId: 'createWorkspaceInvitations',
1717
summary: 'Create Workspace Invitations',
1818
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}`,

‎apps/sim/lib/api/server/routes/copilot-route-inventory.test.ts‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -388,12 +388,6 @@ it('inventories private operation admission without executing route requests', a
388388
"operation": "access_requests.discover",
389389
"path": "/api/v2/workspaces/[workspaceId]/access-requests/discovery",
390390
},
391-
{
392-
"audience": null,
393-
"method": "POST",
394-
"operation": "invitations.send_batch",
395-
"path": "/api/v2/workspaces/[workspaceId]/invitations",
396-
},
397391
{
398392
"audience": null,
399393
"method": "GET",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/** Chat's in-process CLI inviting teammates through the public workspace invitation command. */
2+
3+
import { db } from '@sim/db'
4+
import {
5+
auditLog,
6+
invitation,
7+
invitationWorkspaceGrant,
8+
member,
9+
organization,
10+
permissions,
11+
user,
12+
workspace,
13+
} from '@sim/db/schema'
14+
import { generateId } from '@sim/utils/id'
15+
import { and, eq, inArray } from 'drizzle-orm'
16+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
17+
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
18+
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
19+
import '@/app/api/v2/workspaces/[workspaceId]/invitations/route'
20+
21+
const ORIGIN = 'http://localhost:3000'
22+
const organizationId = generateId()
23+
const workspaceId = generateId()
24+
const adminId = generateId()
25+
const writerId = generateId()
26+
const pendingInvitationId = generateId()
27+
const fixtureDomain = `${generateId()}.invite.test`
28+
const newInvitee = `new@${fixtureDomain}`
29+
const pendingInvitee = `pending@${fixtureDomain}`
30+
const writerInvitee = `writer-target@${fixtureDomain}`
31+
32+
function inviteFromChat(userId: string, emails: string[]) {
33+
const transport = createScopedCliTransport(ORIGIN, { userId, workspaceId, chatId: generateId() })
34+
return withWorkspaceInvocationScope({ workspaceId, organizationId }, () =>
35+
transport(`${ORIGIN}/api/v2/workspaces/${workspaceId}/invitations`, {
36+
method: 'POST',
37+
headers: { 'content-type': 'application/json' },
38+
body: JSON.stringify({ emails, permission: 'write', membership: 'member' }),
39+
})
40+
)
41+
}
42+
43+
function pendingInvitationsFor(email: string) {
44+
return db
45+
.select({ id: invitation.id, inviterId: invitation.inviterId })
46+
.from(invitation)
47+
.where(and(eq(invitation.email, email), eq(invitation.status, 'pending')))
48+
}
49+
50+
function grantsOf(invitationId: string) {
51+
return db
52+
.select({
53+
workspaceId: invitationWorkspaceGrant.workspaceId,
54+
permission: invitationWorkspaceGrant.permission,
55+
})
56+
.from(invitationWorkspaceGrant)
57+
.where(eq(invitationWorkspaceGrant.invitationId, invitationId))
58+
}
59+
60+
describe('chat-delegated workspace invitations', () => {
61+
beforeAll(async () => {
62+
const now = new Date()
63+
await db.insert(user).values(
64+
[adminId, writerId].map((id) => ({
65+
id,
66+
name: 'Invitation fixture',
67+
email: `${id}@${fixtureDomain}`,
68+
emailVerified: true,
69+
createdAt: now,
70+
updatedAt: now,
71+
}))
72+
)
73+
await db.insert(organization).values({
74+
id: organizationId,
75+
name: 'Invitation fixture',
76+
slug: organizationId,
77+
createdAt: now,
78+
updatedAt: now,
79+
})
80+
await db.insert(member).values(
81+
[adminId, writerId].map((userId) => ({
82+
id: generateId(),
83+
userId,
84+
organizationId,
85+
role: 'member',
86+
createdAt: now,
87+
}))
88+
)
89+
await db.insert(workspace).values({
90+
id: workspaceId,
91+
name: 'Invitation fixture',
92+
ownerId: adminId,
93+
billedAccountUserId: adminId,
94+
organizationId,
95+
workspaceMode: 'organization',
96+
})
97+
await db.insert(permissions).values([
98+
{
99+
id: generateId(),
100+
userId: adminId,
101+
entityType: 'workspace',
102+
entityId: workspaceId,
103+
permissionType: 'admin',
104+
},
105+
{
106+
id: generateId(),
107+
userId: writerId,
108+
entityType: 'workspace',
109+
entityId: workspaceId,
110+
permissionType: 'write',
111+
},
112+
])
113+
await db.insert(invitation).values({
114+
id: pendingInvitationId,
115+
kind: 'organization',
116+
email: pendingInvitee,
117+
inviterId: adminId,
118+
organizationId,
119+
membershipIntent: 'internal',
120+
role: 'member',
121+
status: 'pending',
122+
token: generateId(),
123+
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
124+
createdAt: now,
125+
updatedAt: now,
126+
})
127+
})
128+
129+
afterAll(async () => {
130+
await db
131+
.delete(invitation)
132+
.where(inArray(invitation.email, [newInvitee, pendingInvitee, writerInvitee]))
133+
await db.delete(auditLog).where(eq(auditLog.workspaceId, workspaceId))
134+
await db.delete(workspace).where(eq(workspace.id, workspaceId))
135+
await db.delete(organization).where(eq(organization.id, organizationId))
136+
await db.delete(user).where(inArray(user.id, [adminId, writerId]))
137+
})
138+
139+
it('lets a workspace admin invite a new teammate', async () => {
140+
const response = await inviteFromChat(adminId, [newInvitee])
141+
142+
expect(response.status).toBe(200)
143+
expect(await response.json()).toMatchObject({
144+
data: { success: true, successful: [newInvitee], failed: [] },
145+
})
146+
const [created] = await pendingInvitationsFor(newInvitee)
147+
expect(created.inviterId).toBe(adminId)
148+
expect(await grantsOf(created.id)).toEqual([{ workspaceId, permission: 'write' }])
149+
await vi.waitFor(async () => {
150+
const [audit] = await db
151+
.select({ actorId: auditLog.actorId, metadata: auditLog.metadata })
152+
.from(auditLog)
153+
.where(and(eq(auditLog.workspaceId, workspaceId), eq(auditLog.resourceName, newInvitee)))
154+
expect(audit).toMatchObject({
155+
actorId: adminId,
156+
metadata: {
157+
operation: 'workspace_invitations.send_batch',
158+
actor: { kind: 'delegated', serviceId: 'copilot' },
159+
},
160+
})
161+
})
162+
})
163+
164+
it('adds the workspace to a pending organization invitation instead of creating another', async () => {
165+
const response = await inviteFromChat(adminId, [pendingInvitee])
166+
167+
expect(response.status).toBe(200)
168+
expect(await response.json()).toMatchObject({
169+
data: { success: true, successful: [pendingInvitee], failed: [] },
170+
})
171+
expect(await pendingInvitationsFor(pendingInvitee)).toEqual([
172+
{ id: pendingInvitationId, inviterId: adminId },
173+
])
174+
expect(await grantsOf(pendingInvitationId)).toEqual([{ workspaceId, permission: 'write' }])
175+
})
176+
177+
it('refuses a member without workspace admin rights', async () => {
178+
const response = await inviteFromChat(writerId, [writerInvitee])
179+
180+
expect(response.status).toBe(403)
181+
expect(await response.json()).toMatchObject({
182+
error: { code: 'FORBIDDEN', details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' } },
183+
})
184+
expect(await pendingInvitationsFor(writerInvitee)).toEqual([])
185+
})
186+
})

‎apps/sim/lib/invitations/application/operations.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,19 @@ export const invitationAuthorityOperations = {
5050
oauthScope: 'api:write',
5151
}),
5252
} as const
53+
54+
/**
55+
* One workspace's invitations. Direct callers and a Chat caller delegated by the
56+
* same user share it, so both pass the same role and capability checks.
57+
*/
58+
export const workspaceInvitationOperations = {
59+
sendBatch: defineWorkspaceOperation({
60+
id: 'workspace_invitations.send_batch',
61+
minimumRole: 'admin',
62+
capability: 'invitations.send',
63+
workspaceApiKey: 'deny',
64+
principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'],
65+
delegatedServices: ['copilot'],
66+
oauthScope: 'api:write',
67+
}),
68+
} as const

0 commit comments

Comments
 (0)