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 7 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
68 changes: 53 additions & 15 deletions apps/api/src/common/authority-checker.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { PrismaClient, Authority, Workspace, Integration } from '@prisma/client'
//authority-checker.service.ts

rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
import {
PrismaClient,
Authority,
Workspace,
Integration,
ProjectAccessLevel
} from '@prisma/client'
import { VariableWithProjectAndVersion } from '../variable/variable.types'
import {
BadRequestException,
Expand All @@ -12,6 +20,7 @@ import { EnvironmentWithProject } from '../environment/environment.types'
import { ProjectWithSecrets } from '../project/project.types'
import { SecretWithProjectAndVersion } from '../secret/secret.types'
import { CustomLoggerService } from './logger.service'
import checkUserRoleAssociations from './check-user-role-associations'

export interface AuthorityInput {
userId: string
Expand Down Expand Up @@ -117,28 +126,57 @@ export class AuthorityCheckerService {
throw new NotFoundException(`Project with id ${entity?.id} not found`)
}

const projectAccessLevel = project.accessLevel
// Get the authorities of the user in the workspace with the project
const permittedAuthorities = await getCollectiveProjectAuthorities(
userId,
project,
prisma
)
const permittedAuthorities: Set<Authority> =
await getCollectiveProjectAuthorities(userId, project, prisma)
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved

// 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}`
)
if (permittedAuthorities.has(Authority.WORKSPACE_ADMIN)) {
return project
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
}

let canAccessPrivateProject = false

switch (projectAccessLevel) {
case ProjectAccessLevel.GLOBAL:
//everyone can access this
break
case ProjectAccessLevel.INTERNAL:
// Any member in the workspace with READ_PROJECT collective authority or the required authority will be able to access the project
if (
!permittedAuthorities.has(Authority.READ_PROJECT) &&
!permittedAuthorities.has(authority)
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
) {
// If the user does not have the required authority or the READ_PROJECT authority and is neither a workspace admin, throw an error
throw new UnauthorizedException(
`User with id ${userId} does not have the authority in the project with id ${entity?.id}`
)
}
break

case ProjectAccessLevel.PRIVATE:
// Check if the user's role association includes the project's ID in its projectIds field
canAccessPrivateProject = await checkUserRoleAssociations(
userId,
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
project,
authority,
prisma
)

// If either the user does not have access or does not have required authority, throw an error
if (!canAccessPrivateProject || !permittedAuthorities.has(authority)) {
throw new UnauthorizedException(
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
`User with id ${userId} does not have the authority in the project with id ${entity?.id}`
)
}

break
}

// 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 &&
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
!permittedAuthorities.has(Authority.WORKSPACE_ADMIN) &&
!permittedAuthorities.has(Authority.MANAGE_APPROVALS) &&
project.lastUpdatedById !== userId
) {
Expand Down
52 changes: 52 additions & 0 deletions apps/api/src/common/check-user-role-associations.ts
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Authority, PrismaClient, Project, User } from '@prisma/client'

/**
* Given the userId and project, this function checks the set of roles associated
* with the workspace member object of the user in the workspace of the
* current project for associated projects where the project Id matches
* with the current project's id and returns a boolean value.
* @param userId The id of the user
* @param project The project
* @param authority The authority
* @param prisma The prisma client
* @returns
*/

export default async function checkUserRoleAssociations(
userId: User['id'],
project: Project,
authority: Authority,
prisma: PrismaClient
): Promise<boolean> {
const workSpaceMembership = await prisma.workspaceMember.findUnique({
where: {
workspaceId_userId: {
workspaceId: project.workspaceId,
userId
}
},
select: {
roles: {
include: {
role: {
select: {
authorities: true,
projects: true
}
}
}
}
}
})
// checking if the user is a member of the workspace
if (!workSpaceMembership) {
return false
}
const hasAccess = workSpaceMembership.roles.some(
(role) =>
role.role.authorities.includes(authority) &&
role.role.projects.some((p) => p.id === project.id)
)

return hasAccess
}
2 changes: 1 addition & 1 deletion apps/api/src/environment/environment.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ describe('Environment Controller Tests', () => {
description: 'Project 1 description',
storePrivateKey: true,
environments: [],
isPublic: false
accessLevel: 'PRIVATE'
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
},
''
)) as Project
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/event/event.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ describe('Event Controller Tests', () => {
description: 'Some description',
environments: [],
storePrivateKey: false,
isPublic: false
accessLevel: 'GLOBAL'
rayaanoidPrime marked this conversation as resolved.
Show resolved Hide resolved
})) 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
}
Loading
Loading