From 84204c3c37847e2f92a8959e4cc4e007aaa2a363 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 15 Mar 2024 15:51:33 +0530 Subject: [PATCH 01/21] feat(server): added new user additional migration and schemas --- backend/src/@types/knex.d.ts | 8 +++++ ...85040_project-user-additional-privilege.ts | 31 +++++++++++++++++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + .../project-user-additional-privilege.ts | 33 +++++++++++++++++++ 5 files changed, 74 insertions(+) create mode 100644 backend/src/db/migrations/20240313185040_project-user-additional-privilege.ts create mode 100644 backend/src/db/schemas/project-user-additional-privilege.ts diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 29d526e52..9b4bfec96 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -92,6 +92,9 @@ import { TProjects, TProjectsInsert, TProjectsUpdate, + TProjectUserAdditionalPrivilege, + TProjectUserAdditionalPrivilegeInsert, + TProjectUserAdditionalPrivilegeUpdate, TProjectUserMembershipRoles, TProjectUserMembershipRolesInsert, TProjectUserMembershipRolesUpdate, @@ -239,6 +242,11 @@ declare module "knex/types/tables" { TProjectUserMembershipRolesUpdate >; [TableName.ProjectRoles]: Knex.CompositeTableType; + [TableName.ProjectUserAdditionalPrivilege]: Knex.CompositeTableType< + TProjectUserAdditionalPrivilege, + TProjectUserAdditionalPrivilegeInsert, + TProjectUserAdditionalPrivilegeUpdate + >; [TableName.ProjectKeys]: Knex.CompositeTableType; [TableName.Secret]: Knex.CompositeTableType; [TableName.SecretBlindIndex]: Knex.CompositeTableType< diff --git a/backend/src/db/migrations/20240313185040_project-user-additional-privilege.ts b/backend/src/db/migrations/20240313185040_project-user-additional-privilege.ts new file mode 100644 index 000000000..0d9d81507 --- /dev/null +++ b/backend/src/db/migrations/20240313185040_project-user-additional-privilege.ts @@ -0,0 +1,31 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.ProjectUserAdditionalPrivilege))) { + await knex.schema.createTable(TableName.ProjectUserAdditionalPrivilege, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.string("slug", 60).notNullable(); + t.string("description"); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE"); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.jsonb("permissions").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege); + await knex.schema.dropTableIfExists(TableName.ProjectUserAdditionalPrivilege); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index efa80cfac..96993dc10 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -28,6 +28,7 @@ export * from "./project-environments"; export * from "./project-keys"; export * from "./project-memberships"; export * from "./project-roles"; +export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; export * from "./saml-configs"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index bda66682e..8af8951fd 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -20,6 +20,7 @@ export enum TableName { Environment = "project_environments", ProjectMembership = "project_memberships", ProjectRoles = "project_roles", + ProjectUserAdditionalPrivilege = "project_user_additional_privilege", ProjectUserMembershipRole = "project_user_membership_roles", ProjectKeys = "project_keys", Secret = "secrets", diff --git a/backend/src/db/schemas/project-user-additional-privilege.ts b/backend/src/db/schemas/project-user-additional-privilege.ts new file mode 100644 index 000000000..c20d83543 --- /dev/null +++ b/backend/src/db/schemas/project-user-additional-privilege.ts @@ -0,0 +1,33 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ProjectUserAdditionalPrivilegeSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + description: z.string().nullable().optional(), + projectMembershipId: z.string().uuid(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TProjectUserAdditionalPrivilege = z.infer; +export type TProjectUserAdditionalPrivilegeInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TProjectUserAdditionalPrivilegeUpdate = Partial< + Omit, TImmutableDBKeys> +>; From a908471e66fb65f51726e2c055fb44b1fdd93526 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 15 Mar 2024 15:53:49 +0530 Subject: [PATCH 02/21] feat(server): completed user additional privilege services --- .../ee/services/permission/permission-dal.ts | 57 +++++- .../services/permission/permission-service.ts | 12 +- .../project-user-additional-privilege-dal.ts | 10 + ...oject-user-additional-privilege-service.ts | 175 ++++++++++++++++++ ...project-user-additional-privilege-types.ts | 44 +++++ 5 files changed, 291 insertions(+), 7 deletions(-) create mode 100644 backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts create mode 100644 backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts create mode 100644 backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index fc0a85ac6..dda4bee50 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -56,6 +56,11 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.ProjectUserMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .where("userId", userId) @@ -69,9 +74,22 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("orgId").withSchema(TableName.Project), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") - ) - .select("permissions"); + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles), + db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"), + db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessEndTime") + ); const permission = sqlNestRelationships({ data: docs, @@ -102,15 +120,44 @@ export const permissionDALFactory = (db: TDbClient) => { permissions: z.unknown(), customRoleSlug: z.string().optional().nullable() }).parse(data) + }, + { + key: "userApId", + label: "additionalPrivileges" as const, + mapper: ({ + userApId, + userApPermissions, + userApIsTemporary, + userApTemporaryMode, + userApTemporaryRange, + userApTemporaryAccessEndTime, + userApTemporaryAccessStartTime + }) => ({ + id: userApId, + permissions: userApPermissions, + temporaryRange: userApTemporaryRange, + temporaryMode: userApTemporaryMode, + temporaryAccessEndTime: userApTemporaryAccessEndTime, + temporaryAccessStartTime: userApTemporaryAccessStartTime, + isTemporary: userApIsTemporary + }) } ] }); // when introducting cron mode change it here - const activeRoles = permission?.[0]?.roles.filter( + const activeRoles = permission?.[0]?.roles?.filter( ({ isTemporary, temporaryAccessEndTime }) => !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ); - return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined; + + const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + + return permission?.[0] + ? { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges } + : undefined; } catch (error) { throw new DatabaseError({ error, name: "GetProjectPermission" }); } diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index d632be3df..8062a1c43 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -198,9 +198,17 @@ export const permissionServiceFactory = ({ validateOrgSAML(authMethod, membership.orgAuthEnforced); + // join two permissions and pass to build the final permission set + const rolePermissions = userProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const additionalPrivileges = + userProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ + role: ProjectMembershipRole.Custom, + permissions + })) || []; + return { - permission: buildProjectPermission(membership.roles), - membership, + permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)), + membership: userProjectPermission, hasRole: (role: string) => membership.roles.findIndex(({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug) !== -1 }; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts new file mode 100644 index 000000000..6c15d2d5d --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TProjectUserAdditionalPrivilegeDALFactory = ReturnType; + +export const projectUserAdditionalPrivilegeDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ProjectUserAdditionalPrivilege); + return orm; +}; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts new file mode 100644 index 000000000..e1f3ba823 --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -0,0 +1,175 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { BadRequestError } from "@app/lib/errors"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal"; +import { + ProjectUserAdditionalPrivilegeTemporaryMode, + TCreateUserPrivilegeDTO, + TDeleteUserPrivilegeDTO, + TGetUserPrivilegeDetailsDTO, + TUpdateUserPrivilegeDTO +} from "./project-user-additional-privilege-types"; + +type TProjectUserAdditionalPrivilegeServiceFactoryDep = { + projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; + projectMembershipDAL: Pick; + permissionService: Pick; +}; + +export type TProjectUserAdditionalPrivilegeServiceFactory = ReturnType< + typeof projectUserAdditionalPrivilegeServiceFactory +>; + +export const projectUserAdditionalPrivilegeServiceFactory = ({ + projectUserAdditionalPrivilegeDAL, + projectMembershipDAL, + permissionService +}: TProjectUserAdditionalPrivilegeServiceFactoryDep) => { + const create = async ({ + name, + slug, + actor, + actorId, + permissions: customPermission, + actorOrgId, + description, + projectMembershipId, + ...dto + }: TCreateUserPrivilegeDTO) => { + const projectMembership = await projectMembershipDAL.findById(projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, projectMembershipId }); + if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + + if (!dto.isTemporary) { + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ + projectMembershipId, + slug, + permissions: customPermission, + name, + description + }); + return additionalPrivilege; + } + + const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ + projectMembershipId, + slug, + permissions: customPermission, + name, + description, + isTemporary: true, + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: dto.temporaryRange, + temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) + }); + return additionalPrivilege; + }; + + const updateById = async ({ privilegeId, actorOrgId, actor, actorId, ...dto }: TUpdateUserPrivilegeDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + if (dto?.slug) { + const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ + slug: dto.slug, + projectMembershipId: projectMembership.id + }); + if (existingSlug && existingSlug.id !== userPrivilege.id) + throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + } + + const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : userPrivilege.isTemporary; + if (isTemporary) { + const temporaryAccessStartTime = dto?.temporaryAccessStartTime || userPrivilege?.temporaryAccessStartTime; + const temporaryRange = dto?.temporaryRange || userPrivilege?.temporaryRange; + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { + ...dto, + temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), + temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) + }); + return additionalPrivilege; + } + + const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { + ...dto, + isTemporary: false, + temporaryAccessStartTime: null, + temporaryAccessEndTime: null, + temporaryRange: null, + temporaryMode: null + }); + return additionalPrivilege; + }; + + const deleteById = async ({ actorId, actor, actorOrgId, privilegeId }: TDeleteUserPrivilegeDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); + return deletedPrivilege; + }; + + const getPrivilegeDetailsById = async ({ privilegeId, actorOrgId, actor, actorId }: TGetUserPrivilegeDetailsDTO) => { + const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); + if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); + + const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); + + return userPrivilege; + }; + + return { + create, + updateById, + deleteById, + getPrivilegeDetailsById + }; +}; diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts new file mode 100644 index 000000000..efc8e81b1 --- /dev/null +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-types.ts @@ -0,0 +1,44 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum ProjectUserAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TCreateUserPrivilegeDTO = ( + | { + permissions: unknown; + projectMembershipId: string; + name: string; + slug: string; + description?: string; + isTemporary: false; + } + | { + permissions: unknown; + projectMembershipId: string; + name: string; + slug: string; + description?: string; + isTemporary: true; + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } +) & + Omit; + +export type TUpdateUserPrivilegeDTO = { privilegeId: string } & Omit & + Partial<{ + permissions: unknown; + name: string; + slug: string; + description?: string; + isTemporary: boolean; + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + }>; + +export type TDeleteUserPrivilegeDTO = Omit & { privilegeId: string }; + +export type TGetUserPrivilegeDetailsDTO = Omit & { privilegeId: string }; From a5039494cd4ee063d25bd952e81a2a19b654f09f Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 15 Mar 2024 15:54:32 +0530 Subject: [PATCH 03/21] feat(server): completed routes for user additional privilege --- backend/src/@types/fastify.d.ts | 2 + backend/src/ee/routes/v1/index.ts | 7 + .../v1/user-additional-privilege-router.ts | 142 ++++++++++++++++++ backend/src/server/routes/index.ts | 11 +- .../routes/v1/project-membership-router.ts | 14 ++ .../src/server/routes/v1/project-router.ts | 14 ++ .../project-membership-dal.ts | 50 +++++- 7 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 backend/src/ee/routes/v1/user-additional-privilege-router.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 9ed72e782..54e220abf 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -8,6 +8,7 @@ import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secr import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -121,6 +122,7 @@ declare module "fastify" { telemetry: TTelemetryServiceFactory; dynamicSecret: TDynamicSecretServiceFactory; dynamicSecretLease: TDynamicSecretLeaseServiceFactory; + projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index b3fafcfdf..0d7b45ded 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -15,6 +15,7 @@ import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSecretVersionRouter } from "./secret-version-router"; import { registerSnapshotRouter } from "./snapshot-router"; import { registerTrustedIpRouter } from "./trusted-ip-router"; +import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router"; export const registerV1EERoutes = async (server: FastifyZodProvider) => { // org role starts with organization @@ -51,4 +52,10 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); + await server.register( + async (privilegeRouter) => { + await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" }); + }, + { prefix: "/additional-privilege" } + ); }; diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts new file mode 100644 index 000000000..fe40ee259 --- /dev/null +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -0,0 +1,142 @@ +import ms from "ms"; +import { z } from "zod"; + +import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas"; +import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.union([ + z.object({ + projectMembershipId: z.string(), + slug: z.string().max(60).trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + permissions: z.any().array(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + projectMembershipId: z.string(), + slug: z.string().max(60).trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + permissions: z.any().array(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + ...req.body, + permissions: JSON.stringify(req.body.permissions) + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "PATCH", + schema: { + params: z.object({ + privilegeId: z.string() + }), + body: z + .object({ + slug: z.string().max(60).trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + permissions: z.any().array(), + isTemporary: z.boolean(), + temporaryMode: z.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + .partial(), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.updateById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + ...req.body, + permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "DELETE", + schema: { + params: z.object({ + privilegeId: z.string() + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.deleteById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "GET", + schema: { + params: z.object({ + privilegeId: z.string() + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.getPrivilegeDetailsById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 940c414e8..4599a732b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -17,6 +17,8 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal"; import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; +import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; @@ -149,6 +151,7 @@ export const registerRoutes = async ( const projectDAL = projectDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); + const projectUserAdditionalPrivilegeDAL = projectUserAdditionalPrivilegeDALFactory(db); const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db); const projectRoleDAL = projectRoleDALFactory(db); const projectEnvDAL = projectEnvDALFactory(db); @@ -345,6 +348,11 @@ export const registerRoutes = async ( projectRoleDAL, licenseService }); + const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ + permissionService, + projectMembershipDAL, + projectUserAdditionalPrivilegeDAL + }); const projectKeyService = projectKeyServiceFactory({ permissionService, projectKeyDAL, @@ -638,7 +646,8 @@ export const registerRoutes = async ( trustedIp: trustedIpService, scim: scimService, secretBlindIndex: secretBlindIndexService, - telemetry: telemetryService + telemetry: telemetryService, + projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService }); server.decorate("store", { diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 9e2f5bd22..ea0559463 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -40,6 +40,20 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider lastName: true, id: true }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + additionalPrivileges: z.array( + z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date() + }) + ), roles: z.array( z.object({ id: z.string(), diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 55f1fdda9..95619a510 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -73,6 +73,20 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { lastName: true, id: true }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + additionalPrivileges: z.array( + z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date() + }) + ), roles: z.array( z.object({ id: z.string(), diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 8faab487e..fae6c26ba 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -29,6 +29,11 @@ export const projectMembershipDALFactory = (db: TDbClient) => { `${TableName.ProjectUserMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .leftJoin( + TableName.ProjectUserAdditionalPrivilege, + `${TableName.ProjectMembership}.id`, + `${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId` + ) .select( db.ref("id").withSchema(TableName.ProjectMembership), db.ref("isGhost").withSchema(TableName.Users), @@ -47,7 +52,23 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole) + db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), + db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"), + db.ref("name").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApName"), + db.ref("description").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApDescription"), + db.ref("slug").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApSlug"), + db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"), + db.ref("createdAt").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApCreatedAt"), + db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userApTemporaryAccessEndTime") ) .where({ isGhost: false }); @@ -87,6 +108,33 @@ export const projectMembershipDALFactory = (db: TDbClient) => { temporaryAccessStartTime, isTemporary }) + }, + { + label: "additionalPrivileges" as const, + key: "userApId", + mapper: ({ + userApId, + userApDescription, + userApName, + userApSlug, + userApIsTemporary, + userApTemporaryMode, + userApTemporaryRange, + userApTemporaryAccessEndTime, + userApTemporaryAccessStartTime, + userApCreatedAt + }) => ({ + id: userApId, + name: userApName, + description: userApDescription, + slug: userApSlug, + temporaryRange: userApTemporaryRange, + temporaryMode: userApTemporaryMode, + temporaryAccessEndTime: userApTemporaryAccessEndTime, + temporaryAccessStartTime: userApTemporaryAccessStartTime, + isTemporary: userApIsTemporary, + createdAt: userApCreatedAt + }) } ] }); From edbe1c8eae1bc60259e70a0f69f30ef3bd68b6c8 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 15 Mar 2024 15:55:48 +0530 Subject: [PATCH 04/21] feat(ui): hook for new user additional privilege --- frontend/src/hooks/api/index.tsx | 1 + .../projectUserAdditionalPrivilege/index.tsx | 7 +++ .../mutation.tsx | 62 +++++++++++++++++++ .../queries.tsx | 31 ++++++++++ .../projectUserAdditionalPrivilege/types.tsx | 48 ++++++++++++++ frontend/src/hooks/api/users/types.ts | 12 ++++ frontend/src/hooks/api/workspace/queries.tsx | 7 +++ 7 files changed, 168 insertions(+) create mode 100644 frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx create mode 100644 frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx create mode 100644 frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx create mode 100644 frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 48d85c285..8ea1985df 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -12,6 +12,7 @@ export * from "./integrations"; export * from "./keys"; export * from "./ldapConfig"; export * from "./organization"; +export * from "./projectUserAdditionalPrivilege"; export * from "./roles"; export * from "./scim"; export * from "./secretApproval"; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx new file mode 100644 index 000000000..53512c52b --- /dev/null +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/index.tsx @@ -0,0 +1,7 @@ +export { + useCreateProjectUserAdditionalPrivilege, + useDeleteProjectUserAdditionalPrivilege, + useUpdateProjectUserAdditionalPrivilege +} from "./mutation"; +export { useGetProjectUserPrivilegeDetails } from "./queries"; +export type { TProjectUserPrivilege } from "./types"; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx new file mode 100644 index 000000000..135920e0e --- /dev/null +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx @@ -0,0 +1,62 @@ +import { packRules } from "@casl/ability/extra"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { + TCreateProjectUserPrivilegeDTO, + TDeleteProjectUserPrivilegeDTO, + TProjectUserPrivilege, + TUpdateProjectUserPrivlegeDTO +} from "./types"; + +export const useCreateProjectUserAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/additional-privilege/users", { + ...dto, + permissions: packRules(dto.permissions) + }); + return data.privilege; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + } + }); +}; + +export const useUpdateProjectUserAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation<{ privilege: TProjectUserPrivilege }, {}, TUpdateProjectUserPrivlegeDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch( + `/api/v1/additional-privilege/users/${dto.privilegeId}`, + { ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined } + ); + return data.privilege; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + } + }); +}; + +export const useDeleteProjectUserAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation<{ privilege: TProjectUserPrivilege }, {}, TDeleteProjectUserPrivilegeDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete( + `/api/v1/additional-privilege/users/${dto.privilegeId}` + ); + return data.privilege; + }, + onSuccess: (_, { workspaceId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId)); + } + }); +}; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx new file mode 100644 index 000000000..1f814d411 --- /dev/null +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/queries.tsx @@ -0,0 +1,31 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TProjectPermission } from "../roles/types"; +import { TProjectUserPrivilege } from "./types"; + +export const projectUserPrivilegeKeys = { + details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const +}; + +const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => { + const { + data: { privilege } + } = await apiRequest.get<{ + privilege: Omit & { permissions: unknown }; + }>(`/api/v1/additional-privilege/users/${privilegeId}`); + return { + ...privilege, + permissions: unpackRules(privilege.permissions as PackRule[]) + }; +}; + +export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => { + return useQuery({ + enabled: Boolean(privilegeId), + queryKey: projectUserPrivilegeKeys.details(privilegeId), + queryFn: () => fetchProjectUserPrivilegeDetails(privilegeId) + }); +}; diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx new file mode 100644 index 000000000..c8e78db43 --- /dev/null +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/types.tsx @@ -0,0 +1,48 @@ +import { TProjectPermission } from "../roles/types"; + +export enum ProjectUserAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TProjectUserPrivilege = { + projectMembershipId: string; + slug: string; + name: string; + isTemporary: boolean; + id: string; + createdAt: Date; + updatedAt: Date; + description?: string | null | undefined; + temporaryMode?: string | null | undefined; + temporaryRange?: string | null | undefined; + temporaryAccessStartTime?: string | null | undefined; + temporaryAccessEndTime?: Date | null | undefined; + permissions?: TProjectPermission[]; +}; + +export type TCreateProjectUserPrivilegeDTO = { + projectMembershipId: string; + slug: string; + name: string; + workspaceId: string; + description?: string; + isTemporary?: boolean; + temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode; + temporaryRange?: string; + temporaryAccessStartTime?: string; + permissions: TProjectPermission[]; +}; + +export type TUpdateProjectUserPrivlegeDTO = { + privilegeId: string; + workspaceId: string; +} & Partial>; + +export type TDeleteProjectUserPrivilegeDTO = { + privilegeId: string; + workspaceId: string; +}; + +export type TGetProejctUserPrivilegeDetails = { + privilegeId: string; +}; diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index edc3bdf9e..326bb71c0 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -76,6 +76,18 @@ export type TWorkspaceUser = { }; inviteEmail: string; organization: string; + additionalPrivileges: { + id: string; + name: string; + description: string | null | undefined; + slug: string; + temporaryRange: string | null | undefined; + temporaryMode: string | null | undefined; + temporaryAccessEndTime: string | null | undefined; + temporaryAccessStartTime: string | null | undefined; + isTemporary: boolean; + createdAt: string; + }[]; roles: { id: string; role: "owner" | "admin" | "member" | "no-access" | "custom"; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 001454233..30e341a6a 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -315,6 +315,13 @@ export const useGetWorkspaceUsers = (workspaceId: string) => { ); return users; }, + select: (data) => + data.map((el) => ({ + ...el, + additionalPrivileges: el.additionalPrivileges.sort((a, b) => + a.createdAt.localeCompare(b.createdAt) + ) + })), enabled: true }); }; From 81cf19cb4ad1bec8e00be18b2ac0b53f340df24b Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 15 Mar 2024 15:56:16 +0530 Subject: [PATCH 05/21] feat(ui): completed ui for user additional privilege --- frontend/src/components/v2/Tag/Tag.tsx | 1 + .../views/Project/MembersPage/MembersPage.tsx | 10 +- .../AdditionalPrivilegeForm.tsx | 283 ++++++++++++++++++ .../AdditionalPrivilegeSection.tsx | 181 +++++++++++ .../AdditionalPrivilegeTemporaryAccess.tsx | 193 ++++++++++++ .../AdditionalPrivilegeSection/index.tsx | 1 + .../MemberListTab/MemberListTab.tsx | 171 ++++++++--- .../MultiEnvProjectPermission.tsx | 13 +- .../ProjectRoleModifySection.tsx | 2 + 9 files changed, 794 insertions(+), 61 deletions(-) create mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx diff --git a/frontend/src/components/v2/Tag/Tag.tsx b/frontend/src/components/v2/Tag/Tag.tsx index 5f3771f62..7210e388b 100644 --- a/frontend/src/components/v2/Tag/Tag.tsx +++ b/frontend/src/components/v2/Tag/Tag.tsx @@ -20,6 +20,7 @@ const tagVariants = cva( green: "bg-primary-800 text-white" }, size: { + xs: "text-xs px-1 py-0.5", sm: "px-2 py-0.5" } } diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index b565d426c..66c01f773 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -32,15 +32,7 @@ export const MembersPage = withProjectPermission( Project Roles - - - + diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx new file mode 100644 index 000000000..c0d649942 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx @@ -0,0 +1,283 @@ +import { useForm } from "react-hook-form"; +import { faElementor } from "@fortawesome/free-brands-svg-icons"; +import { + faAnchorLock, + faArrowLeft, + faBook, + faCog, + faKey, + faLock, + faNetworkWired, + faPuzzlePiece, + faServer, + faShield, + faTags, + faUser, + faUsers +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { Button, FormControl, Input } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { + useCreateProjectUserAdditionalPrivilege, + useGetProjectUserPrivilegeDetails, + useUpdateProjectUserAdditionalPrivilege +} from "@app/hooks/api"; + +import { MultiEnvProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission"; +import { + formRolePermission2API, + formSchema, + rolePermission2Form, + // rolePermission2Form, + TFormSchema +} from "../ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils"; +import { SecretRollbackPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission"; +import { SingleProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission"; +import { WsProjectPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission"; + +const SINGLE_PERMISSION_LIST = [ + { + title: "Integrations", + subtitle: "Integration management control", + icon: faPuzzlePiece, + formName: "integrations" + }, + { + title: "Secret Protect policy", + subtitle: "Manage policies for secret protection for unauthorized secret changes", + icon: faShield, + formName: ProjectPermissionSub.SecretApproval + }, + { + title: "Roles", + subtitle: "Role management control", + icon: faUsers, + formName: "role" + }, + { + title: "Project Members", + subtitle: "Project members management control", + icon: faUser, + formName: "member" + }, + { + title: "Machine identity management", + subtitle: "Add, view, update and remove (machine) identities from the project", + icon: faServer, + formName: "identity" + }, + { + title: "Webhooks", + subtitle: "Webhook management control", + icon: faAnchorLock, + formName: "webhooks" + }, + { + title: "Service Tokens", + subtitle: "Token management control", + icon: faKey, + formName: "service-tokens" + }, + { + title: "Settings", + subtitle: "Settings control", + icon: faCog, + formName: "settings" + }, + { + title: "Environments", + subtitle: "Environment management control", + icon: faElementor, + formName: "environments" + }, + { + title: "Tags", + subtitle: "Tag management control", + icon: faTags, + formName: "tags" + }, + { + title: "Audit Logs", + subtitle: "Audit log management control", + icon: faBook, + formName: "audit-logs" + }, + { + title: "IP Allowlist", + subtitle: "IP allowlist management control", + icon: faNetworkWired, + formName: "ip-allowlist" + } +] as const; + +type Props = { + onGoBack: VoidFunction; + isIdentity?: boolean; + privilegeId?: string; + workspaceId: string; + // isIdentity true -> actorId is identity Id + // isIdentity false -> actorId is projectMembershipId + actorId: string; +}; + +export const AdditionalPrivilegeForm = ({ onGoBack, privilegeId, actorId, workspaceId }: Props) => { + const { createNotification } = useNotificationContext(); + const isNewRole = !privilegeId; + + const { data: projectUserPrivilegeDetails } = useGetProjectUserPrivilegeDetails( + privilegeId || "" + ); + + const { + handleSubmit, + register, + formState: { isSubmitting, isDirty, errors }, + setValue, + getValues, + control + } = useForm({ + resolver: zodResolver(formSchema), + values: projectUserPrivilegeDetails && { + ...projectUserPrivilegeDetails, + description: projectUserPrivilegeDetails.description || "", + permissions: rolePermission2Form(projectUserPrivilegeDetails.permissions) + } + }); + + const createProjectUserAdditionalPrivilege = useCreateProjectUserAdditionalPrivilege(); + const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege(); + + const handleRoleUpdate = async (el: TFormSchema) => { + try { + await updateProjectUserAdditionalPrivilege.mutateAsync({ + ...el, + permissions: formRolePermission2API(el.permissions), + privilegeId: privilegeId as string, + workspaceId + }); + createNotification({ type: "success", text: "Successfully update privilege" }); + onGoBack(); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update privilege" }); + } + }; + + const handleFormSubmit = async (el: TFormSchema) => { + if (!isNewRole) { + await handleRoleUpdate(el); + return; + } + + try { + await createProjectUserAdditionalPrivilege.mutateAsync({ + ...el, + permissions: formRolePermission2API(el.permissions), + projectMembershipId: actorId, + workspaceId + }); + createNotification({ type: "success", text: "Created new privilege" }); + onGoBack(); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to create privilege" }); + } + }; + + return ( +
+
+
+

+ {isNewRole ? "New" : "Edit"} user additional privilege +

+ +
+

+ Select multiple privilege that can be granted to the user +

+
+ + + + + + + + + +
+
+

Add Privilege

+
+
+
+ +
+
+ +
+ {SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => ( +
+ +
+ ))} +
+ +
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx new file mode 100644 index 000000000..c13af0b98 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx @@ -0,0 +1,181 @@ +import { + faArrowLeft, + faPencil, + faPlus, + faTrash, + faUserShield +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { motion } from "framer-motion"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + DeleteActionModal, + EmptyState, + IconButton, + Tag, + Tooltip +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteProjectUserAdditionalPrivilege } from "@app/hooks/api"; +import { TWorkspaceUser } from "@app/hooks/api/types"; + +import { AdditionalPrivilegeForm } from "./AdditionalPrivilegeForm"; +import { AdditionalPrivilegeTemporaryAccess } from "./AdditionalPrivilegeTemporaryAccess"; + +type Props = { + onGoBack: VoidFunction; + name: string; + projectMembershipId: string; + privileges: TWorkspaceUser["additionalPrivileges"]; +}; + +export const AdditionalPrivilegeSection = ({ + onGoBack, + privileges = [], + projectMembershipId, + name +}: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "modifyPrivilege", + "deletePrivilege" + ] as const); + const { createNotification } = useNotificationContext(); + const deleteProjectUserAdditionalPrivilege = useDeleteProjectUserAdditionalPrivilege(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + + const onPrivilegeDelete = async (privilegeId: string) => { + try { + await deleteProjectUserAdditionalPrivilege.mutateAsync({ + privilegeId, + workspaceId + }); + handlePopUpClose("deletePrivilege"); + createNotification({ + type: "success", + text: "Successfully removed user privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to delete user privilege" + }); + } + }; + + if (popUp.modifyPrivilege.isOpen) { + const privilegeDetails = popUp?.modifyPrivilege?.data as { + id: string; + }; + + return ( + + handlePopUpClose("modifyPrivilege")} + privilegeId={privilegeDetails?.id} + workspaceId={workspaceId} + actorId={projectMembershipId} + /> + + ); + } + + return ( + +
+

+ Additional Privileges - {name} +

+
+ + +
+
+
+ {privileges.length === 0 && ( + + )} + {privileges.map(({ id, name: privilegeName, description, slug, ...dto }) => ( +
+
+
+ {privilegeName} + + {slug} + +
+
{description}
+
+
+ + handlePopUpOpen("modifyPrivilege", { id })} + > + + + + + handlePopUpOpen("deletePrivilege", { name: privilegeName, id })} + > + + + + +
+
+ ))} +
+ handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onDeleteApproved={async () => + onPrivilegeDelete((popUp?.deletePrivilege.data as { id: string }).id) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx new file mode 100644 index 000000000..718a7de55 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx @@ -0,0 +1,193 @@ +import { Controller, useForm } from "react-hook-form"; +import { faClock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { + Button, + FormControl, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Tag, + Tooltip +} from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { useUpdateProjectUserAdditionalPrivilege } from "@app/hooks/api"; +import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types"; + +const temporaryRoleFormSchema = z.object({ + temporaryRange: z.string().min(1, "Required") +}); + +type TTemporaryRoleFormSchema = z.infer; + +type TTemporaryRoleFormProps = { + privilegeId: string; + workspaceId: string; + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; +}; + +export const AdditionalPrivilegeTemporaryAccess = ({ + temporaryConfig: defaultValues = {}, + workspaceId, + privilegeId +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { createNotification } = useNotificationContext(); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege(); + + const handleGrantTemporaryAccess = async (el: TTemporaryRoleFormSchema) => { + try { + await updateProjectUserAdditionalPrivilege.mutateAsync({ + privilegeId: privilegeId as string, + workspaceId, + isTemporary: true, + temporaryRange: el.temporaryRange, + temporaryAccessStartTime: new Date().toISOString(), + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative + }); + createNotification({ type: "success", text: "Successfully updated access" }); + handlePopUpToggle("setTempRole"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update access" }); + } + }; + + const handleRevokeTemporaryAccess = async () => { + try { + await updateProjectUserAdditionalPrivilege.mutateAsync({ + privilegeId: privilegeId as string, + workspaceId, + isTemporary: false + }); + createNotification({ type: "success", text: "Successfully updated access" }); + handlePopUpToggle("setTempRole"); + } catch (err) { + console.log(err); + createNotification({ type: "error", text: "Failed to update access" }); + } + }; + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} + + More + + + } + > + + + )} + /> +
+ + {isTemporaryFieldValue && ( + + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx new file mode 100644 index 000000000..42c37441d --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx @@ -0,0 +1 @@ +export { AdditionalPrivilegeSection } from "./AdditionalPrivilegeSection"; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 342b3b920..fdf5a7309 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -2,9 +2,17 @@ import { useMemo, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; -import { faMagnifyingGlass, faPlus, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faMagnifyingGlass, + faPlus, + faUsers, + faUserShield, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -48,6 +56,7 @@ import { } from "@app/hooks/api"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; +import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection"; import { MemberRoles } from "./MemberRoles"; const addMemberFormSchema = z.object({ @@ -77,7 +86,8 @@ export const MemberListTab = () => { const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addMember", "removeMember", - "upgradePlan" + "upgradePlan", + "additionalPrivilege" ] as const); const { @@ -185,8 +195,41 @@ export const MemberListTab = () => { ); }, [orgUsers, members]); + if (popUp.additionalPrivilege.isOpen) { + const privilegeDetails = popUp?.additionalPrivilege?.data as { + name: string; + index: number; + projectMembershipId: string; + }; + + return ( + + handlePopUpClose("additionalPrivilege")} + privileges={members?.[privilegeDetails.index]?.additionalPrivileges || []} + name={privilegeDetails.name} + projectMembershipId={privilegeDetails.projectMembershipId} + /> + + ); + } + return ( -
+

Members

@@ -223,58 +266,90 @@ export const MemberListTab = () => { {isMembersLoading && } {!isMembersLoading && - filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; - const email = u?.email || inviteEmail; + filterdUsers?.map( + ( + { user: u, inviteEmail, id: membershipId, roles, additionalPrivileges }, + index + ) => { + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; + const hasAdditionalPrivilege = Boolean(additionalPrivileges.length); - return ( - - {name} - {email} - - - {(isAllowed) => ( - - handlePopUpOpen("upgradePlan", { description }) - } - membershipId={membershipId} - /> - )} - - - - {userId !== u?.id && ( + return ( + + {name} + {email} + {(isAllowed) => ( - - handlePopUpOpen("removeMember", { username: u.username }) + + handlePopUpOpen("upgradePlan", { description }) } - > - - + membershipId={membershipId} + /> )} - )} - - - ); - })} + + + {userId !== u?.id && ( +
+ + {(isAllowed) => ( + + handlePopUpOpen("additionalPrivilege", { + name: `${user.firstName} ${user.lastName || ""}`, + index, + projectMembershipId: membershipId + }) + } + > + + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("removeMember", { username: u.username }) + } + > + + + )} + +
+ )} + + + ); + } + )} {!isMembersLoading && filterdUsers?.length === 0 && ( @@ -355,6 +430,6 @@ export const MemberListTab = () => { onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text={(popUp.upgradePlan?.data as { description: string })?.description} /> -
+
); }; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx index db18822f4..188d81c65 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form"; import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { motion } from "framer-motion"; @@ -28,6 +28,7 @@ type Props = { formName: "secrets"; isNonEditable?: boolean; setValue: UseFormSetValue; + getValue: UseFormGetValues; control: Control; title: string; subtitle: string; @@ -44,6 +45,7 @@ enum Permission { export const MultiEnvProjectPermission = ({ isNonEditable, setValue, + getValue, control, formName, title, @@ -69,9 +71,12 @@ export const MultiEnvProjectPermission = ({ const handlePermissionChange = (val: Permission) => { switch (val) { - case Permission.NoAccess: - setValue(`permissions.${formName}`, undefined, { shouldDirty: true }); + case Permission.NoAccess: { + const permissions = getValue("permissions"); + if (permissions) delete permissions[formName]; + setValue("permissions", permissions, { shouldDirty: true }); break; + } case Permission.FullAccess: setValue( `permissions.${formName}`, @@ -101,7 +106,7 @@ export const MultiEnvProjectPermission = ({ className={twMerge( "rounded-md bg-mineshaft-800 px-10 py-6", (selectedPermissionCategory !== Permission.NoAccess || isCustom) && - "border-l-2 border-primary-600" + "border-l-2 border-primary-600" )} >
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 cf2e7e8b9..4349b0913 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 @@ -128,6 +128,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { register, formState: { isSubmitting, isDirty, errors }, setValue, + getValues, control } = useForm({ defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, @@ -226,6 +227,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
Date: Fri, 15 Mar 2024 21:18:11 +0530 Subject: [PATCH 06/21] feat(server): completed identity additional privilege --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 8 + ...3_machine-identity-additional-privilege.ts | 34 +++ .../identity-project-additional-privilege.ts | 33 +++ backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + ...ity-project-additional-privilege-router.ts | 171 ++++++++++++ backend/src/ee/routes/v1/index.ts | 2 + ...entity-project-additional-privilege-dal.ts | 12 + ...ty-project-additional-privilege-service.ts | 259 ++++++++++++++++++ ...tity-project-additional-privilege-types.ts | 51 ++++ backend/src/server/routes/index.ts | 11 +- .../routes/v2/identity-project-router.ts | 14 + .../identity-project/identity-project-dal.ts | 56 +++- 14 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 backend/src/db/migrations/20240315102813_machine-identity-additional-privilege.ts create mode 100644 backend/src/db/schemas/identity-project-additional-privilege.ts create mode 100644 backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts create mode 100644 backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts create mode 100644 backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts create mode 100644 backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 54e220abf..0a9aff8f0 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -5,6 +5,7 @@ import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-se import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -123,6 +124,7 @@ declare module "fastify" { dynamicSecret: TDynamicSecretServiceFactory; dynamicSecretLease: TDynamicSecretLeaseServiceFactory; projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory; + identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 9b4bfec96..7c14eb781 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -38,6 +38,9 @@ import { TIdentityOrgMemberships, TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsUpdate, + TIdentityProjectAdditionalPrivilege, + TIdentityProjectAdditionalPrivilegeInsert, + TIdentityProjectAdditionalPrivilegeUpdate, TIdentityProjectMembershipRole, TIdentityProjectMembershipRoleInsert, TIdentityProjectMembershipRoleUpdate, @@ -302,6 +305,11 @@ declare module "knex/types/tables" { TIdentityProjectMembershipRoleInsert, TIdentityProjectMembershipRoleUpdate >; + [TableName.IdentityProjectAdditionalPrivilege]: Knex.CompositeTableType< + TIdentityProjectAdditionalPrivilege, + TIdentityProjectAdditionalPrivilegeInsert, + TIdentityProjectAdditionalPrivilegeUpdate + >; [TableName.ScimToken]: Knex.CompositeTableType; [TableName.SecretApprovalPolicy]: Knex.CompositeTableType< TSecretApprovalPolicies, diff --git a/backend/src/db/migrations/20240315102813_machine-identity-additional-privilege.ts b/backend/src/db/migrations/20240315102813_machine-identity-additional-privilege.ts new file mode 100644 index 000000000..143fa6bdd --- /dev/null +++ b/backend/src/db/migrations/20240315102813_machine-identity-additional-privilege.ts @@ -0,0 +1,34 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityProjectAdditionalPrivilege))) { + await knex.schema.createTable(TableName.IdentityProjectAdditionalPrivilege, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.string("slug", 60).notNullable(); + t.string("description"); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId") + .references("id") + .inTable(TableName.IdentityProjectMembership) + .onDelete("CASCADE"); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.jsonb("permissions").notNullable(); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege); +} + +export async function down(knex: Knex): Promise { + await dropOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege); + await knex.schema.dropTableIfExists(TableName.IdentityProjectAdditionalPrivilege); +} diff --git a/backend/src/db/schemas/identity-project-additional-privilege.ts b/backend/src/db/schemas/identity-project-additional-privilege.ts new file mode 100644 index 000000000..0e548dd93 --- /dev/null +++ b/backend/src/db/schemas/identity-project-additional-privilege.ts @@ -0,0 +1,33 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityProjectAdditionalPrivilegeSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + description: z.string().nullable().optional(), + projectMembershipId: z.string().uuid(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityProjectAdditionalPrivilege = z.infer; +export type TIdentityProjectAdditionalPrivilegeInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TIdentityProjectAdditionalPrivilegeUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 96993dc10..361ae2e0d 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -10,6 +10,7 @@ export * from "./git-app-org"; export * from "./identities"; export * from "./identity-access-tokens"; export * from "./identity-org-memberships"; +export * from "./identity-project-additional-privilege"; export * from "./identity-project-membership-role"; export * from "./identity-project-memberships"; export * from "./identity-ua-client-secrets"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 8af8951fd..3cb11d694 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -44,6 +44,7 @@ export enum TableName { IdentityOrgMembership = "identity_org_memberships", IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembershipRole = "identity_project_membership_role", + IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege", ScimToken = "scim_tokens", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", 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 new file mode 100644 index 000000000..622b003dd --- /dev/null +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -0,0 +1,171 @@ +import ms from "ms"; +import { z } from "zod"; + +import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; +import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.union([ + z.object({ + identityId: z.string(), + projectId: z.string(), + slug: z.string().max(60).trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + permissions: z.any().array(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + identityId: z.string(), + projectId: z.string(), + slug: z.string().max(60).trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + permissions: z.any().array(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + ...req.body, + permissions: JSON.stringify(req.body.permissions) + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "PATCH", + schema: { + params: z.object({ + privilegeId: z.string() + }), + body: z + .object({ + slug: z.string().max(60).trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + permissions: z.any().array(), + isTemporary: z.boolean(), + temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + .partial(), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.updateById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + ...req.body, + permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "DELETE", + schema: { + params: z.object({ + privilegeId: z.string() + }), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.deleteById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + url: "/:privilegeId", + method: "GET", + schema: { + params: z.object({ + privilegeId: z.string() + }), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.getPrivilegeDetailsById({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + privilegeId: req.params.privilegeId + }); + return { privilege }; + } + }); + + server.route({ + url: "/permissions", + method: "POST", + schema: { + body: z.object({ + identityId: z.string(), + projectId: z.string() + }), + response: { + 200: z.object({ + privileges: IdentityProjectAdditionalPrivilegeSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privileges = await server.services.identityProjectAdditionalPrivilege.listIdentityProjectPrivileges({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + projectId: req.body.projectId, + identityId: req.body.identityId + }); + return { privileges }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 0d7b45ded..72021c81f 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,5 +1,6 @@ import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; +import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; import { registerOrgRoleRouter } from "./org-role-router"; @@ -55,6 +56,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register( async (privilegeRouter) => { await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" }); + await privilegeRouter.register(registerIdentityProjectAdditionalPrivilegeRouter, { prefix: "/identity" }); }, { prefix: "/additional-privilege" } ); diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts new file mode 100644 index 000000000..26252f2d1 --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal.ts @@ -0,0 +1,12 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityProjectAdditionalPrivilegeDALFactory = ReturnType< + typeof identityProjectAdditionalPrivilegeDALFactory +>; + +export const identityProjectAdditionalPrivilegeDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.IdentityProjectAdditionalPrivilege); + return orm; +}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts new file mode 100644 index 000000000..59729917c --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -0,0 +1,259 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal"; +import { + IdentityProjectAdditionalPrivilegeTemporaryMode, + TCreateIdentityPrivilegeDTO, + TDeleteIdentityPrivilegeDTO, + TGetIdentityPrivilegeDetailsDTO, + TListIdentityPrivilegesDTO, + TUpdateIdentityPrivilegeDTO +} from "./identity-project-additional-privilege-types"; + +type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = { + identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory; + identityProjectDAL: Pick; + permissionService: Pick; +}; + +export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< + typeof identityProjectAdditionalPrivilegeServiceFactory +>; + +export const identityProjectAdditionalPrivilegeServiceFactory = ({ + identityProjectAdditionalPrivilegeDAL, + identityProjectDAL, + permissionService +}: TIdentityProjectAdditionalPrivilegeServiceFactoryDep) => { + const create = async ({ + name, + slug, + actor, + actorId, + projectId, + identityId, + permissions: customPermission, + actorOrgId, + description, + ...dto + }: TCreateIdentityPrivilegeDTO) => { + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityId, + identityProjectMembership.projectId, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + + if (!dto.isTemporary) { + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ + projectMembershipId: identityProjectMembership.id, + slug, + permissions: customPermission, + name, + description + }); + return additionalPrivilege; + } + + const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({ + projectMembershipId: identityProjectMembership.id, + slug, + permissions: customPermission, + name, + description, + isTemporary: true, + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative, + temporaryRange: dto.temporaryRange, + temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) + }); + return additionalPrivilege; + }; + + const updateById = async ({ privilegeId, actorOrgId, actor, actorId, ...dto }: TUpdateIdentityPrivilegeDTO) => { + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(privilegeId); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + + const identityProjectMembership = await identityProjectDAL.findById(identityPrivilege.projectMembershipId); + if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityProjectMembership.identityId, + identityProjectMembership.projectId, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + if (dto?.slug) { + const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug: dto.slug, + projectMembershipId: identityProjectMembership.id + }); + if (existingSlug && existingSlug.id !== identityPrivilege.id) + throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); + } + + const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : identityPrivilege.isTemporary; + if (isTemporary) { + const temporaryAccessStartTime = dto?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime; + const temporaryRange = dto?.temporaryRange || identityPrivilege?.temporaryRange; + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { + ...dto, + temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), + temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) + }); + return additionalPrivilege; + } + + const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { + ...dto, + isTemporary: false, + temporaryAccessStartTime: null, + temporaryAccessEndTime: null, + temporaryRange: null, + temporaryMode: null + }); + return additionalPrivilege; + }; + + const deleteById = async ({ actorId, actor, actorOrgId, privilegeId }: TDeleteIdentityPrivilegeDTO) => { + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(privilegeId); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + + const identityProjectMembership = await identityProjectDAL.findById(identityPrivilege.projectMembershipId); + if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityProjectMembership.identityId, + identityProjectMembership.projectId, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); + return deletedPrivilege; + }; + + const getPrivilegeDetailsById = async ({ + privilegeId, + actorOrgId, + actor, + actorId + }: TGetIdentityPrivilegeDetailsDTO) => { + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(privilegeId); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + + const identityProjectMembership = await identityProjectDAL.findById(identityPrivilege.projectMembershipId); + if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityProjectMembership.identityId, + identityProjectMembership.projectId, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + return identityPrivilege; + }; + + const listIdentityProjectPrivileges = async ({ + projectId, + identityId, + actorOrgId, + actor, + actorId + }: TListIdentityPrivilegesDTO) => { + const identityProjectMembership = await identityProjectDAL.findOne({ projectId, identityId }); + if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + identityProjectMembership.projectId, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); + const { permission: identityRolePermission } = await permissionService.getProjectPermission( + ActorType.IDENTITY, + identityProjectMembership.identityId, + identityProjectMembership.projectId, + actorOrgId + ); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivileges) throw new BadRequestError({ message: "Identity additional privilege not found" }); + return identityPrivileges; + }; + + return { + create, + updateById, + deleteById, + getPrivilegeDetailsById, + listIdentityProjectPrivileges + }; +}; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts new file mode 100644 index 000000000..76a8ffb62 --- /dev/null +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts @@ -0,0 +1,51 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum IdentityProjectAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TCreateIdentityPrivilegeDTO = ( + | { + permissions: unknown; + identityId: string; + projectId: string; + name: string; + slug: string; + description?: string; + isTemporary: false; + } + | { + permissions: unknown; + identityId: string; + projectId: string; + name: string; + slug: string; + description?: string; + isTemporary: true; + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } +) & + Omit; + +export type TUpdateIdentityPrivilegeDTO = { privilegeId: string } & Omit & + Partial<{ + permissions: unknown; + name: string; + slug: string; + description?: string; + isTemporary: boolean; + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + }>; + +export type TDeleteIdentityPrivilegeDTO = Omit & { privilegeId: string }; + +export type TGetIdentityPrivilegeDetailsDTO = Omit & { privilegeId: string }; + +export type TListIdentityPrivilegesDTO = Omit & { + identityId: string; + projectId: string; +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 4599a732b..c33f62029 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -11,6 +11,8 @@ import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/pro import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal"; import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue"; import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal"; +import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { licenseDALFactory } from "@app/ee/services/license/license-dal"; @@ -177,6 +179,7 @@ export const registerRoutes = async ( const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db); + const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db); const identityUaDAL = identityUaDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); @@ -556,6 +559,11 @@ export const registerRoutes = async ( identityProjectMembershipRoleDAL, projectRoleDAL }); + const identityProjectAdditionalPrivilegeService = identityProjectAdditionalPrivilegeServiceFactory({ + identityProjectAdditionalPrivilegeDAL, + permissionService, + identityProjectDAL + }); const identityUaService = identityUaServiceFactory({ identityOrgMembershipDAL, permissionService, @@ -647,7 +655,8 @@ export const registerRoutes = async ( scim: scimService, secretBlindIndex: secretBlindIndexService, telemetry: telemetryService, - projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService + projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, + identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService }); server.decorate("store", { diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index d170f87e6..81a59e995 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -156,6 +156,20 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) identityId: z.string(), createdAt: z.date(), updatedAt: z.date(), + additionalPrivileges: z.array( + z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + description: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date() + }) + ), roles: z.array( z.object({ id: z.string(), diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index dd3ba04f4..b6de21d05 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -25,6 +25,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .leftJoin( + TableName.IdentityProjectAdditionalPrivilege, + `${TableName.IdentityProjectMembership}.id`, + `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId` + ) .select( db.ref("id").withSchema(TableName.IdentityProjectMembership), db.ref("createdAt").withSchema(TableName.IdentityProjectMembership), @@ -42,7 +47,29 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole), db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole) + db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole), + db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), + db.ref("name").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApName"), + db.ref("description").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApDescription"), + db.ref("slug").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApSlug"), + db + .ref("temporaryMode") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"), + db.ref("createdAt").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApCreatedAt"), + db + .ref("temporaryRange") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessEndTime") ); const members = sqlNestRelationships({ @@ -86,6 +113,33 @@ export const identityProjectDALFactory = (db: TDbClient) => { temporaryAccessStartTime, isTemporary }) + }, + { + label: "additionalPrivileges" as const, + key: "identityApId", + mapper: ({ + identityApId, + identityApDescription, + identityApName, + identityApSlug, + identityApIsTemporary, + identityApTemporaryMode, + identityApTemporaryRange, + identityApCreatedAt, + identityApTemporaryAccessEndTime, + identityApTemporaryAccessStartTime + }) => ({ + id: identityApId, + name: identityApName, + description: identityApDescription, + slug: identityApSlug, + temporaryRange: identityApTemporaryRange, + temporaryMode: identityApTemporaryMode, + temporaryAccessEndTime: identityApTemporaryAccessEndTime, + temporaryAccessStartTime: identityApTemporaryAccessStartTime, + isTemporary: identityApIsTemporary, + createdAt: identityApCreatedAt + }) } ] }); From 5f84de039f532a6c0335e8560bbbeab0778293c2 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 15 Mar 2024 21:19:09 +0530 Subject: [PATCH 07/21] feat(ui): finished ui for identity additional privilege --- frontend/src/hooks/api/identities/types.ts | 12 + .../index.tsx | 7 + .../mutation.tsx | 74 +++++ .../queries.tsx | 31 ++ .../types.tsx | 48 +++ frontend/src/hooks/api/index.tsx | 1 + .../AdditionalPrivilegeForm.tsx | 73 +++-- .../AdditionalPrivilegeSection.tsx | 46 ++- .../AdditionalPrivilegeTemporaryAccess.tsx | 56 +++- .../components/IdentityTab/IdentityTab.tsx | 281 +++++++++++++++++- .../{IdentitySection => }/IdentityModal.tsx | 0 .../{IdentitySection => }/IdentityRoles.tsx | 0 .../IdentitySection/IdentitySection.tsx | 107 ------- .../IdentitySection/IdentityTable.tsx | 108 ------- .../components/IdentitySection/index.tsx | 1 - .../IdentityTab/components/index.tsx | 1 - .../MemberListTab/MemberListTab.tsx | 2 +- 17 files changed, 569 insertions(+), 279 deletions(-) create mode 100644 frontend/src/hooks/api/identityProjectAdditionalPrivilege/index.tsx create mode 100644 frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx create mode 100644 frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx create mode 100644 frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx rename frontend/src/views/Project/MembersPage/components/IdentityTab/components/{IdentitySection => }/IdentityModal.tsx (100%) rename frontend/src/views/Project/MembersPage/components/IdentityTab/components/{IdentitySection => }/IdentityRoles.tsx (100%) delete mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index fd3e88614..b8829f798 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -41,6 +41,18 @@ export type IdentityMembership = { temporaryAccessStartTime: string | null; temporaryAccessEndTime: string | null; }[]; + additionalPrivileges: { + id: string; + name: string; + description: string | null | undefined; + slug: string; + temporaryRange: string | null | undefined; + temporaryMode: string | null | undefined; + temporaryAccessEndTime: string | null | undefined; + temporaryAccessStartTime: string | null | undefined; + isTemporary: boolean; + createdAt: string; + }[]; createdAt: string; updatedAt: string; }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/index.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/index.tsx new file mode 100644 index 000000000..401a06a72 --- /dev/null +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/index.tsx @@ -0,0 +1,7 @@ +export { + useCreateIdentityProjectAdditionalPrivilege, + useDeleteIdentityProjectAdditionalPrivilege, + useUpdateIdentityProjectAdditionalPrivilege +} from "./mutation"; +export { useGetIdentityProjectPrivilegeDetails } from "./queries"; +export type { TIdentityProjectPrivilege } from "./types"; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx new file mode 100644 index 000000000..420d29485 --- /dev/null +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx @@ -0,0 +1,74 @@ +import { packRules } from "@casl/ability/extra"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { + TCreateIdentityProjectPrivilegeDTO, + TDeleteIdentityProjectPrivilegeDTO, + TIdentityProjectPrivilege, + TUpdateIdentityProjectPrivlegeDTO +} from "./types"; + +export const useCreateIdentityProjectAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation< + { privilege: TIdentityProjectPrivilege }, + {}, + TCreateIdentityProjectPrivilegeDTO + >({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post("/api/v1/additional-privilege/identity", { + ...dto, + permissions: packRules(dto.permissions) + }); + return data.privilege; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + } + }); +}; + +export const useUpdateIdentityProjectAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation< + { privilege: TIdentityProjectPrivilege }, + {}, + TUpdateIdentityProjectPrivlegeDTO + >({ + mutationFn: async (dto) => { + const { data } = await apiRequest.patch( + `/api/v1/additional-privilege/identity/${dto.privilegeId}`, + { ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined } + ); + return data.privilege; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + } + }); +}; + +export const useDeleteIdentityProjectAdditionalPrivilege = () => { + const queryClient = useQueryClient(); + + return useMutation< + { privilege: TIdentityProjectPrivilege }, + {}, + TDeleteIdentityProjectPrivilegeDTO + >({ + mutationFn: async (dto) => { + const { data } = await apiRequest.delete( + `/api/v1/additional-privilege/identity/${dto.privilegeId}` + ); + return data.privilege; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + } + }); +}; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx new file mode 100644 index 000000000..2bbd71c70 --- /dev/null +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx @@ -0,0 +1,31 @@ +import { PackRule, unpackRules } from "@casl/ability/extra"; +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TProjectPermission } from "../roles/types"; +import { TIdentityProjectPrivilege } from "./types"; + +export const identitiyProjectPrivilegeKeys = { + details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const +}; + +const fetchIdentityProjectPrivilegeDetails = async (privilegeId: string) => { + const { + data: { privilege } + } = await apiRequest.get<{ + privilege: Omit & { permissions: unknown }; + }>(`/api/v1/additional-privilege/identity/${privilegeId}`); + return { + ...privilege, + permissions: unpackRules(privilege.permissions as PackRule[]) + }; +}; + +export const useGetIdentityProjectPrivilegeDetails = (privilegeId: string) => { + return useQuery({ + enabled: Boolean(privilegeId), + queryKey: identitiyProjectPrivilegeKeys.details(privilegeId), + queryFn: () => fetchIdentityProjectPrivilegeDetails(privilegeId) + }); +}; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx new file mode 100644 index 000000000..3362e3301 --- /dev/null +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -0,0 +1,48 @@ +import { TProjectPermission } from "../roles/types"; + +export enum IdentityProjectAdditionalPrivilegeTemporaryMode { + Relative = "relative" +} + +export type TIdentityProjectPrivilege = { + projectMembershipId: string; + slug: string; + name: string; + isTemporary: boolean; + id: string; + createdAt: Date; + updatedAt: Date; + description?: string | null | undefined; + temporaryMode?: string | null | undefined; + temporaryRange?: string | null | undefined; + temporaryAccessStartTime?: string | null | undefined; + temporaryAccessEndTime?: Date | null | undefined; + permissions?: TProjectPermission[]; +}; + +export type TCreateIdentityProjectPrivilegeDTO = { + identityId: string; + projectId: string; + slug: string; + name: string; + description?: string; + isTemporary?: boolean; + temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode; + temporaryRange?: string; + temporaryAccessStartTime?: string; + permissions: TProjectPermission[]; +}; + +export type TUpdateIdentityProjectPrivlegeDTO = { + privilegeId: string; + projectId: string; +} & Partial>; + +export type TDeleteIdentityProjectPrivilegeDTO = { + privilegeId: string; + projectId: string; +}; + +export type TGetIdentityProejctPrivilegeDetails = { + privilegeId: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 8ea1985df..ed89b51ba 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -6,6 +6,7 @@ export * from "./bots"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./identities"; +export * from "./identityProjectAdditionalPrivilege"; export * from "./incidentContacts"; export * from "./integrationAuth"; export * from "./integrations"; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx index c0d649942..06d4f4b71 100644 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx @@ -22,8 +22,11 @@ import { useNotificationContext } from "@app/components/context/Notifications/No import { Button, FormControl, Input } from "@app/components/v2"; import { ProjectPermissionSub } from "@app/context"; import { + useCreateIdentityProjectAdditionalPrivilege, useCreateProjectUserAdditionalPrivilege, + useGetIdentityProjectPrivilegeDetails, useGetProjectUserPrivilegeDetails, + useUpdateIdentityProjectAdditionalPrivilege, useUpdateProjectUserAdditionalPrivilege } from "@app/hooks/api"; @@ -32,7 +35,6 @@ import { formRolePermission2API, formSchema, rolePermission2Form, - // rolePermission2Form, TFormSchema } from "../ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils"; import { SecretRollbackPermission } from "../ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission"; @@ -124,14 +126,26 @@ type Props = { actorId: string; }; -export const AdditionalPrivilegeForm = ({ onGoBack, privilegeId, actorId, workspaceId }: Props) => { +export const AdditionalPrivilegeForm = ({ + onGoBack, + privilegeId, + actorId, + workspaceId, + isIdentity +}: Props) => { const { createNotification } = useNotificationContext(); const isNewRole = !privilegeId; const { data: projectUserPrivilegeDetails } = useGetProjectUserPrivilegeDetails( - privilegeId || "" + privilegeId && !isIdentity ? privilegeId : "" ); + const { data: identityProjectPrivilegeDetails } = useGetIdentityProjectPrivilegeDetails( + isIdentity && privilegeId ? privilegeId : "" + ); + + const privileges = isIdentity ? identityProjectPrivilegeDetails : projectUserPrivilegeDetails; + const { handleSubmit, register, @@ -141,24 +155,36 @@ export const AdditionalPrivilegeForm = ({ onGoBack, privilegeId, actorId, worksp control } = useForm({ resolver: zodResolver(formSchema), - values: projectUserPrivilegeDetails && { - ...projectUserPrivilegeDetails, - description: projectUserPrivilegeDetails.description || "", - permissions: rolePermission2Form(projectUserPrivilegeDetails.permissions) + values: privileges && { + ...privileges, + description: privileges.description || "", + permissions: rolePermission2Form(privileges.permissions) } }); const createProjectUserAdditionalPrivilege = useCreateProjectUserAdditionalPrivilege(); const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege(); + const createIdentityProjectAdditionalPrivilege = useCreateIdentityProjectAdditionalPrivilege(); + const updateIdentityProjectAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege(); + const handleRoleUpdate = async (el: TFormSchema) => { try { - await updateProjectUserAdditionalPrivilege.mutateAsync({ - ...el, - permissions: formRolePermission2API(el.permissions), - privilegeId: privilegeId as string, - workspaceId - }); + if (isIdentity) { + await updateIdentityProjectAdditionalPrivilege.mutateAsync({ + ...el, + permissions: formRolePermission2API(el.permissions), + privilegeId: privilegeId as string, + projectId: workspaceId + }); + } else { + await updateProjectUserAdditionalPrivilege.mutateAsync({ + ...el, + permissions: formRolePermission2API(el.permissions), + privilegeId: privilegeId as string, + workspaceId + }); + } createNotification({ type: "success", text: "Successfully update privilege" }); onGoBack(); } catch (err) { @@ -174,12 +200,21 @@ export const AdditionalPrivilegeForm = ({ onGoBack, privilegeId, actorId, worksp } try { - await createProjectUserAdditionalPrivilege.mutateAsync({ - ...el, - permissions: formRolePermission2API(el.permissions), - projectMembershipId: actorId, - workspaceId - }); + if (isIdentity) { + await createIdentityProjectAdditionalPrivilege.mutateAsync({ + ...el, + permissions: formRolePermission2API(el.permissions), + identityId: actorId, + projectId: workspaceId + }); + } else { + await createProjectUserAdditionalPrivilege.mutateAsync({ + ...el, + permissions: formRolePermission2API(el.permissions), + projectMembershipId: actorId, + workspaceId + }); + } createNotification({ type: "success", text: "Created new privilege" }); onGoBack(); } catch (err) { diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx index c13af0b98..75869761f 100644 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx @@ -19,7 +19,10 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useDeleteProjectUserAdditionalPrivilege } from "@app/hooks/api"; +import { + useDeleteIdentityProjectAdditionalPrivilege, + useDeleteProjectUserAdditionalPrivilege +} from "@app/hooks/api"; import { TWorkspaceUser } from "@app/hooks/api/types"; import { AdditionalPrivilegeForm } from "./AdditionalPrivilegeForm"; @@ -28,40 +31,51 @@ import { AdditionalPrivilegeTemporaryAccess } from "./AdditionalPrivilegeTempora type Props = { onGoBack: VoidFunction; name: string; - projectMembershipId: string; + isIdentity?: boolean; + // isIdentity id - identity id else projectMembershipId + actorId: string; privileges: TWorkspaceUser["additionalPrivileges"]; }; export const AdditionalPrivilegeSection = ({ onGoBack, privileges = [], - projectMembershipId, - name + actorId, + name, + isIdentity }: Props) => { const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ "modifyPrivilege", "deletePrivilege" ] as const); const { createNotification } = useNotificationContext(); - const deleteProjectUserAdditionalPrivilege = useDeleteProjectUserAdditionalPrivilege(); const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; + const deleteProjectUserAdditionalPrivilege = useDeleteProjectUserAdditionalPrivilege(); + const deleteProjectIdentityAdditionalPrivilege = useDeleteIdentityProjectAdditionalPrivilege(); const onPrivilegeDelete = async (privilegeId: string) => { try { - await deleteProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId, - workspaceId - }); + if (isIdentity) { + await deleteProjectIdentityAdditionalPrivilege.mutateAsync({ + privilegeId, + projectId: workspaceId + }); + } else { + await deleteProjectUserAdditionalPrivilege.mutateAsync({ + privilegeId, + workspaceId + }); + } handlePopUpClose("deletePrivilege"); createNotification({ type: "success", - text: "Successfully removed user privilege" + text: "Successfully removed privilege" }); } catch (err) { createNotification({ type: "error", - text: "Failed to delete user privilege" + text: "Failed to delete privilege" }); } }; @@ -83,7 +97,8 @@ export const AdditionalPrivilegeSection = ({ onGoBack={() => handlePopUpClose("modifyPrivilege")} privilegeId={privilegeDetails?.id} workspaceId={workspaceId} - actorId={projectMembershipId} + isIdentity={isIdentity} + actorId={actorId} /> ); @@ -119,7 +134,11 @@ export const AdditionalPrivilegeSection = ({
{privileges.length === 0 && ( - + )} {privileges.map(({ id, name: privilegeName, description, slug, ...dto }) => (
; type TTemporaryRoleFormProps = { privilegeId: string; workspaceId: string; + isIdentity?: boolean; temporaryConfig?: { isTemporary?: boolean; temporaryAccessEndTime?: string | null; @@ -41,7 +46,8 @@ type TTemporaryRoleFormProps = { export const AdditionalPrivilegeTemporaryAccess = ({ temporaryConfig: defaultValues = {}, workspaceId, - privilegeId + privilegeId, + isIdentity }: TTemporaryRoleFormProps) => { const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); const { createNotification } = useNotificationContext(); @@ -56,17 +62,29 @@ export const AdditionalPrivilegeTemporaryAccess = ({ isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege(); + const updateProjectIdentityAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege(); const handleGrantTemporaryAccess = async (el: TTemporaryRoleFormSchema) => { try { - await updateProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - workspaceId, - isTemporary: true, - temporaryRange: el.temporaryRange, - temporaryAccessStartTime: new Date().toISOString(), - temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative - }); + if (isIdentity) { + await updateProjectIdentityAdditionalPrivilege.mutateAsync({ + privilegeId: privilegeId as string, + projectId: workspaceId, + isTemporary: true, + temporaryRange: el.temporaryRange, + temporaryAccessStartTime: new Date().toISOString(), + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative + }); + } else { + await updateProjectUserAdditionalPrivilege.mutateAsync({ + privilegeId: privilegeId as string, + workspaceId, + isTemporary: true, + temporaryRange: el.temporaryRange, + temporaryAccessStartTime: new Date().toISOString(), + temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative + }); + } createNotification({ type: "success", text: "Successfully updated access" }); handlePopUpToggle("setTempRole"); } catch (err) { @@ -77,11 +95,19 @@ export const AdditionalPrivilegeTemporaryAccess = ({ const handleRevokeTemporaryAccess = async () => { try { - await updateProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - workspaceId, - isTemporary: false - }); + if (isIdentity) { + await updateProjectIdentityAdditionalPrivilege.mutateAsync({ + privilegeId: privilegeId as string, + projectId: workspaceId, + isTemporary: false + }); + } else { + await updateProjectUserAdditionalPrivilege.mutateAsync({ + privilegeId: privilegeId as string, + workspaceId, + isTemporary: false + }); + } createNotification({ type: "success", text: "Successfully updated access" }); handlePopUpToggle("setTempRole"); } catch (err) { diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index 58397d96a..3aa301583 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -1,17 +1,270 @@ +import Link from "next/link"; +import { + faArrowUpRightFromSquare, + faPlus, + faServer, + faUserShield, + faXmark +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; -import { IdentitySection } from "./components"; +import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; +import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; -export const IdentityTab = () => { - return ( - - - - ); -}; +import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection"; +import { IdentityModal } from "./components/IdentityModal"; +import { IdentityRoles } from "./components/IdentityRoles"; + +export const IdentityTab = withProjectPermission( + () => { + const { createNotification } = useNotificationContext(); + const { currentWorkspace } = useWorkspace(); + + const workspaceId = currentWorkspace?.id ?? ""; + + const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); + const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "identity", + "deleteIdentity", + "upgradePlan", + "additionalPrivilege" + ] as const); + + const onRemoveIdentitySubmit = async (identityId: string) => { + try { + await deleteMutateAsync({ + identityId, + workspaceId + }); + + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + + handlePopUpClose("deleteIdentity"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to remove identity from project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + if (popUp.additionalPrivilege.isOpen) { + const privilegeDetails = popUp?.additionalPrivilege?.data as { + name: string; + index: number; + identityId: string; + }; + + return ( + + handlePopUpClose("additionalPrivilege")} + privileges={data?.[privilegeDetails.index]?.additionalPrivileges || []} + name={privilegeDetails.name} + actorId={privilegeDetails.identityId} + /> + + ); + } + + return ( + +
+
+

Identities

+
+ + + Documentation{" "} + + + +
+ + {(isAllowed) => ( + + )} + +
+ + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map( + ({ identity: { id, name }, roles, createdAt, additionalPrivileges }, index) => { + const hasAdditionalPrivilege = Boolean(additionalPrivileges.length); + return ( + + + + + + + ); + } + )} + {!isLoading && data && data?.length === 0 && ( + + + + )} + +
NameRoleAdded on +
{name} + + {(isAllowed) => ( + + )} + + {format(new Date(createdAt), "yyyy-MM-dd")} +
+ + {(isAllowed) => ( + + handlePopUpOpen("additionalPrivilege", { + name, + index, + identityId: id + }) + } + > + + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + +
+
+ +
+
+ + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveIdentitySubmit( + (popUp?.deleteIdentity?.data as { identityId: string })?.identityId + ) + } + /> +
+
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } +); diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx similarity index 100% rename from frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityModal.tsx rename to frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx similarity index 100% rename from frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityRoles.tsx rename to frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.tsx deleted file mode 100644 index 4410bc639..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentitySection.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import Link from "next/link"; -import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { withProjectPermission } from "@app/hoc"; -import { useDeleteIdentityFromWorkspace } from "@app/hooks/api"; -import { usePopUp } from "@app/hooks/usePopUp"; - -import { IdentityModal } from "./IdentityModal"; -import { IdentityTable } from "./IdentityTable"; - -export const IdentitySection = withProjectPermission( - () => { - - const { currentWorkspace } = useWorkspace(); - - const workspaceId = currentWorkspace?.id ?? ""; - - const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace(); - - const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "identity", - "deleteIdentity", - "upgradePlan" - ] as const); - - const onRemoveIdentitySubmit = async (identityId: string) => { - try { - await deleteMutateAsync({ - identityId, - workspaceId - }); - - createNotification({ - text: "Successfully removed identity from project", - type: "success" - }); - - handlePopUpClose("deleteIdentity"); - } catch (err) { - console.error(err); - const error = err as any; - const text = error?.response?.data?.message ?? "Failed to remove identity from project"; - - createNotification({ - text, - type: "error" - }); - } - }; - - return ( -
-
-

Identities

-
- - - Documentation{" "} - - - -
- - {(isAllowed) => ( - - )} - -
- - - handlePopUpToggle("deleteIdentity", isOpen)} - deleteKey="confirm" - onDeleteApproved={() => - onRemoveIdentitySubmit( - (popUp?.deleteIdentity?.data as { identityId: string })?.identityId - ) - } - /> -
- ); - }, - { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity } -); diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx deleted file mode 100644 index 24197e9fe..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/IdentityTable.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { format } from "date-fns"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - EmptyState, - IconButton, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -import { IdentityRoles } from "./IdentityRoles"; - -type Props = { - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>, - data?: { - identityId?: string; - name?: string; - } - ) => void; -}; - -export const IdentityTable = ({ handlePopUpOpen }: Props) => { - const { currentWorkspace } = useWorkspace(); - const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); - - return ( - - - - - - - - - - - {isLoading && } - {!isLoading && - data && - data.length > 0 && - data.map(({ identity: { id, name }, roles, createdAt }) => { - return ( - - - - - - - ); - })} - {!isLoading && data && data?.length === 0 && ( - - - - )} - -
NameRoleAdded on -
{name} - - {(isAllowed) => ( - - )} - - {format(new Date(createdAt), "yyyy-MM-dd")} - - {(isAllowed) => ( - { - handlePopUpOpen("deleteIdentity", { - identityId: id, - name - }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} - > - - - )} - -
- -
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx deleted file mode 100644 index f0663a6a5..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentitySection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { IdentitySection } from "./IdentitySection"; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx deleted file mode 100644 index f0663a6a5..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { IdentitySection } from "./IdentitySection"; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index fdf5a7309..b114aa17b 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -215,7 +215,7 @@ export const MemberListTab = () => { onGoBack={() => handlePopUpClose("additionalPrivilege")} privileges={members?.[privilegeDetails.index]?.additionalPrivileges || []} name={privilegeDetails.name} - projectMembershipId={privilegeDetails.projectMembershipId} + actorId={privilegeDetails.projectMembershipId} /> ); From 8f85f292db2607e7f47a874e6ad9c9a2e3b58141 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 15 Mar 2024 22:46:17 +0530 Subject: [PATCH 08/21] feat: improved slug with a default generator for ui and server --- ...ity-project-additional-privilege-router.ts | 52 ++++++++++++++++--- .../v1/user-additional-privilege-router.ts | 36 +++++++++++-- .../AdditionalPrivilegeForm.tsx | 34 +++++++----- .../AdditionalPrivilegeSection.tsx | 4 +- .../ProjectRoleModifySection.utils.ts | 1 + 5 files changed, 102 insertions(+), 25 deletions(-) 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 622b003dd..86061d33c 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 @@ -1,8 +1,12 @@ +import { packRules } from "@casl/ability/extra"; +import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { zpStr } from "@app/lib/zod"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -13,22 +17,42 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F schema: { body: z.union([ z.object({ - identityId: z.string(), - projectId: z.string(), - slug: z.string().max(60).trim(), - name: z.string().trim(), + identityId: z.string().min(1), + projectId: z.string().min(1), + // disallow empty string + slug: zpStr( + z + .string() + .max(60) + .trim() + .optional() + .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + ), + name: z.string().trim().min(1), description: z.string().trim().optional(), permissions: z.any().array(), + isPackedPermission: z.boolean().optional().default(true), isTemporary: z.literal(false).default(false) }), z.object({ identityId: z.string(), projectId: z.string(), - slug: z.string().max(60).trim(), + slug: z + .string() + .max(60) + .trim() + .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array(), isTemporary: z.literal(true), + isPackedPermission: z.boolean().optional().default(true), temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), temporaryAccessStartTime: z.string().datetime() @@ -47,7 +71,9 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actor: req.permission.type, actorOrgId: req.permission.orgId, ...req.body, - permissions: JSON.stringify(req.body.permissions) + permissions: JSON.stringify( + req.body.isPackedPermission ? req.body.permissions : packRules(req.body.permissions) + ) }); return { privilege }; } @@ -62,10 +88,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }), body: z .object({ - slug: z.string().max(60).trim(), + // disallow empty string + slug: z + .string() + .max(60) + .trim() + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array(), + isPackedPermission: z.boolean().optional().default(true), isTemporary: z.boolean(), temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), @@ -85,7 +119,9 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actor: req.permission.type, actorOrgId: req.permission.orgId, ...req.body, - permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined, + permissions: req.body.permissions + ? JSON.stringify(req.body.isPackedPermission ? req.body.permissions : packRules(req.body.permissions)) + : undefined, privilegeId: req.params.privilegeId }); return { privilege }; diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index fe40ee259..cd161ab5a 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -1,8 +1,11 @@ +import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { zpStr } from "@app/lib/zod"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -14,7 +17,18 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr body: z.union([ z.object({ projectMembershipId: z.string(), - slug: z.string().max(60).trim(), + // to disallow empty string + slug: zpStr( + z + .string() + .max(60) + .trim() + .optional() + .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + ), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array(), @@ -22,7 +36,17 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }), z.object({ projectMembershipId: z.string(), - slug: z.string().max(60).trim(), + slug: zpStr( + z + .string() + .max(60) + .trim() + .optional() + .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + ), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array(), @@ -60,7 +84,13 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr }), body: z .object({ - slug: z.string().max(60).trim(), + slug: z + .string() + .max(60) + .trim() + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }), name: z.string().trim(), description: z.string().trim().optional(), permissions: z.any().array(), diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx index 06d4f4b71..a6f1d2a48 100644 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx +++ b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeForm.tsx @@ -19,7 +19,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; -import { Button, FormControl, Input } from "@app/components/v2"; +import guidGenerator from "@app/components/utilities/randomId"; +import { Button, FormControl, Input, Spinner } from "@app/components/v2"; import { ProjectPermissionSub } from "@app/context"; import { useCreateIdentityProjectAdditionalPrivilege, @@ -134,17 +135,16 @@ export const AdditionalPrivilegeForm = ({ isIdentity }: Props) => { const { createNotification } = useNotificationContext(); - const isNewRole = !privilegeId; + const isEdit = Boolean(privilegeId); - const { data: projectUserPrivilegeDetails } = useGetProjectUserPrivilegeDetails( - privilegeId && !isIdentity ? privilegeId : "" - ); + const { data: projectUserPrivilegeDetails, isLoading: isProjectUserPrivilegeLoading } = + useGetProjectUserPrivilegeDetails(privilegeId && !isIdentity ? privilegeId : ""); - const { data: identityProjectPrivilegeDetails } = useGetIdentityProjectPrivilegeDetails( - isIdentity && privilegeId ? privilegeId : "" - ); + const { data: identityProjectPrivilegeDetails, isLoading: isIdentityProjectPrivilegeLoading } = + useGetIdentityProjectPrivilegeDetails(isIdentity && privilegeId ? privilegeId : ""); const privileges = isIdentity ? identityProjectPrivilegeDetails : projectUserPrivilegeDetails; + const isLoading = isIdentity ? isIdentityProjectPrivilegeLoading : isProjectUserPrivilegeLoading; const { handleSubmit, @@ -155,6 +155,9 @@ export const AdditionalPrivilegeForm = ({ control } = useForm({ resolver: zodResolver(formSchema), + defaultValues: { + slug: `privilege-${guidGenerator().slice(0, 4).toLowerCase()}` + }, values: privileges && { ...privileges, description: privileges.description || "", @@ -194,7 +197,7 @@ export const AdditionalPrivilegeForm = ({ }; const handleFormSubmit = async (el: TFormSchema) => { - if (!isNewRole) { + if (isEdit) { await handleRoleUpdate(el); return; } @@ -223,12 +226,20 @@ export const AdditionalPrivilegeForm = ({ } }; + if (isEdit && isLoading) { + return ( +
+ +
+ ); + } + return (

- {isNewRole ? "New" : "Edit"} user additional privilege + {!isEdit ? "New" : "Edit"} user additional privilege

-
-

- Select multiple privilege that can be granted to the user -

-
- - - - - - - - - -
-
-

Add Privilege

-
-
-
- -
-
- -
- {SINGLE_PERMISSION_LIST.map(({ title, subtitle, icon, formName }) => ( -
- -
- ))} -
- -
-
-
- - -
-
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx deleted file mode 100644 index 581925ffd..000000000 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeSection.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { - faArrowLeft, - faPencil, - faPlus, - faTrash, - faUserShield -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { motion } from "framer-motion"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - DeleteActionModal, - EmptyState, - IconButton, - Tag, - Tooltip -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { - useDeleteIdentityProjectAdditionalPrivilege, - useDeleteProjectUserAdditionalPrivilege -} from "@app/hooks/api"; -import { TWorkspaceUser } from "@app/hooks/api/types"; - -import { AdditionalPrivilegeForm } from "./AdditionalPrivilegeForm"; -import { AdditionalPrivilegeTemporaryAccess } from "./AdditionalPrivilegeTemporaryAccess"; - -type Props = { - onGoBack: VoidFunction; - name: string; - isIdentity?: boolean; - // isIdentity id - identity id else projectMembershipId - actorId: string; - privileges: TWorkspaceUser["additionalPrivileges"]; -}; - -export const AdditionalPrivilegeSection = ({ - onGoBack, - privileges = [], - actorId, - name, - isIdentity -}: Props) => { - const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ - "modifyPrivilege", - "deletePrivilege" - ] as const); - - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; - const deleteProjectUserAdditionalPrivilege = useDeleteProjectUserAdditionalPrivilege(); - const deleteProjectIdentityAdditionalPrivilege = useDeleteIdentityProjectAdditionalPrivilege(); - - const onPrivilegeDelete = async (privilegeId: string) => { - try { - if (isIdentity) { - await deleteProjectIdentityAdditionalPrivilege.mutateAsync({ - privilegeId, - projectId: workspaceId - }); - } else { - await deleteProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId, - workspaceId - }); - } - handlePopUpClose("deletePrivilege"); - createNotification({ - type: "success", - text: "Successfully removed privilege" - }); - } catch (err) { - createNotification({ - type: "error", - text: "Failed to delete privilege" - }); - } - }; - - if (popUp.modifyPrivilege.isOpen) { - const privilegeDetails = popUp?.modifyPrivilege?.data as { - id: string; - }; - - return ( - - handlePopUpClose("modifyPrivilege")} - privilegeId={privilegeDetails?.id} - workspaceId={workspaceId} - isIdentity={isIdentity} - actorId={actorId} - /> - - ); - } - - return ( - -
-

- Additional Privileges - {name} -

-
- - -
-
-
- {privileges.length === 0 && ( - - )} - {privileges.map(({ id, name: privilegeName, description, slug, ...dto }) => ( -
-
-
- {privilegeName} - - {slug} - -
-
{description}
-
-
- - handlePopUpOpen("modifyPrivilege", { id })} - > - - - - - handlePopUpOpen("deletePrivilege", { name: privilegeName, id })} - > - - - - -
-
- ))} -
- handlePopUpToggle("deletePrivilege", isOpen)} - deleteKey="delete" - onDeleteApproved={async () => - onPrivilegeDelete((popUp?.deletePrivilege.data as { id: string }).id) - } - /> -
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx deleted file mode 100644 index d45e9139e..000000000 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/AdditionalPrivilegeTemporaryAccess.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { Controller, useForm } from "react-hook-form"; -import { faClock } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { twMerge } from "tailwind-merge"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - FormControl, - IconButton, - Input, - Popover, - PopoverContent, - PopoverTrigger, - Tag, - Tooltip -} from "@app/components/v2"; -import { usePopUp } from "@app/hooks"; -import { - useUpdateIdentityProjectAdditionalPrivilege, - useUpdateProjectUserAdditionalPrivilege -} from "@app/hooks/api"; -import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types"; -import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/projectUserAdditionalPrivilege/types"; - -const temporaryRoleFormSchema = z.object({ - temporaryRange: z.string().min(1, "Required") -}); - -type TTemporaryRoleFormSchema = z.infer; - -type TTemporaryRoleFormProps = { - privilegeId: string; - workspaceId: string; - isIdentity?: boolean; - temporaryConfig?: { - isTemporary?: boolean; - temporaryAccessEndTime?: string | null; - temporaryAccessStartTime?: string | null; - temporaryRange?: string | null; - }; -}; - -export const AdditionalPrivilegeTemporaryAccess = ({ - temporaryConfig: defaultValues = {}, - workspaceId, - privilegeId, - isIdentity -}: TTemporaryRoleFormProps) => { - const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); - - const { control, handleSubmit } = useForm({ - resolver: zodResolver(temporaryRoleFormSchema), - values: { - temporaryRange: defaultValues.temporaryRange || "1h" - } - }); - const isTemporaryFieldValue = defaultValues.isTemporary; - const isExpired = - isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); - - const updateProjectUserAdditionalPrivilege = useUpdateProjectUserAdditionalPrivilege(); - const updateProjectIdentityAdditionalPrivilege = useUpdateIdentityProjectAdditionalPrivilege(); - - const handleGrantTemporaryAccess = async (el: TTemporaryRoleFormSchema) => { - try { - if (isIdentity) { - await updateProjectIdentityAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - projectId: workspaceId, - isTemporary: true, - temporaryRange: el.temporaryRange, - temporaryAccessStartTime: new Date().toISOString(), - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative - }); - } else { - await updateProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - workspaceId, - isTemporary: true, - temporaryRange: el.temporaryRange, - temporaryAccessStartTime: new Date().toISOString(), - temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative - }); - } - createNotification({ type: "success", text: "Successfully updated access" }); - handlePopUpToggle("setTempRole"); - } catch (err) { - console.log(err); - createNotification({ type: "error", text: "Failed to update access" }); - } - }; - - const handleRevokeTemporaryAccess = async () => { - try { - if (isIdentity) { - await updateProjectIdentityAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - projectId: workspaceId, - isTemporary: false - }); - } else { - await updateProjectUserAdditionalPrivilege.mutateAsync({ - privilegeId: privilegeId as string, - workspaceId, - isTemporary: false - }); - } - createNotification({ type: "success", text: "Successfully updated access" }); - handlePopUpToggle("setTempRole"); - } catch (err) { - console.log(err); - createNotification({ type: "error", text: "Failed to update access" }); - } - }; - - return ( - { - handlePopUpToggle("setTempRole", isOpen); - }} - > - - - - - - - - -
-
- Configure timed access -
- {isExpired && Expired} - ( - - 1m, 2h, 3d.{" "} - - More - - - } - > - - - )} - /> -
- - {isTemporaryFieldValue && ( - - )} -
-
-
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx b/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx deleted file mode 100644 index 42c37441d..000000000 --- a/frontend/src/views/Project/MembersPage/components/AdditionalPrivilegeSection/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { AdditionalPrivilegeSection } from "./AdditionalPrivilegeSection"; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index 58577f1ec..816d786ed 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -3,13 +3,11 @@ import { faArrowUpRightFromSquare, faPlus, faServer, - faUserShield, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { motion } from "framer-motion"; -import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -32,13 +30,10 @@ import { withProjectPermission } from "@app/hoc"; import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; -import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection"; import { IdentityModal } from "./components/IdentityModal"; -import { IdentityRoles } from "./components/IdentityRoles"; export const IdentityTab = withProjectPermission( () => { - const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id ?? ""; @@ -78,33 +73,6 @@ export const IdentityTab = withProjectPermission( } }; - if (popUp.additionalPrivilege.isOpen) { - const privilegeDetails = popUp?.additionalPrivilege?.data as { - name: string; - index: number; - identityId: string; - }; - - return ( - - handlePopUpClose("additionalPrivilege")} - privileges={data?.[privilegeDetails.index]?.additionalPrivileges || []} - name={privilegeDetails.name} - actorId={privilegeDetails.identityId} - /> - - ); - } - return ( 0 && - data.map( - ({ identity: { id, name }, roles, createdAt, additionalPrivileges }, index) => { - const hasAdditionalPrivilege = Boolean(additionalPrivileges.length); - return ( - - {name} - - - {(isAllowed) => ( - - )} - - - {format(new Date(createdAt), "yyyy-MM-dd")} - -
- { + return ( + + {name} + {format(new Date(createdAt), "yyyy-MM-dd")} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} > - {(isAllowed) => ( - - handlePopUpOpen("additionalPrivilege", { - name, - index, - identityId: id - }) - } - > - - - )} - - - {(isAllowed) => ( - { - handlePopUpOpen("deleteIdentity", { - identityId: id, - name - }); - }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} - > - - - )} - -
- - - ); - } - )} + + + )} + + + + ); + })} {!isLoading && data && data?.length === 0 && ( diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index b114aa17b..9f1711199 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -3,10 +3,11 @@ import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { + faClock, + faEdit, faMagnifyingGlass, faPlus, faUsers, - faUserShield, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -22,6 +23,9 @@ import { DeleteActionModal, EmptyState, FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, IconButton, Input, Modal, @@ -31,10 +35,12 @@ import { Table, TableContainer, TableSkeleton, + Tag, TBody, Td, Th, THead, + Tooltip, Tr, UpgradePlanModal } from "@app/components/v2"; @@ -54,10 +60,11 @@ import { useGetUserWsKey, useGetWorkspaceUsers } from "@app/hooks/api"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TWorkspaceUser } from "@app/hooks/api/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types"; -import { AdditionalPrivilegeSection } from "../AdditionalPrivilegeSection"; -import { MemberRoles } from "./MemberRoles"; +import { MemberRoleForm } from "./MemberRoleForm"; const addMemberFormSchema = z.object({ orgMembershipId: z.string().trim() @@ -65,8 +72,14 @@ const addMemberFormSchema = z.object({ type TAddMemberForm = z.infer; +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; +}; + export const MemberListTab = () => { - const { t } = useTranslation(); const { currentOrg } = useOrganization(); @@ -87,7 +100,7 @@ export const MemberListTab = () => { "addMember", "removeMember", "upgradePlan", - "additionalPrivilege" + "updateRole" ] as const); const { @@ -195,32 +208,6 @@ export const MemberListTab = () => { ); }, [orgUsers, members]); - if (popUp.additionalPrivilege.isOpen) { - const privilegeDetails = popUp?.additionalPrivilege?.data as { - name: string; - index: number; - projectMembershipId: string; - }; - - return ( - - handlePopUpClose("additionalPrivilege")} - privileges={members?.[privilegeDetails.index]?.additionalPrivileges || []} - name={privilegeDetails.name} - actorId={privilegeDetails.projectMembershipId} - /> - - ); - } - return ( { {isMembersLoading && } {!isMembersLoading && - filterdUsers?.map( - ( - { user: u, inviteEmail, id: membershipId, roles, additionalPrivileges }, - index - ) => { - const name = u ? `${u.firstName} ${u.lastName}` : "-"; - const email = u?.email || inviteEmail; - const hasAdditionalPrivilege = Boolean(additionalPrivileges.length); + filterdUsers?.map((projectMember, index) => { + const { user: u, inviteEmail, id: membershipId, roles } = projectMember; + const name = u ? `${u.firstName} ${u.lastName}` : "-"; + const email = u?.email || inviteEmail; - return ( - - {name} - {email} - - - {(isAllowed) => ( - - handlePopUpOpen("upgradePlan", { description }) - } - membershipId={membershipId} - /> + return ( + + {name} + {email} + +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + } )} - - - - {userId !== u?.id && ( -
- - {(isAllowed) => ( - - handlePopUpOpen("additionalPrivilege", { - name: `${user.firstName} ${user.lastName || ""}`, - index, - projectMembershipId: membershipId - }) + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > + new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + new Date( + temporaryAccessEndTime as string + ) && "text-red-600" + )} + /> + +
+ )} +
+
+ ); } - > - -
- )} -
- - {(isAllowed) => ( - - handlePopUpOpen("removeMember", { username: u.username }) - } - > - - - )} - -
+ )} + + )} - - - ); - } - )} + {userId !== u?.id && ( + + + handlePopUpOpen("updateRole", { ...projectMember, index }) + } + > + + + + )} +
+ + + {userId !== u?.id && ( +
+ + {(isAllowed) => ( + + handlePopUpOpen("removeMember", { username: u.username }) + } + > + + + )} + +
+ )} + + + ); + })} {!isMembersLoading && filterdUsers?.length === 0 && ( @@ -418,6 +464,27 @@ export const MemberListTab = () => { )} + handlePopUpToggle("updateRole", state)} + > + + handlePopUpOpen("upgradePlan", { description })} + projectMember={ + filterdUsers?.[ + (popUp.updateRole?.data as TWorkspaceUser & { index: number })?.index + ] as TWorkspaceUser + } + /> + + ; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; +export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const roleForm = useForm({ + resolver: zodResolver(roleFormSchema), + values: { + roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ + slug: customRoleSlug || role, + temporaryAccess: dto.isTemporary + ? { + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } + : { + isTemporary: dto.isTemporary + } + })) + } + }); + const selectedRoleList = useFieldArray({ + name: "roles", + control: roleForm.control + }); + + const formRoleField = roleForm.watch("roles"); + + const updateMembershipRole = useUpdateUserWorkspaceRole(); + + const handleRoleUpdate = async (data: TRoleForm) => { + if (updateMembershipRole.isLoading) return; + + const sanitizedRoles = data.roles.map((el) => { + const { isTemporary } = el.temporaryAccess; + if (!isTemporary) { + return { role: el.slug, isTemporary: false as const }; + } + return { + role: el.slug, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = sanitizedRoles.some( + (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) + ); + + if (hasCustomRoleSelected && subscription && !subscription?.rbac) { + onOpenUpgradeModal( + "You can assign custom roles to members if you upgrade your Infisical plan." + ); + return; + } + + try { + await updateMembershipRole.mutateAsync({ + workspaceId, + membershipId: projectMember.id, + roles: sanitizedRoles + }); + createNotification({ text: "Successfully updated roles", type: "success" }); + roleForm.reset(undefined, { keepValues: true }); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
Roles
+

Select one of the pre-defined or custom roles.

+
+
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ + { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx new file mode 100644 index 000000000..da6243281 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRoleForm.tsx @@ -0,0 +1,20 @@ +import { TWorkspaceUser } from "@app/hooks/api/types"; + +import { MemberRbacSection } from "./MemberRbacSection"; +import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection"; + +type Props = { + projectMember: TWorkspaceUser; + onOpenUpgradeModal: (title: string) => void; +}; +export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => { + return ( +
+ + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx new file mode 100644 index 000000000..8aaf22142 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -0,0 +1,497 @@ +import { Controller, useForm } from "react-hook-form"; +import { faCancel, faCaretDown, faClock, faClose, faSave } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + Checkbox, + DeleteActionModal, + FormControl, + FormLabel, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + TProjectUserPrivilege, + useCreateProjectUserAdditionalPrivilege, + useDeleteProjectUserAdditionalPrivilege, + useListProjectUserPrivileges, + useUpdateProjectUserAdditionalPrivilege +} from "@app/hooks/api"; + +const secretPermissionSchema = z.object({ + secretPath: z.string().optional(), + environmentSlug: z.string(), + [ProjectPermissionActions.Edit]: z.boolean().optional(), + [ProjectPermissionActions.Read]: z.boolean().optional(), + [ProjectPermissionActions.Create]: z.boolean().optional(), + [ProjectPermissionActions.Delete]: z.boolean().optional(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) +}); +type TSecretPermissionForm = z.infer; +const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => { + const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deletePrivilege" + ] as const); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Member + ); + + const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege(); + const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); + + const privilegeForm = useForm({ + resolver: zodResolver(secretPermissionSchema), + values: { + environmentSlug: privilege.permissions?.[0]?.conditions?.environment, + // secret path will be inside $glob operator + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + read: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Read) + ), + edit: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Edit) + ), + create: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + // zod will pick it + temporaryAccess: privilege + } + }); + + const temporaryAccessField = privilegeForm.watch("temporaryAccess"); + const isTemporary = temporaryAccessField?.isTemporary; + const isExpired = + temporaryAccessField.isTemporary && + new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); + + const handleUpdatePrivilege = async (data: TSecretPermissionForm) => { + if (updateUserPrivilege.isLoading) return; + try { + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { 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 updateUserPrivilege.mutateAsync({ + privilegeId: privilege.id, + ...data.temporaryAccess, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })), + projectMembershipId: privilege.projectMembershipId + }); + createNotification({ + type: "success", + text: "Successfully updated privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update privilege" + }); + } + }; + + const handleDeletePrivilege = async () => { + if (deleteUserPrivilege.isLoading) return; + try { + await deleteUserPrivilege.mutateAsync({ + privilegeId: privilege.id, + projectMembershipId: privilege.projectMembershipId + }); + createNotification({ + type: "success", + text: "Successfully deleted privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to delete privilege" + }); + } + }; + + const getAccessLabel = () => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + return ( +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> +
+
+ + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccessField.isTemporary && ( + + )} +
+
+
+
+ {privilegeForm.formState.isDirty ? ( + <> + + + {privilegeForm.formState.isSubmitting ? ( + + ) : ( + + )} + + + + privilegeForm.reset()} + > + + + + + ) : ( + + handlePopUpOpen("deletePrivilege")} + > + + + + )} +
+
+
+ handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onClose={() => handlePopUpClose("deletePrivilege")} + onDeleteApproved={handleDeletePrivilege} + /> +
+ ); +}; + +type Props = { + membershipId: string; +}; + +export const SpecificPrivilegeSection = ({ membershipId }: Props) => { + const { data: userPrivileges, isLoading } = useListProjectUserPrivileges(membershipId); + const { currentWorkspace } = useWorkspace(); + + const createUserPrivilege = useCreateProjectUserAdditionalPrivilege(); + + const handleCreatePrivilege = async () => { + if (createUserPrivilege.isLoading) return; + try { + await createUserPrivilege.mutateAsync({ + permissions: [ + { + action: ProjectPermissionActions.Read, + subject: [ProjectPermissionSub.Secrets], + conditions: { + environment: currentWorkspace?.environments?.[0].slug + } + } + ], + projectMembershipId: membershipId + }); + createNotification({ + type: "success", + text: "Successfully created privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create privilege" + }); + } + }; + + return ( +
+
+ Additional Privileges + {isLoading && } +
+

+ Select individual privileges to associate with the user +

+
+ {userPrivileges + ?.filter(({ permissions }) => + permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) + ) + ?.map((privilege) => ( + + ))} +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx new file mode 100644 index 000000000..765b4061c --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/index.tsx @@ -0,0 +1 @@ +export { MemberRoleForm } from "./MemberRoleForm"; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx deleted file mode 100644 index 50d8cd0d9..000000000 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoles.tsx +++ /dev/null @@ -1,471 +0,0 @@ -import { useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { twMerge } from "tailwind-merge"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - Checkbox, - FormControl, - HoverCard, - HoverCardContent, - HoverCardTrigger, - IconButton, - Input, - Popover, - PopoverContent, - PopoverTrigger, - Spinner, - Tag, - Tooltip -} from "@app/components/v2"; -import { useSubscription, useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api"; -import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; -import { TWorkspaceUser } from "@app/hooks/api/types"; -import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; -import { groupBy } from "@app/lib/fn/array"; - -const temporaryRoleFormSchema = z.object({ - temporaryRange: z.string().min(1, "Required") -}); - -type TTemporaryRoleFormSchema = z.infer; - -type TTemporaryRoleFormProps = { - temporaryConfig?: { - isTemporary?: boolean; - temporaryAccessEndTime?: string | null; - temporaryAccessStartTime?: string | null; - temporaryRange?: string | null; - }; - onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; - onRemoveTemporary: () => void; -}; - -const TemporaryRoleForm = ({ - temporaryConfig: defaultValues = {}, - onSetTemporary, - onRemoveTemporary -}: TTemporaryRoleFormProps) => { - const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); - const { control, handleSubmit } = useForm({ - resolver: zodResolver(temporaryRoleFormSchema), - values: { - temporaryRange: defaultValues.temporaryRange || "1h" - } - }); - const isTemporaryFieldValue = defaultValues.isTemporary; - const isExpired = - isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); - - return ( - { - handlePopUpToggle("setTempRole", isOpen); - }} - > - - - - - - - - -
-
- Configure timed access -
- {isExpired && Expired} - ( - - 1m, 2h, 3d.{" "} - - More - - - } - > - - - )} - /> -
- {isTemporaryFieldValue && ( - - )} - {!isTemporaryFieldValue ? ( - - ) : ( - - )} -
-
-
-
- ); -}; - -const formSchema = z.record( - z.object({ - isChecked: z.boolean().optional(), - temporaryAccess: z.union([ - z.object({ - isTemporary: z.literal(true), - temporaryRange: z.string().min(1), - temporaryAccessStartTime: z.string().datetime(), - temporaryAccessEndTime: z.string().datetime().nullable().optional() - }), - z.boolean() - ]) - }) -); -type TForm = z.infer; - -export type TMemberRolesProp = { - disableEdit?: boolean; - membershipId: string; - onOpenUpgradeModal: (description: string) => void; - roles: TWorkspaceUser["roles"]; -}; - -const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; - -export const MemberRoles = ({ - roles = [], - disableEdit = false, - membershipId, - onOpenUpgradeModal -}: TMemberRolesProp) => { - const { currentWorkspace } = useWorkspace(); - - const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); - const [searchRoles, setSearchRoles] = useState(""); - const { subscription } = useSubscription(); - - const { - handleSubmit, - control, - reset, - setValue, - formState: { isSubmitting, isDirty } - } = useForm({ - resolver: zodResolver(formSchema) - }); - - const workspaceId = currentWorkspace?.id || ""; - - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); - - const updateMembershipRole = useUpdateUserWorkspaceRole(); - - const handleRoleUpdate = async (data: TForm) => { - const selectedRoles = Object.keys(data) - .filter((el) => Boolean(data[el].isChecked)) - .map((el) => { - const isTemporary = Boolean(data[el].temporaryAccess); - if (!isTemporary) { - return { role: el, isTemporary: false as const }; - } - - const tempCfg = data[el].temporaryAccess as { - temporaryRange: string; - temporaryAccessStartTime: string; - }; - - return { - role: el, - isTemporary: true as const, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: tempCfg.temporaryRange, - temporaryAccessStartTime: tempCfg.temporaryAccessStartTime - }; - }); - - const hasCustomRoleSelected = selectedRoles.some( - (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) - ); - - if (hasCustomRoleSelected && subscription && !subscription?.rbac) { - onOpenUpgradeModal( - "You can assign custom roles to members if you upgrade your Infisical plan." - ); - return; - } - - try { - await updateMembershipRole.mutateAsync({ - workspaceId, - membershipId, - roles: selectedRoles - }); - createNotification({ text: "Successfully updated role", type: "success" }); - handlePopUpToggle("editRole"); - setSearchRoles(""); - } catch (err) { - createNotification({ text: "Failed to update role", type: "error" }); - } - }; - - const formatRoleName = (role: string, customRoleName?: string) => { - if (role === ProjectMembershipRole.Custom) return customRoleName; - if (role === ProjectMembershipRole.Member) return "Developer"; - return role; - }; - - return ( -
- {roles - .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) - .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { - const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); - return ( - -
-
{formatRoleName(role, customRoleName)}
- {isTemporary && ( -
- - - -
- )} -
-
- ); - })} - {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( - - - +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} - - - {roles - .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) - .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { - const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); - return ( - -
-
{formatRoleName(role, customRoleName)}
- {isTemporary && ( -
- - new Date(temporaryAccessEndTime as string) && - "text-red-600" - )} - /> - -
- )} -
-
- ); - })}{" "} -
-
- )} -
- { - handlePopUpToggle("editRole", isOpen); - reset(); - }} - > - {!disableEdit && ( - - - - - - )} - - {isRolesLoading ? ( -
- -
- ) : ( -
-
- {projectRoles - ?.filter( - ({ name, slug }) => - name.toLowerCase().includes(searchRoles.toLowerCase()) || - slug.toLowerCase().includes(searchRoles.toLowerCase()) - ) - ?.map(({ id, name, slug }) => { - const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; - - return ( -
-
- ( - { - field.onChange(isChecked); - setValue(`${slug}.temporaryAccess`, false); - }} - > - {name} - - )} - /> -
-
- ( - { - setValue(`${slug}.isChecked`, true, { shouldDirty: true }); - console.log(data); - field.onChange({ isTemporary: true, ...data }); - }} - onRemoveTemporary={() => { - setValue(`${slug}.isChecked`, false, { shouldDirty: true }); - field.onChange(false); - }} - /> - )} - /> -
-
- ); - })} -
-
-
- setSearchRoles(el.target.value)} - leftIcon={} - placeholder="Search roles.." - /> -
-
- -
-
-
- )} -
-
-
-
- ); -}; From c8cbcaf10cfef752764f0186d0987cccedf9a011 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 28 Mar 2024 19:22:54 +0530 Subject: [PATCH 13/21] feat(server): added identity privilege route changes with project slug --- ...ity-project-additional-privilege-router.ts | 145 ++++++++++-------- ...ty-project-additional-privilege-service.ts | 140 +++++++++-------- ...tity-project-additional-privilege-types.ts | 51 +++--- .../ee/services/permission/permission-dal.ts | 66 +++++++- .../services/permission/permission-service.ts | 10 +- backend/src/server/routes/index.ts | 1 + .../routes/v2/identity-project-router.ts | 14 -- .../identity-project/identity-project-dal.ts | 51 +----- 8 files changed, 264 insertions(+), 214 deletions(-) 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 18c196409..aec3a9c97 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 @@ -1,12 +1,13 @@ -import { packRules } from "@casl/ability/extra"; +import { MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; +import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { zpStr } from "@app/lib/zod"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -18,37 +19,37 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F body: z.union([ z.object({ identityId: z.string().min(1), - projectId: z.string().min(1), - // disallow empty string - slug: zpStr( - z - .string() - .max(60) - .trim() - .optional() - .default(`privilege-${slugify(alphaNumericNanoId(12))}`) - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - ), + projectSlug: z.string().min(1), + slug: z + .string() + .min(1) + .max(60) + .trim() + .optional() + .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }), permissions: z.any().array(), - isPackedPermission: z.boolean().optional().default(true), + isPackedPermission: z.boolean().optional().default(false), isTemporary: z.literal(false).default(false) }), z.object({ identityId: z.string(), - projectId: z.string(), + projectSlug: z.string().min(1), slug: z .string() + .min(1) .max(60) .trim() + .optional() .default(`privilege-${slugify(alphaNumericNanoId(12))}`) .refine((v) => slugify(v) === v, { message: "Slug must be a valid slug" }), permissions: z.any().array(), isTemporary: z.literal(true), - isPackedPermission: z.boolean().optional().default(true), + isPackedPermission: z.boolean().optional().default(false), temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), temporaryAccessStartTime: z.string().datetime() @@ -77,30 +78,33 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }); server.route({ - url: "/:privilegeId", + url: "/", method: "PATCH", schema: { - params: z.object({ - privilegeId: z.string() + body: z.object({ + // disallow empty string + slug: z.string().min(1), + identityId: z.string().min(1), + projectSlug: z.string().min(1), + data: z + .object({ + slug: z + .string() + .min(1) + .max(60) + .trim() + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }), + isPackedPermission: z.boolean().optional().default(false), + permissions: z.any().array(), + isTemporary: z.boolean(), + temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + .partial() }), - body: z - .object({ - // disallow empty string - slug: z - .string() - .max(60) - .trim() - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }), - permissions: z.any().array(), - isPackedPermission: z.boolean().optional().default(true), - isTemporary: z.boolean(), - temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), - temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), - temporaryAccessStartTime: z.string().datetime() - }) - .partial(), response: { 200: z.object({ privilege: IdentityProjectAdditionalPrivilegeSchema @@ -109,27 +113,32 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilege.updateById({ + const { isPackedPermission, ...data } = req.body.data; + const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ actorId: req.permission.id, actor: req.permission.type, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - permissions: req.body.permissions - ? JSON.stringify(req.body.isPackedPermission ? req.body.permissions : packRules(req.body.permissions)) - : undefined, - privilegeId: req.params.privilegeId + data: { + ...data, + permissions: data?.permissions + ? JSON.stringify(isPackedPermission ? data?.permissions : packRules(data.permissions)) + : undefined + } }); return { privilege }; } }); server.route({ - url: "/:privilegeId", + url: "/", method: "DELETE", schema: { - params: z.object({ - privilegeId: z.string() + body: z.object({ + slug: z.string().min(1), + identityId: z.string().min(1), + projectSlug: z.string().min(1) }), response: { 200: z.object({ @@ -139,23 +148,27 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilege.deleteById({ + const privilege = await server.services.identityProjectAdditionalPrivilege.deleteBySlug({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - privilegeId: req.params.privilegeId + ...req.body }); return { privilege }; } }); server.route({ - url: "/:privilegeId", + url: "/:slug", method: "GET", schema: { params: z.object({ - privilegeId: z.string() + slug: z.string() + }), + querystring: z.object({ + identityId: z.string().min(1), + projectSlug: z.string().min(1) }), response: { 200: z.object({ @@ -165,24 +178,29 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const privilege = await server.services.identityProjectAdditionalPrivilege.getPrivilegeDetailsById({ + const privilege = await server.services.identityProjectAdditionalPrivilege.getPrivilegeDetailsBySlug({ actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - privilegeId: req.params.privilegeId + slug: req.params.slug, + ...req.query }); return { privilege }; } }); server.route({ - url: "/permissions", - method: "POST", + url: "/", + method: "GET", schema: { - body: z.object({ - identityId: z.string(), - projectId: z.string() + querystring: z.object({ + identityId: z.string().min(1), + projectSlug: z.string().min(1), + unpacked: z + .enum(["false", "true"]) + .transform((el) => el === "true") + .default("true") }), response: { 200: z.object({ @@ -197,9 +215,16 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - projectId: req.body.projectId, - identityId: req.body.identityId + ...req.query }); + if (req.query.unpacked) { + return { + privileges: privileges.map(({ permissions, ...el }) => ({ + ...el, + permissions: unpackRules(permissions as PackRule>>[]) + })) + }; + } return { privileges }; } }); diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts index 3a919d983..81dc11a00 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service.ts @@ -5,6 +5,7 @@ import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { ActorType } from "@app/services/auth/auth-type"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; @@ -21,6 +22,7 @@ import { type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = { identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory; identityProjectDAL: Pick; + projectDAL: Pick; permissionService: Pick; }; @@ -31,19 +33,24 @@ export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType< export const identityProjectAdditionalPrivilegeServiceFactory = ({ identityProjectAdditionalPrivilegeDAL, identityProjectDAL, - permissionService + permissionService, + projectDAL }: TIdentityProjectAdditionalPrivilegeServiceFactoryDep) => { const create = async ({ slug, actor, actorId, - projectId, identityId, + projectSlug, permissions: customPermission, actorOrgId, actorAuthMethod, ...dto }: TCreateIdentityPrivilegeDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); @@ -65,7 +72,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ ); const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ slug, @@ -96,19 +103,23 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ return additionalPrivilege; }; - const updateById = async ({ - privilegeId, + const updateBySlug = async ({ + projectSlug, + slug, + identityId, + data, actorOrgId, actor, actorId, - actorAuthMethod, - ...dto + actorAuthMethod }: TUpdateIdentityPrivilegeDTO) => { - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(privilegeId); - if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; - const identityProjectMembership = await identityProjectDAL.findById(identityPrivilege.projectMembershipId); - if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity` }); + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); const { permission } = await permissionService.getProjectPermission( actor, @@ -127,23 +138,28 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ ); const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); - if (dto?.slug) { + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + if (data?.slug) { const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ - slug: dto.slug, + slug: data.slug, projectMembershipId: identityProjectMembership.id }); if (existingSlug && existingSlug.id !== identityPrivilege.id) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); } - const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : identityPrivilege.isTemporary; + const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary; if (isTemporary) { - const temporaryAccessStartTime = dto?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime; - const temporaryRange = dto?.temporaryRange || identityPrivilege?.temporaryRange; + const temporaryAccessStartTime = data?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime; + const temporaryRange = data?.temporaryRange || identityPrivilege?.temporaryRange; const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { - ...dto, + ...data, temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) }); @@ -151,7 +167,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ } const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, { - ...dto, + ...data, isTemporary: false, temporaryAccessStartTime: null, temporaryAccessEndTime: null, @@ -161,18 +177,22 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ return additionalPrivilege; }; - const deleteById = async ({ + const deleteBySlug = async ({ actorId, + slug, + identityId, + projectSlug, actor, actorOrgId, - privilegeId, actorAuthMethod }: TDeleteIdentityPrivilegeDTO) => { - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(privilegeId); - if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; - const identityProjectMembership = await identityProjectDAL.findById(identityPrivilege.projectMembershipId); - if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity` }); + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); const { permission } = await permissionService.getProjectPermission( actor, @@ -191,25 +211,34 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ ); const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" }); + + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); return deletedPrivilege; }; - const getPrivilegeDetailsById = async ({ - privilegeId, + const getPrivilegeDetailsBySlug = async ({ + projectSlug, + identityId, + slug, actorOrgId, actor, actorId, actorAuthMethod }: TGetIdentityPrivilegeDetailsDTO) => { - const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findById(privilegeId); - if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); - - const identityProjectMembership = await identityProjectDAL.findById(identityPrivilege.projectMembershipId); - if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity` }); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -218,31 +247,31 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); - const { permission: identityRolePermission } = await permissionService.getProjectPermission( - ActorType.IDENTITY, - identityProjectMembership.identityId, - identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId - ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); + + const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ + slug, + projectMembershipId: identityProjectMembership.id + }); + if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" }); return identityPrivilege; }; const listIdentityProjectPrivileges = async ({ - projectId, identityId, actorOrgId, actor, actorId, - actorAuthMethod + actorAuthMethod, + projectSlug }: TListIdentityPrivilegesDTO) => { - const identityProjectMembership = await identityProjectDAL.findOne({ projectId, identityId }); - if (!identityProjectMembership) throw new BadRequestError({ message: `Failed to find identity` }); + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId }); + if (!identityProjectMembership) + throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` }); const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -251,29 +280,18 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity); - const { permission: identityRolePermission } = await permissionService.getProjectPermission( - ActorType.IDENTITY, - identityProjectMembership.identityId, - identityProjectMembership.projectId, - actorAuthMethod, - actorOrgId - ); - const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); - if (!hasRequiredPriviledges) - throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({ projectMembershipId: identityProjectMembership.id }); - if (!identityPrivileges) throw new BadRequestError({ message: "Identity additional privilege not found" }); return identityPrivileges; }; return { create, - updateById, - deleteById, - getPrivilegeDetailsById, + updateBySlug, + deleteBySlug, + getPrivilegeDetailsBySlug, listIdentityProjectPrivileges }; }; diff --git a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts index 0d2b64cc5..88ff01d7d 100644 --- a/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts +++ b/backend/src/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types.ts @@ -4,19 +4,16 @@ export enum IdentityProjectAdditionalPrivilegeTemporaryMode { Relative = "relative" } -export type TCreateIdentityPrivilegeDTO = ( +export type TCreateIdentityPrivilegeDTO = { + permissions: unknown; + identityId: string; + projectSlug: string; + slug: string; +} & ( | { - permissions: unknown; - identityId: string; - projectId: string; - slug: string; isTemporary: false; } | { - permissions: unknown; - identityId: string; - projectId: string; - slug: string; isTemporary: true; temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; temporaryRange: string; @@ -25,21 +22,33 @@ export type TCreateIdentityPrivilegeDTO = ( ) & Omit; -export type TUpdateIdentityPrivilegeDTO = { privilegeId: string } & Omit & - Partial<{ - permissions: unknown; - slug: string; - isTemporary: boolean; - temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; - temporaryRange: string; - temporaryAccessStartTime: string; - }>; +export type TUpdateIdentityPrivilegeDTO = { slug: string; identityId: string; projectSlug: string } & Omit< + TProjectPermission, + "projectId" +> & { + data: Partial<{ + permissions: unknown; + slug: string; + isTemporary: boolean; + temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + }>; + }; -export type TDeleteIdentityPrivilegeDTO = Omit & { privilegeId: string }; +export type TDeleteIdentityPrivilegeDTO = Omit & { + slug: string; + identityId: string; + projectSlug: string; +}; -export type TGetIdentityPrivilegeDetailsDTO = Omit & { privilegeId: string }; +export type TGetIdentityPrivilegeDetailsDTO = Omit & { + slug: string; + identityId: string; + projectSlug: string; +}; export type TListIdentityPrivilegesDTO = Omit & { identityId: string; - projectId: string; + projectSlug: string; }; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index dda4bee50..a8072c0d3 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -144,6 +144,8 @@ export const permissionDALFactory = (db: TDbClient) => { } ] }); + + if (!permission?.[0]) return undefined; // when introducting cron mode change it here const activeRoles = permission?.[0]?.roles?.filter( ({ isTemporary, temporaryAccessEndTime }) => @@ -155,9 +157,7 @@ export const permissionDALFactory = (db: TDbClient) => { !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ); - return permission?.[0] - ? { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges } - : undefined; + return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; } catch (error) { throw new DatabaseError({ error, name: "GetProjectPermission" }); } @@ -176,6 +176,11 @@ export const permissionDALFactory = (db: TDbClient) => { `${TableName.IdentityProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .leftJoin( + TableName.IdentityProjectAdditionalPrivilege, + `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) .join( // Join the Project table to later select orgId TableName.Project, @@ -191,9 +196,28 @@ export const permissionDALFactory = (db: TDbClient) => { db.ref("role").withSchema(TableName.IdentityProjectMembership).as("oldRoleField"), db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), - db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") - ) - .select("permissions"); + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles), + db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), + db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"), + db + .ref("temporaryMode") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"), + db + .ref("temporaryRange") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessEndTime") + ); const permission = sqlNestRelationships({ data: docs, @@ -218,16 +242,44 @@ export const permissionDALFactory = (db: TDbClient) => { permissions: z.unknown(), customRoleSlug: z.string().optional().nullable() }).parse(data) + }, + { + key: "identityApId", + label: "additionalPrivileges" as const, + mapper: ({ + identityApId, + identityApPermissions, + identityApIsTemporary, + identityApTemporaryMode, + identityApTemporaryRange, + identityApTemporaryAccessEndTime, + identityApTemporaryAccessStartTime + }) => ({ + id: identityApId, + permissions: identityApPermissions, + temporaryRange: identityApTemporaryRange, + temporaryMode: identityApTemporaryMode, + temporaryAccessEndTime: identityApTemporaryAccessEndTime, + temporaryAccessStartTime: identityApTemporaryAccessStartTime, + isTemporary: identityApIsTemporary + }) } ] }); + if (!permission?.[0]) return undefined; + // when introducting cron mode change it here const activeRoles = permission?.[0]?.roles.filter( ({ isTemporary, temporaryAccessEndTime }) => !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ); - return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined; + const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + + return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; } catch (error) { throw new DatabaseError({ error, name: "GetProjectIdentityPermission" }); } diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index d836e88df..f4e423797 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -238,8 +238,16 @@ export const permissionServiceFactory = ({ throw new UnauthorizedError({ name: "You are not a member of this organization" }); } + const rolePermissions = + identityProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const additionalPrivileges = + identityProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ + role: ProjectMembershipRole.Custom, + permissions + })) || []; + return { - permission: buildProjectPermission(identityProjectPermission.roles), + permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)), membership: identityProjectPermission, hasRole: (role: string) => identityProjectPermission.roles.findIndex( diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c33f62029..7745a8a04 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -560,6 +560,7 @@ export const registerRoutes = async ( projectRoleDAL }); const identityProjectAdditionalPrivilegeService = identityProjectAdditionalPrivilegeServiceFactory({ + projectDAL, identityProjectAdditionalPrivilegeDAL, permissionService, identityProjectDAL diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index 81a59e995..d170f87e6 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -156,20 +156,6 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) identityId: z.string(), createdAt: z.date(), updatedAt: z.date(), - additionalPrivileges: z.array( - z.object({ - id: z.string(), - name: z.string(), - slug: z.string(), - description: z.string().optional().nullable(), - isTemporary: z.boolean(), - temporaryMode: z.string().optional().nullable(), - temporaryRange: z.string().nullable().optional(), - temporaryAccessStartTime: z.date().nullable().optional(), - temporaryAccessEndTime: z.date().nullable().optional(), - createdAt: z.date() - }) - ), roles: z.array( z.object({ id: z.string(), diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index b6de21d05..e932d2068 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -47,29 +47,7 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole), db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole), - db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), - db.ref("name").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApName"), - db.ref("description").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApDescription"), - db.ref("slug").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApSlug"), - db - .ref("temporaryMode") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryMode"), - db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"), - db.ref("createdAt").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApCreatedAt"), - db - .ref("temporaryRange") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryRange"), - db - .ref("temporaryAccessStartTime") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryAccessStartTime"), - db - .ref("temporaryAccessEndTime") - .withSchema(TableName.IdentityProjectAdditionalPrivilege) - .as("identityApTemporaryAccessEndTime") + db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole) ); const members = sqlNestRelationships({ @@ -113,33 +91,6 @@ export const identityProjectDALFactory = (db: TDbClient) => { temporaryAccessStartTime, isTemporary }) - }, - { - label: "additionalPrivileges" as const, - key: "identityApId", - mapper: ({ - identityApId, - identityApDescription, - identityApName, - identityApSlug, - identityApIsTemporary, - identityApTemporaryMode, - identityApTemporaryRange, - identityApCreatedAt, - identityApTemporaryAccessEndTime, - identityApTemporaryAccessStartTime - }) => ({ - id: identityApId, - name: identityApName, - description: identityApDescription, - slug: identityApSlug, - temporaryRange: identityApTemporaryRange, - temporaryMode: identityApTemporaryMode, - temporaryAccessEndTime: identityApTemporaryAccessEndTime, - temporaryAccessStartTime: identityApTemporaryAccessStartTime, - isTemporary: identityApIsTemporary, - createdAt: identityApCreatedAt - }) } ] }); From 64e868a151bd97975273f8fe08ce956568a4878b Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 28 Mar 2024 19:23:29 +0530 Subject: [PATCH 14/21] feat(ui): updated ui with identity privilege hooks and new role form --- frontend/src/hooks/api/identities/types.ts | 48 +- .../mutation.tsx | 69 +-- .../queries.tsx | 82 ++- .../types.tsx | 52 +- .../components/IdentityTab/IdentityTab.tsx | 161 +++++- .../IdentityRoleForm/IdentityRbacSection.tsx | 341 ++++++++++++ .../IdentityRoleForm/IdentityRoleForm.tsx | 20 + .../SpecificPrivilegeSection.tsx | 515 ++++++++++++++++++ .../components/IdentityRoleForm/index.tsx | 1 + .../IdentityTab/components/IdentityRoles.tsx | 459 ---------------- .../MemberListTab/MemberListTab.tsx | 2 +- .../SpecificPrivilegeSection.tsx | 4 +- 12 files changed, 1196 insertions(+), 558 deletions(-) create mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx create mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx delete mode 100644 frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index b8829f798..4ac19c351 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -29,30 +29,30 @@ export type IdentityMembershipOrg = { export type IdentityMembership = { id: string; identity: Identity; - roles: { - id: string; - role: "owner" | "admin" | "member" | "no-access" | "custom"; - customRoleId: string; - customRoleName: string; - customRoleSlug: string; - isTemporary: boolean; - temporaryMode: string | null; - temporaryRange: string | null; - temporaryAccessStartTime: string | null; - temporaryAccessEndTime: string | null; - }[]; - additionalPrivileges: { - id: string; - name: string; - description: string | null | undefined; - slug: string; - temporaryRange: string | null | undefined; - temporaryMode: string | null | undefined; - temporaryAccessEndTime: string | null | undefined; - temporaryAccessStartTime: string | null | undefined; - isTemporary: boolean; - createdAt: string; - }[]; + roles: Array< + { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + } & ( + | { + isTemporary: false; + temporaryRange: null; + temporaryMode: null; + temporaryAccessEndTime: null; + temporaryAccessStartTime: null; + } + | { + isTemporary: true; + temporaryRange: string; + temporaryMode: string; + temporaryAccessEndTime: string; + temporaryAccessStartTime: string; + } + ) + >; createdAt: string; updatedAt: string; }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx index 420d29485..d9a2a0b0a 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { workspaceKeys } from "../workspace/queries"; +import { identitiyProjectPrivilegeKeys } from "./queries"; import { TCreateIdentityProjectPrivilegeDTO, TDeleteIdentityProjectPrivilegeDTO, @@ -14,20 +14,19 @@ import { export const useCreateIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); - return useMutation< - { privilege: TIdentityProjectPrivilege }, - {}, - TCreateIdentityProjectPrivilegeDTO - >({ + return useMutation({ mutationFn: async (dto) => { const { data } = await apiRequest.post("/api/v1/additional-privilege/identity", { ...dto, + isPackedPermission: true, permissions: packRules(dto.permissions) }); return data.privilege; }, - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); } }); }; @@ -35,20 +34,24 @@ export const useCreateIdentityProjectAdditionalPrivilege = () => { export const useUpdateIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); - return useMutation< - { privilege: TIdentityProjectPrivilege }, - {}, - TUpdateIdentityProjectPrivlegeDTO - >({ - mutationFn: async (dto) => { - const { data } = await apiRequest.patch( - `/api/v1/additional-privilege/identity/${dto.privilegeId}`, - { ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined } - ); - return data.privilege; + return useMutation({ + mutationFn: async ({ slug, projectSlug, identityId, data }) => { + const { data: res } = await apiRequest.patch("/api/v1/additional-privilege/identity", { + slug, + projectSlug, + identityId, + data: { + isPackedPermission: true, + ...data, + permissions: data.permissions ? packRules(data.permissions) : undefined + } + }); + return res.privilege; }, - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); } }); }; @@ -56,19 +59,21 @@ export const useUpdateIdentityProjectAdditionalPrivilege = () => { export const useDeleteIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); - return useMutation< - { privilege: TIdentityProjectPrivilege }, - {}, - TDeleteIdentityProjectPrivilegeDTO - >({ - mutationFn: async (dto) => { - const { data } = await apiRequest.delete( - `/api/v1/additional-privilege/identity/${dto.privilegeId}` - ); + return useMutation({ + mutationFn: async ({ identityId, projectSlug, slug }) => { + const { data } = await apiRequest.delete("/api/v1/additional-privilege/identity", { + data: { + identityId, + projectSlug, + slug + } + }); return data.privilege; }, - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(workspaceKeys.getWorkspaceIdentityMemberships(projectId)); + onSuccess: (_, { projectSlug, identityId }) => { + queryClient.invalidateQueries( + identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }) + ); } }); }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx index 2bbd71c70..8ad60ba6c 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx @@ -4,28 +4,74 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { TProjectPermission } from "../roles/types"; -import { TIdentityProjectPrivilege } from "./types"; +import { + TGetIdentityProejctPrivilegeDetails as TGetIdentityProjectPrivilegeDetails, + TIdentityProjectPrivilege, + TListIdentityUserPrivileges as TListIdentityProjectPrivileges +} from "./types"; export const identitiyProjectPrivilegeKeys = { - details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const + details: ({ identityId, slug, projectSlug }: TGetIdentityProjectPrivilegeDetails) => + [ + "identity-user-privilege", + { + identityId, + projectSlug, + slug + } + ] as const, + list: ({ projectSlug, identityId }: TListIdentityProjectPrivileges) => + ["identity-user-privileges", { identityId, projectSlug }] as const }; -const fetchIdentityProjectPrivilegeDetails = async (privilegeId: string) => { - const { - data: { privilege } - } = await apiRequest.get<{ - privilege: Omit & { permissions: unknown }; - }>(`/api/v1/additional-privilege/identity/${privilegeId}`); - return { - ...privilege, - permissions: unpackRules(privilege.permissions as PackRule[]) - }; -}; - -export const useGetIdentityProjectPrivilegeDetails = (privilegeId: string) => { +export const useGetIdentityProjectPrivilegeDetails = ({ + projectSlug, + identityId, + slug +}: TGetIdentityProjectPrivilegeDetails) => { return useQuery({ - enabled: Boolean(privilegeId), - queryKey: identitiyProjectPrivilegeKeys.details(privilegeId), - queryFn: () => fetchIdentityProjectPrivilegeDetails(privilegeId) + enabled: Boolean(projectSlug && identityId && slug), + queryKey: identitiyProjectPrivilegeKeys.details({ projectSlug, slug, identityId }), + queryFn: async () => { + const { + data: { privilege } + } = await apiRequest.get<{ + privilege: Omit & { permissions: unknown }; + }>(`/api/v1/additional-privilege/identity/${slug}`, { + params: { + identityId, + projectSlug + } + }); + return { + ...privilege, + permissions: unpackRules(privilege.permissions as PackRule[]) + }; + } + }); +}; + +export const useListIdentityProjectPrivileges = ({ + projectSlug, + identityId +}: TListIdentityProjectPrivileges) => { + return useQuery({ + enabled: Boolean(projectSlug && identityId), + queryKey: identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }), + queryFn: async () => { + const { + data: { privileges } + } = await apiRequest.get<{ + privileges: Array< + Omit & { permissions: unknown } + >; + }>("/api/v1/additional-privilege/identity", { + params: { identityId, projectSlug, unpacked: false } + }); + return privileges.map((el) => ({ + ...el, + permissions: unpackRules(el.permissions as PackRule[]) + })); + } }); }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index 3362e3301..1e07db597 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -7,25 +7,31 @@ export enum IdentityProjectAdditionalPrivilegeTemporaryMode { export type TIdentityProjectPrivilege = { projectMembershipId: string; slug: string; - name: string; - isTemporary: boolean; id: string; createdAt: Date; updatedAt: Date; - description?: string | null | undefined; - temporaryMode?: string | null | undefined; - temporaryRange?: string | null | undefined; - temporaryAccessStartTime?: string | null | undefined; - temporaryAccessEndTime?: Date | null | undefined; permissions?: TProjectPermission[]; -}; +} & ( + | { + isTemporary: true; + temporaryMode: string; + temporaryRange: string; + temporaryAccessStartTime: string; + temporaryAccessEndTime?: string; + } + | { + isTemporary: false; + temporaryMode?: null; + temporaryRange?: null; + temporaryAccessStartTime?: null; + temporaryAccessEndTime?: null; + } + ); export type TCreateIdentityProjectPrivilegeDTO = { identityId: string; - projectId: string; - slug: string; - name: string; - description?: string; + projectSlug: string; + slug?: string; isTemporary?: boolean; temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode; temporaryRange?: string; @@ -34,15 +40,25 @@ export type TCreateIdentityProjectPrivilegeDTO = { }; export type TUpdateIdentityProjectPrivlegeDTO = { - privilegeId: string; - projectId: string; -} & Partial>; + projectSlug: string; + identityId: string; + slug: string; + data: Partial>; +}; export type TDeleteIdentityProjectPrivilegeDTO = { - privilegeId: string; - projectId: string; + projectSlug: string; + identityId: string; + slug: string; +}; + +export type TListIdentityUserPrivileges = { + projectSlug: string; + identityId: string; }; export type TGetIdentityProejctPrivilegeDetails = { - privilegeId: string; + projectSlug: string; + identityId: string; + slug: string; }; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index 816d786ed..d82c53d2a 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -1,6 +1,8 @@ import Link from "next/link"; import { faArrowUpRightFromSquare, + faClock, + faEdit, faPlus, faServer, faXmark @@ -8,6 +10,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -15,23 +18,39 @@ import { Button, DeleteActionModal, EmptyState, + HoverCard, + HoverCardContent, + HoverCardTrigger, IconButton, + Modal, + ModalContent, Table, TableContainer, TableSkeleton, + Tag, TBody, Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { withProjectPermission } from "@app/hoc"; import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; +import { IdentityMembership } from "@app/hooks/api/identities/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { usePopUp } from "@app/hooks/usePopUp"; import { IdentityModal } from "./components/IdentityModal"; +import { IdentityRoleForm } from "./components/IdentityRoleForm"; +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; +const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; +}; export const IdentityTab = withProjectPermission( () => { const { currentWorkspace } = useWorkspace(); @@ -45,7 +64,7 @@ export const IdentityTab = withProjectPermission( "identity", "deleteIdentity", "upgradePlan", - "additionalPrivilege" + "updateRole" ] as const); const onRemoveIdentitySubmit = async (identityId: string) => { @@ -75,7 +94,7 @@ export const IdentityTab = withProjectPermission( return ( 0 && - data.map(({ identity: { id, name }, createdAt }) => { + data.map((identityMember, index) => { + const { + identity: { id, name }, + roles, + createdAt + } = identityMember; return ( {name} + + +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id: roleId, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + } + )} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map( + ({ + role, + customRoleName, + id: roleId, + isTemporary, + temporaryAccessEndTime + }) => { + const isExpired = + new Date() > + new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + new Date( + temporaryAccessEndTime as string + ) && "text-red-600" + )} + /> + +
+ )} +
+
+ ); + } + )} +
+
+ )} + + + handlePopUpOpen("updateRole", { ...identityMember, index }) + } + > + + + +
+ {format(new Date(createdAt), "yyyy-MM-dd")} - + handlePopUpToggle("updateRole", state)} + > + + + handlePopUpOpen("upgradePlan", { description }) + } + identityProjectMember={ + data?.[ + (popUp.updateRole?.data as IdentityMembership & { index: number })?.index + ] as IdentityMembership + } + /> + + ; + +type Props = { + identityProjectMember: IdentityMembership; + onOpenUpgradeModal: (title: string) => void; +}; +export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Identity + ); + + const roleForm = useForm({ + resolver: zodResolver(roleFormSchema), + values: { + roles: identityProjectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({ + slug: customRoleSlug || role, + temporaryAccess: dto.isTemporary + ? { + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } + : { + isTemporary: dto.isTemporary + } + })) + } + }); + const selectedRoleList = useFieldArray({ + name: "roles", + control: roleForm.control + }); + + const formRoleField = roleForm.watch("roles"); + + const updateMembershipRole = useUpdateIdentityWorkspaceRole(); + + const handleRoleUpdate = async (data: TRoleForm) => { + if (updateMembershipRole.isLoading) return; + + const sanitizedRoles = data.roles.map((el) => { + const { isTemporary } = el.temporaryAccess; + if (!isTemporary) { + return { role: el.slug, isTemporary: false as const }; + } + return { + role: el.slug, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: el.temporaryAccess.temporaryRange, + temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime + }; + }); + + const hasCustomRoleSelected = sanitizedRoles.some( + (el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole) + ); + + if (hasCustomRoleSelected && subscription && !subscription?.rbac) { + onOpenUpgradeModal( + "You can assign custom roles to members if you upgrade your Infisical plan." + ); + return; + } + + try { + await updateMembershipRole.mutateAsync({ + workspaceId, + identityId: identityProjectMember.identity.id, + roles: sanitizedRoles + }); + createNotification({ text: "Successfully updated roles", type: "success" }); + roleForm.reset(undefined, { keepValues: true }); + } catch (err) { + createNotification({ text: "Failed to update role", type: "error" }); + } + }; + + if (isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+
Roles
+

Select one of the pre-defined or custom roles.

+
+
+
+ {selectedRoleList.fields.map(({ id }, index) => { + const { temporaryAccess } = formRoleField[index]; + const isTemporary = temporaryAccess?.isTemporary; + const isExpired = + temporaryAccess.isTemporary && + new Date() > new Date(temporaryAccess.temporaryAccessEndTime || ""); + + return ( +
+ ( + + )} + /> + + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccess.isTemporary && ( + + )} +
+
+
+
+ + { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + + + +
+ ); + })} +
+
+ + {(isAllowed) => ( + + )} + + +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx new file mode 100644 index 000000000..e354f1015 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRoleForm.tsx @@ -0,0 +1,20 @@ +import { IdentityMembership } from "@app/hooks/api/identities/types"; + +import { IdentityRbacSection } from "./IdentityRbacSection"; +import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection"; + +type Props = { + identityProjectMember: IdentityMembership; + onOpenUpgradeModal: (title: string) => void; +}; +export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => { + return ( +
+ + +
+ ); +}; 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 new file mode 100644 index 000000000..806072a89 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -0,0 +1,515 @@ +import { Controller, useForm } from "react-hook-form"; +import { faCancel, faCaretDown, faClock, faClose, faSave } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { formatDistance } from "date-fns"; +import ms from "ms"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + Checkbox, + DeleteActionModal, + FormControl, + FormLabel, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Select, + SelectItem, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { + TProjectUserPrivilege, + useCreateIdentityProjectAdditionalPrivilege, + useDeleteIdentityProjectAdditionalPrivilege, + useUpdateIdentityProjectAdditionalPrivilege +} from "@app/hooks/api"; +import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries"; + +const secretPermissionSchema = z.object({ + secretPath: z.string().optional(), + environmentSlug: z.string(), + [ProjectPermissionActions.Edit]: z.boolean().optional(), + [ProjectPermissionActions.Read]: z.boolean().optional(), + [ProjectPermissionActions.Create]: z.boolean().optional(), + [ProjectPermissionActions.Delete]: z.boolean().optional(), + temporaryAccess: z.discriminatedUnion("isTemporary", [ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.object({ + isTemporary: z.literal(false) + }) + ]) +}); +type TSecretPermissionForm = z.infer; +const SpecificPrivilegeSecretForm = ({ + privilege, + identityId +}: { + privilege: TProjectUserPrivilege; + identityId: string; +}) => { + const { currentWorkspace } = useWorkspace(); + const projectSlug = currentWorkspace?.slug || ""; + + const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ + "deletePrivilege" + ] as const); + const { permission } = useProjectPermission(); + const isMemberEditDisabled = permission.cannot( + ProjectPermissionActions.Edit, + ProjectPermissionSub.Identity + ); + + const updateIdentityPrivilege = useUpdateIdentityProjectAdditionalPrivilege(); + const deleteIdentityPrivilege = useDeleteIdentityProjectAdditionalPrivilege(); + + const privilegeForm = useForm({ + resolver: zodResolver(secretPermissionSchema), + values: { + environmentSlug: privilege.permissions?.[0]?.conditions?.environment, + // secret path will be inside $glob operator + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + read: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Read) + ), + edit: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Edit) + ), + create: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Create) + ), + delete: privilege.permissions?.some(({ action }) => + action.includes(ProjectPermissionActions.Delete) + ), + // zod will pick it + temporaryAccess: privilege + } + }); + + const temporaryAccessField = privilegeForm.watch("temporaryAccess"); + const isTemporary = temporaryAccessField?.isTemporary; + const isExpired = + temporaryAccessField.isTemporary && + new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); + + const handleUpdatePrivilege = async (data: TSecretPermissionForm) => { + if (updateIdentityPrivilege.isLoading) return; + try { + const actions = [ + { action: ProjectPermissionActions.Read, allowed: data.read }, + { action: ProjectPermissionActions.Create, allowed: data.create }, + { 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({ + data: { + ...data.temporaryAccess, + permissions: actions + .filter(({ allowed }) => allowed) + .map(({ action }) => ({ + action, + subject: [ProjectPermissionSub.Secrets], + conditions + })) + }, + slug: privilege.slug, + identityId, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully updated privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update privilege" + }); + } + }; + + const handleDeletePrivilege = async () => { + if (deleteIdentityPrivilege.isLoading) return; + try { + await deleteIdentityPrivilege.mutateAsync({ + identityId, + slug: privilege.slug, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully deleted privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to delete privilege" + }); + } + }; + + const getAccessLabel = () => { + if (isExpired) return "Access expired"; + if (!temporaryAccessField?.isTemporary) return "Permanent"; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); + }; + + return ( +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> + ( +
+ + field.onChange(isChecked)} + /> +
+ )} + /> +
+
+ + + + + + + +
+
+ Configure timed access +
+ {isExpired && Expired} + ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+ + {temporaryAccessField.isTemporary && ( + + )} +
+
+
+
+ {privilegeForm.formState.isDirty ? ( + <> + + + {privilegeForm.formState.isSubmitting ? ( + + ) : ( + + )} + + + + privilegeForm.reset()} + > + + + + + ) : ( + + handlePopUpOpen("deletePrivilege")} + > + + + + )} +
+
+
+ handlePopUpToggle("deletePrivilege", isOpen)} + deleteKey="delete" + onClose={() => handlePopUpClose("deletePrivilege")} + onDeleteApproved={handleDeletePrivilege} + /> +
+ ); +}; + +type Props = { + identityId: string; +}; + +export const SpecificPrivilegeSection = ({ identityId }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectSlug = currentWorkspace?.slug || ""; + const { data: identityPrivileges, isLoading } = useListIdentityProjectPrivileges({ + identityId, + projectSlug + }); + + const createIdentityPrivilege = useCreateIdentityProjectAdditionalPrivilege(); + + const handleCreatePrivilege = async () => { + if (createIdentityPrivilege.isLoading) return; + try { + await createIdentityPrivilege.mutateAsync({ + permissions: [ + { + action: ProjectPermissionActions.Read, + subject: [ProjectPermissionSub.Secrets], + conditions: { + environment: currentWorkspace?.environments?.[0].slug + } + } + ], + identityId, + projectSlug + }); + createNotification({ + type: "success", + text: "Successfully created privilege" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create privilege" + }); + } + }; + + return ( +
+
+ Additional Privileges + {isLoading && } +
+

+ Select individual privileges to associate with the identity. +

+
+ {identityPrivileges + ?.filter(({ permissions }) => + permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets) + ) + ?.map((privilege) => ( + + ))} +
+ + {(isAllowed) => ( + + )} + +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx new file mode 100644 index 000000000..f59675cb3 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/index.tsx @@ -0,0 +1 @@ +export { IdentityRoleForm } from "./IdentityRoleForm"; diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx deleted file mode 100644 index 6ff10cfa8..000000000 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoles.tsx +++ /dev/null @@ -1,459 +0,0 @@ -import { useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { twMerge } from "tailwind-merge"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - Checkbox, - FormControl, - HoverCard, - HoverCardContent, - HoverCardTrigger, - IconButton, - Input, - Popover, - PopoverContent, - PopoverTrigger, - Spinner, - Tag, - Tooltip -} from "@app/components/v2"; -import { useWorkspace } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api"; -import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; -import { TWorkspaceUser } from "@app/hooks/api/types"; -import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; -import { groupBy } from "@app/lib/fn/array"; - -const temporaryRoleFormSchema = z.object({ - temporaryRange: z.string().min(1, "Required") -}); - -type TTemporaryRoleFormSchema = z.infer; - -type TTemporaryRoleFormProps = { - temporaryConfig?: { - isTemporary?: boolean; - temporaryAccessEndTime?: string | null; - temporaryAccessStartTime?: string | null; - temporaryRange?: string | null; - }; - onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; - onRemoveTemporary: () => void; -}; - -const IdentityTemporaryRoleForm = ({ - temporaryConfig: defaultValues = {}, - onSetTemporary, - onRemoveTemporary -}: TTemporaryRoleFormProps) => { - const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); - const { control, handleSubmit } = useForm({ - resolver: zodResolver(temporaryRoleFormSchema), - values: { - temporaryRange: defaultValues.temporaryRange || "1h" - } - }); - const isTemporaryFieldValue = defaultValues.isTemporary; - const isExpired = - isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); - - return ( - { - handlePopUpToggle("setTempRole", isOpen); - }} - > - - - - - - - - -
-
- Set Role Temporarily -
- {isExpired && Expired} - ( - - 1m, 2h, 3d.{" "} - - More - - - } - > - - - )} - /> -
- {isTemporaryFieldValue && ( - - )} - {!isTemporaryFieldValue ? ( - - ) : ( - - )} -
-
-
-
- ); -}; - -const formSchema = z.record( - z.object({ - isChecked: z.boolean().optional(), - temporaryAccess: z.union([ - z.object({ - isTemporary: z.literal(true), - temporaryRange: z.string().min(1), - temporaryAccessStartTime: z.string().datetime(), - temporaryAccessEndTime: z.string().datetime().nullable().optional() - }), - z.boolean() - ]) - }) -); -type TForm = z.infer; - -export type TMemberRolesProp = { - disableEdit?: boolean; - identityId: string; - roles: TWorkspaceUser["roles"]; -}; - -const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; - -export const IdentityRoles = ({ - roles = [], - disableEdit = false, - identityId -}: TMemberRolesProp) => { - const { currentWorkspace } = useWorkspace(); - - const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); - const [searchRoles, setSearchRoles] = useState(""); - - const { - handleSubmit, - control, - reset, - setValue, - formState: { isSubmitting, isDirty } - } = useForm({ - resolver: zodResolver(formSchema) - }); - - const workspaceId = currentWorkspace?.id || ""; - - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); - - const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole(); - - const handleRoleUpdate = async (data: TForm) => { - const selectedRoles = Object.keys(data) - .filter((el) => Boolean(data[el].isChecked)) - .map((el) => { - const isTemporary = Boolean(data[el].temporaryAccess); - if (!isTemporary) { - return { role: el, isTemporary: false as const }; - } - - const tempCfg = data[el].temporaryAccess as { - temporaryRange: string; - temporaryAccessStartTime: string; - }; - - return { - role: el, - isTemporary: true as const, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, - temporaryRange: tempCfg.temporaryRange, - temporaryAccessStartTime: tempCfg.temporaryAccessStartTime - }; - }); - - try { - await updateIdentityWorkspaceRole.mutateAsync({ - workspaceId, - identityId, - roles: selectedRoles - }); - createNotification({ text: "Successfully updated identity role", type: "success" }); - handlePopUpToggle("editRole"); - setSearchRoles(""); - } catch (err) { - createNotification({ text: "Failed to update identity role", type: "error" }); - } - }; - - const formatRoleName = (role: string, customRoleName?: string) => { - if (role === ProjectMembershipRole.Custom) return customRoleName; - if (role === ProjectMembershipRole.Member) return "Developer"; - return role; - }; - - return ( -
- {roles - .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) - .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { - const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); - return ( - -
-
{formatRoleName(role, customRoleName)}
- {isTemporary && ( -
- - - -
- )} -
-
- ); - })} - {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( - - - +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} - - - {roles - .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) - .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { - const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); - return ( - -
-
{formatRoleName(role, customRoleName)}
- {isTemporary && ( -
- - new Date(temporaryAccessEndTime as string) && - "text-red-600" - )} - /> - -
- )} -
-
- ); - })}{" "} -
-
- )} -
- { - handlePopUpToggle("editRole", isOpen); - reset(); - }} - > - {!disableEdit && ( - - - - - - )} - - {isRolesLoading ? ( -
- -
- ) : ( -
-
- {projectRoles - ?.filter( - ({ name, slug }) => - name.toLowerCase().includes(searchRoles.toLowerCase()) || - slug.toLowerCase().includes(searchRoles.toLowerCase()) - ) - ?.map(({ id, name, slug }) => { - const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; - - return ( -
-
- ( - { - field.onChange(isChecked); - setValue(`${slug}.temporaryAccess`, false); - }} - > - {name} - - )} - /> -
-
- ( - { - setValue(`${slug}.isChecked`, true, { shouldDirty: true }); - console.log(data); - field.onChange({ isTemporary: true, ...data }); - }} - onRemoveTemporary={() => { - setValue(`${slug}.isChecked`, false, { shouldDirty: true }); - field.onChange(false); - }} - /> - )} - /> -
-
- ); - })} -
-
-
- setSearchRoles(el.target.value)} - leftIcon={} - placeholder="Search roles.." - /> -
-
- -
-
-
- )} -
-
-
-
- ); -}; diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 9f1711199..3bb29ff61 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -210,7 +210,7 @@ export const MemberListTab = () => { return ( {privilegeForm.formState.isSubmitting ? ( - + ) : ( )} @@ -460,7 +460,7 @@ export const SpecificPrivilegeSection = ({ membershipId }: Props) => { return (
-
+
Additional Privileges {isLoading && }
From e46256f45b5b9b437293d8d5769f5aaf0308d6f2 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Thu, 28 Mar 2024 19:53:10 +0530 Subject: [PATCH 15/21] feat: added description for all api endpoints --- ...ity-project-additional-privilege-router.ts | 104 ++++++++++++------ .../v1/user-additional-privilege-router.ts | 66 +++++++---- backend/src/lib/api-docs/constants.ts | 78 +++++++++++++ .../IdentityRoleForm/IdentityRbacSection.tsx | 2 +- .../MemberRoleForm/MemberRbacSection.tsx | 2 +- 5 files changed, 192 insertions(+), 60 deletions(-) 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 aec3a9c97..df28a627d 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 @@ -7,6 +7,7 @@ import { z } from "zod"; import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission"; +import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -18,41 +19,59 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F schema: { body: z.union([ z.object({ - identityId: z.string().min(1), - projectSlug: z.string().min(1), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), slug: z .string() .min(1) .max(60) .trim() - .optional() .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") .refine((v) => slugify(v) === v, { message: "Slug must be a valid slug" - }), - permissions: z.any().array(), - isPackedPermission: z.boolean().optional().default(false), - isTemporary: z.literal(false).default(false) + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + isPackedPermission: z + .boolean() + .optional() + .default(false) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.isPackPermission), + isTemporary: z.literal(false).default(false).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.isTemporary) }), z.object({ - identityId: z.string(), - projectSlug: z.string().min(1), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), slug: z .string() .min(1) .max(60) .trim() - .optional() .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") .refine((v) => slugify(v) === v, { message: "Slug must be a valid slug" - }), - permissions: z.any().array(), - isTemporary: z.literal(true), - isPackedPermission: z.boolean().optional().default(false), - temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), - temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), - temporaryAccessStartTime: z.string().datetime() + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + isPackedPermission: z + .boolean() + .optional() + .default(false) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.isPackPermission), + isTemporary: z.literal(true).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.isTemporary), + temporaryMode: z + .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) }) ]), response: { @@ -83,9 +102,9 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F schema: { body: z.object({ // disallow empty string - slug: z.string().min(1), - identityId: z.string().min(1), - projectSlug: z.string().min(1), + slug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.slug), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), data: z .object({ slug: z @@ -93,15 +112,29 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F .min(1) .max(60) .trim() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") .refine((v) => slugify(v) === v, { message: "Slug must be a valid slug" - }), - isPackedPermission: z.boolean().optional().default(false), - permissions: z.any().array(), - isTemporary: z.boolean(), - temporaryMode: z.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode), - temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), - temporaryAccessStartTime: z.string().datetime() + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), + isPackedPermission: z + .boolean() + .optional() + .default(false) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isPackPermission), + permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + 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") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime) }) .partial() }), @@ -136,9 +169,9 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F method: "DELETE", schema: { body: z.object({ - slug: z.string().min(1), - identityId: z.string().min(1), - projectSlug: z.string().min(1) + slug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.slug), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.projectSlug) }), response: { 200: z.object({ @@ -164,11 +197,11 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F method: "GET", schema: { params: z.object({ - slug: z.string() + slug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.slug) }), querystring: z.object({ - identityId: z.string().min(1), - projectSlug: z.string().min(1) + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.projectSlug) }), response: { 200: z.object({ @@ -195,12 +228,13 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F method: "GET", schema: { querystring: z.object({ - identityId: z.string().min(1), - projectSlug: z.string().min(1), + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug), unpacked: z .enum(["false", "true"]) .transform((el) => el === "true") .default("true") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.unpacked) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index d0643b22c..c003971e4 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; +import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -15,37 +16,47 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr schema: { body: z.union([ z.object({ - projectMembershipId: z.string(), + projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), slug: z .string() .min(1) .max(60) .trim() .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") .refine((v) => slugify(v) === v, { message: "Slug must be a valid slug" - }), - permissions: z.any().array(), - isTemporary: z.literal(false).default(false) + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), + isTemporary: z.literal(false).default(false).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.isTemporary) }), z.object({ - projectMembershipId: z.string(), + projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), slug: z .string() .min(1) .max(60) .trim() .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") .refine((v) => slugify(v) === v, { message: "Slug must be a valid slug" - }), - name: z.string().trim(), - description: z.string().trim().optional(), - permissions: z.any().array(), - isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode), - temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), - temporaryAccessStartTime: z.string().datetime() + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), + isTemporary: z.literal(true).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.isTemporary), + temporaryMode: z + .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) }) ]), response: { @@ -73,7 +84,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr method: "PATCH", schema: { params: z.object({ - privilegeId: z.string() + privilegeId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.privilegeId) }), body: z .object({ @@ -84,12 +95,21 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") .refine((v) => slugify(v) === v, { message: "Slug must be a valid slug" - }), - permissions: z.any().array(), - isTemporary: z.boolean(), - temporaryMode: z.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode), - temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), - temporaryAccessStartTime: z.string().datetime() + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + isTemporary: z.boolean().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), + temporaryMode: z + .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime) }) .partial(), response: { @@ -118,7 +138,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr method: "DELETE", schema: { params: z.object({ - privilegeId: z.string() + privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.DELETE.privilegeId) }), response: { 200: z.object({ @@ -144,7 +164,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr method: "GET", schema: { querystring: z.object({ - projectMembershipId: z.string() + projectMembershipId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.LIST.projectMembershipId) }), response: { 200: z.object({ @@ -170,7 +190,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr method: "GET", schema: { params: z.object({ - privilegeId: z.string() + privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.GET_BY_PRIVILEGEID.privilegeId) }), response: { 200: z.object({ diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8736e80c6..e0d82b2d1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -397,3 +397,81 @@ export const SECRET_TAGS = { projectId: "The ID of the project to delete the tag from." } } as const; + +export const IDENTITY_ADDITIONAL_PRIVILEGE = { + CREATE: { + projectSlug: "The slug of the project of the dynamic secret in.", + identityId: "The ID of the identity to delete.", + slug: "The slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + UPDATE: { + projectSlug: "The slug of the project of the dynamic secret in.", + identityId: "The ID of the identity to delete.", + slug: "The slug of the privilege to create.", + newSlug: "The new slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + DELETE: { + projectSlug: "The slug of the project of the dynamic secret in.", + identityId: "The ID of the identity to delete.", + slug: "The slug of the privilege to create." + }, + GET_BY_SLUG: { + projectSlug: "The slug of the project of the dynamic secret in.", + identityId: "The ID of the identity to delete.", + slug: "The slug of the privilege to create." + }, + LIST: { + projectSlug: "The slug of the project of the dynamic secret in.", + identityId: "The ID of the identity to delete.", + unpacked: "Whether the system should send the permissions as unpacked" + } +}; + +export const PROJECT_USER_ADDITIONAL_PRIVILEGE = { + CREATE: { + projectMembershipId: "Project membership id of user", + slug: "The slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + UPDATE: { + privilegeId: "The id of privilege object", + slug: "The slug of the privilege to create.", + newSlug: "The new slug of the privilege to create.", + permissions: + "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + isPackPermission: "Whether the server should pack(compact) the permission object.", + isTemporary: "Whether the privilege is temporary.", + temporaryMode: "Type of temporary access given. Types: relative", + temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", + temporaryAccessStartTime: "ISO time for which temporary access should begin." + }, + DELETE: { + privilegeId: "The id of privilege object" + }, + GET_BY_PRIVILEGEID: { + privilegeId: "The id of privilege object" + }, + LIST: { + projectMembershipId: "Project membership id of user" + } +}; 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 9d25c7ed0..fddc067bc 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 @@ -331,7 +331,7 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal )} isLoading={roleForm.formState.isSubmitting} > - Save Changes + Save Roles
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 ecd1ee40e..73642344e 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx @@ -328,7 +328,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) )} isLoading={roleForm.formState.isSubmitting} > - Save Changes + Save Roles
From 31ad6b0c866e36951027c98c22e55bca4e306272 Mon Sep 17 00:00:00 2001 From: Vladyslav Matsiiako Date: Thu, 28 Mar 2024 18:17:59 -0700 Subject: [PATCH 16/21] update style --- .../components/IdentityTab/IdentityTab.tsx | 6 +- .../IdentityRoleForm/IdentityRbacSection.tsx | 75 ++++++++---------- .../SpecificPrivilegeSection.tsx | 56 +++++++------- .../MemberListTab/MemberListTab.tsx | 4 +- .../MemberRoleForm/MemberRbacSection.tsx | 77 +++++++++---------- .../SpecificPrivilegeSection.tsx | 52 ++++++------- 6 files changed, 128 insertions(+), 142 deletions(-) diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx index d82c53d2a..213b08246 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/IdentityTab.tsx @@ -308,12 +308,12 @@ export const IdentityTab = withProjectPermission( onOpenChange={(state) => handlePopUpToggle("updateRole", state)} > 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 fddc067bc..a4bd57ad3 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 @@ -1,8 +1,8 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; -import { faCaretDown, faClock, faClose } from "@fortawesome/free-solid-svg-icons"; +import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { formatDistance } from "date-fns"; +import { format } from "date-fns"; import ms from "ms"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; @@ -21,8 +21,7 @@ import { Select, SelectItem, Spinner, - Tag, - Tooltip + Tag } from "@app/components/v2"; import { ProjectPermissionActions, @@ -183,29 +182,24 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal /> - : undefined} + rightIcon={} + isDisabled={isMemberEditDisabled} + className={twMerge( + "border-none bg-mineshaft-600 hover:bg-mineshaft-500 py-2.5 capitalize text-xs", + isTemporary && "text-primary", + isExpired && "text-red-600" + )} > - - + {!temporaryAccess?.isTemporary + ? "Permanent" + : `Expires at ${format( + new Date(temporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`} + - - { - if (selectedRoleList.fields.length > 1) { - selectedRoleList.remove(index); - } - }} - > - - - + { + if (selectedRoleList.fields.length > 1) { + selectedRoleList.remove(index); + } + }} + > + +
); })} @@ -311,6 +303,7 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal +
+ + + +
{ @@ -324,7 +343,7 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal )} isLoading={roleForm.formState.isSubmitting} > - Save Roles + Save Roles
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 9d543f466..b8c9e8caa 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 @@ -1,8 +1,15 @@ import { Controller, useForm } from "react-hook-form"; -import { faArrowRotateLeft, faCaretDown, faCheck, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowRotateLeft, + faCaretDown, + faCheck, + faClock, + faPlus, + faTrash +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { format } from "date-fns"; +import { format, formatDistance } from "date-fns"; import ms from "ms"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; @@ -173,10 +180,15 @@ const SpecificPrivilegeSecretForm = ({ } }; - const getAccessLabel = () => { + const getAccessLabel = (exactTime = false) => { if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - return `Until ${format(new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss")}`; + if (exactTime) + return `Until ${format( + new Date(temporaryAccessField.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); }; return ( @@ -281,24 +293,23 @@ const SpecificPrivilegeSecretForm = ({
- - - +
+ + + +
privilegeForm.reset()} @@ -389,7 +400,10 @@ const SpecificPrivilegeSecretForm = ({ - + {privilegeForm.formState.isSubmitting ? ( - + ) : ( - + )} ) : ( - + handlePopUpOpen("deletePrivilege")} > @@ -480,7 +497,7 @@ export const SpecificPrivilegeSection = ({ identityId }: Props) => { Additional Privileges {isLoading && }
-

+

Select individual privileges to associate with the identity.

diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx index 20a2079ac..b59286577 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberListTab.tsx @@ -76,6 +76,7 @@ const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; const formatRoleName = (role: string, customRoleName?: string) => { if (role === ProjectMembershipRole.Custom) return customRoleName; if (role === ProjectMembershipRole.Member) return "Developer"; + if (role === ProjectMembershipRole.NoAccess) return "No access"; return role; }; @@ -279,7 +280,9 @@ export const MemberListTab = () => { return (
-
{formatRoleName(role, customRoleName)}
+
+ {formatRoleName(role, customRoleName)} +
{isTemporary && (
onChange(e)} - className="w-full bg-mineshaft-600 hover:bg-mineshaft-500 duration-200" + className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500" > {projectRoles?.map(({ name, slug, id: projectRoleId }) => ( @@ -181,25 +183,42 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) )} /> - - + +
+ + + +
{ @@ -321,7 +340,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) )} isLoading={roleForm.formState.isSubmitting} > - Save Roles + Save Roles
diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index f50480f0f..2df959a94 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -1,8 +1,15 @@ import { Controller, useForm } from "react-hook-form"; -import { faArrowRotateLeft, faCaretDown, faCheck, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowRotateLeft, + faCaretDown, + faCheck, + faClock, + faPlus, + faTrash +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; -import { format } from "date-fns"; +import { format, formatDistance } from "date-fns"; import ms from "ms"; import { twMerge } from "tailwind-merge"; import { z } from "zod"; @@ -161,10 +168,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri } }; - const getAccessLabel = () => { + const getAccessLabel = (exactTime = false) => { if (isExpired) return "Access expired"; if (!temporaryAccessField?.isTemporary) return "Permanent"; - return `Until ${format(new Date(temporaryAccessField.temporaryAccessEndTime || ""), "yyyy-MM-dd HH:mm:ss")}`; + if (exactTime) + return `Until ${format( + new Date(temporaryAccessField.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}`; + return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); }; return ( @@ -269,24 +281,23 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
- - - +
+ + + +
privilegeForm.reset()} @@ -377,7 +388,10 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri - + {privilegeForm.formState.isSubmitting ? ( - + ) : ( - + )} ) : ( - + handlePopUpOpen("deletePrivilege")} > @@ -463,7 +480,7 @@ export const SpecificPrivilegeSection = ({ membershipId }: Props) => { Additional Privileges {isLoading && }
-

+

Select individual privileges to associate with the user.

From af2dcdd0c72faae852c6864f59a4efcf81345768 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Fri, 29 Mar 2024 23:51:26 +0530 Subject: [PATCH 18/21] feat: updated api description and changed slug to privilege slug --- ...ity-project-additional-privilege-router.ts | 26 ++++++++------- backend/src/lib/api-docs/constants.ts | 32 +++++++++++-------- .../mutation.tsx | 8 ++--- .../queries.tsx | 12 +++---- .../types.tsx | 6 ++-- .../SpecificPrivilegeSection.tsx | 4 +-- 6 files changed, 48 insertions(+), 40 deletions(-) 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 df28a627d..f17b6e560 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 @@ -102,7 +102,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F schema: { body: z.object({ // disallow empty string - slug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.slug), + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.slug), identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), data: z @@ -146,17 +146,19 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { isPackedPermission, ...data } = req.body.data; + const { isPackedPermission, ...updatedInfo } = req.body.data; const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ actorId: req.permission.id, actor: req.permission.type, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - ...req.body, + slug: req.body.privilegeSlug, + identityId: req.body.identityId, + projectSlug: req.body.projectSlug, data: { - ...data, - permissions: data?.permissions - ? JSON.stringify(isPackedPermission ? data?.permissions : packRules(data.permissions)) + ...updatedInfo, + permissions: updatedInfo?.permissions + ? JSON.stringify(isPackedPermission ? updatedInfo?.permissions : packRules(updatedInfo.permissions)) : undefined } }); @@ -169,7 +171,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F method: "DELETE", schema: { body: z.object({ - slug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.slug), + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.slug), identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.projectSlug) }), @@ -186,18 +188,20 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actor: req.permission.type, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - ...req.body + slug: req.body.privilegeSlug, + identityId: req.body.identityId, + projectSlug: req.body.projectSlug }); return { privilege }; } }); server.route({ - url: "/:slug", + url: "/:privilegeSlug", method: "GET", schema: { params: z.object({ - slug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.slug) + privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.slug) }), querystring: z.object({ identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.identityId), @@ -216,7 +220,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorAuthMethod: req.permission.authMethod, actor: req.permission.type, actorOrgId: req.permission.orgId, - slug: req.params.slug, + slug: req.params.privilegeSlug, ...req.query }); return { privilege }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index e0d82b2d1..70ba7e87b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -400,7 +400,7 @@ export const SECRET_TAGS = { export const IDENTITY_ADDITIONAL_PRIVILEGE = { CREATE: { - projectSlug: "The slug of the project of the dynamic secret in.", + projectSlug: "The slug of the project of the identity in.", identityId: "The ID of the identity to delete.", slug: "The slug of the privilege to create.", permissions: @@ -412,12 +412,16 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { temporaryAccessStartTime: "ISO time for which temporary access should begin." }, UPDATE: { - projectSlug: "The slug of the project of the dynamic secret in.", - identityId: "The ID of the identity to delete.", - slug: "The slug of the privilege to create.", - newSlug: "The new slug of the privilege to create.", - permissions: - "The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape", + projectSlug: "The slug of the project of the identity in.", + 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. +Example unpacked permission shape +1. [["read", "secrets", {environment: "dev", secretPath: {$glob: "/"}}]] +2. [["read", "secrets", {environment: "dev"}], ["create", "secrets", {environment: "dev"}]] +2. [["read", "secrets", {environment: "dev"}]] +`, isPackPermission: "Whether the server should pack(compact) the permission object.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", @@ -425,18 +429,18 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { temporaryAccessStartTime: "ISO time for which temporary access should begin." }, DELETE: { - projectSlug: "The slug of the project of the dynamic secret in.", + projectSlug: "The slug of the project of the identity in.", identityId: "The ID of the identity to delete.", - slug: "The slug of the privilege to create." + slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { - projectSlug: "The slug of the project of the dynamic secret in.", - identityId: "The ID of the identity to delete.", - slug: "The slug of the privilege to create." + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to list.", + slug: "The slug of the privilege." }, LIST: { - projectSlug: "The slug of the project of the dynamic secret in.", - identityId: "The ID of the identity to delete.", + projectSlug: "The slug of the project of the identity in.", + identityId: "The ID of the identity to list.", unpacked: "Whether the system should send the permissions as unpacked" } }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx index d9a2a0b0a..fb79fa464 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx @@ -35,9 +35,9 @@ export const useUpdateIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ slug, projectSlug, identityId, data }) => { + mutationFn: async ({ privilegeSlug, projectSlug, identityId, data }) => { const { data: res } = await apiRequest.patch("/api/v1/additional-privilege/identity", { - slug, + privilegeSlug, projectSlug, identityId, data: { @@ -60,12 +60,12 @@ export const useDeleteIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ identityId, projectSlug, slug }) => { + mutationFn: async ({ identityId, projectSlug, privilegeSlug }) => { const { data } = await apiRequest.delete("/api/v1/additional-privilege/identity", { data: { identityId, projectSlug, - slug + privilegeSlug } }); return data.privilege; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx index 8ad60ba6c..72534c158 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/queries.tsx @@ -11,13 +11,13 @@ import { } from "./types"; export const identitiyProjectPrivilegeKeys = { - details: ({ identityId, slug, projectSlug }: TGetIdentityProjectPrivilegeDetails) => + details: ({ identityId, privilegeSlug, projectSlug }: TGetIdentityProjectPrivilegeDetails) => [ "identity-user-privilege", { identityId, projectSlug, - slug + privilegeSlug } ] as const, list: ({ projectSlug, identityId }: TListIdentityProjectPrivileges) => @@ -27,17 +27,17 @@ export const identitiyProjectPrivilegeKeys = { export const useGetIdentityProjectPrivilegeDetails = ({ projectSlug, identityId, - slug + privilegeSlug }: TGetIdentityProjectPrivilegeDetails) => { return useQuery({ - enabled: Boolean(projectSlug && identityId && slug), - queryKey: identitiyProjectPrivilegeKeys.details({ projectSlug, slug, identityId }), + enabled: Boolean(projectSlug && identityId && privilegeSlug), + queryKey: identitiyProjectPrivilegeKeys.details({ projectSlug, privilegeSlug, identityId }), queryFn: async () => { const { data: { privilege } } = await apiRequest.get<{ privilege: Omit & { permissions: unknown }; - }>(`/api/v1/additional-privilege/identity/${slug}`, { + }>(`/api/v1/additional-privilege/identity/${privilegeSlug}`, { params: { identityId, projectSlug diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index 1e07db597..80323a2a9 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -42,14 +42,14 @@ export type TCreateIdentityProjectPrivilegeDTO = { export type TUpdateIdentityProjectPrivlegeDTO = { projectSlug: string; identityId: string; - slug: string; + privilegeSlug: string; data: Partial>; }; export type TDeleteIdentityProjectPrivilegeDTO = { projectSlug: string; identityId: string; - slug: string; + privilegeSlug: string; }; export type TListIdentityUserPrivileges = { @@ -60,5 +60,5 @@ export type TListIdentityUserPrivileges = { export type TGetIdentityProejctPrivilegeDetails = { projectSlug: string; identityId: string; - slug: string; + privilegeSlug: string; }; 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 b8c9e8caa..bf3e46a20 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 @@ -144,7 +144,7 @@ const SpecificPrivilegeSecretForm = ({ conditions })) }, - slug: privilege.slug, + privilegeSlug: privilege.slug, identityId, projectSlug }); @@ -165,7 +165,7 @@ const SpecificPrivilegeSecretForm = ({ try { await deleteIdentityPrivilege.mutateAsync({ identityId, - slug: privilege.slug, + privilegeSlug: privilege.slug, projectSlug }); createNotification({ From d0c0d5835c70e1ea65fb90af06efe8df5859aa7d Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Sat, 30 Mar 2024 01:29:34 +0530 Subject: [PATCH 19/21] feat: splitted privilege create route into two for permanent and temp to get params shape in api doc --- ...ity-project-additional-privilege-router.ts | 143 +++++++++--------- .../v1/user-additional-privilege-router.ts | 114 ++++++++------ .../mutation.tsx | 16 +- .../mutation.tsx | 2 +- 4 files changed, 147 insertions(+), 128 deletions(-) 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 f17b6e560..f5f4207e5 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 @@ -14,66 +14,25 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/", + url: "/permanent", method: "POST", schema: { - body: z.union([ - z.object({ - identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), - projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .default(`privilege-${slugify(alphaNumericNanoId(12))}`) - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), - isPackedPermission: z - .boolean() - .optional() - .default(false) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.isPackPermission), - isTemporary: z.literal(false).default(false).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.isTemporary) - }), - z.object({ - identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), - projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), - slug: z - .string() - .min(1) - .max(60) - .trim() - .default(`privilege-${slugify(alphaNumericNanoId(12))}`) - .refine((val) => val.toLowerCase() === val, "Must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), - isPackedPermission: z - .boolean() - .optional() - .default(false) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.isPackPermission), - isTemporary: z.literal(true).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.isTemporary), - temporaryMode: z - .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), - temporaryRange: z - .string() - .refine((val) => ms(val) > 0, "Temporary range must be a positive number") - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), - temporaryAccessStartTime: z - .string() - .datetime() - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) - }) - ]), + body: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(slugify(alphaNumericNanoId(12))) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + }), response: { 200: z.object({ privilege: IdentityProjectAdditionalPrivilegeSchema @@ -88,9 +47,60 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, - permissions: JSON.stringify( - req.body.isPackedPermission ? req.body.permissions : packRules(req.body.permissions) - ) + isTemporary: false, + permissions: JSON.stringify(packRules(req.body.permissions)) + }); + return { privilege }; + } + }); + + server.route({ + url: "/temporary", + method: "POST", + schema: { + body: z.object({ + identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId), + projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug), + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(slugify(alphaNumericNanoId(12))) + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + temporaryMode: z + .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) + }), + response: { + 200: z.object({ + privilege: IdentityProjectAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + isTemporary: true, + permissions: JSON.stringify(packRules(req.body.permissions)) }); return { privilege }; } @@ -117,11 +127,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F message: "Slug must be a valid slug" }) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), - isPackedPermission: z - .boolean() - .optional() - .default(false) - .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isPackPermission), permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z @@ -146,7 +151,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { isPackedPermission, ...updatedInfo } = req.body.data; + const updatedInfo = req.body.data; const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ actorId: req.permission.id, actor: req.permission.type, @@ -157,9 +162,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: req.body.projectSlug, data: { ...updatedInfo, - permissions: updatedInfo?.permissions - ? JSON.stringify(isPackedPermission ? updatedInfo?.permissions : packRules(updatedInfo.permissions)) - : undefined + permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined } }); return { privilege }; diff --git a/backend/src/ee/routes/v1/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index c003971e4..9b6bfb6fb 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -11,54 +11,24 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { server.route({ - url: "/", + url: "/permanent", method: "POST", schema: { - body: z.union([ - z.object({ - projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .default(`privilege-${slugify(alphaNumericNanoId(12))}`) - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), - isTemporary: z.literal(false).default(false).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.isTemporary) - }), - z.object({ - projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), - slug: z - .string() - .min(1) - .max(60) - .trim() - .default(`privilege-${slugify(alphaNumericNanoId(12))}`) - .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") - .refine((v) => slugify(v) === v, { - message: "Slug must be a valid slug" - }) - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), - isTemporary: z.literal(true).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.isTemporary), - temporaryMode: z - .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), - temporaryRange: z - .string() - .refine((val) => ms(val) > 0, "Temporary range must be a positive number") - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), - temporaryAccessStartTime: z - .string() - .datetime() - .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) - }) - ]), + body: z.object({ + projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(slugify(alphaNumericNanoId(12))) + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions) + }), response: { 200: z.object({ privilege: ProjectUserAdditionalPrivilegeSchema @@ -73,6 +43,58 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, ...req.body, + isTemporary: false, + permissions: JSON.stringify(req.body.permissions) + }); + return { privilege }; + } + }); + + server.route({ + url: "/temporary", + method: "POST", + schema: { + body: z.object({ + projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId), + slug: z + .string() + .min(1) + .max(60) + .trim() + .default(`privilege-${slugify(alphaNumericNanoId(12))}`) + .refine((v) => v.toLowerCase() === v, "Slug must be lowercase") + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), + permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions), + temporaryMode: z + .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime) + }), + response: { + 200: z.object({ + privilege: ProjectUserAdditionalPrivilegeSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const privilege = await server.services.projectUserAdditionalPrivilege.create({ + actorId: req.permission.id, + actor: req.permission.type, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + isTemporary: true, permissions: JSON.stringify(req.body.permissions) }); return { privilege }; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx index fb79fa464..0ea4318c4 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx @@ -1,4 +1,3 @@ -import { packRules } from "@casl/ability/extra"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; @@ -16,11 +15,10 @@ export const useCreateIdentityProjectAdditionalPrivilege = () => { return useMutation({ mutationFn: async (dto) => { - const { data } = await apiRequest.post("/api/v1/additional-privilege/identity", { - ...dto, - isPackedPermission: true, - permissions: packRules(dto.permissions) - }); + const { data } = await apiRequest.post( + "/api/v1/additional-privilege/identity/permanent", + dto + ); return data.privilege; }, onSuccess: (_, { projectSlug, identityId }) => { @@ -40,11 +38,7 @@ export const useUpdateIdentityProjectAdditionalPrivilege = () => { privilegeSlug, projectSlug, identityId, - data: { - isPackedPermission: true, - ...data, - permissions: data.permissions ? packRules(data.permissions) : undefined - } + data }); return res.privilege; }, diff --git a/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx index 6a07efdf4..fb21a425e 100644 --- a/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx +++ b/frontend/src/hooks/api/projectUserAdditionalPrivilege/mutation.tsx @@ -16,7 +16,7 @@ export const useCreateProjectUserAdditionalPrivilege = () => { return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({ mutationFn: async (dto) => { - const { data } = await apiRequest.post("/api/v1/additional-privilege/users", { + const { data } = await apiRequest.post("/api/v1/additional-privilege/users/permanent", { ...dto, permissions: packRules(dto.permissions) }); From 42cd98d4d98e064aed713eb6cb12e593ab53fd8b Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 1 Apr 2024 23:13:08 +0530 Subject: [PATCH 20/21] feat: changed update patch function to privilegeDetails for identity privilege --- .../routes/v1/identity-project-additional-privilege-router.ts | 4 ++-- .../hooks/api/identityProjectAdditionalPrivilege/mutation.tsx | 4 ++-- .../hooks/api/identityProjectAdditionalPrivilege/types.tsx | 2 +- .../components/IdentityRoleForm/SpecificPrivilegeSection.tsx | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) 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 f5f4207e5..4863a506e 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 @@ -115,7 +115,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.slug), identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.identityId), projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug), - data: z + privilegeDetails: z .object({ slug: z .string() @@ -151,7 +151,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const updatedInfo = req.body.data; + const updatedInfo = req.body.privilegeDetails; const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ actorId: req.permission.id, actor: req.permission.type, diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx index 0ea4318c4..bb3f6ca88 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/mutation.tsx @@ -33,12 +33,12 @@ export const useUpdateIdentityProjectAdditionalPrivilege = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ privilegeSlug, projectSlug, identityId, data }) => { + mutationFn: async ({ privilegeSlug, projectSlug, identityId, privilegeDetails }) => { const { data: res } = await apiRequest.patch("/api/v1/additional-privilege/identity", { privilegeSlug, projectSlug, identityId, - data + privilegeDetails }); return res.privilege; }, diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index 80323a2a9..fad549e38 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -43,7 +43,7 @@ export type TUpdateIdentityProjectPrivlegeDTO = { projectSlug: string; identityId: string; privilegeSlug: string; - data: Partial>; + privilegeDetails: Partial>; }; export type TDeleteIdentityProjectPrivilegeDTO = { 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 bf3e46a20..83a6b6e45 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 @@ -134,7 +134,7 @@ const SpecificPrivilegeSecretForm = ({ conditions.secretPath = { $glob: data.secretPath }; } await updateIdentityPrivilege.mutateAsync({ - data: { + privilegeDetails: { ...data.temporaryAccess, permissions: actions .filter(({ allowed }) => allowed) From 9903f7c4a0ba148972f0bd81f66ce9bef2a41a3d Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 1 Apr 2024 23:34:25 +0530 Subject: [PATCH 21/21] feat: fixed wrong permission type in bulk api op --- backend/src/services/secret/secret-service.ts | 4 ++-- .../views/SecretMainPage/components/ActionBar/ActionBar.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index f8fed95bb..ed96edb41 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -655,7 +655,7 @@ export const secretServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionActions.Edit, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); @@ -741,7 +741,7 @@ export const secretServiceFactory = ({ actorOrgId ); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionActions.Delete, subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) ); diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 13869b3f2..f670ea496 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -416,7 +416,7 @@ export const ActionBar = ({ {Object.keys(selectedSecrets).length} Selected