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

feat(api): Added Project Level Access #221

Merged
merged 12 commits into from
May 17, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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
Loading