Skip to content

Commit df565c7

Browse files
committed
[COLLAB-23]: Verify Tiles permissions on role change or pipe transfer (#1283)
## TL;DR This PR adds permission checks to ensure users have appropriate access to Tiles when: 1. Creating a flow transfer - verifies the current owner has editor rights to all Tiles in the Pipe 2. Approving a flow transfer - checks the old owner has editor permissions before adding the new owner as a collaborator 3. Adding flow collaborators - ensures the user adding collaborators has editor access to the Tile 4. Prevents downgrading Tile permissions when a Pipe collaborator is downgraded from editor to viewer ## Tests - [x] Should not be able to create a pipe transfer if the current owner is not an editor of a Tile that is used in a step of the Pipe (could happen if the Tile action was created, then the Tile collaborator role was removed or changed to Viewer) - [x] Should not be able to accept a pipe transfer if the old owner is not an editor of a Tile that is used in a step of the Pipe (could happen if the transfer was created, then the Tile collaborator role was removed or changed to Viewer) - [x] Should not be able to add a new collaborator if the user is not an editor of a Tile - [x] Tile collaborator role should not be downgraded if the Pipe role is changed from editor to viewer
1 parent 08e857f commit df565c7

File tree

8 files changed

+260
-11
lines changed

8 files changed

+260
-11
lines changed

packages/backend/src/graphql/__tests__/mutations/update-flow-transfer-status.itest.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { randomUUID } from 'crypto'
22
import { AES } from 'crypto-js'
3-
import { beforeEach, describe, expect, it } from 'vitest'
3+
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

55
import appConfig from '@/config/app'
66
import updateFlowTransferStatus from '@/graphql/mutations/update-flow-transfer-status'
@@ -10,10 +10,11 @@ import ExecutionStep from '@/models/execution-step'
1010
import Flow from '@/models/flow'
1111
import FlowTransfer from '@/models/flow-transfers'
1212
import Step from '@/models/step'
13+
import TableCollaborator from '@/models/table-collaborators'
1314
import User from '@/models/user'
1415
import Context from '@/types/express/context'
1516

16-
import { generateMockContext } from './tiles/table.mock'
17+
import { generateMockContext, generateMockTable } from './tiles/table.mock'
1718
import { generateMockFlow, generateMockUser } from './flow.mock'
1819

1920
describe('updateFlowTransferStatus', () => {
@@ -286,5 +287,108 @@ describe('updateFlowTransferStatus', () => {
286287
const slackStep = await Step.query().findById(slackStepId)
287288
expect(slackStep.status).toBe('completed')
288289
})
290+
291+
it('verifies old owner has editor access to table before adding new owner as collaborator', async () => {
292+
const { table } = await generateMockTable({
293+
userId: owner.id,
294+
databaseType: 'pg',
295+
})
296+
297+
// Create a tiles step that references the table
298+
const tilesStepId = randomUUID()
299+
await Step.query().insert({
300+
id: tilesStepId,
301+
flowId: mockFlow.id,
302+
key: 'createTileRow',
303+
appKey: 'tiles',
304+
type: 'action',
305+
position: 2,
306+
parameters: { rowData: [], tableId: table.id },
307+
status: 'completed',
308+
})
309+
310+
// Spy on hasAccess to verify it's called with correct parameters
311+
const hasAccessSpy = vi
312+
.spyOn(TableCollaborator, 'hasAccess')
313+
.mockResolvedValue(undefined)
314+
315+
const addCollaboratorSpy = vi
316+
.spyOn(TableCollaborator, 'addCollaborator')
317+
.mockResolvedValue(undefined)
318+
319+
// Approve the transfer as new owner
320+
context.currentUser = newOwner
321+
const result = await updateFlowTransferStatus(
322+
null,
323+
{ input: { id: transfer.id, status: 'approved' } },
324+
context,
325+
)
326+
327+
expect(result.status).toBe('approved')
328+
329+
// Verify hasAccess was called with old owner's ID and 'editor' role
330+
expect(hasAccessSpy).toHaveBeenCalledWith(
331+
owner.id, // oldOwnerId
332+
table.id, // tableId
333+
'editor', // required role
334+
)
335+
336+
// Verify addCollaborator was called to add new owner as editor
337+
expect(addCollaboratorSpy).toHaveBeenCalledWith({
338+
userId: newOwner.id,
339+
tableId: table.id,
340+
role: 'editor',
341+
trx: expect.anything(),
342+
})
343+
344+
hasAccessSpy.mockRestore()
345+
addCollaboratorSpy.mockRestore()
346+
})
347+
348+
it('throws error when old owner does not have editor access to table', async () => {
349+
const { table } = await generateMockTable({
350+
userId: owner.id,
351+
databaseType: 'pg',
352+
})
353+
354+
// Create a tiles step that references the table
355+
const tilesStepId = randomUUID()
356+
await Step.query().insert({
357+
id: tilesStepId,
358+
flowId: mockFlow.id,
359+
key: 'createTileRow',
360+
appKey: 'tiles',
361+
type: 'action',
362+
position: 2,
363+
parameters: { rowData: [], tableId: table.id },
364+
status: 'completed',
365+
})
366+
367+
// Mock hasAccess to throw ForbiddenError (old owner doesn't have access)
368+
const { ForbiddenError } = await import('@/errors/graphql-errors')
369+
const hasAccessSpy = vi
370+
.spyOn(TableCollaborator, 'hasAccess')
371+
.mockRejectedValue(
372+
new ForbiddenError(
373+
'You do not have sufficient permissions for this tile',
374+
),
375+
)
376+
377+
// Approve the transfer as new owner
378+
context.currentUser = newOwner
379+
380+
// Should throw an error indicating old owner lacks permissions
381+
await expect(
382+
updateFlowTransferStatus(
383+
null,
384+
{ input: { id: transfer.id, status: 'approved' } },
385+
context,
386+
),
387+
).rejects.toThrow(
388+
'Previous owner does not have sufficient permissions to add you as an Editor of the Tile.',
389+
)
390+
391+
hasAccessSpy.mockRestore()
392+
})
289393
})
290394
})

packages/backend/src/graphql/__tests__/mutations/update-step.itest.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,10 +640,12 @@ describe('updateStep mutation', () => {
640640
})
641641

642642
it('should call add to flow_connections and add table collaborator for tiles app with tableId parameter', async () => {
643-
const mockTableId = 'table-123'
643+
const mockTableId = randomUUID()
644644
const { default: FlowConnections } = await import(
645645
'@/models/flow-connections'
646646
)
647+
// mock the check that the user has access to the tile
648+
vi.spyOn(TableCollaborator, 'hasAccess').mockResolvedValue(undefined)
647649
const addCollaboratorSpy = vi
648650
.spyOn(TableCollaborator, 'addCollaborator')
649651
.mockResolvedValue(undefined)

packages/backend/src/graphql/__tests__/mutations/upsert-flow-collaborator.itest.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ describe('upsert flow collaborator', () => {
166166
db: 'pg',
167167
})
168168

169+
await TableCollaborator.query().insert({
170+
tableId: tilesTableId,
171+
userId: context.currentUser.id,
172+
role: 'owner',
173+
})
174+
169175
await Step.query().insert([
170176
{
171177
id: randomUUID(),
@@ -647,6 +653,90 @@ describe('upsert flow collaborator', () => {
647653
})
648654
expect(tableCollaborators).toHaveLength(1)
649655
})
656+
657+
it('should not allow adding collaborators if they are not an owner or editor of the tile', async () => {
658+
// add a Tile step
659+
await Step.query().insert({
660+
id: randomUUID(),
661+
flowId: dummyFlow.id,
662+
key: 'createTileRow',
663+
appKey: 'tiles',
664+
type: 'action',
665+
parameters: { tableId: tilesTableId1 },
666+
position: 1,
667+
})
668+
669+
// add the editor as a collaborator of the Pipe first
670+
await FlowCollaborator.query().insert({
671+
flowId: dummyFlow.id,
672+
userId: editor.id,
673+
role: 'editor',
674+
updatedBy: context.currentUser.id,
675+
})
676+
677+
// editor should not be able to add the viewer as the editor is not a collaborator of the tile
678+
context.currentUser = editor
679+
await expect(
680+
upsertFlowCollaborator(
681+
null,
682+
{
683+
input: {
684+
flowId: dummyFlow.id,
685+
email: viewer.email,
686+
role: 'viewer',
687+
},
688+
},
689+
context,
690+
),
691+
).rejects.toThrow('You do not have sufficient permissions for this tile')
692+
})
693+
694+
it('should not downgrade the role on Tiles when the Pipe collaborator is being downgraded from an Editor to a Viewer', async () => {
695+
// add a Tile step
696+
await Step.query().insert({
697+
id: randomUUID(),
698+
flowId: dummyFlow.id,
699+
key: 'createTileRow',
700+
appKey: 'tiles',
701+
type: 'action',
702+
parameters: { tableId: tilesTableId1 },
703+
position: 1,
704+
})
705+
706+
// add the editor as a collaborator of the Pipe first
707+
await upsertFlowCollaborator(
708+
null,
709+
{
710+
input: { flowId: dummyFlow.id, email: editor.email, role: 'editor' },
711+
},
712+
context,
713+
)
714+
715+
// Check that only one table collaborator was added (duplicates should be handled)
716+
const tableCollaborators = await TableCollaborator.query().where({
717+
user_id: editor.id,
718+
})
719+
expect(tableCollaborators).toHaveLength(1)
720+
expect(tableCollaborators[0].tableId).toBe(tilesTableId1)
721+
expect(tableCollaborators[0].role).toBe('editor')
722+
723+
// now we downgrade the Pipe collaborator from an Editor to a Viewer
724+
await upsertFlowCollaborator(
725+
null,
726+
{
727+
input: { flowId: dummyFlow.id, email: editor.email, role: 'viewer' },
728+
},
729+
context,
730+
)
731+
732+
// Check that the table collaborator role was not downgraded
733+
const tableCollaborators2 = await TableCollaborator.query().where({
734+
user_id: editor.id,
735+
})
736+
expect(tableCollaborators2).toHaveLength(1)
737+
expect(tableCollaborators2[0].tableId).toBe(tilesTableId1)
738+
expect(tableCollaborators2[0].role).toBe('editor')
739+
})
650740
})
651741

652742
it('should not allow adding collaborators if collaborators flag is false', async () => {

packages/backend/src/graphql/mutations/create-flow-transfer.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import FlowTransfer from '@/models/flow-transfers'
2+
import TableCollaborator from '@/models/table-collaborators'
23
import User from '@/models/user'
34

45
import type { MutationResolvers } from '../__generated__/types.generated'
@@ -11,8 +12,9 @@ const createFlowTransfer: MutationResolvers['createFlowTransfer'] = async (
1112
const { flowId, newOwnerEmail } = params.input
1213

1314
// check if flow belongs to the old owner first
14-
await context.currentUser
15+
const flow = await context.currentUser
1516
.$relatedQuery('flows')
17+
.withGraphFetched('steps')
1618
.where('id', flowId)
1719
.throwIfNotFound('This pipe does not belong to you.')
1820

@@ -49,6 +51,24 @@ const createFlowTransfer: MutationResolvers['createFlowTransfer'] = async (
4951
)
5052
}
5153

54+
// COLLABORATORS:
55+
// check that the current owner has the rights to make the new owner
56+
// an editor of the Tile (if any)
57+
const tilesSteps =
58+
flow?.[0].steps?.filter((step) => step.appKey === 'tiles') ?? []
59+
60+
// check that the current owner has the rights to make the new owner
61+
// an editor of all the Tiles in the Pipe
62+
await Promise.all(
63+
tilesSteps.map(async (step) => {
64+
await TableCollaborator.hasAccess(
65+
context.currentUser.id,
66+
step.parameters.tableId as string,
67+
'editor',
68+
)
69+
}),
70+
)
71+
5272
const newTransfer: FlowTransfer = await context.currentUser
5373
.$relatedQuery('sentFlowTransfers')
5474
.insert({

packages/backend/src/graphql/mutations/update-flow-transfer-status.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BadUserInputError } from '@/errors/graphql-errors'
1+
import { BadUserInputError, ForbiddenError } from '@/errors/graphql-errors'
22
import { getConnectionDetails } from '@/helpers/get-shared-connection-details'
33
import logger from '@/helpers/logger'
44
import Connection from '@/models/connection'
@@ -201,6 +201,13 @@ const updateFlowTransferStatus: MutationResolvers['updateFlowTransferStatus'] =
201201
const tableInserts = await Promise.all(
202202
sharedTables.map(async (tableId) => {
203203
try {
204+
// the old owner should have the permissions to add the new owner as an editor of the table
205+
await TableCollaborator.hasAccess(
206+
flowTransfer.oldOwnerId,
207+
tableId,
208+
'editor',
209+
)
210+
204211
// there may be instances where the new pipe owner is already the owner of the table
205212
// we catch the error but do nothing`
206213
await TableCollaborator.addCollaborator({
@@ -210,6 +217,11 @@ const updateFlowTransferStatus: MutationResolvers['updateFlowTransferStatus'] =
210217
trx,
211218
})
212219
} catch (e) {
220+
if (e instanceof ForbiddenError) {
221+
throw new Error(
222+
'Previous owner does not have sufficient permissions to add you as an Editor of the Tile.',
223+
)
224+
}
213225
// do nothing if the new owner of the pipe is already the owner of the tile
214226
if (
215227
!(e instanceof BadUserInputError) &&

packages/backend/src/helpers/add-flow-connection.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ async function addFlowTableConnection({
6464
addedBy,
6565
trx,
6666
}: AddTableFlowConnectionParams): Promise<void> {
67+
// COLLABORATORS:
68+
// check that the user is already an Owner / Editor of the Tile first
69+
// otherwise they should not be allowed to add a new collaborator
70+
await TableCollaborator.hasAccess(addedBy, tableId, 'editor')
71+
6772
// first try to add the connection to the flow_connections table
6873
const addedConnection = await FlowConnections.addFlowConnection({
6974
flowId,

packages/backend/src/models/table-collaborators.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,17 @@ class TableCollaborator extends Base {
112112
if (existingCollaborator.role === 'owner') {
113113
throw new BadUserInputError('Cannot change owner role')
114114
}
115+
116+
/**
117+
* COLLABORATORS:
118+
* should not downgrade the role on Tiles when the Pipe collaborator is being
119+
* downgraded from an Editor to a Viewer.
120+
* Tile Owner / Editor should do that manually.
121+
*/
122+
if (role === 'viewer' && existingCollaborator.role === 'editor') {
123+
return
124+
}
125+
115126
await existingCollaborator
116127
.$query(trx)
117128
.patchAndFetch({

packages/frontend/src/components/EditorSettings/FlowShare/AddNewCollaborator.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,17 @@ const AddNewCollaborator = ({
7272
)
7373

7474
const onConfirm = useCallback(async () => {
75-
setIsAdding(true)
76-
const email = getValues('email')
77-
await onAdd(email, role)
78-
resetField('email')
79-
onClose()
80-
setIsAdding(false)
75+
try {
76+
setIsAdding(true)
77+
const email = getValues('email')
78+
await onAdd(email, role)
79+
resetField('email')
80+
} catch (error) {
81+
console.error(error)
82+
} finally {
83+
onClose()
84+
setIsAdding(false)
85+
}
8186
}, [onAdd, getValues, role, onClose, resetField])
8287

8388
return (

0 commit comments

Comments
 (0)