Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Access requests #5039

Merged
merged 10 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions server/migrations/009.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE project_access_request(
id serial,
project_id text NOT NULL REFERENCES project(proj_id) ON DELETE CASCADE,
user_id character varying NOT NULL REFERENCES user_details(user_id) ON DELETE CASCADE,
token text NOT NULL,
status integer NOT NULL DEFAULT 0,
created_at timestamp with time zone NOT NULL DEFAULT NOW(),
updated_at timestamp with time zone NOT NULL DEFAULT NOW()
);

CREATE INDEX "idx_project_access_request_project_id" ON "public"."project_access_request"(project_id);

ALTER TABLE ONLY "project_access_request"
ADD CONSTRAINT "unique_project_access_request_project_id_user_id" UNIQUE ("project_id", "user_id");

ALTER TABLE ONLY "project_access_request"
ADD CONSTRAINT "unique_project_access_request_project_id_token" UNIQUE ("project_id", "token");

3 changes: 2 additions & 1 deletion server/src/Utopia/Web/Database/Migrations.hs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ migrateDatabase verbose includeInitial pool = withResource pool $ \connection ->
, MigrationFile "004.sql" "./migrations/004.sql"
, MigrationFile "005.sql" "./migrations/005.sql"
, MigrationFile "006.sql" "./migrations/006.sql"
, MigrationFile "007.sql" "./migrations/007.sql"
, MigrationFile "007.sql" "./migrations/007.sql"
, MigrationFile "008.sql" "./migrations/008.sql"
, MigrationFile "009.sql" "./migrations/009.sql"
]
let initialMigrationCommand = if includeInitial
then [MigrationFile "initial.sql" "./migrations/initial.sql"]
Expand Down
147 changes: 147 additions & 0 deletions utopia-remix/app/models/projectAccessRequest.server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { prisma } from '../db.server'
import {
createTestProject,
createTestProjectAccessRequest,
createTestUser,
truncateTables,
} from '../test-util'
import { AccessRequestStatus } from '../types'
import { createAccessRequest, updateAccessRequestStatus } from './projectAccessRequest.server'

describe('projectAccessRequest', () => {
describe('createAccessRequest', () => {
afterEach(async () => {
await truncateTables([
prisma.projectID,
prisma.projectAccessRequest,
prisma.project,
prisma.userDetails,
])
})
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestUser(prisma, { id: 'that-guy' })
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
await createTestProject(prisma, { id: 'two', ownerId: 'alice' })
})
it('requires an existing project', async () => {
const fn = async () => createAccessRequest({ projectId: 'unknown', userId: 'that-guy' })
await expect(fn).rejects.toThrow('project not found')
})
it('requires a non-deleted project', async () => {
await createTestProject(prisma, { id: 'deleted', ownerId: 'bob', deleted: true })
const fn = async () => createAccessRequest({ projectId: 'deleted', userId: 'that-guy' })
await expect(fn).rejects.toThrow('project not found')
})
describe('when the project is ok', () => {
it('does nothing if the user is the owner', async () => {
await createAccessRequest({ projectId: 'one', userId: 'bob' })
const requests = await prisma.projectAccessRequest.count()
expect(requests).toBe(0)
})
it('creates the new request in a pending state', async () => {
await createAccessRequest({ projectId: 'one', userId: 'that-guy' })
const requests = await prisma.projectAccessRequest.findMany()
expect(requests.length).toBe(1)
expect(requests[0].project_id).toBe('one')
expect(requests[0].user_id).toBe('that-guy')
expect(requests[0].status).toBe(AccessRequestStatus.PENDING)
expect(requests[0].token.length).toBeGreaterThan(0)
})
it('errors if the request is for a non existing user', async () => {
const fn = async () =>
createAccessRequest({ projectId: 'one', userId: 'a-guy-that-doesnt-exist' })
await expect(fn).rejects.toThrow()
})
it('does nothing if the request already exists for the same user', async () => {
await createTestProjectAccessRequest(prisma, {
projectId: 'one',
userId: 'that-guy',
status: AccessRequestStatus.REJECTED,
token: 'something',
})
await createAccessRequest({ projectId: 'one', userId: 'that-guy' })
const requests = await prisma.projectAccessRequest.findMany()
expect(requests.length).toBe(1)
expect(requests[0].project_id).toBe('one')
expect(requests[0].user_id).toBe('that-guy')
expect(requests[0].status).toBe(AccessRequestStatus.REJECTED)
expect(requests[0].token.length).toBeGreaterThan(0)
})
})
})

describe('updateAccessRequestStatus', () => {
afterEach(async () => {
await truncateTables([
prisma.projectID,
prisma.projectAccessRequest,
prisma.project,
prisma.userDetails,
])
})
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestUser(prisma, { id: 'that-guy' })
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
await createTestProject(prisma, { id: 'two', ownerId: 'alice' })
})
it('requires an existing project', async () => {
const fn = async () =>
updateAccessRequestStatus({
projectId: 'unknown',
ownerId: 'that-guy',
token: 'something',
status: AccessRequestStatus.APPROVED,
})
await expect(fn).rejects.toThrow('project not found')
})
it('requires the user to be the owner of the project', async () => {
const fn = async () =>
updateAccessRequestStatus({
projectId: 'one',
ownerId: 'alice',
token: 'something',
status: AccessRequestStatus.APPROVED,
})
await expect(fn).rejects.toThrow('project not found')
})
it('errors if the request is not find by its token', async () => {
await createTestProjectAccessRequest(prisma, {
projectId: 'one',
userId: 'alice',
token: 'something',
status: AccessRequestStatus.PENDING,
})
const fn = async () =>
updateAccessRequestStatus({
projectId: 'one',
ownerId: 'bob',
token: 'WRONG',
status: AccessRequestStatus.APPROVED,
})
await expect(fn).rejects.toThrow('not found')
})
it("updates the request's status", async () => {
await createTestProjectAccessRequest(prisma, {
projectId: 'one',
userId: 'alice',
token: 'something',
status: AccessRequestStatus.PENDING,
})
await updateAccessRequestStatus({
projectId: 'one',
ownerId: 'bob',
token: 'something',
status: AccessRequestStatus.APPROVED,
})
const requests = await prisma.projectAccessRequest.findMany()
expect(requests.length).toBe(1)
expect(requests[0].project_id).toBe('one')
expect(requests[0].user_id).toBe('alice')
expect(requests[0].status).toBe(AccessRequestStatus.APPROVED)
})
})
})
82 changes: 82 additions & 0 deletions utopia-remix/app/models/projectAccessRequest.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { prisma } from '../db.server'
import { AccessRequestStatus } from '../types'
import * as uuid from 'uuid'
import { ensure } from '../util/api.server'
import { Status } from '../util/statusCodes'

function makeRequestToken(): string {
return uuid.v4()
}

/**
* Create a new access request for a given project and the given user.
* The user must not be an owner of the project.
* If successful, a new request in the pending state will be created.
* If there already is a request for the projectId+userId pair, nothing happens.
*/
export async function createAccessRequest(params: { projectId: string; userId: string }) {
const token = makeRequestToken()
await prisma.$transaction(async (tx) => {
// let's check that the project exists, is not soft-deleted, and is not owned by the user
const project = await tx.project.findFirst({
where: {
proj_id: params.projectId,
OR: [{ deleted: null }, { deleted: false }],
},
select: { owner_id: true },
})
ensure(project != null, 'project not found', Status.NOT_FOUND)
if (project.owner_id === params.userId) {
// nothing to do
return
}

// the request can be created. it cannot be re-issued (for now…?)
await tx.projectAccessRequest.upsert({
where: {
project_id_user_id: {
project_id: params.projectId,
user_id: params.userId,
},
},
update: {}, // ON CONFLICT DO NOTHING
create: {
status: AccessRequestStatus.PENDING,
project_id: params.projectId,
user_id: params.userId,
token: token,
},
})
})
}

/**
* Update the status for the given request (via its token) for the given project owned by ownerId.
*/
export async function updateAccessRequestStatus(params: {
projectId: string
ownerId: string
token: string
status: AccessRequestStatus
}) {
await prisma.$transaction(async (tx) => {
// check that the project exists
const projectCount = await tx.project.count({
where: { proj_id: params.projectId, owner_id: params.ownerId },
})
ensure(projectCount === 1, 'project not found', Status.NOT_FOUND)

await tx.projectAccessRequest.update({
where: {
project_id_token: {
project_id: params.projectId,
token: params.token,
},
},
data: {
status: params.status,
updated_at: new Date(),
},
})
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ActionFunctionArgs } from '@remix-run/node'
import type { Params } from '@remix-run/react'
import { ensure, handle, requireUser } from '../util/api.server'
import { Status } from '../util/statusCodes'
import { validateProjectAccess } from '../handlers/validators'
import { updateAccessRequestStatus } from '../models/projectAccessRequest.server'
import { AccessRequestStatus, UserProjectPermission } from '../types'

export async function action(args: ActionFunctionArgs) {
return handle(args, {
POST: {
handler: handleApproveAccessRequest,
validator: validateProjectAccess(UserProjectPermission.CAN_MANAGE_PROJECT, {
getProjectId: (params) => params.id,
}),
},
})
}

export async function handleApproveAccessRequest(req: Request, params: Params<string>) {
const user = await requireUser(req)

const projectId = params.id
ensure(projectId != null, 'project id is null', Status.BAD_REQUEST)

const token = params.token
ensure(token != null && typeof token === 'string', 'token is invalid', Status.BAD_REQUEST)

await updateAccessRequestStatus({
projectId: projectId,
ownerId: user.user_id,
token: token,
status: AccessRequestStatus.APPROVED,
})

return {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ActionFunctionArgs } from '@remix-run/node'
import type { Params } from '@remix-run/react'
import { ensure, handle, requireUser } from '../util/api.server'
import { Status } from '../util/statusCodes'
import { validateProjectAccess } from '../handlers/validators'
import { updateAccessRequestStatus } from '../models/projectAccessRequest.server'
import { AccessRequestStatus, UserProjectPermission } from '../types'

export async function action(args: ActionFunctionArgs) {
return handle(args, {
POST: {
handler: handleApproveAccessRequest,
validator: validateProjectAccess(UserProjectPermission.CAN_MANAGE_PROJECT, {
getProjectId: (params) => params.id,
}),
},
})
}

export async function handleApproveAccessRequest(req: Request, params: Params<string>) {
const user = await requireUser(req)

const projectId = params.id
ensure(projectId != null, 'project id is null', Status.BAD_REQUEST)

const token = params.token
ensure(token != null && typeof token === 'string', 'token is invalid', Status.BAD_REQUEST)

await updateAccessRequestStatus({
projectId: projectId,
ownerId: user.user_id,
token: token,
status: AccessRequestStatus.REJECTED,
})

return {}
}
29 changes: 29 additions & 0 deletions utopia-remix/app/routes/internal.projects.$id.access.request.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ActionFunctionArgs } from '@remix-run/node'
import type { Params } from '@remix-run/react'
import { ensure, handle, requireUser } from '../util/api.server'
import { Status } from '../util/statusCodes'
import { ALLOW } from '../handlers/validators'
import { createAccessRequest } from '../models/projectAccessRequest.server'

export async function action(args: ActionFunctionArgs) {
return handle(args, {
POST: {
handler: handleRequestAccess,
validator: ALLOW,
},
})
}

export async function handleRequestAccess(req: Request, params: Params<string>) {
const user = await requireUser(req)

const projectId = params.id
ensure(projectId != null, 'project id is null', Status.BAD_REQUEST)

await createAccessRequest({
projectId: projectId,
userId: user.user_id,
})

return {}
}
14 changes: 14 additions & 0 deletions utopia-remix/app/test-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ export async function createTestProjectAccess(
})
}

export async function createTestProjectAccessRequest(
client: UtopiaPrismaClient,
params: { projectId: string; userId: string; status: AccessLevel; token: string },
) {
await client.projectAccessRequest.create({
data: {
project_id: params.projectId,
user_id: params.userId,
status: params.status,
token: params.token,
},
})
}

interface DeletableModel {
/* eslint-disable-next-line no-empty-pattern */
deleteMany: ({}) => Promise<any>
Expand Down
6 changes: 6 additions & 0 deletions utopia-remix/app/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,9 @@ export function getOperationDescription(op: Operation, project: ProjectWithoutCo
assertNever(op)
}
}

export enum AccessRequestStatus {
PENDING,
APPROVED,
REJECTED,
}
Loading
Loading