diff --git a/apps/sim/ee/access-requests/lib/application/authorization.ts b/apps/sim/ee/access-requests/lib/application/authorization.ts index f2f78ed1f14..7f9e189bb6a 100644 --- a/apps/sim/ee/access-requests/lib/application/authorization.ts +++ b/apps/sim/ee/access-requests/lib/application/authorization.ts @@ -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' @@ -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 { @@ -94,7 +94,7 @@ export async function authorizeAccessRequestScope( forUpdate = false, expected?: Pick ): Promise { - requireAllowedWorkspacePrincipal(principal, operation) + requireAccessRequestPrincipal(principal, operation) if (operation.admin && scope.kind !== 'organization') { throw new OrchestrationError('forbidden', 'Organization administrator access is required') } diff --git a/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts index 26cc0b4d2f3..3088ee2c6a2 100644 --- a/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts +++ b/apps/sim/ee/access-requests/lib/application/authorized-use-case.ts @@ -1,4 +1,4 @@ -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 { @@ -6,7 +6,6 @@ import { 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 { @@ -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 { @@ -64,13 +64,6 @@ type ReadAccessRequestUseCase = AccessRequestUseCase 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( definition: AccessRequestUseCase, diff --git a/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts b/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts new file mode 100644 index 00000000000..f919fff64c7 --- /dev/null +++ b/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts @@ -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() +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) => + settingsServerTool.execute( + { scope: 'organization', action: 'execute', section: 'requests', operation, input }, + chatContext(userId) + ) as Promise<{ result: Record }> +} + +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') + }) +}) diff --git a/apps/sim/ee/access-requests/lib/application/operations.ts b/apps/sim/ee/access-requests/lib/application/operations.ts index 13a313be0cc..e9f70949e1d 100644 --- a/apps/sim/ee/access-requests/lib/application/operations.ts +++ b/apps/sim/ee/access-requests/lib/application/operations.ts @@ -1,32 +1,52 @@ -import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { + DelegatedPrincipal, + OrganizationDelegatedPrincipal, + Principal, +} from '@sim/auth/principal' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' -/** Chat acting for the requester joins the direct callers on the workspace routes only. */ +/** + * Chat acting for the requester joins the direct callers on the workspace routes, and Chat acting + * for an organization administrator joins them on review. + */ export type AccessRequestPrincipal = | Extract | (DelegatedPrincipal & { serviceId: 'copilot' }) + | (OrganizationDelegatedPrincipal & { serviceId: 'copilot' }) export const ACCESS_REQUEST_DELEGATION_AUDIENCE = 'sim:settings' +const DIRECT_CALLERS = ['session', 'personal_api_key', 'oauth_access_token'] as const + +/** Review admits Chat acting for an organization administrator, under the settings audience. */ +const CHAT_REVIEW_POLICY = { + principalKinds: [...DIRECT_CALLERS, 'organization_delegated'], + delegationAudience: ACCESS_REQUEST_DELEGATION_AUDIENCE, + delegatedServices: ['copilot'], + contentDelivery: 'none', +} as const + function defineAccessRequestOperation( id: string, oauthScope: 'api:read' | 'api:write', - admin = false + audience: 'member' | 'admin' | 'review' = 'member' ) { + const admin = audience !== 'member' const organizationOperation = defineOrganizationOperation({ id, oauthScope, minimumRole: admin ? 'admin' : 'member', - principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + ...(audience === 'review' ? CHAT_REVIEW_POLICY : { principalKinds: DIRECT_CALLERS }), /** * permission-group-exempt: reviewing and requesting withheld access must remain reachable. */ capability: 'none', }) /** - * A member's own requests admit Chat delegated by that member. Review stays with direct - * callers: it is organization-scoped and never reached through a workspace. + * A member's own requests admit Chat delegated by that member through a workspace. Review is + * organization-scoped, so Chat reaches it only through an organization conversation; request + * settings stay with direct callers. */ const workspaceOperation = admin ? defineWorkspaceOperation({ @@ -79,27 +99,27 @@ export const accessRequestOperations = { listOrganization: defineAccessRequestOperation( 'access_requests.list_organization', 'api:read', - true + 'review' ), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - preview: defineAccessRequestOperation('access_requests.preview', 'api:read', true), + preview: defineAccessRequestOperation('access_requests.preview', 'api:read', 'review'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - resolve: defineAccessRequestOperation('access_requests.resolve', 'api:write', true), + resolve: defineAccessRequestOperation('access_requests.resolve', 'api:write', 'review'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ - getSettings: defineAccessRequestOperation('access_requests.get_settings', 'api:read', true), + getSettings: defineAccessRequestOperation('access_requests.get_settings', 'api:read', 'admin'), /** * permission-group-exempt: requesting and reviewing access must remain reachable when the requested capability is denied. */ updateSettings: defineAccessRequestOperation( 'access_requests.update_settings', 'api:write', - true + 'admin' ), } as const diff --git a/apps/sim/ee/access-requests/lib/application/principal.ts b/apps/sim/ee/access-requests/lib/application/principal.ts new file mode 100644 index 00000000000..f94add1a8f9 --- /dev/null +++ b/apps/sim/ee/access-requests/lib/application/principal.ts @@ -0,0 +1,22 @@ +import type { Principal } from '@sim/auth/principal' +import { requireAllowedOrganizationPrincipal } from '@/lib/core/application/organization-authorization' +import { requireAllowedWorkspacePrincipal } from '@/lib/core/application/workspace-authorization' +import type { + AccessRequestOperation, + AccessRequestPrincipal, +} from '@/ee/access-requests/lib/application/operations' + +/** + * Runs before the request scope is derived. Organization-delegated Chat only ever reaches review, + * so it is checked against the organization policy; every other caller against the workspace one. + */ +export function requireAccessRequestPrincipal( + principal: Principal, + operation: AccessRequestOperation +): asserts principal is AccessRequestPrincipal { + if (principal.kind === 'organization_delegated') { + requireAllowedOrganizationPrincipal(principal, operation.organizationOperation) + } else { + requireAllowedWorkspacePrincipal(principal, operation) + } +} diff --git a/apps/sim/lib/api/contracts/access-requests.ts b/apps/sim/lib/api/contracts/access-requests.ts index fa908082ab4..5a1e7239940 100644 --- a/apps/sim/lib/api/contracts/access-requests.ts +++ b/apps/sim/lib/api/contracts/access-requests.ts @@ -30,7 +30,7 @@ export const ACCESS_REQUEST_STATUSES = [ 'closed', ] as const -export const ACCESS_REQUEST_PAGE_SIZE = 50 +const ACCESS_REQUEST_PAGE_SIZE = 50 export const ACCESS_REQUEST_MAX_PAGE_SIZE = 100 const requestIdSchema = z diff --git a/apps/sim/lib/core/application/organization-authorization.ts b/apps/sim/lib/core/application/organization-authorization.ts index f65538cd71e..9b8e4462724 100644 --- a/apps/sim/lib/core/application/organization-authorization.ts +++ b/apps/sim/lib/core/application/organization-authorization.ts @@ -110,16 +110,24 @@ export async function requireOrganizationSubjectMembership( return { organizationId, userId, role: parsedRole.data } } +/** Refuses a principal kind or OAuth grant the operation does not admit, before any lookup. */ +export function requireAllowedOrganizationPrincipal( + principal: Principal, + operation: OrganizationOperation +): void { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + } + requireOAuthOperationScope(principal, operation) +} + export async function authorizeOrganizationOperation( principal: Principal, operation: OrganizationOperation, context: OrganizationAuthorizationContext, options: OrganizationAuthorizationOptions = {} ): Promise { - if (!operation.principalKinds.some((kind) => kind === principal.kind)) { - throw new PrincipalKindAuthorizationError(principal.kind, operation.id) - } - requireOAuthOperationScope(principal, operation) + requireAllowedOrganizationPrincipal(principal, operation) if (principal.kind === 'organization_delegated') { const now = Date.now() if ( diff --git a/apps/sim/lib/mothership/tools/server/settings-access-requests.ts b/apps/sim/lib/mothership/tools/server/settings-access-requests.ts new file mode 100644 index 00000000000..8367ebb0d37 --- /dev/null +++ b/apps/sim/lib/mothership/tools/server/settings-access-requests.ts @@ -0,0 +1,73 @@ +import { z } from 'zod' +import { + ACCESS_REQUEST_STATUSES, + accessRequestParamsSchema, + listOrganizationAccessRequestsQuerySchema, +} from '@/lib/api/contracts/access-requests' +import { v2ResolveAccessRequestBodySchema } from '@/lib/api/contracts/v2/access-requests' +import type { SettingsContext } from '@/lib/mothership/application/settings-context' +import { + settingsOperation, + settingsOrganizationId, +} from '@/lib/mothership/tools/server/settings-operation' +import { listOrganizationAccessRequests } from '@/ee/access-requests/lib/application/requests' +import { + previewAccessRequest, + resolveAccessRequest, +} from '@/ee/access-requests/lib/application/review' +import type { ResolveAccessRequestDecision } from '@/ee/access-requests/lib/types' + +const requestTarget = { requestId: accessRequestParamsSchema.shape.requestId } +const [applyDecisionSchema, declineDecisionSchema] = v2ResolveAccessRequestBodySchema.options +const listQuerySchema = listOrganizationAccessRequestsQuerySchema.extend({ + status: z + .enum(ACCESS_REQUEST_STATUSES) + .default('pending') + .describe('Request status to list; defaults to pending.'), +}) + +/** The first page of pending requests, or the page `query` selects. */ +export function readSettingsAccessRequests( + context: SettingsContext, + query: z.output = listQuerySchema.parse({}) +) { + return listOrganizationAccessRequests.execute({ + principal: context.principal, + input: { ...query, organizationId: settingsOrganizationId(context) }, + }) +} + +async function resolve( + context: SettingsContext, + requestId: string, + decision: ResolveAccessRequestDecision +) { + const { request, changed } = await resolveAccessRequest.execute({ + principal: context.principal, + input: { organizationId: settingsOrganizationId(context), requestId, decision }, + }) + return { request, changed } +} + +/** Admin role, fingerprint freshness, grants, audit and the requester's notice stay in the use cases. */ +export const accessRequestSettingsActions = { + list: settingsOperation('read', listQuerySchema, readSettingsAccessRequests), + preview: settingsOperation('read', z.strictObject(requestTarget), (context, input) => + previewAccessRequest.execute({ + principal: context.principal, + input: { ...input, organizationId: settingsOrganizationId(context) }, + }) + ), + approve: settingsOperation( + 'write', + applyDecisionSchema.omit({ action: true }).extend(requestTarget).strict(), + (context, { requestId, ...decision }) => + resolve(context, requestId, { action: 'apply', ...decision }) + ), + deny: settingsOperation( + 'write', + declineDecisionSchema.omit({ action: true }).extend(requestTarget).strict(), + (context, { requestId, ...decision }) => + resolve(context, requestId, { action: 'decline', ...decision }) + ), +} diff --git a/apps/sim/lib/mothership/tools/server/settings-operations.ts b/apps/sim/lib/mothership/tools/server/settings-operations.ts index 682730a3d1b..ee51866369d 100644 --- a/apps/sim/lib/mothership/tools/server/settings-operations.ts +++ b/apps/sim/lib/mothership/tools/server/settings-operations.ts @@ -48,6 +48,7 @@ import { removeSlackSearchInstallation, } from '@/lib/knowledge/application/slack-search/installations' import { slackSearchSettingsPatchSchema } from '@/lib/knowledge/application/slack-search/settings-projection' +import { accessRequestSettingsActions } from '@/lib/mothership/tools/server/settings-access-requests' import { memberUsageLimitSettingsActions, usageLimitSettingsActions, @@ -472,6 +473,7 @@ export const settingsOperations: Record = { }), }, 'organization/members': { get: readSettingsRoster }, + 'organization/requests': { get: (context) => readSettingsAccessRequests(context) }, 'organization/sim-links': { get: (context) => readSettingsSimLinks(context) }, 'organization/whitelabeling': { schema: updateOrganizationWhitelabelBodySchema.strict(), diff --git a/apps/sim/lib/settings/sections.ts b/apps/sim/lib/settings/sections.ts index 01ec61b240b..a60585ff2bc 100644 --- a/apps/sim/lib/settings/sections.ts +++ b/apps/sim/lib/settings/sections.ts @@ -53,6 +53,7 @@ export const settingsSections: Record