Skip to content

Commit 4705e45

Browse files
authored
Revoke FGA roles (#5139)
* revoke relations * tests * add comment * restore
1 parent 867b759 commit 4705e45

6 files changed

+155
-16
lines changed

utopia-remix/app/models/projectAccessRequest.server.spec.ts

+46-2
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ import { prisma } from '../db.server'
22
import {
33
createTestProject,
44
createTestProjectAccessRequest,
5+
createTestProjectCollaborator,
56
createTestUser,
67
truncateTables,
78
} from '../test-util'
8-
import { AccessRequestStatus } from '../types'
9+
import { AccessRequestStatus, UserProjectRole } from '../types'
910
import {
1011
createAccessRequest,
1112
listProjectAccessRequests,
1213
updateAccessRequestStatus,
1314
} from './projectAccessRequest.server'
15+
import * as permissionsService from '../services/permissionsService.server'
1416

1517
describe('projectAccessRequest', () => {
1618
describe('createAccessRequest', () => {
@@ -78,6 +80,9 @@ describe('projectAccessRequest', () => {
7880
})
7981

8082
describe('updateAccessRequestStatus', () => {
83+
let revokeAllRolesFromUserMock: jest.SpyInstance
84+
let grantProjectRoleToUserMock: jest.SpyInstance
85+
8186
afterEach(async () => {
8287
await truncateTables([
8388
prisma.projectID,
@@ -86,13 +91,19 @@ describe('projectAccessRequest', () => {
8691
prisma.project,
8792
prisma.userDetails,
8893
])
94+
95+
revokeAllRolesFromUserMock.mockRestore()
96+
grantProjectRoleToUserMock.mockRestore()
8997
})
9098
beforeEach(async () => {
9199
await createTestUser(prisma, { id: 'bob' })
92100
await createTestUser(prisma, { id: 'alice' })
93101
await createTestUser(prisma, { id: 'that-guy' })
94102
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
95103
await createTestProject(prisma, { id: 'two', ownerId: 'alice' })
104+
105+
revokeAllRolesFromUserMock = jest.spyOn(permissionsService, 'revokeAllRolesFromUser')
106+
grantProjectRoleToUserMock = jest.spyOn(permissionsService, 'grantProjectRoleToUser')
96107
})
97108
it('requires an existing project', async () => {
98109
const fn = async () =>
@@ -171,6 +182,39 @@ describe('projectAccessRequest', () => {
171182
const collabs = await prisma.projectCollaborator.findMany({ where: { project_id: 'one' } })
172183
expect(collabs.length).toBe(1)
173184
expect(collabs[0].user_id).toBe('alice')
185+
186+
expect(grantProjectRoleToUserMock).toHaveBeenCalledWith(
187+
'one',
188+
'alice',
189+
UserProjectRole.VIEWER,
190+
)
191+
expect(revokeAllRolesFromUserMock).not.toHaveBeenCalled()
192+
})
193+
it('removes the user from the collaborators if rejected', async () => {
194+
await createTestProjectCollaborator(prisma, { projectId: 'one', userId: 'alice' })
195+
const existingCollabs = await prisma.projectCollaborator.findMany({
196+
where: { project_id: 'one' },
197+
})
198+
expect(existingCollabs.length).toBe(1)
199+
200+
await createTestProjectAccessRequest(prisma, {
201+
projectId: 'one',
202+
userId: 'alice',
203+
token: 'something',
204+
status: AccessRequestStatus.PENDING,
205+
})
206+
await updateAccessRequestStatus({
207+
projectId: 'one',
208+
ownerId: 'bob',
209+
token: 'something',
210+
status: AccessRequestStatus.REJECTED,
211+
})
212+
213+
const collabs = await prisma.projectCollaborator.findMany({ where: { project_id: 'one' } })
214+
expect(collabs.length).toBe(0)
215+
216+
expect(grantProjectRoleToUserMock).not.toHaveBeenCalled()
217+
expect(revokeAllRolesFromUserMock).toHaveBeenCalledWith('one', 'alice')
174218
})
175219
})
176220

@@ -199,7 +243,7 @@ describe('projectAccessRequest', () => {
199243
await createTestProjectAccessRequest(prisma, {
200244
userId: 'p1',
201245
projectId: 'two',
202-
status: AccessRequestStatus.APPROVED,
246+
status: AccessRequestStatus.REJECTED,
203247
token: 'test1',
204248
})
205249
await createTestProjectAccessRequest(prisma, {

utopia-remix/app/models/projectAccessRequest.server.ts

+30-13
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { ensure } from '../util/api.server'
55
import { Status } from '../util/statusCodes'
66
import type { ProjectAccessRequestWithUserDetails } from '../types'
77
import { AccessRequestStatus, UserProjectRole } from '../types'
8-
import { addToProjectCollaboratorsWithRunner } from './projectCollaborators.server'
8+
import {
9+
addToProjectCollaboratorsWithRunner,
10+
removeFromProjectCollaboratorsWithRunner,
11+
} from './projectCollaborators.server'
12+
import { assertNever } from '../util/assertNever'
913

1014
function makeRequestToken(): string {
1115
return uuid.v4()
@@ -84,19 +88,32 @@ export async function updateAccessRequestStatus(params: {
8488
},
8589
})
8690

87-
// …finally, grant the role.
88-
if (params.status === AccessRequestStatus.APPROVED) {
89-
await addToProjectCollaboratorsWithRunner(tx, {
90-
projectId: params.projectId,
91-
userId: request.user_id,
92-
})
93-
await permissionsService.grantProjectRoleToUser(
94-
params.projectId,
95-
request.user_id,
96-
UserProjectRole.VIEWER,
97-
)
91+
// …finally, update FGA permissions
92+
switch (params.status) {
93+
case AccessRequestStatus.APPROVED:
94+
await addToProjectCollaboratorsWithRunner(tx, {
95+
projectId: params.projectId,
96+
userId: request.user_id,
97+
})
98+
await permissionsService.grantProjectRoleToUser(
99+
params.projectId,
100+
request.user_id,
101+
UserProjectRole.VIEWER,
102+
)
103+
break
104+
case AccessRequestStatus.REJECTED:
105+
await removeFromProjectCollaboratorsWithRunner(tx, {
106+
projectId: params.projectId,
107+
userId: request.user_id,
108+
})
109+
await permissionsService.revokeAllRolesFromUser(params.projectId, request.user_id)
110+
break
111+
case AccessRequestStatus.PENDING:
112+
// do nothing
113+
break
114+
default:
115+
assertNever(params.status)
98116
}
99-
// NOTE (ruggi): there should be a way to revoke the permission if the request is REJECTED (TODO)
100117
})
101118
}
102119

utopia-remix/app/models/projectCollaborators.server.spec.ts

+38
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getCollaborators,
1010
addToProjectCollaborators,
1111
listProjectCollaborators,
12+
removeFromProjectCollaborators,
1213
} from './projectCollaborators.server'
1314

1415
describe('projectCollaborators model', () => {
@@ -112,6 +113,43 @@ describe('projectCollaborators model', () => {
112113
})
113114
})
114115

116+
describe('removeFromProjectCollaborators', () => {
117+
beforeEach(async () => {
118+
await createTestUser(prisma, { id: 'bob' })
119+
await createTestUser(prisma, { id: 'alice' })
120+
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
121+
await createTestProject(prisma, { id: 'two', ownerId: 'alice' })
122+
await createTestProject(prisma, { id: 'three', ownerId: 'bob' })
123+
await createTestProjectCollaborator(prisma, { projectId: 'one', userId: 'bob' })
124+
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'alice' })
125+
await createTestProjectCollaborator(prisma, { projectId: 'two', userId: 'bob' })
126+
})
127+
128+
it('errors if the project is not found', async () => {
129+
const removed = await removeFromProjectCollaborators({ projectId: 'WRONG', userId: 'alice' })
130+
expect(removed.count).toBe(0)
131+
})
132+
it('does nothing if the user is not found', async () => {
133+
const removed = await removeFromProjectCollaborators({ projectId: 'one', userId: 'alice' })
134+
expect(removed.count).toBe(0)
135+
136+
const collaborators = await prisma.projectCollaborator.findMany({
137+
where: { project_id: 'one' },
138+
})
139+
expect(collaborators.map((p) => p.user_id)).toEqual(['bob'])
140+
})
141+
it('removes the user from the collaborators if present', async () => {
142+
const removed = await removeFromProjectCollaborators({ projectId: 'two', userId: 'bob' })
143+
expect(removed.count).toBe(1)
144+
145+
let collaborators = await prisma.projectCollaborator.findMany({
146+
where: { project_id: 'two' },
147+
orderBy: { id: 'asc' },
148+
})
149+
expect(collaborators.map((p) => p.user_id)).toEqual(['alice'])
150+
})
151+
})
152+
115153
describe('listProjectCollaborators', () => {
116154
beforeEach(async () => {
117155
await createTestUser(prisma, { id: 'bob' })

utopia-remix/app/models/projectCollaborators.server.ts

+22-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import type { UserDetails } from 'prisma-client'
1+
import type { ProjectCollaborator, UserDetails } from 'prisma-client'
22
import type { UtopiaPrismaClient } from '../db.server'
33
import { prisma } from '../db.server'
44
import type { CollaboratorsByProject } from '../types'
55
import { userToCollaborator } from '../types'
6+
import type { GetBatchResult } from 'prisma-client/runtime/library.js'
67

78
export async function getCollaborators(params: {
89
ids: string[]
@@ -48,6 +49,26 @@ export async function addToProjectCollaboratorsWithRunner(
4849
})
4950
}
5051

52+
export async function removeFromProjectCollaborators(params: {
53+
projectId: string
54+
userId: string
55+
}): Promise<GetBatchResult> {
56+
return removeFromProjectCollaboratorsWithRunner(prisma, params)
57+
}
58+
59+
export async function removeFromProjectCollaboratorsWithRunner(
60+
runner: Pick<UtopiaPrismaClient, 'projectCollaborator'>,
61+
params: { projectId: string; userId: string },
62+
) {
63+
// using delete many so not to explode if the record is not found
64+
return await runner.projectCollaborator.deleteMany({
65+
where: {
66+
user_id: params.userId,
67+
project_id: params.projectId,
68+
},
69+
})
70+
}
71+
5172
export async function listProjectCollaborators(params: { id: string }): Promise<UserDetails[]> {
5273
const collaborators = await prisma.projectCollaborator.findMany({
5374
where: { project_id: params.id },

utopia-remix/app/services/fgaService.server.ts

+10
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,13 @@ function accessLevelToFgaWrites(projectId: string, accessLevel: AccessLevel): Cl
162162
assertNever(accessLevel)
163163
}
164164
}
165+
166+
export async function revokeRelations(projectId: string, userId: string, relations: string[]) {
167+
return await Promise.all(
168+
relations.map((relation) =>
169+
fgaClient.write({
170+
deletes: [{ user: `user:${userId}`, relation: relation, object: `project:${projectId}` }],
171+
}),
172+
),
173+
)
174+
}

utopia-remix/app/services/permissionsService.server.ts

+9
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,12 @@ export async function grantProjectRoleToUser(
7373
assertNever(role)
7474
}
7575
}
76+
77+
export async function revokeAllRolesFromUser(projectId: string, userId: string) {
78+
return await fgaService.revokeRelations(projectId, userId, [
79+
'viewer',
80+
'collaborator',
81+
'editor',
82+
'admin',
83+
])
84+
}

0 commit comments

Comments
 (0)