feat(automate): use updated hybrid function search (#4085)

* fix(automate): use new function query for workspace functions

* fix(automate): include workspace resource claims in auth flow

* chore(automate): do not use btoa
This commit is contained in:
Chuck Driesler
2025-02-28 16:03:34 +00:00
committed by GitHub
parent 6f0133a39b
commit 3cc68bb0e2
10 changed files with 135 additions and 32 deletions
@@ -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 {
@@ -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<string, string | number | boolean | undefined>
query: Record<string, string[] | string | number | boolean | undefined>
}>
) => {
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<string>
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<GetFunctionsResponse>({
url,
method: 'get',
token: authToken
})
}
export type GetPublicFunctionsResponse = {
totalCount: number
cursor: Nullable<string>
items: FunctionWithVersionsSchemaType[]
@@ -482,7 +526,7 @@ export const getPublicFunctions = async (params: {
}
})
return await invokeSafeJsonRequest<GetFunctionsResponse>({
return await invokeSafeJsonRequest<GetPublicFunctionsResponse>({
url,
method: 'get'
})
@@ -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) {
@@ -20,6 +20,8 @@ export type FunctionSchemaType = {
speckleUserId: string
speckleServerOrigin: string
}>
functionCreatorSpeckleUserId: Nullable<string>
functionCreatorSpeckleServerOrigin: Nullable<string>
workspaceIds: string[]
}
@@ -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 }
})
}
@@ -89,6 +89,14 @@ const cleanFunctionLogo = (logo: MaybeNullOrUndefined<string>): Nullable<string>
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
}
@@ -265,6 +265,11 @@ export type AutomateAuthCodePayloadTest = {
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
/** Additional resources to validate user access to. */
export type AutomateAuthCodeResources = {
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
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<AutomateAuthCodeResources>;
};
@@ -4938,6 +4944,7 @@ export type ResolversTypes = {
ArchiveCommentInput: ArchiveCommentInput;
AuthStrategy: ResolverTypeWrapper<AuthStrategy>;
AutomateAuthCodePayloadTest: AutomateAuthCodePayloadTest;
AutomateAuthCodeResources: AutomateAuthCodeResources;
AutomateFunction: ResolverTypeWrapper<AutomateFunctionGraphQLReturn>;
AutomateFunctionCollection: ResolverTypeWrapper<Omit<AutomateFunctionCollection, 'items'> & { items: Array<ResolversTypes['AutomateFunction']> }>;
AutomateFunctionRelease: ResolverTypeWrapper<AutomateFunctionReleaseGraphQLReturn>;
@@ -5251,6 +5258,7 @@ export type ResolversParentTypes = {
ArchiveCommentInput: ArchiveCommentInput;
AuthStrategy: AuthStrategy;
AutomateAuthCodePayloadTest: AutomateAuthCodePayloadTest;
AutomateAuthCodeResources: AutomateAuthCodeResources;
AutomateFunction: AutomateFunctionGraphQLReturn;
AutomateFunctionCollection: Omit<AutomateFunctionCollection, 'items'> & { items: Array<ResolversParentTypes['AutomateFunction']> };
AutomateFunctionRelease: AutomateFunctionReleaseGraphQLReturn;
@@ -246,6 +246,11 @@ export type AutomateAuthCodePayloadTest = {
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
/** Additional resources to validate user access to. */
export type AutomateAuthCodeResources = {
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
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<AutomateAuthCodeResources>;
};
@@ -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 =
@@ -247,6 +247,11 @@ export type AutomateAuthCodePayloadTest = {
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
/** Additional resources to validate user access to. */
export type AutomateAuthCodeResources = {
workspaceId?: InputMaybe<Scalars['String']['input']>;
};
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<AutomateAuthCodeResources>;
};