Skip to content

Commit faa571c

Browse files
christopherholland-workdaychristopherholland-workday
andauthored
Mass Assignment in Assistant Update Endpoint (#5945)
* Mass Assignment in Assistant Update Endpoint Allows Cross-Workspace Resource Reassignment * Mass Assignment in Assistant Update Endpoint Allows Cross-Workspace Resource Reassignment * Mass Assignment in Assistant Update Endpoint Allows Cross-Workspace Resource Reassignment * Mass Assignment in Assistant Update Endpoint --------- Co-authored-by: christopherholland-workday <christopher.holland+evisort@workday.com>
1 parent 24e713b commit faa571c

4 files changed

Lines changed: 128 additions & 13 deletions

File tree

packages/server/src/controllers/assistants/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const createAssistant = async (req: Request, res: Response, next: NextFunction)
3636
await checkUsageLimit('flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingAssistantCount + newAssistantCount)
3737

3838
body.workspaceId = workspaceId
39-
const apiResponse = await assistantsService.createAssistant(body, orgId)
39+
const apiResponse = await assistantsService.createAssistant(body, orgId, workspaceId)
4040

4141
return res.json(apiResponse)
4242
} catch (error) {

packages/server/src/services/assistants/index.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { stripProtectedFields } from '../../utils/stripProtectedFields'
12
import { extractResponseContent, ICommonObject } from 'flowise-components'
23
import { StatusCodes } from 'http-status-codes'
34
import { cloneDeep, isEqual, uniqWith } from 'lodash'
@@ -20,7 +21,7 @@ import { ASSISTANT_PROMPT_GENERATOR } from '../../utils/prompt'
2021
import { checkUsageLimit } from '../../utils/quotaUsage'
2122
import nodesService from '../nodes'
2223

23-
const createAssistant = async (requestBody: any, orgId: string): Promise<Assistant> => {
24+
const createAssistant = async (requestBody: any, orgId: string, workspaceId: string): Promise<Assistant> => {
2425
try {
2526
const appServer = getRunningExpressApp()
2627
if (!requestBody.details) {
@@ -29,8 +30,11 @@ const createAssistant = async (requestBody: any, orgId: string): Promise<Assista
2930
const assistantDetails = JSON.parse(requestBody.details)
3031

3132
if (requestBody.type === 'CUSTOM') {
33+
// For CUSTOM assistants the credential field is a client-generated UUID used as an
34+
// internal identifier, not a reference to the Credential entity, so no lookup is needed.
3235
const newAssistant = new Assistant()
33-
Object.assign(newAssistant, requestBody)
36+
Object.assign(newAssistant, stripProtectedFields(requestBody))
37+
newAssistant.workspaceId = workspaceId
3438

3539
const assistant = appServer.AppDataSource.getRepository(Assistant).create(newAssistant)
3640
const dbResponse = await appServer.AppDataSource.getRepository(Assistant).save(assistant)
@@ -51,7 +55,8 @@ const createAssistant = async (requestBody: any, orgId: string): Promise<Assista
5155

5256
try {
5357
const credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({
54-
id: requestBody.credential
58+
id: requestBody.credential,
59+
workspaceId: workspaceId
5560
})
5661

5762
if (!credential) {
@@ -297,12 +302,24 @@ const updateAssistant = async (assistantId: string, requestBody: any, workspaceI
297302
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Assistant ${assistantId} not found`)
298303
}
299304

305+
if (requestBody.details !== undefined) {
306+
if (!requestBody.details) {
307+
throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Details cannot be empty`)
308+
}
309+
let parsedDetails: any
310+
try {
311+
parsedDetails = JSON.parse(requestBody.details)
312+
} catch (e) {
313+
throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Details must be valid JSON`)
314+
}
315+
if (assistant.type === 'CUSTOM' && !parsedDetails?.name) {
316+
throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Details must include a name field`)
317+
}
318+
}
319+
300320
if (assistant.type === 'CUSTOM') {
301-
const body = requestBody
302-
const updateAssistant = new Assistant()
303-
Object.assign(updateAssistant, body)
321+
Object.assign(assistant, stripProtectedFields(requestBody))
304322

305-
appServer.AppDataSource.getRepository(Assistant).merge(assistant, updateAssistant)
306323
const dbResponse = await appServer.AppDataSource.getRepository(Assistant).save(assistant)
307324
return dbResponse
308325
}
@@ -312,7 +329,8 @@ const updateAssistant = async (assistantId: string, requestBody: any, workspaceI
312329
const body = requestBody
313330
const assistantDetails = JSON.parse(body.details)
314331
const credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({
315-
id: body.credential
332+
id: body.credential,
333+
workspaceId: workspaceId
316334
})
317335

318336
if (!credential) {
@@ -376,11 +394,12 @@ const updateAssistant = async (assistantId: string, requestBody: any, workspaceI
376394
}
377395
if (savedToolResources) newAssistantDetails.tool_resources = savedToolResources
378396

379-
const updateAssistant = new Assistant()
380-
body.details = JSON.stringify(newAssistantDetails)
381-
Object.assign(updateAssistant, body)
397+
// Explicit allowlist — mutate only allowed fields on the fetched entity (same
398+
// reasoning as the CUSTOM path above: avoid merge() with an intermediate entity).
399+
assistant.details = JSON.stringify(newAssistantDetails)
400+
if (body.credential !== undefined) assistant.credential = body.credential
401+
if (body.iconSrc !== undefined) assistant.iconSrc = body.iconSrc
382402

383-
appServer.AppDataSource.getRepository(Assistant).merge(assistant, updateAssistant)
384403
const dbResponse = await appServer.AppDataSource.getRepository(Assistant).save(assistant)
385404
return dbResponse
386405
} catch (error) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Fields that are managed exclusively by the server and must never be
3+
* overwritten by user-supplied request bodies.
4+
*/
5+
export const PROTECTED_FIELDS = ['id', 'createdDate', 'updatedDate', 'workspaceId', 'organizationId'] as const
6+
7+
export type ProtectedField = (typeof PROTECTED_FIELDS)[number]
8+
9+
/**
10+
* Returns a shallow copy of `body` with all server-managed fields removed.
11+
* Use this before assigning a request body to a database entity to prevent
12+
* mass assignment of fields such as `workspaceId`, `id`, and timestamps.
13+
*
14+
* @example
15+
* Object.assign(entity, stripProtectedFields(req.body))
16+
*/
17+
export function stripProtectedFields<T extends Record<string, unknown>>(body: T): Omit<T, ProtectedField> {
18+
const sanitized = { ...body }
19+
for (const field of PROTECTED_FIELDS) {
20+
delete sanitized[field]
21+
}
22+
return sanitized as Omit<T, ProtectedField>
23+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { PROTECTED_FIELDS, stripProtectedFields } from '../../src/utils/stripProtectedFields'
2+
3+
describe('stripProtectedFields', () => {
4+
it('removes id from input', () => {
5+
const result = stripProtectedFields({ id: 'abc-123', name: 'test' })
6+
expect(result).not.toHaveProperty('id')
7+
expect(result).toHaveProperty('name', 'test')
8+
})
9+
10+
it('removes createdDate from input', () => {
11+
const result = stripProtectedFields({ createdDate: new Date().toISOString(), name: 'test' })
12+
expect(result).not.toHaveProperty('createdDate')
13+
})
14+
15+
it('removes updatedDate from input', () => {
16+
const result = stripProtectedFields({ updatedDate: new Date().toISOString(), name: 'test' })
17+
expect(result).not.toHaveProperty('updatedDate')
18+
})
19+
20+
it('removes workspaceId from input', () => {
21+
const result = stripProtectedFields({ workspaceId: 'ws-999', details: '{}' })
22+
expect(result).not.toHaveProperty('workspaceId')
23+
expect(result).toHaveProperty('details', '{}')
24+
})
25+
26+
it('removes organizationId from input', () => {
27+
const result = stripProtectedFields({ organizationId: 'org-456', credential: 'cred-uuid' })
28+
expect(result).not.toHaveProperty('organizationId')
29+
expect(result).toHaveProperty('credential', 'cred-uuid')
30+
})
31+
32+
it('removes all protected fields when all are present', () => {
33+
const body = {
34+
id: 'abc',
35+
createdDate: '2026-01-01T00:00:00.000Z',
36+
updatedDate: '2026-01-02T00:00:00.000Z',
37+
workspaceId: '11111111-2222-3333-4444-555555555555',
38+
organizationId: 'org-789',
39+
details: '{"name":"my assistant"}',
40+
credential: 'cred-uuid',
41+
iconSrc: null
42+
}
43+
const result = stripProtectedFields(body)
44+
for (const field of PROTECTED_FIELDS) {
45+
expect(result).not.toHaveProperty(field)
46+
}
47+
expect(result).toEqual({ details: '{"name":"my assistant"}', credential: 'cred-uuid', iconSrc: null })
48+
})
49+
50+
it('preserves all non-protected fields', () => {
51+
const body = { details: '{}', credential: 'cred-1', iconSrc: 'https://example.com/icon.png', type: 'CUSTOM' }
52+
const result = stripProtectedFields(body)
53+
expect(result).toEqual(body)
54+
})
55+
56+
it('returns an empty object when given an empty object', () => {
57+
const result = stripProtectedFields({})
58+
expect(result).toEqual({})
59+
})
60+
61+
it('returns an equal shallow copy when no protected fields are present', () => {
62+
const body = { name: 'tool', description: 'does stuff', color: '#ff0000' }
63+
const result = stripProtectedFields(body)
64+
expect(result).toEqual(body)
65+
})
66+
67+
it('does not mutate the original input object', () => {
68+
const original = { id: 'abc', workspaceId: 'ws-1', name: 'assistant' }
69+
const copy = { ...original }
70+
stripProtectedFields(original)
71+
expect(original).toEqual(copy)
72+
})
73+
})

0 commit comments

Comments
 (0)