Skip to content

Commit 1181fb1

Browse files
committed
fix(mothership): let Chat read its own permission config and file version history
1 parent 0c5dbfb commit 1181fb1

6 files changed

Lines changed: 448 additions & 10 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ it('inventories private operation admission without executing use cases', async
103103
expect(inventory.length).toBeGreaterThan(200)
104104
expect(inventory.find((route) => route.path === '/api/v2/chat')?.operation.id).toBe('chat.send')
105105
const audiences = inventory.map((route) => copilotRouteAudience(route.operation, route.useCase))
106-
/** Public organization and version-history operations require a direct caller. */
106+
/**
107+
* Operations that require a direct caller. Chat reaches organization administration through
108+
* `settings organization`; version delete and download stay direct-only on purpose.
109+
*/
107110
const refused = inventory
108111
.filter((_, index) => !audiences[index])
109112
.map(({ method, path, operation }) => ({ method, path, operation: operation.id }))
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/** Chat's in-process CLI reading the delegating member's own permission-group restrictions. */
2+
3+
import { db } from '@sim/db'
4+
import {
5+
member,
6+
organization,
7+
permissionGroup,
8+
permissionGroupMember,
9+
permissionGroupWorkspace,
10+
permissions,
11+
user,
12+
workspace,
13+
} from '@sim/db/schema'
14+
import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
15+
import { generateId } from '@sim/utils/id'
16+
import { eq, inArray } from 'drizzle-orm'
17+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
18+
19+
vi.mock('@/lib/core/config/env-flags', () => ({ ...envFlagsMock, isAccessControlEnabled: true }))
20+
21+
import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
22+
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
23+
import '@/app/api/v2/workspaces/[workspaceId]/permission-config/route'
24+
25+
const ORIGIN = 'http://localhost:3000'
26+
const organizationId = generateId()
27+
const workspaceId = generateId()
28+
const otherWorkspaceId = generateId()
29+
const restrictedId = generateId()
30+
const defaultMemberId = generateId()
31+
const outsiderId = generateId()
32+
const restrictedGroupId = generateId()
33+
const defaultGroupId = generateId()
34+
35+
function readConfigFromChat(userId: string, chatWorkspaceId = workspaceId) {
36+
const transport = createScopedCliTransport(ORIGIN, {
37+
userId,
38+
workspaceId: chatWorkspaceId,
39+
chatId: generateId(),
40+
})
41+
return withWorkspaceInvocationScope({ workspaceId: chatWorkspaceId, organizationId }, () =>
42+
transport(`${ORIGIN}/api/v2/workspaces/${workspaceId}/permission-config`)
43+
)
44+
}
45+
46+
describe('chat-delegated permission configuration', () => {
47+
beforeAll(async () => {
48+
const now = new Date()
49+
const userIds = [restrictedId, defaultMemberId, outsiderId]
50+
await db.insert(user).values(
51+
userIds.map((id) => ({
52+
id,
53+
name: 'Permission config fixture',
54+
email: `${id}@permission-config.test`,
55+
emailVerified: true,
56+
createdAt: now,
57+
updatedAt: now,
58+
}))
59+
)
60+
await db.insert(organization).values({
61+
id: organizationId,
62+
name: 'Permission config fixture',
63+
slug: organizationId,
64+
createdAt: now,
65+
updatedAt: now,
66+
})
67+
await db.insert(member).values(
68+
userIds.map((userId) => ({
69+
id: generateId(),
70+
userId,
71+
organizationId,
72+
role: 'member',
73+
createdAt: now,
74+
}))
75+
)
76+
await db.insert(workspace).values(
77+
[workspaceId, otherWorkspaceId].map((id) => ({
78+
id,
79+
name: 'Permission config fixture',
80+
ownerId: restrictedId,
81+
billedAccountUserId: restrictedId,
82+
organizationId,
83+
workspaceMode: 'organization' as const,
84+
}))
85+
)
86+
await db.insert(permissions).values([
87+
...[restrictedId, defaultMemberId].map((userId) => ({
88+
id: generateId(),
89+
userId,
90+
entityType: 'workspace',
91+
entityId: workspaceId,
92+
permissionType: 'read' as const,
93+
})),
94+
{
95+
id: generateId(),
96+
userId: outsiderId,
97+
entityType: 'workspace',
98+
entityId: otherWorkspaceId,
99+
permissionType: 'admin' as const,
100+
},
101+
])
102+
await db.insert(permissionGroup).values([
103+
{
104+
id: defaultGroupId,
105+
organizationId,
106+
name: 'Everyone',
107+
createdBy: restrictedId,
108+
isDefault: true,
109+
config: {},
110+
},
111+
{
112+
id: restrictedGroupId,
113+
organizationId,
114+
name: 'Contractors',
115+
createdBy: restrictedId,
116+
config: { disableInvitations: true },
117+
},
118+
])
119+
await db.insert(permissionGroupWorkspace).values({
120+
id: generateId(),
121+
permissionGroupId: restrictedGroupId,
122+
workspaceId,
123+
organizationId,
124+
})
125+
await db.insert(permissionGroupMember).values({
126+
id: generateId(),
127+
permissionGroupId: restrictedGroupId,
128+
organizationId,
129+
userId: restrictedId,
130+
})
131+
})
132+
133+
afterAll(async () => {
134+
await db.delete(workspace).where(inArray(workspace.id, [workspaceId, otherWorkspaceId]))
135+
await db.delete(organization).where(eq(organization.id, organizationId))
136+
await db.delete(user).where(inArray(user.id, [restrictedId, defaultMemberId, outsiderId]))
137+
})
138+
139+
it("answers with the delegating member's own group", async () => {
140+
const restricted = await readConfigFromChat(restrictedId)
141+
expect(restricted.status).toBe(200)
142+
expect(await restricted.json()).toMatchObject({
143+
data: {
144+
permissionGroupId: restrictedGroupId,
145+
groupName: 'Contractors',
146+
config: { disableInvitations: true },
147+
entitled: true,
148+
organizationId,
149+
isOrgAdmin: false,
150+
},
151+
})
152+
153+
const unrestricted = await readConfigFromChat(defaultMemberId)
154+
expect(unrestricted.status).toBe(200)
155+
expect(await unrestricted.json()).toMatchObject({
156+
data: {
157+
permissionGroupId: defaultGroupId,
158+
groupName: 'Everyone',
159+
config: { disableInvitations: false },
160+
},
161+
})
162+
})
163+
164+
it('refuses a chat pinned to another workspace', async () => {
165+
const response = await readConfigFromChat(outsiderId, otherWorkspaceId)
166+
167+
expect(response.status).toBe(404)
168+
expect(await response.json()).toMatchObject({
169+
error: { code: 'NOT_FOUND', message: 'Resource not found in the selected workspace' },
170+
})
171+
})
172+
173+
it('refuses a delegating user who holds no role in the workspace', async () => {
174+
const response = await readConfigFromChat(outsiderId)
175+
176+
expect(response.status).toBe(404)
177+
expect(await response.json()).toMatchObject({
178+
error: { code: 'NOT_FOUND', message: 'Workspace not found' },
179+
})
180+
})
181+
})

‎apps/sim/lib/permission-groups/application/operations.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,14 @@ export const permissionGroupOperations = {
6262
export const permissionGroupWorkspaceOperations = {
6363
/**
6464
* permission-group-exempt: Members must be able to read their own restrictions.
65+
* Chat delegated by the member reads the same config, so it can say why a tool is withheld.
6566
*/
6667
readUserConfig: defineWorkspaceOperation({
6768
id: 'permission_groups.read_user_config',
6869
minimumRole: 'read',
6970
workspaceApiKey: 'deny',
70-
principalKinds: ['session', 'personal_api_key', 'oauth_access_token'],
71+
principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'],
72+
delegatedServices: ['copilot'],
7173
oauthScope: 'api:read',
7274
capability: 'none',
7375
}),

‎apps/sim/lib/permission-groups/application/read-user-config.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
12
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case'
23
import { permissionGroupWorkspaceOperations } from '@/lib/permission-groups/application/operations'
34
import {
@@ -11,18 +12,19 @@ export const readUserPermissionConfig = defineAuthorizedWorkspaceUseCase({
1112
operation: permissionGroupWorkspaceOperations.readUserConfig,
1213
resolveContext: ({ input }: { input: { workspaceId: string } }) =>
1314
resolveActiveWorkspaceApplicationContext(input.workspaceId),
14-
authorizationOptions: {},
15+
authorizationOptions: { delegation: { audience: 'sim:settings', isWithinScope: () => true } },
1516
execute: async ({ principal, context }) => {
17+
const userId = requirePrincipalSubjectUserId(principal)
1618
const organizationId = context.workspaceOrganizationId
1719
const [isOrgAdmin, entitled] = organizationId
1820
? await Promise.all([
19-
isOrganizationAdminOrOwner(principal.userId, organizationId),
21+
isOrganizationAdminOrOwner(userId, organizationId),
2022
isOrganizationPermissionRegimeActive(organizationId),
2123
])
2224
: [false, false]
2325
const resolved =
2426
organizationId && entitled
25-
? await resolveWorkspaceGroup(principal.userId, organizationId, context.workspaceId)
27+
? await resolveWorkspaceGroup(userId, organizationId, context.workspaceId)
2628
: null
2729

2830
return {

0 commit comments

Comments
 (0)