diff --git a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index 9a1a91672..58c6793d7 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -5,10 +5,15 @@ import { z } from "zod"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { ProjectPermissionSchema, SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchemas"; +import { + ProjectPermissionSchema, + ProjectSpecificPrivilegePermissionSchema, + SanitizedIdentityPrivilegeSchema +} from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { @@ -39,7 +44,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + permissions: ProjectPermissionSchema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .optional(), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission + ).optional() }), response: { 200: z.object({ @@ -49,6 +59,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { permissions, privilegePermission } = req.body; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; const privilege = await server.services.identityProjectAdditionalPrivilege.create({ actorId: req.permission.id, actor: req.permission.type, @@ -57,7 +79,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ...req.body, slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), isTemporary: false, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: JSON.stringify(packRules(permission)) }); return { privilege }; } @@ -90,7 +112,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + permissions: ProjectPermissionSchema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .optional(), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission + ).optional(), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), @@ -111,6 +138,19 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { permissions, privilegePermission } = req.body; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ actorId: req.permission.id, actor: req.permission.type, @@ -119,7 +159,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ...req.body, slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), isTemporary: true, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: JSON.stringify(packRules(permission)) }); return { privilege }; } @@ -156,13 +196,16 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission + ).optional(), isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), temporaryRange: z .string() - .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .refine((val) => typeof val === "undefined" || ms(val) > 0, "Temporary range must be a positive number") .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), temporaryAccessStartTime: z .string() @@ -179,7 +222,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const updatedInfo = req.body.privilegeDetails; + const { permissions, privilegePermission, ...updatedInfo } = req.body.privilegeDetails; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ actorId: req.permission.id, actor: req.permission.type, @@ -190,7 +244,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: req.body.projectSlug, data: { ...updatedInfo, - permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined + permissions: permission ? JSON.stringify(packRules(permission)) : undefined } }); return { privilege }; diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 380f61e23..6691032a8 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -23,7 +23,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { .min(1) .trim() .refine( - (val) => !Object.keys(OrgMembershipRole).includes(val), + (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) .refine((v) => slugify(v) === v, { diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index bb4d2fa8e..69038a057 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,146 +1,232 @@ +import { packRules } from "@casl/ability/extra"; +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ProjectPermissionSchema, SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:projectId/roles", + url: "/:projectSlug/roles", config: { rateLimit: writeLimit }, schema: { + description: "Create a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) }), body: z.object({ - slug: z.string().trim(), - name: z.string().trim(), - description: z.string().trim().optional(), - permissions: z.any().array() + slug: z + .string() + .toLowerCase() + .trim() + .min(1) + .refine( + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }) + .describe(PROJECT_ROLE.CREATE.slug), + name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), + description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.CREATE.permissions) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.createRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.createRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + data: { + ...req.body, + permissions: JSON.stringify(packRules(req.body.permissions)) + } + }); return { role }; } }); server.route({ method: "PATCH", - url: "/:projectId/roles/:roleId", + url: "/:projectSlug/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + description: "Update a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - roleId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z.string().trim().optional(), - name: z.string().trim().optional(), - description: z.string().trim().optional(), - permissions: z.any().array() + slug: z + .string() + .toLowerCase() + .trim() + .optional() + .describe(PROJECT_ROLE.UPDATE.slug) + .refine( + (val) => + typeof val === "undefined" || + !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((val) => typeof val === "undefined" || slugify(val) === val, { + message: "Slug must be a valid" + }), + name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.updateRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.params.roleId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.updateRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleId: req.params.roleId, + data: { + ...req.body, + permissions: JSON.stringify(packRules(req.body.permissions)) + } + }); return { role }; } }); server.route({ method: "DELETE", - url: "/:projectId/roles/:roleId", + url: "/:projectSlug/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + description: "Delete a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - roleId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.deleteRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.params.roleId, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.deleteRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleId: req.params.roleId + }); return { role }; } }); server.route({ method: "GET", - url: "/:projectId/roles", + url: "/:projectSlug/roles", + config: { + rateLimit: readLimit + }, + schema: { + description: "List project role", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug) + }), + response: { + 200: z.object({ + roles: ProjectRolesSchema.omit({ permissions: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const roles = await server.services.projectRole.listRoles({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug + }); + return { roles }; + } + }); + + server.route({ + method: "GET", + url: "/:projectSlug/roles/slug/:slug", config: { rateLimit: readLimit }, schema: { params: z.object({ - projectId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug), + slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug) }), response: { 200: z.object({ - data: z.object({ - roles: ProjectRolesSchema.omit({ permissions: true }) - .merge(z.object({ permissions: z.unknown() })) - .array() - }) + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectRole.listRoles( - req.permission.type, - req.permission.id, - req.params.projectId, - req.permission.authMethod, - req.permission.orgId - ); - return { data: { roles } }; + const role = await server.services.projectRole.getRoleBySlug({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleSlug: req.params.slug + }); + return { role }; } }); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 6ae5a9d33..7aa0b24cf 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -519,7 +519,8 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { projectSlug: "The slug of the project of the identity in.", identityId: "The ID of the identity to create.", slug: "The slug of the privilege to create.", - permissions: `The permission object for the privilege. + permissions: `@deprecated - use privilegePermission +The permission object for the privilege. - Read secrets \`\`\` { "permissions": [{"action": "read", "subject": "secrets"]} @@ -533,6 +534,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { - { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } \`\`\` `, + privilegePermission: "The permission object for the privilege.", isPackPermission: "Whether the server should pack(compact) the permission object.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", @@ -544,7 +546,8 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { identityId: "The ID of the identity to update.", slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", - permissions: `The permission object for the privilege. + permissions: `@deprecated - use privilegePermission +The permission object for the privilege. - Read secrets \`\`\` { "permissions": [{"action": "read", "subject": "secrets"]} @@ -558,6 +561,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { - { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } \`\`\` `, + privilegePermission: "The permission object for the privilege.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", @@ -715,3 +719,32 @@ export const AUDIT_LOG_STREAMS = { id: "The ID of the audit log stream to get details." } }; + +export const PROJECT_ROLE = { + CREATE: { + projectSlug: "The slug of the project to create role.", + slug: "The slug of the role.", + name: "The name of the role.", + description: "The description for the role.", + permissions: "The permissions assigned to the role." + }, + UPDATE: { + projectSlug: "The slug of the project to update role.", + roleId: "The ID of the role to update", + slug: "The slug of the role.", + name: "The name of the role.", + description: "The description for the role.", + permissions: "The permissions assigned to the role." + }, + DELETE: { + projectSlug: "The slug of the project to delete role.", + roleId: "The ID of the role to update" + }, + GET_ROLE_BY_SLUG: { + projectSlug: "The slug of the project.", + roleSlug: "The slug of the role to get details" + }, + LIST: { + projectSlug: "The slug of the project to list roles." + } +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 505ab6eda..4087ae39b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -523,7 +523,8 @@ export const registerRoutes = async ( permissionService, projectRoleDAL, projectUserMembershipRoleDAL, - identityProjectMembershipRoleDAL + identityProjectMembershipRoleDAL, + projectDAL }); const snapshotService = secretSnapshotServiceFactory({ diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index cf9f23851..5b0b754f3 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -4,6 +4,7 @@ import { DynamicSecretsSchema, IdentityProjectAdditionalPrivilegeSchema, IntegrationAuthsSchema, + ProjectRolesSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas"; @@ -88,10 +89,38 @@ export const ProjectPermissionSchema = z.object({ .optional() }); +export const ProjectSpecificPrivilegePermissionSchema = z.object({ + actions: z + .nativeEnum(ProjectPermissionActions) + .describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read") + .array() + .min(1), + subject: z + .enum([ProjectPermissionSub.Secrets]) + .describe("The entity this permission pertains to. Possible options: secrets, environments"), + conditions: z + .object({ + environment: z.string().describe("The environment slug this permission should allow."), + secretPath: z + .object({ + $glob: z + .string() + .min(1) + .describe("The secret path this permission should allow. Can be a glob pattern such as /folder-name/*/** ") + }) + .optional() + }) + .describe("When specified, only matching conditions will be allowed to access given resource.") +}); + export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ permissions: UnpackedPermissionSchema.array() }); +export const SanitizedRoleSchema = ProjectRolesSchema.extend({ + permissions: UnpackedPermissionSchema.array() +}); + export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 831af3200..5a50c4a44 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -1,25 +1,30 @@ -import { ForbiddenError } from "@casl/ability"; -import { packRules } from "@casl/ability/extra"; +import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; -import { ProjectMembershipRole, TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { ProjectMembershipRole } from "@app/db/schemas"; +import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { projectAdminPermissions, projectMemberPermissions, projectNoAccessPermissions, ProjectPermissionActions, + ProjectPermissionSet, ProjectPermissionSub, projectViewerPermission } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; -import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { ActorAuthMethod } from "../auth/auth-type"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; +import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types"; type TProjectRoleServiceFactoryDep = { projectRoleDAL: TProjectRoleDALFactory; + projectDAL: Pick; permissionService: Pick; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory; @@ -27,20 +32,68 @@ type TProjectRoleServiceFactoryDep = { export type TProjectRoleServiceFactory = ReturnType; +const unpackPermissions = (permissions: unknown) => + UnpackedPermissionSchema.array().parse( + unpackRules((permissions || []) as PackRule>>[]) + ); + +const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { + return [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + projectId, + name: "Admin", + slug: ProjectMembershipRole.Admin, + permissions: projectAdminPermissions, + description: "Complete administration access over the project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + projectId, + name: "Developer", + slug: ProjectMembershipRole.Member, + permissions: projectMemberPermissions, + description: "Non-administrative role in an project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + projectId, + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + permissions: projectViewerPermission, + description: "Non-administrative role in an project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + projectId, + name: "No Access", + slug: ProjectMembershipRole.NoAccess, + permissions: projectNoAccessPermissions, + description: "No access to any resources in the project", + createdAt: new Date(), + updatedAt: new Date() + } + ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); +}; + export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService, identityProjectMembershipRoleDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + projectDAL }: TProjectRoleServiceFactoryDep) => { - const createRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const createRole = async ({ projectSlug, data, actor, actorId, actorAuthMethod, actorOrgId }: TCreateRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -53,21 +106,54 @@ export const projectRoleServiceFactory = ({ if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); const role = await projectRoleDAL.create({ ...data, - projectId, - permissions: JSON.stringify(data.permissions) + projectId }); - return role; + return { ...role, permissions: unpackPermissions(role.permissions) }; }; - const updateRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - roleId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const getRoleBySlug = async ({ + actor, + actorId, + projectSlug, + actorAuthMethod, + actorOrgId, + roleSlug + }: TGetRoleBySlugDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + if (roleSlug !== "custom" && Object.values(ProjectMembershipRole).includes(roleSlug as ProjectMembershipRole)) { + const predefinedRole = getPredefinedRoles(projectId, roleSlug as ProjectMembershipRole)[0]; + return { ...predefinedRole, permissions: UnpackedPermissionSchema.array().parse(predefinedRole.permissions) }; + } + + const customRole = await projectRoleDAL.findOne({ slug: roleSlug, projectId }); + if (!customRole) throw new BadRequestError({ message: "Role not found" }); + return { ...customRole, permissions: unpackPermissions(customRole.permissions) }; + }; + + const updateRole = async ({ + roleId, + projectSlug, + actorOrgId, + actorAuthMethod, + actorId, + actor, + data + }: TUpdateRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -81,22 +167,16 @@ export const projectRoleServiceFactory = ({ if (existingRole && existingRole.id !== roleId) throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); } - const [updatedRole] = await projectRoleDAL.update( - { id: roleId, projectId }, - { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } - ); + const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data); if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); - return updatedRole; + return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) }; }; - const deleteRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - roleId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const deleteRole = async ({ actor, actorId, actorAuthMethod, actorOrgId, projectSlug, roleId }: TDeleteRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -125,16 +205,14 @@ export const projectRoleServiceFactory = ({ const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Delete role" }); - return deletedRole; + return { ...deletedRole, permissions: unpackPermissions(deletedRole.permissions) }; }; - const listRoles = async ( - actor: ActorType, - actorId: string, - projectId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const listRoles = async ({ projectSlug, actorOrgId, actorAuthMethod, actorId, actor }: TListRolesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -144,52 +222,7 @@ export const projectRoleServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); const customRoles = await projectRoleDAL.find({ projectId }); - const roles = [ - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid - projectId, - name: "Admin", - slug: ProjectMembershipRole.Admin, - description: "Complete administration access over the project", - permissions: packRules(projectAdminPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response - projectId, - name: "Developer", - slug: ProjectMembershipRole.Member, - description: "Non-administrative role in an project", - permissions: packRules(projectMemberPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response - projectId, - name: "Viewer", - slug: ProjectMembershipRole.Viewer, - description: "Non-administrative role in an project", - permissions: packRules(projectViewerPermission), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - projectId, - name: "No Access", - slug: "no-access", - description: "No access to any resources in the project", - permissions: packRules(projectNoAccessPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - ...(customRoles || []).map(({ permissions, ...data }) => ({ - ...data, - permissions - })) - ]; + const roles = [...getPredefinedRoles(projectId), ...(customRoles || [])]; return roles; }; @@ -209,5 +242,5 @@ export const projectRoleServiceFactory = ({ return { permissions: packRules(permission.rules), membership }; }; - return { createRole, updateRole, deleteRole, listRoles, getUserPermission }; + return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug }; }; diff --git a/backend/src/services/project-role/project-role-types.ts b/backend/src/services/project-role/project-role-types.ts index e69de29bb..62b627a79 100644 --- a/backend/src/services/project-role/project-role-types.ts +++ b/backend/src/services/project-role/project-role-types.ts @@ -0,0 +1,27 @@ +import { TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateRoleDTO = { + data: Omit; + projectSlug: string; +} & Omit; + +export type TGetRoleBySlugDTO = { + roleSlug: string; + projectSlug: string; +} & Omit; + +export type TUpdateRoleDTO = { + roleId: string; + data: Omit; + projectSlug: string; +} & Omit; + +export type TDeleteRoleDTO = { + roleId: string; + projectSlug: string; +} & Omit; + +export type TListRolesDTO = { + projectSlug: string; +} & Omit; diff --git a/docs/api-reference/endpoints/project-roles/create.mdx b/docs/api-reference/endpoints/project-roles/create.mdx new file mode 100644 index 000000000..2220b9309 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/workspace/{projectSlug}/roles" +--- diff --git a/docs/api-reference/endpoints/project-roles/delete.mdx b/docs/api-reference/endpoints/project-roles/delete.mdx new file mode 100644 index 000000000..6362c2154 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/workspace/{projectSlug}/roles/{roleId}" +--- diff --git a/docs/api-reference/endpoints/project-roles/get-by-slug.mdx b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx new file mode 100644 index 000000000..18817bca9 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Slug" +openapi: "GET /api/v1/workspace/{projectSlug}/roles/slug/{slug}" +--- diff --git a/docs/api-reference/endpoints/project-roles/list.mdx b/docs/api-reference/endpoints/project-roles/list.mdx new file mode 100644 index 000000000..ca83d6e7d --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/workspace/{projectSlug}/roles" +--- diff --git a/docs/api-reference/endpoints/project-roles/update.mdx b/docs/api-reference/endpoints/project-roles/update.mdx new file mode 100644 index 000000000..5a3d9668e --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/workspace/{projectSlug}/roles/{roleId}" +--- diff --git a/docs/mint.json b/docs/mint.json index e07def53b..10416eb55 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -476,6 +476,16 @@ "api-reference/endpoints/project-identities/delete-identity-membership" ] }, + { + "group": "Project Roles", + "pages": [ + "api-reference/endpoints/project-roles/create", + "api-reference/endpoints/project-roles/update", + "api-reference/endpoints/project-roles/delete", + "api-reference/endpoints/project-roles/get-by-slug", + "api-reference/endpoints/project-roles/list" + ] + }, { "group": "Environments", "pages": [ diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index fad549e38..df04f3e8a 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -12,21 +12,30 @@ export type TIdentityProjectPrivilege = { updatedAt: Date; permissions?: TProjectPermission[]; } & ( - | { + | { isTemporary: true; temporaryMode: string; temporaryRange: string; temporaryAccessStartTime: string; temporaryAccessEndTime?: string; } - | { + | { isTemporary: false; temporaryMode?: null; temporaryRange?: null; temporaryAccessStartTime?: null; temporaryAccessEndTime?: null; } - ); +); + +export type TProjectSpecificPrivilegePermission = { + conditions: { + environment: string; + secretPath?: { $glob: string }; + }; + actions: string[]; + subject: string; +}; export type TCreateIdentityProjectPrivilegeDTO = { identityId: string; @@ -36,14 +45,16 @@ export type TCreateIdentityProjectPrivilegeDTO = { temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode; temporaryRange?: string; temporaryAccessStartTime?: string; - permissions: TProjectPermission[]; + privilegePermission: TProjectSpecificPrivilegePermission; }; export type TUpdateIdentityProjectPrivlegeDTO = { projectSlug: string; identityId: string; privilegeSlug: string; - privilegeDetails: Partial>; + privilegeDetails: Partial< + Omit + >; }; export type TDeleteIdentityProjectPrivilegeDTO = { diff --git a/frontend/src/hooks/api/roles/index.tsx b/frontend/src/hooks/api/roles/index.tsx index 50736b30d..53c05a7b6 100644 --- a/frontend/src/hooks/api/roles/index.tsx +++ b/frontend/src/hooks/api/roles/index.tsx @@ -8,6 +8,7 @@ export { } from "./mutation"; export { useGetOrgRoles, + useGetProjectRoleBySlug, useGetProjectRoles, useGetUserOrgPermissions, useGetUserProjectPermissions diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index 6df6a3933..ae3e170de 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -17,13 +17,10 @@ export const useCreateProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, permissions, ...dto }: TCreateProjectRoleDTO) => - apiRequest.post(`/api/v1/workspace/${projectId}/roles`, { - ...dto, - permissions: permissions.length ? packRules(permissions) : [] - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => + apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; @@ -32,13 +29,10 @@ export const useUpdateProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, projectId, permissions, ...dto }: TUpdateProjectRoleDTO) => - apiRequest.patch(`/api/v1/workspace/${projectId}/roles/${id}`, { - ...dto, - permissions: permissions?.length ? packRules(permissions) : [] - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => + apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; @@ -47,12 +41,10 @@ export const useDeleteProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, id }: TDeleteProjectRoleDTO) => - apiRequest.delete(`/api/v1/workspace/${projectId}/roles/${id}`, { - data: { projectId } - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ projectSlug, id }: TDeleteProjectRoleDTO) => + apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 3280bface..f04af697d 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -14,7 +14,6 @@ import { TGetUserProjectPermissionDTO, TOrgRole, TPermission, - TProjectPermission, TProjectRole } from "./types"; @@ -37,7 +36,9 @@ const glob: JsInterpreter> = (node, object, context) => { const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); export const roleQueryKeys = { - getProjectRoles: (projectId: string) => ["roles", { projectId }] as const, + getProjectRoles: (projectSlug: string) => ["roles", { projectSlug }] as const, + getProjectRoleBySlug: (projectSlug: string, roleSlug: string) => + ["roles", { projectSlug, roleSlug }] as const, getOrgRoles: (orgId: string) => ["org-roles", { orgId }] as const, getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) => ["user-permissions", { orgId }] as const, @@ -46,20 +47,29 @@ export const roleQueryKeys = { }; const getProjectRoles = async (projectId: string) => { - const { data } = await apiRequest.get<{ - data: { roles: Array & { permissions: unknown }> }; - }>(`/api/v1/workspace/${projectId}/roles`); - return data.data.roles.map(({ permissions, ...el }) => ({ - ...el, - permissions: unpackRules(permissions as PackRule[]) - })); + const { data } = await apiRequest.get<{ roles: Array> }>( + `/api/v1/workspace/${projectId}/roles` + ); + return data.roles; }; -export const useGetProjectRoles = (projectId: string) => +export const useGetProjectRoles = (projectSlug: string) => useQuery({ - queryKey: roleQueryKeys.getProjectRoles(projectId), - queryFn: () => getProjectRoles(projectId), - enabled: Boolean(projectId) + queryKey: roleQueryKeys.getProjectRoles(projectSlug), + queryFn: () => getProjectRoles(projectSlug), + enabled: Boolean(projectSlug) + }); + +export const useGetProjectRoleBySlug = (projectSlug: string, roleSlug: string) => + useQuery({ + queryKey: roleQueryKeys.getProjectRoleBySlug(projectSlug, roleSlug), + queryFn: async () => { + const { data } = await apiRequest.get<{ role: TProjectRole }>( + `/api/v1/workspace/${projectSlug}/roles/slug/${roleSlug}` + ); + return data.role; + }, + enabled: Boolean(projectSlug && roleSlug) }); const getOrgRoles = async (orgId: string) => { diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 97a90b421..e2d1b533a 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -71,7 +71,7 @@ export type TDeleteOrgRoleDTO = { }; export type TCreateProjectRoleDTO = { - projectId: string; + projectSlug: string; name: string; description?: string; slug: string; @@ -79,11 +79,11 @@ export type TCreateProjectRoleDTO = { }; export type TUpdateProjectRoleDTO = { - projectId: string; + projectSlug: string; id: string; } & Partial>; export type TDeleteProjectRoleDTO = { - projectId: string; + projectSlug: string; id: string; }; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx index 818c6eae0..f0ed1c5b7 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -5,25 +5,19 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { - Button, - FormControl, - Modal, - ModalContent, - Select, - SelectItem} from "@app/components/v2"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; -import { - useAddGroupToWorkspace, - useGetOrganizationGroups, - useGetProjectRoles, - useListWorkspaceGroups, +import { + useAddGroupToWorkspace, + useGetOrganizationGroups, + useGetProjectRoles, + useListWorkspaceGroups } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - slug: z.string(), - role: z.string() + slug: z.string(), + role: z.string() }); export type FormData = z.infer; @@ -33,150 +27,146 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; }; -export const GroupModal = ({ - popUp, - handlePopUpToggle -}: Props) => { - const { currentOrg } = useOrganization(); - const { currentWorkspace } = useWorkspace(); +export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); - const orgId = currentOrg?.id || ""; - const workspaceId = currentWorkspace?.id || ""; - - const { data: groups } = useGetOrganizationGroups(orgId); - const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.slug || ""); - - const { data: roles } = useGetProjectRoles(workspaceId); - - const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); - - const filteredGroupMembershipOrgs = useMemo(() => { - const wsGroupIds = new Map(); + const orgId = currentOrg?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - groupMemberships?.forEach((groupMembership) => { - wsGroupIds.set(groupMembership.group.id, true); - }); + const { data: groups } = useGetOrganizationGroups(orgId); + const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.slug || ""); - return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); - }, [groups, groupMemberships]); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(schema) + const { data: roles } = useGetProjectRoles(projectSlug); + + const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); + + const filteredGroupMembershipOrgs = useMemo(() => { + const wsGroupIds = new Map(); + + groupMemberships?.forEach((groupMembership) => { + wsGroupIds.set(groupMembership.group.id, true); + }); + + return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); + }, [groups, groupMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ slug, role }: FormData) => { + try { + await addGroupToWorkspaceMutateAsync({ + projectSlug: currentWorkspace?.slug || "", + groupSlug: slug, + role: role || undefined }); - const onFormSubmit = async ({ slug, role }: FormData) => { - try { - await addGroupToWorkspaceMutateAsync({ - projectSlug: currentWorkspace?.slug || "", - groupSlug: slug, - role: role || undefined - }); - - reset(); - handlePopUpToggle("group", false); - - createNotification({ - text: "Successfully added group to project", - type: "success" - }); - - } catch (err) { - createNotification({ - text: "Failed to add group to project", - type: "error" - }); - } + reset(); + handlePopUpToggle("group", false); + + createNotification({ + text: "Successfully added group to project", + type: "success" + }); + } catch (err) { + createNotification({ + text: "Failed to add group to project", + type: "error" + }); } - - return ( - { - handlePopUpToggle("group", isOpen); - reset(); - }} - > - - {filteredGroupMembershipOrgs.length ? ( -
- ( - - - - )} - /> - ( - - - - )} - /> -
- - -
- - ) : ( -
-
- All groups in your organization have already been added to this project. -
- - - -
- )} -
-
- ); -} \ No newline at end of file + }; + + return ( + { + handlePopUpToggle("group", isOpen); + reset(); + }} + > + + {filteredGroupMembershipOrgs.length ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx index 83ea00ede..449c0c95f 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx @@ -201,11 +201,7 @@ export type TMemberRolesProp = { const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; -export const GroupRoles = ({ - roles = [], - disableEdit = false, - groupSlug -}: TMemberRolesProp) => { +export const GroupRoles = ({ roles = [], disableEdit = false, groupSlug }: TMemberRolesProp) => { const { currentWorkspace } = useWorkspace(); const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); const [searchRoles, setSearchRoles] = useState(""); @@ -220,9 +216,9 @@ export const GroupRoles = ({ resolver: zodResolver(formSchema) }); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole(); @@ -317,7 +313,7 @@ export const GroupRoles = ({ icon={faClock} className={twMerge( new Date() > new Date(temporaryAccessEndTime as string) && - "text-red-600" + "text-red-600" )} /> @@ -390,14 +386,14 @@ export const GroupRoles = ({ defaultValue={ userProjectRoleDetails?.isTemporary ? { - isTemporary: true, - temporaryAccessStartTime: - userProjectRoleDetails.temporaryAccessStartTime as string, - temporaryRange: - userProjectRoleDetails.temporaryRange as string, - temporaryAccessEndTime: - userProjectRoleDetails.temporaryAccessEndTime - } + isTemporary: true, + temporaryAccessStartTime: + userProjectRoleDetails.temporaryAccessStartTime as string, + temporaryRange: + userProjectRoleDetails.temporaryRange as string, + temporaryAccessEndTime: + userProjectRoleDetails.temporaryAccessEndTime + } : false } render={({ field }) => ( diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx index 72d39537e..5637b2f1a 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx @@ -30,17 +30,17 @@ type Props = { }; export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { - const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); const orgId = currentOrg?.id || ""; const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; const { data: identityMembershipOrgs } = useGetIdentityMembershipOrgs(orgId); const { data: identityMemberships } = useGetWorkspaceIdentityMemberships(workspaceId); - const { data: roles } = useGetProjectRoles(workspaceId); + const { data: roles } = useGetProjectRoles(projectSlug); const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx index ca7e31464..05aeb1fb0 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx @@ -65,7 +65,8 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const projectSlug = currentWorkspace?.slug || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( ProjectPermissionActions.Edit, @@ -79,14 +80,14 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal slug: customRoleSlug || role, temporaryAccess: dto.isTemporary ? { - isTemporary: true, - temporaryRange: dto.temporaryRange, - temporaryAccessEndTime: dto.temporaryAccessEndTime, - temporaryAccessStartTime: dto.temporaryAccessStartTime - } + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } : { - isTemporary: dto.isTemporary - } + isTemporary: dto.isTemporary + } })) } }); @@ -191,9 +192,9 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal ? isExpired ? "Timed Access Expired" : `Until ${format( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}` + new Date(temporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}` : "Non expiry access" } > @@ -212,9 +213,9 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal ? isExpired ? "Access Expired" : formatDistance( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - new Date() - ) + new Date(temporaryAccess.temporaryAccessEndTime || ""), + new Date() + ) : "Permanent"} @@ -338,7 +339,7 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal type="submit" className={twMerge( "transition-all", - "opacity-0 cursor-default", + "cursor-default opacity-0", roleForm.formState.isDirty && "cursor-pointer opacity-100" )} isDisabled={!roleForm.formState.isDirty} diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx index 6c1d4654d..7f76992bb 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -131,20 +131,17 @@ const SpecificPrivilegeSecretForm = ({ { action: ProjectPermissionActions.Delete, allowed: data.delete }, { action: ProjectPermissionActions.Edit, allowed: data.edit } ]; - const conditions: Record = { environment: data.environmentSlug }; - if (data.secretPath) { - conditions.secretPath = { $glob: data.secretPath }; - } await updateIdentityPrivilege.mutateAsync({ privilegeDetails: { ...data.temporaryAccess, - permissions: actions - .filter(({ allowed }) => allowed) - .map(({ action }) => ({ - action, - subject: ProjectPermissionSub.Secrets, - conditions - })) + privilegePermission: { + actions: actions.filter(({ allowed }) => allowed).map(({ action }) => action), + subject: ProjectPermissionSub.Secrets, + conditions: { + environment: data.environmentSlug, + ...(data.secretPath ? { secretPath: { $glob: data.secretPath } } : {}) + } + } }, privilegeSlug: privilege.slug, identityId, @@ -474,15 +471,13 @@ export const SpecificPrivilegeSection = ({ identityId }: Props) => { if (createIdentityPrivilege.isLoading) return; try { await createIdentityPrivilege.mutateAsync({ - permissions: [ - { - action: ProjectPermissionActions.Read, - subject: ProjectPermissionSub.Secrets, - conditions: { - environment: currentWorkspace?.environments?.[0].slug - } + privilegePermission: { + actions: [ProjectPermissionActions.Read], + subject: ProjectPermissionSub.Secrets, + conditions: { + environment: currentWorkspace?.environments?.[0].slug as string } - ], + }, identityId, projectSlug }); diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx index 04497c62c..5cad54801 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx @@ -65,7 +65,8 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const projectSlug = currentWorkspace?.slug || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( ProjectPermissionActions.Edit, @@ -79,14 +80,14 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) slug: customRoleSlug || role, temporaryAccess: dto.isTemporary ? { - isTemporary: true, - temporaryRange: dto.temporaryRange, - temporaryAccessEndTime: dto.temporaryAccessEndTime, - temporaryAccessStartTime: dto.temporaryAccessStartTime - } + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } : { - isTemporary: dto.isTemporary - } + isTemporary: dto.isTemporary + } })) } }); @@ -191,9 +192,9 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) ? isExpired ? "Timed Access Expired" : `Until ${format( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}` + new Date(temporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}` : "Non expiry access" } > @@ -212,9 +213,9 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) ? isExpired ? "Access Expired" : formatDistance( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - new Date() - ) + new Date(temporaryAccess.temporaryAccessEndTime || ""), + new Date() + ) : "Permanent"} @@ -335,7 +336,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) type="submit" className={twMerge( "transition-all", - "opacity-0 cursor-default", + "cursor-default opacity-0", roleForm.formState.isDirty && "cursor-pointer opacity-100" )} isDisabled={!roleForm.formState.isDirty} diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx index 6cfec25ea..5eb443fb6 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -3,7 +3,6 @@ import { motion } from "framer-motion"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; -import { TProjectRole } from "@app/hooks/api/roles/types"; import { ProjectRoleList } from "./components/ProjectRoleList"; import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection"; @@ -21,7 +20,7 @@ export const ProjectRoleListTab = withProjectPermission( exit={{ opacity: 0, translateX: 30 }} > handlePopUpClose("editRole")} /> @@ -33,7 +32,7 @@ export const ProjectRoleListTab = withProjectPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: -30 }} > - handlePopUpOpen("editRole", role)} /> + handlePopUpOpen("editRole", slug)} /> ); }, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index d8a22bdb4..e19e61aec 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -24,7 +24,7 @@ import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; type Props = { - onSelectRole: (role?: TProjectRole) => void; + onSelectRole: (slug?: string) => void; }; export const ProjectRoleList = ({ onSelectRole }: Props) => { @@ -32,10 +32,9 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - console.log(roles); + const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { mutateAsync: deleteRole } = useDeleteProjectRole(); @@ -43,7 +42,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { const { id } = popUp?.deleteRole?.data as TProjectRole; try { await deleteRole({ - projectId: workspaceId, + projectSlug, id }); createNotification({ type: "success", text: "Successfully removed the role" }); @@ -109,7 +108,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { onSelectRole(role)} + onClick={() => onSelectRole(role.slug)} variant="plain" > @@ -146,9 +145,8 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { handlePopUpClose("deleteRole")} onDeleteApproved={handleRoleDelete} diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index 4a57e2810..cd2eaeee0 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -19,9 +19,13 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; +import { Button, FormControl, Input, Spinner } from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useCreateProjectRole, useUpdateProjectRole } from "@app/hooks/api"; +import { + useCreateProjectRole, + useGetProjectRoleBySlug, + useUpdateProjectRole +} from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; import { MultiEnvProjectPermission } from "./MultiEnvProjectPermission"; @@ -117,17 +121,20 @@ const SINGLE_PERMISSION_LIST = [ ] as const; type Props = { - role?: TProjectRole; + roleSlug?: string; onGoBack: VoidFunction; }; -export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { - const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(role?.slug || ""); - const isNewRole = !role?.slug; +export const ProjectRoleModifySection = ({ roleSlug, onGoBack }: Props) => { + const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(roleSlug || ""); + const isNewRole = !roleSlug; - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; + const { data: roleDetails, isLoading: isRoleDetailsLoading } = useGetProjectRoleBySlug( + currentWorkspace?.slug || "", + roleSlug as string + ); const { handleSubmit, @@ -137,19 +144,21 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { getValues, control } = useForm({ - defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, + values: roleDetails + ? { ...roleDetails, permissions: rolePermission2Form(roleDetails.permissions) } + : ({} as TProjectRole), resolver: zodResolver(formSchema) }); const { mutateAsync: createRole } = useCreateProjectRole(); const { mutateAsync: updateRole } = useUpdateProjectRole(); const handleRoleUpdate = async (el: TFormSchema) => { - if (!role?.id) return; + if (!roleDetails?.id) return; try { await updateRole({ - id: role?.id, - projectId: workspaceId, + id: roleDetails?.id as string, + projectSlug, ...el, permissions: formRolePermission2API(el.permissions) }); @@ -169,7 +178,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { try { await createRole({ - projectId: workspaceId, + projectSlug, ...el, permissions: formRolePermission2API(el.permissions) }); @@ -181,6 +190,14 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { } }; + if (!isNewRole && isRoleDetailsLoading) { + return ( +
+ +
+ ); + } + return (
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index 5dc90cf8b..4d4d45e64 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -95,10 +95,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const formVal: Record = {}; permissions.forEach((permission) => { - const { - subject: [subject], - action - } = permission; + const { subject: caslSub, action } = permission; + const subject = typeof caslSub === "string" ? caslSub : caslSub[0]; if (!formVal?.[subject]) formVal[subject] = {}; if (subject === "secrets") { @@ -123,7 +121,7 @@ const multiEnvForm2Api = ( const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); // if any of them is set in all push it without any condition PERMISSION_ACTIONS.forEach((action) => { - if (formVal?.all?.[action]) permissions.push({ action, subject: [subject] }); + if (formVal?.all?.[action]) permissions.push({ action, subject }); }); if (!isFullAccess) { @@ -144,7 +142,7 @@ const multiEnvForm2Api = ( if (formVal[slug]?.secretPath) conditions.secretPath = { $glob: formVal?.[slug]?.secretPath }; - permissions.push({ action, subject: [subject], conditions }); + permissions.push({ action, subject, conditions }); } }); }); @@ -161,7 +159,7 @@ export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { } else { Object.entries(actions).forEach(([action, isAllowed]) => { if (isAllowed) { - permissions.push({ subject: [rule], action }); + permissions.push({ subject: rule, action }); } }); }