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
6 changes: 6 additions & 0 deletions apps/sim/app/api/v2/access-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ vi.mock('@/ee/access-requests/lib/application/requests', () => ({
},
createAccessRequest: { operation: { id: 'access_requests.create' }, execute: mocks.create },
cancelAccessRequest: { operation: { id: 'access_requests.cancel' }, execute: mocks.cancel },
workspaceAccessRequestUseCases: {
discover: { operation: { id: 'access_requests.discover' }, execute: mocks.discover },
listMine: { operation: { id: 'access_requests.list_mine' }, execute: mocks.listMine },
create: { operation: { id: 'access_requests.create' }, execute: mocks.create },
cancel: { operation: { id: 'access_requests.cancel' }, execute: mocks.cancel },
},
listOrganizationAccessRequests: {
operation: { id: 'access_requests.list_organization' },
execute: mocks.listOrganization,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { v2CancelWorkspaceAccessRequestContract } from '@/lib/api/contracts/v2/a
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests'
import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations'
import { cancelAccessRequest } from '@/ee/access-requests/lib/application/requests'
import { workspaceAccessRequestUseCases } from '@/ee/access-requests/lib/application/requests'

export const POST = defineV2JsonRoute({
contract: v2CancelWorkspaceAccessRequestContract,
Expand All @@ -14,6 +14,6 @@ export const POST = defineV2JsonRoute({
requestId: params.requestId,
scope: { kind: 'workspace' as const, workspaceId: params.workspaceId },
}),
useCase: cancelAccessRequest,
useCase: workspaceAccessRequestUseCases.cancel,
present: ({ request }) => ({ data: request }),
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/
import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests'
import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response'
import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations'
import { discoverAccessRequests } from '@/ee/access-requests/lib/application/requests'
import { workspaceAccessRequestUseCases } from '@/ee/access-requests/lib/application/requests'

function cursorFilters(
params: { workspaceId: string },
Expand Down Expand Up @@ -33,7 +33,7 @@ export const GET = defineV2JsonRoute({
cursorFilters(params, query)
),
}),
useCase: discoverAccessRequests,
useCase: workspaceAccessRequestUseCases.discover,
present: ({ entries, hasMore }, { params, query }) => ({
data: entries,
nextCursor: hasMore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/
import { v2AccessRequestErrorPolicy } from '@/lib/api/server/routes/access-requests'
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'
import { accessRequestOperations } from '@/ee/access-requests/lib/application/operations'
import {
createAccessRequest,
listMyAccessRequests,
} from '@/ee/access-requests/lib/application/requests'
import { workspaceAccessRequestUseCases } from '@/ee/access-requests/lib/application/requests'

function cursorFilters(params: { workspaceId: string }, query: { status?: string }) {
return cursorScopeKey(cursorRoute(v2ListMyWorkspaceAccessRequestsContract, params), {
Expand Down Expand Up @@ -40,7 +37,7 @@ export const GET = defineV2JsonRoute({
),
},
}),
useCase: listMyAccessRequests,
useCase: workspaceAccessRequestUseCases.listMine,
present: ({ requests, nextCursorKeys }, { params, query }) => ({
data: requests,
nextCursor: writeSortedCursor(
Expand All @@ -62,6 +59,6 @@ export const POST = defineV2JsonRoute({
...body,
scope: { kind: 'workspace' as const, workspaceId: params.workspaceId },
}),
useCase: createAccessRequest,
useCase: workspaceAccessRequestUseCases.create,
present: ({ request }) => ({ data: request }),
})
13 changes: 13 additions & 0 deletions apps/sim/ee/access-requests/lib/application/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { db } from '@sim/db'
import { member, permissions, user, workspace } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import {
createDelegatedPrincipal,
createPersonalApiKeyPrincipal,
createSessionPrincipal,
} from '@sim/testing/factories/principal.factory'
Expand Down Expand Up @@ -185,6 +186,18 @@ describe('access request scope authorization', () => {
expect(dbChainMockFns.select).not.toHaveBeenCalled()
})

it("refuses Chat on a member's organization-scoped request before any lookup", async () => {
const chat = createDelegatedPrincipal({
subjectUserId: 'person',
workspaceId: 'workspace',
audience: 'sim:settings',
})
await expect(
authorizeAccessRequestScope(chat, accessRequestOperations.create, organizationScope)
).rejects.toMatchObject({ detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED' })
expect(dbChainMockFns.select).not.toHaveBeenCalled()
})

it('conceals an archived or removed workspace before membership lookup', async () => {
queueTableRows(workspace, [])
await expect(
Expand Down
21 changes: 16 additions & 5 deletions apps/sim/ee/access-requests/lib/application/authorization.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { db } from '@sim/db'
import { member, permissions, user, workspace } from '@sim/db/schema'
import {
Expand All @@ -10,13 +11,15 @@ import { isAccountBlocked } from '@/lib/auth/ban'
import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization'
import {
authorizeWorkspaceOperation,
PrincipalKindAuthorizationError,
requireAllowedWorkspacePrincipal,
} from '@/lib/core/application/workspace-authorization'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import type { DbOrTx } from '@/lib/db/types'
import type {
AccessRequestOperation,
AccessRequestPrincipal,
import {
ACCESS_REQUEST_DELEGATION_AUDIENCE,
type AccessRequestOperation,
type AccessRequestPrincipal,
} from '@/ee/access-requests/lib/application/operations'
import type { AccessRequestScope } from '@/ee/access-requests/lib/targets'

Expand Down Expand Up @@ -95,6 +98,10 @@ export async function authorizeAccessRequestScope(
if (operation.admin && scope.kind !== 'organization') {
throw new OrchestrationError('forbidden', 'Organization administrator access is required')
}
if (principal.kind === 'delegated' && scope.kind !== 'workspace') {
throw new PrincipalKindAuthorizationError(principal.kind, operation.id)
}
const actorUserId = requirePrincipalSubjectUserId(principal)
let canonicalWorkspace:
| Pick<typeof workspace.$inferSelect, 'id' | 'organizationId' | 'allowPersonalApiKeys'>
| undefined
Expand All @@ -119,7 +126,7 @@ export async function authorizeAccessRequestScope(
}
const membership = await loadAccessRequestMembership(
executor,
principal.userId,
actorUserId,
scope,
organizationId,
forUpdate
Expand All @@ -136,7 +143,11 @@ export async function authorizeAccessRequestScope(
workspaceOrganizationId: canonicalWorkspace.organizationId,
allowPersonalApiKeys: canonicalWorkspace.allowPersonalApiKeys,
},
{ executor, forUpdate }
{
executor,
forUpdate,
delegation: { audience: ACCESS_REQUEST_DELEGATION_AUDIENCE, isWithinScope: () => true },
}
)
} else if (scope.kind === 'organization') {
await authorizeOrganizationOperation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,12 @@ describe('authorized access request execution', () => {
projectAudit: () => audit,
})
await expect(useCase.execute({ principal: caller, input })).resolves.toBe('result')
expect(prepare).toHaveBeenCalledWith({ principal: caller, input, context })
expect(prepare).toHaveBeenCalledWith({
principal: caller,
actorUserId: 'requester',
input,
context,
})
expect(mocks.authorize).toHaveBeenNthCalledWith(
2,
caller,
Expand All @@ -122,6 +127,7 @@ describe('authorized access request execution', () => {
)
expect(execute).toHaveBeenCalledWith({
principal: caller,
actorUserId: 'requester',
input,
context,
executor: transaction,
Expand Down Expand Up @@ -194,7 +200,12 @@ describe('authorized access request execution', () => {
execute,
})
await expect(useCase.execute({ principal, input })).resolves.toEqual({ id: 'request' })
expect(prepare).toHaveBeenCalledExactlyOnceWith({ principal, input, context })
expect(prepare).toHaveBeenCalledExactlyOnceWith({
principal,
actorUserId: 'requester',
input,
context,
})
expect(prepare.mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(db.transaction).mock.invocationCallOrder[0]
)
Expand All @@ -213,6 +224,7 @@ describe('authorized access request execution', () => {
)
expect(execute).toHaveBeenCalledExactlyOnceWith({
principal,
actorUserId: 'requester',
input,
context,
executor: transaction,
Expand Down
38 changes: 30 additions & 8 deletions apps/sim/ee/access-requests/lib/application/authorized-use-case.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Principal } from '@sim/auth/principal'
import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { db } from '@sim/db'
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
import {
Expand All @@ -13,14 +13,17 @@ import {
type AccessRequestContext,
authorizeAccessRequestScope,
} from '@/ee/access-requests/lib/application/authorization'
import type {
AccessRequestOperation,
AccessRequestPrincipal,
import {
ACCESS_REQUEST_DELEGATION_AUDIENCE,
type AccessRequestOperation,
type AccessRequestPrincipal,
} from '@/ee/access-requests/lib/application/operations'
import type { AccessRequestScope } from '@/ee/access-requests/lib/targets'

interface AccessRequestPreparationArgs<I> {
principal: AccessRequestPrincipal
/** The person acting: the caller, or the member Chat acts for. */
actorUserId: string
input: I
context: AccessRequestContext
}
Expand Down Expand Up @@ -107,7 +110,8 @@ export function defineAuthorizedAccessRequestUseCase<I, R, P = undefined>(
const scope = definition.scope(input)
const initial = await authorizeAccessRequestScope(principal, definition.operation, scope)
return runWithOutboundOrganization(initial.organizationId, async () => {
const preparation = { principal, input, context: initial }
const actorUserId = requirePrincipalSubjectUserId(principal)
const preparation = { principal, actorUserId, input, context: initial }
let context = initial
let result: R
if (definition.mutation) {
Expand All @@ -124,19 +128,26 @@ export function defineAuthorizedAccessRequestUseCase<I, R, P = undefined>(
true,
initial
)
return execute({ principal, input, context, executor })
return execute({ principal, actorUserId, input, context, executor })
})
} else {
const execute = await prepareExecution(definition, preparation)
result = await execute({ principal, input, context, executor: db })
result = await execute({ principal, actorUserId, input, context, executor: db })
}
if (definition.projectAudit) {
recordProjectedUseCaseAuditEntries(
definition.operation,
context.workspaceId,
principal,
request,
definition.projectAudit({ principal, input, context, executor: db, result }),
definition.projectAudit({
principal,
actorUserId,
input,
context,
executor: db,
result,
}),
context.organizationId ?? undefined
)
}
Expand All @@ -145,3 +156,14 @@ export function defineAuthorizedAccessRequestUseCase<I, R, P = undefined>(
},
}
}

/**
* The workspace routes' copy of a member use case. Declaring the audience admits Chat acting for
* the requester, pinned to its own workspace; the organization routes keep the shared use case,
* so Chat never reaches them.
*/
export function admitWorkspaceDelegation<I, R>(
useCase: OperationUseCase<AccessRequestOperation, I, R>
): OperationUseCase<AccessRequestOperation, I, R> {
return Object.freeze({ ...useCase, delegationAudience: ACCESS_REQUEST_DELEGATION_AUDIENCE })
}
Loading