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
25 changes: 0 additions & 25 deletions apps/sim/lib/api/server/routes/copilot-refused-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,6 @@
"path": "/api/v2/credentials/[credentialId]/members/[userId]",
"operation": "credentials.members.remove"
},
{
"method": "GET",
"path": "/api/v2/files/[fileId]/versions",
"operation": "files.versions.list"
},
{
"method": "GET",
"path": "/api/v2/files/[fileId]/versions/[version]",
"operation": "files.versions.read"
},
{
"method": "DELETE",
"path": "/api/v2/files/[fileId]/versions/[version]",
Expand All @@ -39,16 +29,6 @@
"path": "/api/v2/files/[fileId]/versions/[version]/content",
"operation": "files.versions.download"
},
{
"method": "POST",
"path": "/api/v2/files/[fileId]/versions/[version]/revert",
"operation": "files.versions.revert"
},
{
"method": "GET",
"path": "/api/v2/files/[fileId]/versions/[version]/text",
"operation": "files.versions.read_content"
},
{
"method": "GET",
"path": "/api/v2/meta",
Expand Down Expand Up @@ -288,10 +268,5 @@
"method": "GET",
"path": "/api/v2/workspaces/[workspaceId]/access-requests/discovery",
"operation": "access_requests.discover"
},
{
"method": "GET",
"path": "/api/v2/workspaces/[workspaceId]/permission-config",
"operation": "permission_groups.read_user_config"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ it('inventories private operation admission without executing use cases', async
expect(inventory.length).toBeGreaterThan(200)
expect(inventory.find((route) => route.path === '/api/v2/chat')?.operation.id).toBe('chat.send')
const audiences = inventory.map((route) => copilotRouteAudience(route.operation, route.useCase))
/** Public organization and version-history operations require a direct caller. */
/**
* Operations that require a direct caller. Chat reaches organization administration through
* `settings organization`; version delete and download stay direct-only on purpose.
*/
const refused = inventory
.filter((_, index) => !audiences[index])
.map(({ method, path, operation }) => ({ method, path, operation: operation.id }))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/** Chat's in-process CLI reading the delegating member's own permission-group restrictions. */

import { db } from '@sim/db'
import {
member,
organization,
permissionGroup,
permissionGroupMember,
permissionGroupWorkspace,
permissions,
user,
workspace,
} from '@sim/db/schema'
import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
import { generateId } from '@sim/utils/id'
import { eq, inArray } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/core/config/env-flags', () => ({ ...envFlagsMock, isAccessControlEnabled: true }))

import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
import '@/app/api/v2/workspaces/[workspaceId]/permission-config/route'

const ORIGIN = 'http://localhost:3000'
const organizationId = generateId()
const workspaceId = generateId()
const otherWorkspaceId = generateId()
const restrictedId = generateId()
const defaultMemberId = generateId()
const outsiderId = generateId()
const restrictedGroupId = generateId()
const defaultGroupId = generateId()

function readConfigFromChat(userId: string, chatWorkspaceId = workspaceId) {
const transport = createScopedCliTransport(ORIGIN, {
userId,
workspaceId: chatWorkspaceId,
chatId: generateId(),
})
return withWorkspaceInvocationScope({ workspaceId: chatWorkspaceId, organizationId }, () =>
transport(`${ORIGIN}/api/v2/workspaces/${workspaceId}/permission-config`)
)
}

describe('chat-delegated permission configuration', () => {
beforeAll(async () => {
const now = new Date()
const userIds = [restrictedId, defaultMemberId, outsiderId]
await db.insert(user).values(
userIds.map((id) => ({
id,
name: 'Permission config fixture',
email: `${id}@permission-config.test`,
emailVerified: true,
createdAt: now,
updatedAt: now,
}))
)
await db.insert(organization).values({
id: organizationId,
name: 'Permission config fixture',
slug: organizationId,
createdAt: now,
updatedAt: now,
})
await db.insert(member).values(
userIds.map((userId) => ({
id: generateId(),
userId,
organizationId,
role: 'member',
createdAt: now,
}))
)
await db.insert(workspace).values(
[workspaceId, otherWorkspaceId].map((id) => ({
id,
name: 'Permission config fixture',
ownerId: restrictedId,
billedAccountUserId: restrictedId,
organizationId,
workspaceMode: 'organization' as const,
}))
)
await db.insert(permissions).values([
...[restrictedId, defaultMemberId].map((userId) => ({
id: generateId(),
userId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'read' as const,
})),
{
id: generateId(),
userId: outsiderId,
entityType: 'workspace',
entityId: otherWorkspaceId,
permissionType: 'admin' as const,
},
])
await db.insert(permissionGroup).values([
{
id: defaultGroupId,
organizationId,
name: 'Everyone',
createdBy: restrictedId,
isDefault: true,
config: {},
},
{
id: restrictedGroupId,
organizationId,
name: 'Contractors',
createdBy: restrictedId,
config: { disableInvitations: true },
},
])
await db.insert(permissionGroupWorkspace).values({
id: generateId(),
permissionGroupId: restrictedGroupId,
workspaceId,
organizationId,
})
await db.insert(permissionGroupMember).values({
id: generateId(),
permissionGroupId: restrictedGroupId,
organizationId,
userId: restrictedId,
})
})

afterAll(async () => {
await db.delete(workspace).where(inArray(workspace.id, [workspaceId, otherWorkspaceId]))
await db.delete(organization).where(eq(organization.id, organizationId))
await db.delete(user).where(inArray(user.id, [restrictedId, defaultMemberId, outsiderId]))
})

it("answers with the delegating member's own group", async () => {
const restricted = await readConfigFromChat(restrictedId)
expect(restricted.status).toBe(200)
expect(await restricted.json()).toMatchObject({
data: {
permissionGroupId: restrictedGroupId,
groupName: 'Contractors',
config: { disableInvitations: true },
entitled: true,
organizationId,
isOrgAdmin: false,
},
})

const unrestricted = await readConfigFromChat(defaultMemberId)
expect(unrestricted.status).toBe(200)
expect(await unrestricted.json()).toMatchObject({
data: {
permissionGroupId: defaultGroupId,
groupName: 'Everyone',
config: { disableInvitations: false },
},
})
})

it('refuses a chat pinned to another workspace', async () => {
const response = await readConfigFromChat(outsiderId, otherWorkspaceId)

expect(response.status).toBe(404)
expect(await response.json()).toMatchObject({
error: { code: 'NOT_FOUND', message: 'Resource not found in the selected workspace' },
})
})

it('refuses a delegating user who holds no role in the workspace', async () => {
const response = await readConfigFromChat(outsiderId)

expect(response.status).toBe(404)
expect(await response.json()).toMatchObject({
error: { code: 'NOT_FOUND', message: 'Workspace not found' },
})
})
})
4 changes: 3 additions & 1 deletion apps/sim/lib/permission-groups/application/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@ export const permissionGroupOperations = {
export const permissionGroupWorkspaceOperations = {
/**
* permission-group-exempt: Members must be able to read their own restrictions.
* Chat delegated by the member reads the same config, so it can say why a tool is withheld.
*/
readUserConfig: defineWorkspaceOperation({
id: 'permission_groups.read_user_config',
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['session', 'personal_api_key', 'oauth_access_token'],
principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'],
delegatedServices: ['copilot'],
oauthScope: 'api:read',
capability: 'none',
}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case'
import { permissionGroupWorkspaceOperations } from '@/lib/permission-groups/application/operations'
import {
Expand All @@ -11,18 +12,19 @@ export const readUserPermissionConfig = defineAuthorizedWorkspaceUseCase({
operation: permissionGroupWorkspaceOperations.readUserConfig,
resolveContext: ({ input }: { input: { workspaceId: string } }) =>
resolveActiveWorkspaceApplicationContext(input.workspaceId),
authorizationOptions: {},
authorizationOptions: { delegation: { audience: 'sim:settings', isWithinScope: () => true } },
execute: async ({ principal, context }) => {
const userId = requirePrincipalSubjectUserId(principal)
const organizationId = context.workspaceOrganizationId
const [isOrgAdmin, entitled] = organizationId
? await Promise.all([
isOrganizationAdminOrOwner(principal.userId, organizationId),
isOrganizationAdminOrOwner(userId, organizationId),
isOrganizationPermissionRegimeActive(organizationId),
])
: [false, false]
const resolved =
organizationId && entitled
? await resolveWorkspaceGroup(principal.userId, organizationId, context.workspaceId)
? await resolveWorkspaceGroup(userId, organizationId, context.workspaceId)
: null

return {
Expand Down
Loading
Loading