diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 441d3ce4c..185463f81 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -5,6 +5,7 @@ import { Redis } from "ioredis"; import { TUsers } from "@app/db/schemas"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; +import { TAssumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-service"; import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; @@ -109,6 +110,7 @@ declare module "@fastify/request-context" { }; }; identityPermissionMetadata?: Record; // filled by permission service + assumedPrivilegeDetails?: { requesterId: string; actorId: string; actorType: ActorType; projectId: string }; } } @@ -241,6 +243,7 @@ declare module "fastify" { kmipOperation: TKmipOperationServiceFactory; gateway: TGatewayServiceFactory; secretRotationV2: TSecretRotationV2ServiceFactory; + assumePrivileges: TAssumePrivilegeServiceFactory; }; // 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/assume-privilege-router.ts b/backend/src/ee/routes/v1/assume-privilege-router.ts new file mode 100644 index 000000000..5ee5723fd --- /dev/null +++ b/backend/src/ee/routes/v1/assume-privilege-router.ts @@ -0,0 +1,124 @@ +import { requestContext } from "@fastify/request-context"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; + +export const registerAssumePrivilegeRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/assume-privileges", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string() + }), + body: z.object({ + actorType: z.enum([ActorType.USER, ActorType.IDENTITY]), + actorId: z.string() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req, res) => { + if (req.auth.authMode === AuthMode.JWT) { + const payload = await server.services.assumePrivileges.assumeProjectPrivileges({ + targetActorType: req.body.actorType, + targetActorId: req.body.actorId, + projectId: req.params.projectId, + actorPermissionDetails: req.permission, + tokenVersionId: req.auth.tokenVersionId + }); + + const appCfg = getConfig(); + void res.setCookie("infisical-project-assume-privileges", payload.assumePrivilegesToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + maxAge: 3600 // 1 hour in seconds + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.PROJECT_ASSUME_PRIVILEGE_SESSION_START, + metadata: { + projectId: req.params.projectId, + requesterEmail: req.auth.user.username, + requesterId: req.auth.user.id, + targetActorType: req.body.actorType, + targetActorId: req.body.actorId, + duration: "1hr" + } + } + }); + + return { message: "Successfully assumed role" }; + } + + throw new BadRequestError({ message: "Invalid auth mode" }); + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/assume-privileges", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req, res) => { + const assumedPrivilegeDetails = requestContext.get("assumedPrivilegeDetails"); + if (req.auth.authMode === AuthMode.JWT && assumedPrivilegeDetails) { + const appCfg = getConfig(); + void res.setCookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + expires: new Date(0) + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.PROJECT_ASSUME_PRIVILEGE_SESSION_END, + metadata: { + projectId: req.params.projectId, + requesterEmail: req.auth.user.username, + requesterId: req.auth.user.id, + targetActorId: assumedPrivilegeDetails.actorId, + targetActorType: assumedPrivilegeDetails.actorType + } + } + }); + return { message: "Successfully exited assumed role" }; + } + + throw new BadRequestError({ message: "Invalid auth mode" }); + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 2bf85e9c4..9c474ef38 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -2,6 +2,7 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; +import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; @@ -45,6 +46,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await projectRouter.register(registerProjectRoleRouter); await projectRouter.register(registerProjectRouter); await projectRouter.register(registerTrustedIpRouter); + await projectRouter.register(registerAssumePrivilegeRouter); }, { prefix: "/workspace" } ); diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index 469460491..949d4cf7e 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,7 +1,7 @@ import { packRules } from "@casl/ability/extra"; import { z } from "zod"; -import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas"; import { backfillPermissionV1SchemaToV2Schema, ProjectPermissionV1Schema @@ -245,13 +245,22 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { response: { 200: z.object({ data: z.object({ - membership: ProjectMembershipsSchema.extend({ + membership: z.object({ + id: z.string(), roles: z .object({ role: z.string() }) .array() }), + assumedPrivilegeDetails: z + .object({ + actorId: z.string(), + actorType: z.string(), + actorName: z.string(), + actorEmail: z.string().optional() + }) + .optional(), permissions: z.any().array() }) }) @@ -259,14 +268,20 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const { permissions, membership } = await server.services.projectRole.getUserPermission( + const { permissions, membership, assumedPrivilegeDetails } = await server.services.projectRole.getUserPermission( req.permission.id, req.params.projectId, req.permission.authMethod, req.permission.orgId ); - return { data: { permissions, membership } }; + return { + data: { + permissions, + membership, + assumedPrivilegeDetails + } + }; } }); }; diff --git a/backend/src/ee/services/assume-privilege/assume-privilege-service.ts b/backend/src/ee/services/assume-privilege/assume-privilege-service.ts new file mode 100644 index 000000000..709ce44b6 --- /dev/null +++ b/backend/src/ee/services/assume-privilege/assume-privilege-service.ts @@ -0,0 +1,101 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { ActionProjectType } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { + ProjectPermissionIdentityActions, + ProjectPermissionMemberActions, + ProjectPermissionSub +} from "../permission/project-permission"; +import { TAssumeProjectPrivilegeDTO } from "./assume-privilege-types"; + +type TAssumePrivilegeServiceFactoryDep = { + projectDAL: Pick; + permissionService: Pick; +}; + +export type TAssumePrivilegeServiceFactory = ReturnType; + +export const assumePrivilegeServiceFactory = ({ projectDAL, permissionService }: TAssumePrivilegeServiceFactoryDep) => { + const assumeProjectPrivileges = async ({ + targetActorType, + targetActorId, + projectId, + actorPermissionDetails, + tokenVersionId + }: TAssumeProjectPrivilegeDTO) => { + const project = await projectDAL.findById(projectId); + if (!project) throw new NotFoundError({ message: `Project with ID '${projectId}' not found` }); + const { permission } = await permissionService.getProjectPermission({ + actor: actorPermissionDetails.type, + actorId: actorPermissionDetails.id, + projectId, + actorAuthMethod: actorPermissionDetails.authMethod, + actorOrgId: actorPermissionDetails.orgId, + actionProjectType: ActionProjectType.Any + }); + + if (targetActorType === ActorType.USER) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionMemberActions.AssumePrivileges, + ProjectPermissionSub.Member + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.AssumePrivileges, + ProjectPermissionSub.Identity + ); + } + + // check entity is part of project + await permissionService.getProjectPermission({ + actor: targetActorType, + actorId: targetActorId, + projectId, + actorAuthMethod: actorPermissionDetails.authMethod, + actorOrgId: actorPermissionDetails.orgId, + actionProjectType: ActionProjectType.Any + }); + + const appCfg = getConfig(); + const assumePrivilegesToken = jwt.sign( + { + tokenVersionId, + actorType: targetActorType, + actorId: targetActorId, + projectId, + requesterId: actorPermissionDetails.id + }, + appCfg.AUTH_SECRET, + { expiresIn: "1hr" } + ); + + return { actorType: targetActorType, actorId: targetActorId, projectId, assumePrivilegesToken }; + }; + + const verifyAssumePrivilegeToken = (token: string, tokenVersionId: string) => { + const appCfg = getConfig(); + const decodedToken = jwt.verify(token, appCfg.AUTH_SECRET) as { + tokenVersionId: string; + projectId: string; + requesterId: string; + actorType: ActorType; + actorId: string; + }; + if (decodedToken.tokenVersionId !== tokenVersionId) { + throw new ForbiddenRequestError({ message: "Invalid token version" }); + } + return decodedToken; + }; + + return { + assumeProjectPrivileges, + verifyAssumePrivilegeToken + }; +}; diff --git a/backend/src/ee/services/assume-privilege/assume-privilege-types.ts b/backend/src/ee/services/assume-privilege/assume-privilege-types.ts new file mode 100644 index 000000000..55b6c8449 --- /dev/null +++ b/backend/src/ee/services/assume-privilege/assume-privilege-types.ts @@ -0,0 +1,10 @@ +import { OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; + +export type TAssumeProjectPrivilegeDTO = { + targetActorType: ActorType.USER | ActorType.IDENTITY; + targetActorId: string; + projectId: string; + tokenVersionId: string; + actorPermissionDetails: OrgServiceActor; +}; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 247bdfacb..b6527f3f4 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -320,7 +320,9 @@ export enum EventType { DELETE_SECRET_ROTATION = "delete-secret-rotation", SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets", - PROJECT_ACCESS_REQUEST = "project-access-request" + PROJECT_ACCESS_REQUEST = "project-access-request", + PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start", + PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end" } export const filterableSecretEvents: EventType[] = [ @@ -2454,6 +2456,29 @@ interface ProjectAccessRequestEvent { }; } +interface ProjectAssumePrivilegesEvent { + type: EventType.PROJECT_ASSUME_PRIVILEGE_SESSION_START; + metadata: { + projectId: string; + requesterId: string; + requesterEmail: string; + targetActorType: ActorType; + targetActorId: string; + duration: string; + }; +} + +interface ProjectAssumePrivilegesExitEvent { + type: EventType.PROJECT_ASSUME_PRIVILEGE_SESSION_END; + metadata: { + projectId: string; + requesterId: string; + requesterEmail: string; + targetActorType: ActorType; + targetActorId: string; + }; +} + interface SetupKmipEvent { type: EventType.SETUP_KMIP; metadata: { @@ -2759,6 +2784,8 @@ export type Event = | KmipOperationLocateEvent | KmipOperationRegisterEvent | ProjectAccessRequestEvent + | ProjectAssumePrivilegesEvent + | ProjectAssumePrivilegesExitEvent | CreateSecretRequestEvent | SecretApprovalRequestReview | GetSecretRotationsEvent diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 0082c3d17..3d2f96f82 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -551,13 +551,26 @@ export const permissionServiceFactory = ({ }; const getProjectPermission = async ({ - actor, - actorId, + actor: inputActor, + actorId: inputActorId, projectId, actorAuthMethod, actorOrgId, actionProjectType }: TGetProjectPermissionArg): Promise> => { + let actor = inputActor; + let actorId = inputActorId; + const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); + if ( + assumedPrivilegeDetailsCtx && + actor === ActorType.USER && + actorId === assumedPrivilegeDetailsCtx.requesterId && + projectId === assumedPrivilegeDetailsCtx.projectId + ) { + actor = assumedPrivilegeDetailsCtx.actorType; + actorId = assumedPrivilegeDetailsCtx.actorId; + } + switch (actor) { case ActorType.USER: return getUserProjectPermission({ diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index d171fb3d8..8e6645073 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -50,7 +50,8 @@ export enum ProjectPermissionIdentityActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionMemberActions { @@ -58,7 +59,8 @@ export enum ProjectPermissionMemberActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionGroupActions { @@ -714,7 +716,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionMemberActions.Edit, ProjectPermissionMemberActions.Delete, ProjectPermissionMemberActions.Read, - ProjectPermissionMemberActions.GrantPrivileges + ProjectPermissionMemberActions.GrantPrivileges, + ProjectPermissionMemberActions.AssumePrivileges ], ProjectPermissionSub.Member ); @@ -736,7 +739,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionIdentityActions.Edit, ProjectPermissionIdentityActions.Delete, ProjectPermissionIdentityActions.Read, - ProjectPermissionIdentityActions.GrantPrivileges + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionIdentityActions.AssumePrivileges ], ProjectPermissionSub.Identity ); diff --git a/backend/src/server/plugins/auth/inject-assume-privilege.ts b/backend/src/server/plugins/auth/inject-assume-privilege.ts new file mode 100644 index 000000000..58eb5c99c --- /dev/null +++ b/backend/src/server/plugins/auth/inject-assume-privilege.ts @@ -0,0 +1,24 @@ +import { requestContext } from "@fastify/request-context"; +import fp from "fastify-plugin"; + +import { AuthMode } from "@app/services/auth/auth-type"; + +export const injectAssumePrivilege = fp(async (server: FastifyZodProvider) => { + server.addHook("onRequest", async (req, res) => { + const assumeRoleCookie = req.cookies["infisical-project-assume-privileges"]; + try { + if (req?.auth?.authMode === AuthMode.JWT && assumeRoleCookie) { + const decodedToken = server.services.assumePrivileges.verifyAssumePrivilegeToken( + assumeRoleCookie, + req.auth.tokenVersionId + ); + if (decodedToken) { + requestContext.set("assumedPrivilegeDetails", decodedToken); + } + } + } catch (error) { + req.log.error({ error }, "Failed to verify assume privilege token"); + void res.clearCookie("infisical-project-assume-privileges"); + } + }); +}); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2c1b768fb..3d95a81eb 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -12,6 +12,7 @@ import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-appr import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service"; +import { assumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-service"; import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; @@ -248,6 +249,7 @@ import { workflowIntegrationDALFactory } from "@app/services/workflow-integratio import { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service"; import { injectAuditLogInfo } from "../plugins/audit-log"; +import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege"; import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectPermission } from "../plugins/auth/inject-permission"; import { injectRateLimits } from "../plugins/inject-rate-limits"; @@ -427,6 +429,11 @@ export const registerRoutes = async ( serviceTokenDAL, projectDAL }); + const assumePrivilegeService = assumePrivilegeServiceFactory({ + projectDAL, + permissionService + }); + const licenseService = licenseServiceFactory({ permissionService, orgDAL, @@ -1022,7 +1029,9 @@ export const registerRoutes = async ( projectRoleDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, - projectDAL + projectDAL, + identityDAL, + userDAL }); const snapshotService = secretSnapshotServiceFactory({ @@ -1675,7 +1684,8 @@ export const registerRoutes = async ( kmip: kmipService, kmipOperation: kmipOperationService, gateway: gatewayService, - secretRotationV2: secretRotationV2Service + secretRotationV2: secretRotationV2Service, + assumePrivileges: assumePrivilegeService }); const cronJobs: CronJob[] = []; @@ -1696,6 +1706,7 @@ export const registerRoutes = async ( }); await server.register(injectIdentity, { userDAL, serviceTokenDAL }); + await server.register(injectAssumePrivilege); await server.register(injectPermission); await server.register(injectRateLimits); await server.register(injectAuditLogInfo); diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 04ca958c6..717c6f1b6 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -33,6 +33,14 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { secure: appCfg.HTTPS_ENABLED }); + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + maxAge: 0 + }); + return { message: "Successfully logged out" }; } }); diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index cddfc1c2b..af2d97b8e 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -77,6 +77,14 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { secure: cfg.HTTPS_ENABLED }); + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: cfg.HTTPS_ENABLED, + maxAge: 0 + }); + return { token: tokens.access, isMfaEnabled: false }; } }); @@ -131,6 +139,14 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { secure: appCfg.HTTPS_ENABLED }); + void res.cookie("infisical-project-assume-privileges", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED, + maxAge: 0 + }); + return { encryptionVersion: data.user.encryptionVersion, token: data.token.access, diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index fc2fb9319..211dcff4f 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; +import { requestContext } from "@fastify/request-context"; import { ActionProjectType, ProjectMembershipRole, TableName } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -12,10 +13,12 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; -import { ActorAuthMethod } from "../auth/auth-type"; +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TUserDALFactory } from "../user/user-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; import { getPredefinedRoles } from "./project-role-fns"; import { @@ -29,6 +32,8 @@ import { type TProjectRoleServiceFactoryDep = { projectRoleDAL: TProjectRoleDALFactory; + identityDAL: Pick; + userDAL: Pick; projectDAL: Pick; permissionService: Pick; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; @@ -47,7 +52,9 @@ export const projectRoleServiceFactory = ({ permissionService, identityProjectMembershipRoleDAL, projectUserMembershipRoleDAL, - projectDAL + projectDAL, + identityDAL, + userDAL }: TProjectRoleServiceFactoryDep) => { const createRole = async ({ data, actor, actorId, actorAuthMethod, actorOrgId, filter }: TCreateRoleDTO) => { let projectId = ""; @@ -220,14 +227,42 @@ export const projectRoleServiceFactory = ({ actorAuthMethod: ActorAuthMethod, actorOrgId: string | undefined ) => { - const { permission, membership } = await permissionService.getUserProjectPermission({ - userId, + const { permission, membership } = await permissionService.getProjectPermission({ + actor: ActorType.USER, + actorId: userId, projectId, - authMethod: actorAuthMethod, - userOrgId: actorOrgId, + actorAuthMethod, + actorOrgId, actionProjectType: ActionProjectType.Any }); - return { permissions: packRules(permission.rules), membership }; + // just to satisfy ts + if (!("roles" in membership)) throw new BadRequestError({ message: "Service token not allowed" }); + + const assumedPrivilegeDetailsCtx = requestContext.get("assumedPrivilegeDetails"); + const isAssumingPrivilege = assumedPrivilegeDetailsCtx?.projectId === projectId; + const assumedPrivilegeDetails = isAssumingPrivilege + ? { + actorId: assumedPrivilegeDetailsCtx?.actorId, + actorType: assumedPrivilegeDetailsCtx?.actorType, + actorName: "", + actorEmail: "" + } + : undefined; + + if (assumedPrivilegeDetails?.actorType === ActorType.IDENTITY) { + const identityDetails = await identityDAL.findById(assumedPrivilegeDetails.actorId); + if (!identityDetails) + throw new NotFoundError({ message: `Identity with ID ${assumedPrivilegeDetails.actorId} not found` }); + assumedPrivilegeDetails.actorName = identityDetails.name; + } else if (assumedPrivilegeDetails?.actorType === ActorType.USER) { + const userDetails = await userDAL.findById(assumedPrivilegeDetails?.actorId); + if (!userDetails) + throw new NotFoundError({ message: `User with ID ${assumedPrivilegeDetails.actorId} not found` }); + assumedPrivilegeDetails.actorName = `${userDetails?.firstName} ${userDetails?.lastName || ""}`; + assumedPrivilegeDetails.actorEmail = userDetails?.email || ""; + } + + return { permissions: packRules(permission.rules), membership, assumedPrivilegeDetails }; }; return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug }; diff --git a/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx b/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx new file mode 100644 index 000000000..c6bbec305 --- /dev/null +++ b/frontend/src/components/v2/ConfirmActionModal/ConfirmActionModal.tsx @@ -0,0 +1,113 @@ +import { ReactNode, useEffect, useState } from "react"; + +import { useToggle } from "@app/hooks"; + +import { Button } from "../Button"; +import { FormControl } from "../FormControl"; +import { Input } from "../Input"; +import { Modal, ModalClose, ModalContent } from "../Modal"; + +type Props = { + isOpen?: boolean; + onClose?: () => void; + onChange?: (isOpen: boolean) => void; + confirmKey: string; + title: string; + subTitle?: string; + onConfirmed: () => Promise; + buttonText?: string; + formContent?: ReactNode; + children?: ReactNode; + confirmationMessage?: ReactNode; +}; + +export const ConfirmActionModal = ({ + isOpen, + onClose, + onChange, + confirmKey, + onConfirmed, + title, + subTitle = "This action is irreversible.", + buttonText = "Yes", + formContent, + confirmationMessage, + children +}: Props): JSX.Element => { + const [inputData, setInputData] = useState(""); + const [isLoading, setIsLoading] = useToggle(); + + useEffect(() => { + setInputData(""); + }, [isOpen]); + + const onDelete = async () => { + setIsLoading.on(); + try { + await onConfirmed(); + } finally { + setIsLoading.off(); + } + }; + + return ( + { + setInputData(""); + if (onChange) onChange(isOpenState); + }} + > + + + + + + + } + onClose={onClose} + > + {formContent} +
{ + evt.preventDefault(); + if (confirmKey === inputData) onDelete(); + }} + > + + {confirmationMessage || ( + <> + Type {confirmKey} to perform this action + + )} + + } + className="mb-0" + > + setInputData(e.target.value)} + placeholder={`Type ${confirmKey} here`} + /> + + {children} +
+
+
+ ); +}; diff --git a/frontend/src/components/v2/ConfirmActionModal/index.tsx b/frontend/src/components/v2/ConfirmActionModal/index.tsx new file mode 100644 index 000000000..8c292343f --- /dev/null +++ b/frontend/src/components/v2/ConfirmActionModal/index.tsx @@ -0,0 +1 @@ +export { ConfirmActionModal } from "./ConfirmActionModal"; diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 49c10805c..f17c5763f 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -14,7 +14,7 @@ export const PageHeader = ({ title, description, children, className }: Props) =

{title}

-
{children}
+
{children}
{description}
diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 9dcf72e40..ede71e324 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -6,6 +6,7 @@ export * from "./Breadcrumb"; export * from "./Button"; export * from "./Card"; export * from "./Checkbox"; +export * from "./ConfirmActionModal"; export * from "./ContentLoader"; export * from "./DatePicker"; export * from "./DeleteActionModal"; diff --git a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx index f398eb0db..a824f6fa6 100644 --- a/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx +++ b/frontend/src/context/ProjectPermissionContext/ProjectPermissionContext.tsx @@ -14,12 +14,13 @@ export const useProjectPermission = () => { strict: false, select: (el) => el?.projectId }); + if (!projectId) { throw new Error("useProjectPermission to be used within "); } const { - data: { permission, membership } + data: { permission, membership, assumedPrivilegeDetails } } = useSuspenseQuery({ queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId: projectId }), queryFn: () => fetchUserProjectPermissions({ workspaceId: projectId }), @@ -29,6 +30,7 @@ export const useProjectPermission = () => { const ability = evaluatePermissionsAbility(rule); return { permission: ability, + assumedPrivilegeDetails: data.assumedPrivilegeDetails, membership: { ...data.membership, roles: data.membership.roles.map(({ role }) => role) @@ -42,5 +44,5 @@ export const useProjectPermission = () => { [] ); - return { permission, membership, hasProjectRole }; + return { permission, membership, hasProjectRole, assumedPrivilegeDetails }; }; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index a327913d9..f7ba69e17 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -58,7 +58,8 @@ export enum ProjectPermissionIdentityActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionMemberActions { @@ -66,7 +67,8 @@ export enum ProjectPermissionMemberActions { Create = "create", Edit = "edit", Delete = "delete", - GrantPrivileges = "grant-privileges" + GrantPrivileges = "grant-privileges", + AssumePrivileges = "assume-privileges" } export enum ProjectPermissionGroupActions { @@ -247,7 +249,7 @@ export type ProjectPermissionSet = ] | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] - | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionMemberActions, ProjectPermissionSub.Member] | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] @@ -258,7 +260,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ - ProjectPermissionActions, + ProjectPermissionIdentityActions, ( | ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) diff --git a/frontend/src/hooks/api/assumePrivileges/index.tsx b/frontend/src/hooks/api/assumePrivileges/index.tsx new file mode 100644 index 000000000..ab4b122c5 --- /dev/null +++ b/frontend/src/hooks/api/assumePrivileges/index.tsx @@ -0,0 +1 @@ +export { useAssumeProjectPrivileges, useRemoveAssumeProjectPrivilege } from "./mutations"; diff --git a/frontend/src/hooks/api/assumePrivileges/mutations.tsx b/frontend/src/hooks/api/assumePrivileges/mutations.tsx new file mode 100644 index 000000000..50e4e5b6c --- /dev/null +++ b/frontend/src/hooks/api/assumePrivileges/mutations.tsx @@ -0,0 +1,28 @@ +import { useMutation } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TProjectAssumePrivilegesDTO } from "./types"; + +export const useAssumeProjectPrivileges = () => + useMutation({ + mutationFn: async ({ projectId, actorId, actorType }: TProjectAssumePrivilegesDTO) => { + const { data } = await apiRequest.post<{ message: string }>( + `/api/v1/workspace/${projectId}/assume-privileges`, + { actorId, actorType } + ); + + return data; + } + }); + +export const useRemoveAssumeProjectPrivilege = () => + useMutation({ + mutationFn: async ({ projectId }: { projectId: string }) => { + const { data } = await apiRequest.delete<{ message: string }>( + `/api/v1/workspace/${projectId}/assume-privileges` + ); + + return data; + } + }); diff --git a/frontend/src/hooks/api/assumePrivileges/types.ts b/frontend/src/hooks/api/assumePrivileges/types.ts new file mode 100644 index 000000000..07e14d518 --- /dev/null +++ b/frontend/src/hooks/api/assumePrivileges/types.ts @@ -0,0 +1,7 @@ +import { ActorType } from "../auditLogs/enums"; + +export type TProjectAssumePrivilegesDTO = { + projectId: string; + actorType: ActorType; + actorId: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 52d6dceb2..6e8e0ac93 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,6 +1,7 @@ export * from "./accessApproval"; export * from "./admin"; export * from "./apiKeys"; +export * from "./assumePrivileges"; export * from "./auditLogs"; export * from "./auditLogStreams"; export * from "./auth"; diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 3353a0e96..a406b6d45 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -10,6 +10,7 @@ import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext/type import { groupBy } from "@app/lib/fn/array"; import { omit } from "@app/lib/fn/object"; +import { ActorType } from "../auditLogs/enums"; import { OrgUser, TProjectMembership } from "../users/types"; import { TGetUserOrgPermissionsDTO, @@ -137,6 +138,12 @@ export const fetchUserProjectPermissions = async ({ data: { permissions: PackRule>>[]; membership: Omit & { roles: { role: string }[] }; + assumedPrivilegeDetails?: { + actorId: string; + actorType: ActorType; + actorEmail: string; + actorName: string; + }; }; }>(`/api/v1/workspace/${workspaceId}/permissions`, {}); diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index dc64ccb12..a862c31a3 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -13,6 +13,7 @@ import { TBreadcrumbFormat } from "@app/components/v2"; import { + useProjectPermission, ProjectPermissionActions, ProjectPermissionSub, useSubscription, @@ -25,6 +26,7 @@ import { } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/workspace/types"; +import { AssumePrivilegeModeBanner } from "./components/AssumePrivilegeModeBanner"; import { ProjectSelect } from "./components/ProjectSelect"; // This is a generic layout shared by all types of projects. @@ -35,6 +37,7 @@ export const ProjectLayout = () => { const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; const { t } = useTranslation(); + const { assumedPrivilegeDetails } = useProjectPermission(); const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; const { subscription } = useSubscription(); @@ -68,6 +71,7 @@ export const ProjectLayout = () => { return ( <>
+ {assumedPrivilegeDetails && }
{ + const { currentWorkspace } = useWorkspace(); + const exitAssumePrivilegeMode = useRemoveAssumeProjectPrivilege(); + const { assumedPrivilegeDetails } = useProjectPermission(); + + if (!assumedPrivilegeDetails) return null; + + return ( +
+
+ + You are currently viewing the project with privileges of{" "} + + {assumedPrivilegeDetails?.actorType === ActorType.IDENTITY ? "identity" : "user"}{" "} + {assumedPrivilegeDetails?.actorName} + +
+
+ +
+
+ ); +}; diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/index.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/index.tsx new file mode 100644 index 000000000..e8ebac19e --- /dev/null +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/index.tsx @@ -0,0 +1 @@ +export { AssumePrivilegeModeBanner } from "./AssumePrivilegeModeBanner"; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx index 70155eb13..d18f8715a 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/MemberRbacSection.tsx @@ -27,6 +27,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useSubscription, @@ -68,7 +69,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member ); diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx index e9c9d15fc..e8bc03698 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -37,6 +37,7 @@ import { import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useWorkspace @@ -88,7 +89,8 @@ export const SpecificPrivilegeSecretForm = ({ ] as const); const { permission } = useProjectPermission(); const isMemberEditDisabled = - permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege; + permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) && + Boolean(privilege); const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); const requestAccess = useCreateAccessRequest(); diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 825a34a33..493a348dd 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -6,13 +6,27 @@ import { formatRelative } from "date-fns"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { + Button, + ConfirmActionModal, + DeleteActionModal, + EmptyState, + PageHeader, + Spinner +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionIdentityActions, + ProjectPermissionSub, + useWorkspace +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { + useAssumeProjectPrivileges, useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMembershipDetails } from "@app/hooks/api"; +import { ActorType } from "@app/hooks/api/auditLogs/enums"; import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection"; import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection"; @@ -35,8 +49,28 @@ const Page = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteIdentity", - "upgradePlan" + "assumePrivileges" ] as const); + const assumePrivileges = useAssumeProjectPrivileges(); + + const handleAssumePrivileges = async () => { + assumePrivileges.mutate( + { + actorId: identityId, + actorType: ActorType.IDENTITY, + projectId: workspaceId + }, + { + onSuccess: () => { + createNotification({ + type: "success", + text: "Identity privilege assumption has started" + }); + window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`; + } + } + ); + }; const onRemoveIdentitySubmit = async () => { try { @@ -100,6 +134,24 @@ const Page = () => { > Copy Membership ID + + {(isAllowed) => ( + + )} + { deleteKey="remove" onDeleteApproved={() => onRemoveIdentitySubmit()} /> + handlePopUpToggle("assumePrivileges", isOpen)} + onConfirmed={handleAssumePrivileges} + buttonText="Confirm" + /> ) : ( diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx index 17d223954..8557519c9 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeModifySection.tsx @@ -32,7 +32,7 @@ import { Tooltip } from "@app/components/v2"; import { - ProjectPermissionActions, + ProjectPermissionIdentityActions, ProjectPermissionSub, useProjectPermission, useWorkspace @@ -97,7 +97,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({ }); const { permission } = useProjectPermission(); const isIdentityEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) ); diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx index ef566be71..be3ae90b3 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx @@ -22,7 +22,12 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionIdentityActions, + ProjectPermissionSub, + useProjectPermission +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api"; import { IdentityMembership } from "@app/hooks/api/identities/types"; @@ -83,7 +88,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe identityId={identityId} privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} isDisabled={permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, subject(ProjectPermissionSub.Identity, { identityId }) diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx index 5aa4ffc32..c5860ec11 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx @@ -27,6 +27,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionIdentityActions, ProjectPermissionSub, useProjectPermission, useWorkspace @@ -66,7 +67,7 @@ export const IdentityRoleModify = ({ identityProjectMembership }: Props) => { const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { permission } = useProjectPermission(); const isIdentityEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionIdentityActions.Edit, ProjectPermissionSub.Identity ); diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index 790214141..c061fd6f2 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -6,15 +6,28 @@ import { formatRelative } from "date-fns"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2"; +import { + Button, + ConfirmActionModal, + DeleteActionModal, + EmptyState, + PageHeader, + Spinner +} from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useOrganization, useWorkspace } from "@app/context"; import { usePopUp } from "@app/hooks"; -import { useDeleteUserFromWorkspace, useGetWorkspaceUserDetails } from "@app/hooks/api"; +import { + useAssumeProjectPrivileges, + useDeleteUserFromWorkspace, + useGetWorkspaceUserDetails +} from "@app/hooks/api"; +import { ActorType } from "@app/hooks/api/auditLogs/enums"; import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection"; import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection"; @@ -35,12 +48,34 @@ export const Page = () => { const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } = useDeleteUserFromWorkspace(); + const assumePrivileges = useAssumeProjectPrivileges(); const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "removeMember", - "upgradePlan" + "upgradePlan", + "assumePrivileges" ] as const); + const handleAssumePrivileges = async () => { + const { userId } = popUp?.assumePrivileges?.data as { userId: string }; + assumePrivileges.mutate( + { + actorId: userId, + actorType: ActorType.USER, + projectId: workspaceId + }, + { + onSuccess: () => { + createNotification({ + type: "success", + text: "User privilege assumption has started" + }); + window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`; + } + } + ); + }; + const handleRemoveUser = async () => { if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return; @@ -91,7 +126,29 @@ export const Page = () => { description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`} > + {(isAllowed) => ( + + )} + + + { onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)} onDeleteApproved={handleRemoveUser} /> + handlePopUpToggle("assumePrivileges", isOpen)} + onConfirmed={handleAssumePrivileges} + buttonText="Confirm" + /> handlePopUpToggle("upgradePlan", isOpen)} diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx index 95a7ffa66..76c631e43 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MemberProjectAdditionalPrivilegeSection.tsx @@ -23,6 +23,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useUser @@ -90,7 +91,7 @@ export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: P privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} isDisabled={ isOwnProjectMembershipDetails || - permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) + permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) } />
diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx index 4e329a2d9..2cf1e5567 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberProjectAdditionalPrivilegeSection/MembershipProjectAdditionalPrivilegeModifySection.tsx @@ -31,7 +31,7 @@ import { Tooltip } from "@app/components/v2"; import { - ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useWorkspace @@ -95,7 +95,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({ const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member ); diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx index 3a140ba56..739b22b7d 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/components/MemberRoleDetailsSection/MemberRoleModify.tsx @@ -27,6 +27,7 @@ import { } from "@app/components/v2"; import { ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useSubscription, @@ -69,7 +70,7 @@ export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) = const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( - ProjectPermissionActions.Edit, + ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member ); diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index 57d617527..de2295940 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -92,7 +92,8 @@ const MemberPolicyActionSchema = z.object({ [ProjectPermissionMemberActions.Create]: z.boolean().optional(), [ProjectPermissionMemberActions.Edit]: z.boolean().optional(), [ProjectPermissionMemberActions.Delete]: z.boolean().optional(), - [ProjectPermissionMemberActions.GrantPrivileges]: z.boolean().optional() + [ProjectPermissionMemberActions.GrantPrivileges]: z.boolean().optional(), + [ProjectPermissionMemberActions.AssumePrivileges]: z.boolean().optional() }); const IdentityPolicyActionSchema = z.object({ @@ -100,7 +101,8 @@ const IdentityPolicyActionSchema = z.object({ [ProjectPermissionIdentityActions.Create]: z.boolean().optional(), [ProjectPermissionIdentityActions.Edit]: z.boolean().optional(), [ProjectPermissionIdentityActions.Delete]: z.boolean().optional(), - [ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional() + [ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional(), + [ProjectPermissionIdentityActions.AssumePrivileges]: z.boolean().optional() }); const GroupPolicyActionSchema = z.object({ @@ -577,6 +579,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const canEdit = action.includes(ProjectPermissionMemberActions.Edit); const canDelete = action.includes(ProjectPermissionMemberActions.Delete); const canGrantPrivileges = action.includes(ProjectPermissionMemberActions.GrantPrivileges); + const canAssumePrivileges = action.includes(ProjectPermissionMemberActions.AssumePrivileges); if (!formVal[subject]) formVal[subject] = [{}]; @@ -587,6 +590,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { if (canDelete) formVal[subject]![0][ProjectPermissionMemberActions.Delete] = true; if (canGrantPrivileges) formVal[subject]![0][ProjectPermissionMemberActions.GrantPrivileges] = true; + if (canAssumePrivileges) + formVal[subject]![0][ProjectPermissionMemberActions.AssumePrivileges] = true; return; } @@ -596,6 +601,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const canEdit = action.includes(ProjectPermissionIdentityActions.Edit); const canDelete = action.includes(ProjectPermissionIdentityActions.Delete); const canGrantPrivileges = action.includes(ProjectPermissionIdentityActions.GrantPrivileges); + const canAssumePrivileges = action.includes( + ProjectPermissionIdentityActions.AssumePrivileges + ); if (!formVal[subject]) formVal[subject] = [{ conditions: [] }]; @@ -606,6 +614,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { if (canDelete) formVal[subject]![0][ProjectPermissionIdentityActions.Delete] = true; if (canGrantPrivileges) formVal[subject]![0][ProjectPermissionIdentityActions.GrantPrivileges] = true; + if (canAssumePrivileges) + formVal[subject]![0][ProjectPermissionIdentityActions.AssumePrivileges] = true; return; } @@ -904,7 +914,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Add", value: ProjectPermissionMemberActions.Create }, { label: "Modify", value: ProjectPermissionMemberActions.Edit }, { label: "Remove", value: ProjectPermissionMemberActions.Delete }, - { label: "Grant Privileges", value: ProjectPermissionMemberActions.GrantPrivileges } + { label: "Grant Privileges", value: ProjectPermissionMemberActions.GrantPrivileges }, + { label: "Assume Privileges", value: ProjectPermissionMemberActions.AssumePrivileges } ] }, [ProjectPermissionSub.Identity]: { @@ -914,7 +925,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Add", value: ProjectPermissionIdentityActions.Create }, { label: "Modify", value: ProjectPermissionIdentityActions.Edit }, { label: "Remove", value: ProjectPermissionIdentityActions.Delete }, - { label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges } + { label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges }, + { label: "Assume Privileges", value: ProjectPermissionIdentityActions.AssumePrivileges } ] }, [ProjectPermissionSub.Groups]: { diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index b857e3038..9afe4795d 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -25,7 +25,7 @@ import { } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; import { - ProjectPermissionActions, + ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, useSubscription, @@ -289,7 +289,10 @@ export const AccessApprovalRequest = ({ ))} - {!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( + {!!permission.can( + ProjectPermissionMemberActions.Read, + ProjectPermissionSub.Member + ) && (