diff --git a/packages/server/assets/automate/typedefs/automate.graphql b/packages/server/assets/automate/typedefs/automate.graphql index 7698362da..a75e61660 100644 --- a/packages/server/assets/automate/typedefs/automate.graphql +++ b/packages/server/assets/automate/typedefs/automate.graphql @@ -352,6 +352,13 @@ input AutomateAuthCodePayloadTest { action: String! } +""" +Additional resources to validate user access to. +""" +input AutomateAuthCodeResources { + workspaceId: String +} + extend type Query { automateFunctions( filter: AutomateFunctionsFilter @@ -366,7 +373,10 @@ extend type Query { """ Part of the automation/function creation handshake mechanism """ - automateValidateAuthCode(payload: AutomateAuthCodePayloadTest!): Boolean! + automateValidateAuthCode( + payload: AutomateAuthCodePayloadTest! + resources: AutomateAuthCodeResources + ): Boolean! } extend type Mutation { diff --git a/packages/server/modules/automate/clients/executionEngine.ts b/packages/server/modules/automate/clients/executionEngine.ts index c60cdd908..d29219d2a 100644 --- a/packages/server/modules/automate/clients/executionEngine.ts +++ b/packages/server/modules/automate/clients/executionEngine.ts @@ -28,7 +28,7 @@ import { retry, timeoutAt } from '@speckle/shared' -import { has, isObjectLike } from 'lodash' +import { has, isObjectLike, isEmpty } from 'lodash' export type AuthCodePayloadWithOrigin = AuthCodePayload & { origin: string } @@ -48,7 +48,7 @@ export type AutomationCreateResponse = { const getApiUrl = ( path?: string, options?: Partial<{ - query: Record + query: Record }> ) => { const automateUrl = speckleAutomateUrl() @@ -62,9 +62,10 @@ const getApiUrl = ( const url = new URL(path, automateUrl) if (options?.query) { Object.entries(options.query).forEach(([key, val]) => { - if (isNullOrUndefined(val)) return + if (isEmpty(val) || isNullOrUndefined(val)) return try { - url.searchParams.append(key, val.toString()) + const urlValue = typeof val === 'object' ? val.join(',') : val.toString() + url.searchParams.append(key, urlValue) } catch { console.log({ val }) } @@ -460,7 +461,50 @@ export const getFunctionRelease = async (params: { : null } +export type GetFunctionsParams = { + auth?: AuthCodePayload + filters: { + query?: string + cursor?: string + limit?: number + requireRelease?: boolean + includeFeatured?: boolean + includeWorkspaces?: string[] + includeUsers?: string[] + } +} + export type GetFunctionsResponse = { + items: FunctionSchemaType[] + cursor: Nullable + totalCount: number +} + +export const getFunctions = async (params: GetFunctionsParams) => { + const url = getApiUrl(`/api/v2/functions`, { + query: { + requireRelease: true, + ...params.filters + } + }) + + const authToken = params.auth + ? Buffer.from( + JSON.stringify({ + ...params.auth, + origin: getServerOrigin() + }) + ).toString('base64') + : undefined + + return await invokeSafeJsonRequest({ + url, + method: 'get', + token: authToken + }) +} + +export type GetPublicFunctionsResponse = { totalCount: number cursor: Nullable items: FunctionWithVersionsSchemaType[] @@ -482,7 +526,7 @@ export const getPublicFunctions = async (params: { } }) - return await invokeSafeJsonRequest({ + return await invokeSafeJsonRequest({ url, method: 'get' }) diff --git a/packages/server/modules/automate/graph/resolvers/automate.ts b/packages/server/modules/automate/graph/resolvers/automate.ts index e4dc15f9a..0eec4ba48 100644 --- a/packages/server/modules/automate/graph/resolvers/automate.ts +++ b/packages/server/modules/automate/graph/resolvers/automate.ts @@ -736,9 +736,13 @@ export = (FF_AUTOMATE_MODULE_ENABLED emit: getEventBus().emit }) const payload = removeNullOrUndefinedKeys(args.payload) + const resources = removeNullOrUndefinedKeys(args.resources ?? {}) return await validate({ - ...payload, - action: args.payload.action as AuthCodePayloadAction + payload: { + ...payload, + action: args.payload.action as AuthCodePayloadAction + }, + resources }) }, async automateFunction(_parent, { id }, ctx) { diff --git a/packages/server/modules/automate/helpers/executionEngine.ts b/packages/server/modules/automate/helpers/executionEngine.ts index 5e5a0db14..97b4a3195 100644 --- a/packages/server/modules/automate/helpers/executionEngine.ts +++ b/packages/server/modules/automate/helpers/executionEngine.ts @@ -20,6 +20,8 @@ export type FunctionSchemaType = { speckleUserId: string speckleServerOrigin: string }> + functionCreatorSpeckleUserId: Nullable + functionCreatorSpeckleServerOrigin: Nullable workspaceIds: string[] } diff --git a/packages/server/modules/automate/services/authCode.ts b/packages/server/modules/automate/services/authCode.ts index ac2bf3233..8c89bc783 100644 --- a/packages/server/modules/automate/services/authCode.ts +++ b/packages/server/modules/automate/services/authCode.ts @@ -19,7 +19,6 @@ export enum AuthCodePayloadAction { export type AuthCodePayload = { code: string userId: string - workspaceId?: string action: AuthCodePayloadAction } @@ -49,8 +48,14 @@ export const createStoredAuthCodeFactory = export const validateStoredAuthCodeFactory = (deps: { redis: Redis; emit: EventBus['emit'] }) => - async (payload: AuthCodePayload) => { + async (params: { + payload: AuthCodePayload + resources?: { + workspaceId?: string + } + }) => { const { redis, emit } = deps + const { payload, resources } = params const potentialPayloadString = await redis.get(payload.code) const potentialPayload: unknown = potentialPayloadString @@ -67,10 +72,11 @@ export const validateStoredAuthCodeFactory = throw new AutomateAuthCodeHandshakeError('Invalid automate auth payload') } - if (payload.workspaceId) { + // Token is valid, confirm user is authorized to access specified resources. + if (resources?.workspaceId) { emit({ eventName: 'workspace.authorized', - payload: { userId: payload.userId, workspaceId: payload.workspaceId } + payload: { userId: payload.userId, workspaceId: resources?.workspaceId } }) } diff --git a/packages/server/modules/automate/services/functionManagement.ts b/packages/server/modules/automate/services/functionManagement.ts index 45ff90edd..e71ad02ac 100644 --- a/packages/server/modules/automate/services/functionManagement.ts +++ b/packages/server/modules/automate/services/functionManagement.ts @@ -89,6 +89,14 @@ const cleanFunctionLogo = (logo: MaybeNullOrUndefined): Nullable export const convertFunctionToGraphQLReturn = ( fn: FunctionSchemaType ): AutomateFunctionGraphQLReturn => { + const functionCreator: FunctionSchemaType['functionCreator'] = + fn.functionCreatorSpeckleUserId && fn.functionCreatorSpeckleServerOrigin + ? { + speckleUserId: fn.functionCreatorSpeckleUserId, + speckleServerOrigin: fn.functionCreatorSpeckleServerOrigin + } + : fn.functionCreator + const ret: AutomateFunctionGraphQLReturn = { id: fn.functionId, name: fn.functionName, @@ -98,7 +106,7 @@ export const convertFunctionToGraphQLReturn = ( logo: cleanFunctionLogo(fn.logo), tags: fn.tags, supportedSourceApps: fn.supportedSourceApps, - functionCreator: fn.functionCreator, + functionCreator, workspaceIds: fn.workspaceIds } diff --git a/packages/server/modules/core/graph/generated/graphql.ts b/packages/server/modules/core/graph/generated/graphql.ts index e3d2f8f47..b2f276023 100644 --- a/packages/server/modules/core/graph/generated/graphql.ts +++ b/packages/server/modules/core/graph/generated/graphql.ts @@ -265,6 +265,11 @@ export type AutomateAuthCodePayloadTest = { workspaceId?: InputMaybe; }; +/** Additional resources to validate user access to. */ +export type AutomateAuthCodeResources = { + workspaceId?: InputMaybe; +}; + export type AutomateFunction = { __typename?: 'AutomateFunction'; /** Only returned if user is a part of this speckle server */ @@ -2747,6 +2752,7 @@ export type QueryAutomateFunctionsArgs = { export type QueryAutomateValidateAuthCodeArgs = { payload: AutomateAuthCodePayloadTest; + resources?: InputMaybe; }; @@ -4938,6 +4944,7 @@ export type ResolversTypes = { ArchiveCommentInput: ArchiveCommentInput; AuthStrategy: ResolverTypeWrapper; AutomateAuthCodePayloadTest: AutomateAuthCodePayloadTest; + AutomateAuthCodeResources: AutomateAuthCodeResources; AutomateFunction: ResolverTypeWrapper; AutomateFunctionCollection: ResolverTypeWrapper & { items: Array }>; AutomateFunctionRelease: ResolverTypeWrapper; @@ -5251,6 +5258,7 @@ export type ResolversParentTypes = { ArchiveCommentInput: ArchiveCommentInput; AuthStrategy: AuthStrategy; AutomateAuthCodePayloadTest: AutomateAuthCodePayloadTest; + AutomateAuthCodeResources: AutomateAuthCodeResources; AutomateFunction: AutomateFunctionGraphQLReturn; AutomateFunctionCollection: Omit & { items: Array }; AutomateFunctionRelease: AutomateFunctionReleaseGraphQLReturn; diff --git a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts index 472244db8..11ed499d1 100644 --- a/packages/server/modules/cross-server-sync/graph/generated/graphql.ts +++ b/packages/server/modules/cross-server-sync/graph/generated/graphql.ts @@ -246,6 +246,11 @@ export type AutomateAuthCodePayloadTest = { workspaceId?: InputMaybe; }; +/** Additional resources to validate user access to. */ +export type AutomateAuthCodeResources = { + workspaceId?: InputMaybe; +}; + export type AutomateFunction = { __typename?: 'AutomateFunction'; /** Only returned if user is a part of this speckle server */ @@ -2728,6 +2733,7 @@ export type QueryAutomateFunctionsArgs = { export type QueryAutomateValidateAuthCodeArgs = { payload: AutomateAuthCodePayloadTest; + resources?: InputMaybe; }; diff --git a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts index a35dde9d4..3090fbf10 100644 --- a/packages/server/modules/workspaces/graph/resolvers/workspaces.ts +++ b/packages/server/modules/workspaces/graph/resolvers/workspaces.ts @@ -41,7 +41,7 @@ import { import { createProjectInviteFactory } from '@/modules/serverinvites/services/projectInviteManagement' import { getInvitationTargetUsersFactory } from '@/modules/serverinvites/services/retrieval' import { authorizeResolver } from '@/modules/shared' -import { getFeatureFlags, getServerOrigin } from '@/modules/shared/helpers/envHelper' +import { getFeatureFlags } from '@/modules/shared/helpers/envHelper' import { getEventBus } from '@/modules/shared/services/eventBus' import { WorkspaceInviteResourceType } from '@/modules/workspacesCore/domain/constants' import { @@ -169,17 +169,12 @@ import { listWorkspaceSsoMembershipsFactory } from '@/modules/workspaces/repositories/sso' import { getDecryptor } from '@/modules/workspaces/helpers/sso' -import { getWorkspaceFunctions } from '@/modules/automate/clients/executionEngine' +import { getFunctions } from '@/modules/automate/clients/executionEngine' import { ExecutionEngineFailedResponseError, ExecutionEngineNetworkError } from '@/modules/automate/errors/executionEngine' import { getDefaultRegionFactory } from '@/modules/workspaces/repositories/regions' -import { - AuthCodePayloadAction, - createStoredAuthCodeFactory -} from '@/modules/automate/services/authCode' -import { getGenericRedis } from '@/modules/shared/redis/redis' import { convertFunctionToGraphQLReturn } from '@/modules/automate/services/functionManagement' import { getWorkspacePlanFactory, @@ -202,6 +197,11 @@ import { OperationTypeNode } from 'graphql' import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans' import { GetWorkspaceCollaboratorsArgs } from '@/modules/workspaces/domain/operations' import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types' +import { getGenericRedis } from '@/modules/shared/redis/redis' +import { + AuthCodePayloadAction, + createStoredAuthCodeFactory +} from '@/modules/automate/services/authCode' const eventBus = getEventBus() const getServerInfo = getServerInfoFactory({ db }) @@ -1079,6 +1079,13 @@ export = FF_WORKSPACES_MODULE_ENABLED }, automateFunctions: async (parent, args, context) => { try { + await authorizeResolver( + context.userId, + parent.id, + Roles.Workspace.Member, + context.resourceAccessRules + ) + const authCode = await createStoredAuthCodeFactory({ redis: getGenericRedis() })({ @@ -1086,14 +1093,16 @@ export = FF_WORKSPACES_MODULE_ENABLED action: AuthCodePayloadAction.ListWorkspaceFunctions }) - const res = await getWorkspaceFunctions({ - workspaceId: parent.id, - query: removeNullOrUndefinedKeys(args), - body: { - speckleServerAuthenticationPayload: { - ...authCode, - origin: getServerOrigin() - } + const res = await getFunctions({ + auth: authCode, + filters: { + query: args.filter?.search ?? undefined, + cursor: args.cursor ?? undefined, + limit: args.limit, + requireRelease: true, + includeFeatured: true, + includeWorkspaces: [parent.id], + includeUsers: [] } }) @@ -1105,12 +1114,12 @@ export = FF_WORKSPACES_MODULE_ENABLED } } - const items = res.functions.map(convertFunctionToGraphQLReturn) + const items = res.items.map(convertFunctionToGraphQLReturn) return { - cursor: undefined, - totalCount: res.functions.length, - items + items, + cursor: res.cursor, + totalCount: res.totalCount } } catch (e) { const isNotFound = diff --git a/packages/server/test/graphql/generated/graphql.ts b/packages/server/test/graphql/generated/graphql.ts index 80068b083..6f0e1c765 100644 --- a/packages/server/test/graphql/generated/graphql.ts +++ b/packages/server/test/graphql/generated/graphql.ts @@ -247,6 +247,11 @@ export type AutomateAuthCodePayloadTest = { workspaceId?: InputMaybe; }; +/** Additional resources to validate user access to. */ +export type AutomateAuthCodeResources = { + workspaceId?: InputMaybe; +}; + export type AutomateFunction = { __typename?: 'AutomateFunction'; /** Only returned if user is a part of this speckle server */ @@ -2729,6 +2734,7 @@ export type QueryAutomateFunctionsArgs = { export type QueryAutomateValidateAuthCodeArgs = { payload: AutomateAuthCodePayloadTest; + resources?: InputMaybe; };