Skip to content

Commit

Permalink
feat(api): Added Project Level Access (#221)
Browse files Browse the repository at this point in the history
  • Loading branch information
rayaanoidPrime authored May 17, 2024
1 parent e015acf commit 564f5ed
Show file tree
Hide file tree
Showing 13 changed files with 8,146 additions and 10,291 deletions.
2 changes: 1 addition & 1 deletion apps/api/src/approval/approval.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface UpdateProjectMetadata {
name?: UpdateProject['name']
description?: UpdateProject['description']
storePrivateKey?: UpdateProject['storePrivateKey']
isPublic?: UpdateProject['isPublic']
accessLevel?: UpdateProject['accessLevel']
regenerateKeyPair?: boolean
privateKey?: UpdateProject['privateKey']
}
Expand Down
65 changes: 49 additions & 16 deletions apps/api/src/common/authority-checker.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { PrismaClient, Authority, Workspace, Integration } from '@prisma/client'
import {
PrismaClient,
Authority,
Workspace,
Integration,
ProjectAccessLevel
} from '@prisma/client'
import { VariableWithProjectAndVersion } from '../variable/variable.types'
import {
BadRequestException,
Expand Down Expand Up @@ -118,35 +124,62 @@ export class AuthorityCheckerService {
}

// Get the authorities of the user in the workspace with the project
const permittedAuthorities = await getCollectiveProjectAuthorities(
userId,
project,
prisma
)
const permittedAuthoritiesForProject: Set<Authority> =
await getCollectiveProjectAuthorities(userId, project, prisma)

// If the user does not have the required authority, or is not a workspace admin, throw an error
if (
!permittedAuthorities.has(authority) &&
!permittedAuthorities.has(Authority.WORKSPACE_ADMIN)
) {
throw new UnauthorizedException(
`User with id ${userId} does not have the authority in the project with id ${entity?.id}`
const permittedAuthoritiesForWorkspace: Set<Authority> =
await getCollectiveWorkspaceAuthorities(
project.workspaceId,
userId,
prisma
)
}

// If the project is pending creation, only the user who created the project, a workspace admin or
// a user with the MANAGE_APPROVALS authority can fetch the project
if (
project.pendingCreation &&
!permittedAuthorities.has(Authority.WORKSPACE_ADMIN) &&
!permittedAuthorities.has(Authority.MANAGE_APPROVALS) &&
!permittedAuthoritiesForWorkspace.has(Authority.WORKSPACE_ADMIN) &&
!permittedAuthoritiesForWorkspace.has(Authority.MANAGE_APPROVALS) &&
project.lastUpdatedById !== userId
) {
throw new BadRequestException(
`The project with id ${entity?.id} is pending creation and cannot be fetched by the user with id ${userId}`
)
}

const projectAccessLevel = project.accessLevel
switch (projectAccessLevel) {
case ProjectAccessLevel.GLOBAL:
//everyone can access this
break
case ProjectAccessLevel.INTERNAL:
// Any workspace member with the required collective authority over the workspace or
// WORKSPACE_ADMIN authority will be able to access the project
if (
!permittedAuthoritiesForWorkspace.has(authority) &&
!permittedAuthoritiesForWorkspace.has(Authority.WORKSPACE_ADMIN)
) {
throw new UnauthorizedException(
`User with id ${userId} does not have the authority in the project with id ${entity?.id}`
)
}
break

case ProjectAccessLevel.PRIVATE:
// Any member with the required collective authority over the project or
// a member with WORKSPACE_ADMIN authority will be able to access the project
if (
!permittedAuthoritiesForProject.has(authority) &&
!permittedAuthoritiesForProject.has(Authority.WORKSPACE_ADMIN)
) {
throw new UnauthorizedException(
`User with id ${userId} does not have the authority in the project with id ${entity?.id}`
)
}

break
}

return project
}

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/environment/environment.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
EventTriggerer,
EventType,
Project,
ProjectAccessLevel,
User,
Workspace
} from '@prisma/client'
Expand Down Expand Up @@ -102,7 +103,7 @@ describe('Environment Controller Tests', () => {
description: 'Project 1 description',
storePrivateKey: true,
environments: [],
isPublic: false
accessLevel: ProjectAccessLevel.PRIVATE
},
''
)) as Project
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/event/event.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
EventTriggerer,
EventType,
Project,
ProjectAccessLevel,
Secret,
User,
Variable,
Expand Down Expand Up @@ -138,7 +139,7 @@ describe('Event Controller Tests', () => {
description: 'Some description',
environments: [],
storePrivateKey: false,
isPublic: false
accessLevel: ProjectAccessLevel.GLOBAL
})) as Project
project = newProject

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `isPublic` on the `Project` table. All the data in the column will be lost.
*/
-- CreateEnum
CREATE TYPE "ProjectAccessLevel" AS ENUM ('GLOBAL', 'INTERNAL', 'PRIVATE');

-- AlterTable
ALTER TABLE "Project" DROP COLUMN "isPublic",
ADD COLUMN "accessLevel" "ProjectAccessLevel" NOT NULL DEFAULT 'PRIVATE';
8 changes: 7 additions & 1 deletion apps/api/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ enum AuthProvider {
EMAIL_OTP
}

enum ProjectAccessLevel {
GLOBAL
INTERNAL
PRIVATE
}

model Event {
id String @id @default(cuid())
source EventSource
Expand Down Expand Up @@ -292,7 +298,7 @@ model Project {
privateKey String? // We store this only if the user wants us to do so!
storePrivateKey Boolean @default(false)
isDisabled Boolean @default(false) // This is set to true when the user stops his subscription and still has premium features in use
isPublic Boolean @default(false)
accessLevel ProjectAccessLevel @default(PRIVATE)
pendingCreation Boolean @default(false)
lastUpdatedBy User? @relation(fields: [lastUpdatedById], references: [id], onUpdate: Cascade, onDelete: SetNull)
Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/project/dto/create.project/create.project.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { ProjectAccessLevel } from '@prisma/client'
import { CreateEnvironment } from '../../../environment/dto/create.environment/create.environment'
import {
IsArray,
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString
Expand All @@ -24,7 +26,7 @@ export class CreateProject {
@IsOptional()
environments?: CreateEnvironment[]

@IsBoolean()
@IsEnum(ProjectAccessLevel)
@IsOptional()
isPublic?: boolean
accessLevel?: ProjectAccessLevel
}
150 changes: 148 additions & 2 deletions apps/api/src/project/project.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
EventTriggerer,
EventType,
Project,
ProjectAccessLevel,
User,
Workspace
} from '@prisma/client'
Expand All @@ -38,6 +39,7 @@ describe('Project Controller Tests', () => {
let user1: User, user2: User
let workspace1: Workspace, workspace2: Workspace
let project1: Project, project2: Project
let globalProject: Project, internalProject: Project

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
Expand Down Expand Up @@ -144,7 +146,7 @@ describe('Project Controller Tests', () => {
workspaceId: workspace1.id,
lastUpdatedById: user1.id,
isDisabled: false,
isPublic: false,
accessLevel: ProjectAccessLevel.PRIVATE,
publicKey: expect.any(String),
privateKey: expect.any(String),
createdAt: expect.any(String),
Expand Down Expand Up @@ -291,7 +293,7 @@ describe('Project Controller Tests', () => {
workspaceId: workspace1.id,
lastUpdatedById: user1.id,
isDisabled: false,
isPublic: false,
accessLevel: ProjectAccessLevel.PRIVATE,
publicKey: project1.publicKey,
createdAt: expect.any(String),
updatedAt: expect.any(String),
Expand Down Expand Up @@ -702,6 +704,150 @@ describe('Project Controller Tests', () => {
})
})

describe('Project Controller tests for access levels', () => {
beforeEach(async () => {
globalProject = (await projectService.createProject(
user1,
workspace1.id,
{
name: 'Global Project',
description: 'Global Project description',
storePrivateKey: true,
accessLevel: ProjectAccessLevel.GLOBAL
}
)) as Project

internalProject = (await projectService.createProject(
user1,
workspace1.id,
{
name: 'Internal Project',
description: 'Internal Project description',
storePrivateKey: true,
accessLevel: ProjectAccessLevel.INTERNAL
}
)) as Project
})

afterEach(async () => {
await prisma.user.deleteMany()
await prisma.workspace.deleteMany()
})

it('should allow any user to access a global project', async () => {
const response = await app.inject({
method: 'GET',
url: `/project/${globalProject.id}`,
headers: {
'x-e2e-user-email': user2.email // user2 is not a member of workspace1
}
})

expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({
...globalProject,
lastUpdatedById: user1.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
secrets: []
})
})

it('should allow workspace members with READ_PROJECT to access an internal project', async () => {
const response = await app.inject({
method: 'GET',
url: `/project/${internalProject.id}`,
headers: {
'x-e2e-user-email': user1.email // user1 is a member of workspace1
}
})

expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({
...internalProject,
lastUpdatedById: user1.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
secrets: []
})
})

it('should not allow non-members to access an internal project', async () => {
const response = await app.inject({
method: 'GET',
url: `/project/${internalProject.id}`,
headers: {
'x-e2e-user-email': user2.email // user2 is not a member of workspace1
}
})

expect(response.statusCode).toBe(401)
expect(response.json()).toEqual({
statusCode: 401,
error: 'Unauthorized',
message: `User with id ${user2.id} does not have the authority in the project with id ${internalProject.id}`
})
})
})

it('should allow users with sufficient access to access a private project', async () => {
const privateProject = (await projectService.createProject(
user1,
workspace1.id,
{
name: 'Private Project',
description: 'Private Project description',
storePrivateKey: true,
accessLevel: ProjectAccessLevel.PRIVATE
}
)) as Project

const response = await app.inject({
method: 'GET',
url: `/project/${privateProject.id}`,
headers: {
'x-e2e-user-email': user1.email
}
})

expect(response.statusCode).toBe(200)
expect(response.json()).toEqual({
...privateProject,
lastUpdatedById: user1.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
secrets: []
})
})

it('should not allow users without sufficient access to access a private project', async () => {
const privateProject = (await projectService.createProject(
user1,
workspace1.id,
{
name: 'Private Project',
description: 'Private Project description',
storePrivateKey: true,
accessLevel: ProjectAccessLevel.PRIVATE
}
)) as Project

const response = await app.inject({
method: 'GET',
url: `/project/${privateProject.id}`,
headers: {
'x-e2e-user-email': user2.email // user2 is not a member of workspace1
}
})

expect(response.statusCode).toBe(401)
expect(response.json()).toEqual({
statusCode: 401,
error: 'Unauthorized',
message: `User with id ${user2.id} does not have the authority in the project with id ${privateProject.id}`
})
})

afterAll(async () => {
await cleanUp(prisma)
})
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/project/service/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class ProjectService {
description: dto.description,
storePrivateKey: dto.storePrivateKey,
publicKey,
isPublic: dto.isPublic,
accessLevel: dto.accessLevel,
pendingCreation: approvalEnabled
}

Expand Down Expand Up @@ -441,7 +441,7 @@ export class ProjectService {
description: dto.description,
storePrivateKey: dto.storePrivateKey,
privateKey: dto.storePrivateKey ? project.privateKey : null,
isPublic: dto.isPublic
accessLevel: dto.accessLevel
}

const versionUpdateOps = []
Expand Down
Loading

0 comments on commit 564f5ed

Please sign in to comment.