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
4 changes: 2 additions & 2 deletions apps/sim/ee/access-requests/lib/application/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { authorizeOrganizationOperation } from '@/lib/core/application/organizat
import {
authorizeWorkspaceOperation,
PrincipalKindAuthorizationError,
requireAllowedWorkspacePrincipal,
} from '@/lib/core/application/workspace-authorization'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import type { DbOrTx } from '@/lib/db/types'
Expand All @@ -21,6 +20,7 @@ import {
type AccessRequestOperation,
type AccessRequestPrincipal,
} from '@/ee/access-requests/lib/application/operations'
import { requireAccessRequestPrincipal } from '@/ee/access-requests/lib/application/principal'
import type { AccessRequestScope } from '@/ee/access-requests/lib/targets'

export interface AccessRequestContext {
Expand Down Expand Up @@ -94,7 +94,7 @@ export async function authorizeAccessRequestScope(
forUpdate = false,
expected?: Pick<AccessRequestContext, 'organizationId' | 'membershipId'>
): Promise<AccessRequestContext> {
requireAllowedWorkspacePrincipal(principal, operation)
requireAccessRequestPrincipal(principal, operation)
if (operation.admin && scope.kind !== 'organization') {
throw new OrchestrationError('forbidden', 'Organization administrator access is required')
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { db } from '@sim/db'
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import {
recordProjectedUseCaseAuditEntries,
type WorkspaceUseCaseAuditEntry,
} from '@/lib/core/application/authorized-workspace-use-case'
import type { OperationUseCase } from '@/lib/core/application/operation'
import { requireAllowedWorkspacePrincipal } from '@/lib/core/application/workspace-authorization'
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
import type { DbClient, DbOrTx, DbTransaction } from '@/lib/db/types'
import {
Expand All @@ -18,6 +17,7 @@ import {
type AccessRequestOperation,
type AccessRequestPrincipal,
} from '@/ee/access-requests/lib/application/operations'
import { requireAccessRequestPrincipal } from '@/ee/access-requests/lib/application/principal'
import type { AccessRequestScope } from '@/ee/access-requests/lib/targets'

interface AccessRequestPreparationArgs<I> {
Expand Down Expand Up @@ -64,13 +64,6 @@ type ReadAccessRequestUseCase<I, R, P> = AccessRequestUseCase<I, R, P, DbClient>
mutation?: false
}

function requireAccessRequestPrincipal(
principal: Principal,
operation: AccessRequestOperation
): asserts principal is AccessRequestPrincipal {
requireAllowedWorkspacePrincipal(principal, operation)
}

/** Runs preparation before any transaction opens and binds its result to `execute`. */
async function prepareExecution<I, R, P, E extends DbOrTx>(
definition: AccessRequestUseCase<I, R, P, E>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
/** Organization Chat reviewing access requests through the generic settings tool. */

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

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

import { settingsServerTool } from '@/lib/mothership/tools/server/settings'
import { createAccessRequest } from '@/ee/access-requests/lib/application/requests'
import { PERMISSION_ACCESS_REQUEST_DECIDED_EVENT } from '@/ee/access-requests/lib/notification-events'

const organizationId = generateId()
const workspaceId = generateId()
const adminId = generateId()
const memberId = generateId()
const requesterId = generateId()
const groupId = generateId()
const chats = new Map<string, string>()
const requestIds: Record<'tables' | 'files', string> = { tables: '', files: '' }

function chatContext(userId: string) {
const chatId = chats.get(userId)
if (!chatId) throw new Error('Fixture chat missing')
return {
userId,
organizationId,
chatId,
toolCallId: generateId(),
requestMode: 'agent',
copilotToolExecution: true,
}
}

function asChat(userId: string) {
return (operation: string, input: Record<string, unknown>) =>
settingsServerTool.execute(
{ scope: 'organization', action: 'execute', section: 'requests', operation, input },
chatContext(userId)
) as Promise<{ result: Record<string, unknown> }>
}

function readRequest(id: string) {
return db
.select({
status: permissionAccessRequest.status,
decidedBy: permissionAccessRequest.decidedBy,
decisionReason: permissionAccessRequest.decisionReason,
})
.from(permissionAccessRequest)
.where(eq(permissionAccessRequest.id, id))
.then(([row]) => row)
}

async function readGroupConfig() {
const [group] = await db
.select({ config: permissionGroup.config })
.from(permissionGroup)
.where(eq(permissionGroup.id, groupId))
return group.config
}

function decidedEvents(id: string) {
return db
.select({ id: outboxEvent.id })
.from(outboxEvent)
.where(
and(
eq(outboxEvent.eventType, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT),
sql`${outboxEvent.payload}->>'requestId' = ${id}`
)
)
}

async function waitForAudit(resourceId: string, action: string) {
await vi.waitFor(async () => {
const [audit] = await db
.select({ actorId: auditLog.actorId, metadata: auditLog.metadata })
.from(auditLog)
.where(and(eq(auditLog.resourceId, resourceId), eq(auditLog.action, action)))
expect(audit).toMatchObject({
actorId: adminId,
metadata: {
operation: 'access_requests.resolve',
actor: { kind: 'organization_delegated', serviceId: 'copilot', subjectUserId: adminId },
},
})
})
}

describe('organization Chat review of access requests', () => {
beforeAll(async () => {
const now = new Date()
const people = [adminId, memberId, requesterId]
await db.insert(user).values(
people.map((id) => ({
id,
name: 'Access review fixture',
email: `${id}@access-review.test`,
emailVerified: true,
createdAt: now,
updatedAt: now,
}))
)
await db.insert(organization).values({
id: organizationId,
name: 'Access review fixture',
slug: organizationId,
createdAt: now,
updatedAt: now,
})
await db.insert(member).values(
people.map((userId) => ({
id: generateId(),
userId,
organizationId,
role: userId === adminId ? 'admin' : 'member',
createdAt: now,
}))
)
await insertWorkspaceFixture(db, [
{
id: workspaceId,
name: 'Access review fixture',
ownerId: adminId,
billedAccountUserId: adminId,
organizationId,
workspaceMode: 'organization' as const,
},
])
await db.insert(permissions).values({
id: generateId(),
userId: requesterId,
entityType: 'workspace',
entityId: workspaceId,
permissionType: 'write' as const,
})
await db.insert(permissionGroup).values({
id: groupId,
organizationId,
name: 'Tables and files withheld',
createdBy: adminId,
config: { hideTablesTab: true, hideFilesTab: true },
})
await db.insert(permissionGroupWorkspace).values({
id: generateId(),
permissionGroupId: groupId,
workspaceId,
organizationId,
})
for (const userId of [adminId, memberId]) {
const [chat] = await db
.insert(copilotChats)
.values({
userId,
organizationId,
type: 'mothership',
config: { conversationMode: 'agent' },
model: 'fixture-model',
})
.returning({ id: copilotChats.id })
chats.set(userId, chat.id)
}
const requester = { kind: 'session', userId: requesterId, sessionId: 'fixture' } as const
for (const [key, configKey] of [
['tables', 'hideTablesTab'],
['files', 'hideFilesTab'],
] as const) {
const { request } = await createAccessRequest.execute({
principal: requester,
input: {
scope: { kind: 'workspace', workspaceId },
target: { kind: 'feature', configKey },
reason: 'Need it for a report',
},
})
requestIds[key] = request.id
}
})

afterAll(async () => {
const ids = Object.values(requestIds)
await db.delete(outboxEvent).where(inArray(sql`${outboxEvent.payload}->>'requestId'`, ids))
await db
.delete(permissionAccessRequest)
.where(eq(permissionAccessRequest.organizationId, organizationId))
await db.delete(auditLog).where(inArray(auditLog.resourceId, [...ids, groupId]))
await db.delete(copilotChats).where(eq(copilotChats.organizationId, organizationId))
await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
await db.delete(organization).where(eq(organization.id, organizationId))
await db.delete(user).where(inArray(user.id, [adminId, memberId, requesterId]))
})

it("refuses a non-admin member's Chat every review operation", async () => {
const asMember = asChat(memberId)
const fingerprint = 'f'.repeat(64)

await expect(asMember('list', {})).rejects.toMatchObject({ code: 'forbidden' })
await expect(asMember('preview', { requestId: requestIds.tables })).rejects.toMatchObject({
code: 'forbidden',
})
await expect(
asMember('approve', { requestId: requestIds.tables, expectedFingerprint: fingerprint })
).rejects.toMatchObject({ code: 'forbidden' })
await expect(
asMember('deny', { requestId: requestIds.files, reason: 'No' })
).rejects.toMatchObject({ code: 'forbidden' })

expect(await readRequest(requestIds.tables)).toMatchObject({ status: 'pending' })
expect(await readRequest(requestIds.files)).toMatchObject({ status: 'pending' })
expect(await readGroupConfig()).toMatchObject({ hideTablesTab: true, hideFilesTab: true })
})

it("lets an admin's Chat list, preview and approve a request, granting it", async () => {
const asAdmin = asChat(adminId)

const section = (await settingsServerTool.execute(
{ scope: 'organization', action: 'get', section: 'requests' },
chatContext(adminId)
)) as { value: { requests: { id: string; status: string }[] } }
expect(section.value.requests.map(({ id }) => id).sort()).toEqual(
Object.values(requestIds).sort()
)

const { result: preview } = await asAdmin('preview', { requestId: requestIds.tables })
expect(preview).toMatchObject({ canApply: true, resolutionKind: 'permission' })

const { result } = await asAdmin('approve', {
requestId: requestIds.tables,
expectedFingerprint: preview.fingerprint,
})

expect(result).toMatchObject({ changed: true, request: { status: 'fulfilled' } })
expect(await readRequest(requestIds.tables)).toMatchObject({
status: 'fulfilled',
decidedBy: adminId,
})
expect(await readGroupConfig()).toMatchObject({ hideTablesTab: false, hideFilesTab: true })
expect(await decidedEvents(requestIds.tables)).toHaveLength(1)
await waitForAudit(requestIds.tables, 'permission_access_request.fulfilled')

const { result: pending } = await asAdmin('list', {})
expect((pending.requests as { id: string }[]).map(({ id }) => id)).toEqual([requestIds.files])
})

it("lets an admin's Chat deny a request with a reason, changing no policy", async () => {
const { result } = await asChat(adminId)('deny', {
requestId: requestIds.files,
reason: 'Files stay restricted this quarter',
})

expect(result).toMatchObject({ changed: true, request: { status: 'declined' } })
expect(await readRequest(requestIds.files)).toEqual({
status: 'declined',
decidedBy: adminId,
decisionReason: 'Files stay restricted this quarter',
})
expect(await readGroupConfig()).toMatchObject({ hideTablesTab: false, hideFilesTab: true })
expect(await decidedEvents(requestIds.files)).toHaveLength(1)
await waitForAudit(requestIds.files, 'permission_access_request.declined')
})
})
Loading
Loading