From 4147323ef60b30f40adad00b6535e60b3ddd925d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 20:17:42 -0800 Subject: [PATCH 1/2] improvement(chat): let Chat review organization access requests Organization admins can list, preview, approve and deny access requests from an organization conversation through the settings tool's new requests section. The admin list, preview and resolve operations now admit organization-delegated Copilot principals under the sim:settings audience; role, audit, outbox notice and fingerprint checks are unchanged. Request settings stay session-only. --- .../lib/application/authorization.ts | 4 +- .../lib/application/authorized-use-case.ts | 11 +- .../application/copilot-review.integration.ts | 275 ++++++++++++++++++ .../lib/application/operations.ts | 42 ++- .../lib/application/principal.ts | 22 ++ .../application/organization-authorization.ts | 16 +- .../tools/server/settings-access-requests.ts | 75 +++++ .../tools/server/settings-operations.ts | 2 + .../lib/mothership/tools/server/settings.ts | 2 + apps/sim/lib/settings/sections.ts | 1 + scripts/check-unused-exports.baseline.json | 3 - 11 files changed, 424 insertions(+), 29 deletions(-) create mode 100644 apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts create mode 100644 apps/sim/ee/access-requests/lib/application/principal.ts create mode 100644 apps/sim/lib/mothership/tools/server/settings-access-requests.ts 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..f567ca08d12 --- /dev/null +++ b/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts @@ -0,0 +1,275 @@ +/** 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') + }) + + 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/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..b2c9b17c32e --- /dev/null +++ b/apps/sim/lib/mothership/tools/server/settings-access-requests.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { + ACCESS_REQUEST_PAGE_SIZE, + 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 + +/** The first page of pending requests, or the page `query` selects. */ +export function readSettingsAccessRequests( + context: SettingsContext, + query: z.output = { + status: 'pending', + limit: ACCESS_REQUEST_PAGE_SIZE, + offset: 0, + } +) { + 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', + listOrganizationAccessRequestsQuerySchema, + 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 Date: Mon, 2 Nov 2026 20:38:14 -0800 Subject: [PATCH 2/2] fix(chat): default access request list to pending The settings tool parses list input before calling the reader, so an omitted status reached it as undefined and listed every status. The list schema now defaults status to pending, matching get; explicit statuses still apply. --- .../application/copilot-review.integration.ts | 3 +++ apps/sim/lib/api/contracts/access-requests.ts | 2 +- .../tools/server/settings-access-requests.ts | 20 +++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) 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 index f567ca08d12..f919fff64c7 100644 --- a/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts +++ b/apps/sim/ee/access-requests/lib/application/copilot-review.integration.ts @@ -254,6 +254,9 @@ describe('organization Chat review of access requests', () => { 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 () => { 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/mothership/tools/server/settings-access-requests.ts b/apps/sim/lib/mothership/tools/server/settings-access-requests.ts index b2c9b17c32e..8367ebb0d37 100644 --- a/apps/sim/lib/mothership/tools/server/settings-access-requests.ts +++ b/apps/sim/lib/mothership/tools/server/settings-access-requests.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { - ACCESS_REQUEST_PAGE_SIZE, + ACCESS_REQUEST_STATUSES, accessRequestParamsSchema, listOrganizationAccessRequestsQuerySchema, } from '@/lib/api/contracts/access-requests' @@ -19,15 +19,17 @@ import type { ResolveAccessRequestDecision } from '@/ee/access-requests/lib/type 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 = { - status: 'pending', - limit: ACCESS_REQUEST_PAGE_SIZE, - offset: 0, - } + query: z.output = listQuerySchema.parse({}) ) { return listOrganizationAccessRequests.execute({ principal: context.principal, @@ -49,11 +51,7 @@ async function resolve( /** Admin role, fingerprint freshness, grants, audit and the requester's notice stay in the use cases. */ export const accessRequestSettingsActions = { - list: settingsOperation( - 'read', - listOrganizationAccessRequestsQuerySchema, - readSettingsAccessRequests - ), + list: settingsOperation('read', listQuerySchema, readSettingsAccessRequests), preview: settingsOperation('read', z.strictObject(requestTarget), (context, input) => previewAccessRequest.execute({ principal: context.principal,