feat: implemented user role impersonation

This commit is contained in:
=
2025-04-21 18:43:21 +05:30
parent eba12912f8
commit 7219ba3b46
40 changed files with 750 additions and 51 deletions
+3
View File
@@ -5,6 +5,7 @@ import { Redis } from "ioredis";
import { TUsers } from "@app/db/schemas"; import { TUsers } from "@app/db/schemas";
import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; 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 { 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 { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
@@ -109,6 +110,7 @@ declare module "@fastify/request-context" {
}; };
}; };
identityPermissionMetadata?: Record<string, unknown>; // filled by permission service identityPermissionMetadata?: Record<string, unknown>; // filled by permission service
projectAssumeRole?: { userId: string; actorId: string; actorType: ActorType; projectId: string };
} }
} }
@@ -241,6 +243,7 @@ declare module "fastify" {
kmipOperation: TKmipOperationServiceFactory; kmipOperation: TKmipOperationServiceFactory;
gateway: TGatewayServiceFactory; gateway: TGatewayServiceFactory;
secretRotationV2: TSecretRotationV2ServiceFactory; secretRotationV2: TSecretRotationV2ServiceFactory;
assumePrivileges: TAssumePrivilegeServiceFactory;
}; };
// this is exclusive use for middlewares in which we need to inject data // this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer // everywhere else access using service layer
@@ -0,0 +1,86 @@
import { z } from "zod";
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({
actorType: req.body.actorType,
actorId: req.body.actorId,
projectId: req.params.projectId,
projectPermission: 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
});
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) => {
if (req.auth.authMode === AuthMode.JWT) {
const appCfg = getConfig();
void res.setCookie("infisical-project-assume-privileges", "", {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
return { message: "Successfully exited assumed role" };
}
throw new BadRequestError({ message: "Invalid auth mode" });
}
});
};
+2
View File
@@ -2,6 +2,7 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat
import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
import { registerAssumePrivilegeRouter } from "./assume-privilege-router";
import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerCaCrlRouter } from "./certificate-authority-crl-router";
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-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(registerProjectRoleRouter);
await projectRouter.register(registerProjectRouter); await projectRouter.register(registerProjectRouter);
await projectRouter.register(registerTrustedIpRouter); await projectRouter.register(registerTrustedIpRouter);
await projectRouter.register(registerAssumePrivilegeRouter);
}, },
{ prefix: "/workspace" } { prefix: "/workspace" }
); );
@@ -1,7 +1,7 @@
import { packRules } from "@casl/ability/extra"; import { packRules } from "@casl/ability/extra";
import { z } from "zod"; import { z } from "zod";
import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas";
import { import {
backfillPermissionV1SchemaToV2Schema, backfillPermissionV1SchemaToV2Schema,
ProjectPermissionV1Schema ProjectPermissionV1Schema
@@ -245,13 +245,22 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
response: { response: {
200: z.object({ 200: z.object({
data: z.object({ data: z.object({
membership: ProjectMembershipsSchema.extend({ membership: z.object({
id: z.string(),
roles: z roles: z
.object({ .object({
role: z.string() role: z.string()
}) })
.array() .array()
}), }),
impersonation: z
.object({
actorId: z.string(),
actorType: z.string(),
actorName: z.string(),
actorEmail: z.string().optional()
})
.optional(),
permissions: z.any().array() permissions: z.any().array()
}) })
}) })
@@ -259,14 +268,20 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const { permissions, membership } = await server.services.projectRole.getUserPermission( const { permissions, membership, impersonation } = await server.services.projectRole.getUserPermission(
req.permission.id, req.permission.id,
req.params.projectId, req.params.projectId,
req.permission.authMethod, req.permission.authMethod,
req.permission.orgId req.permission.orgId
); );
return { data: { permissions, membership } }; return {
data: {
permissions,
membership,
impersonation
}
};
} }
}); });
}; };
@@ -0,0 +1,98 @@
import { ForbiddenError } from "@casl/ability";
import jwt from "jsonwebtoken";
import { ActionProjectType } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { 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<TProjectDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TAssumePrivilegeServiceFactory = ReturnType<typeof assumePrivilegeServiceFactory>;
export const assumePrivilegeServiceFactory = ({ projectDAL, permissionService }: TAssumePrivilegeServiceFactoryDep) => {
const assumeProjectPrivileges = async ({
actorType,
actorId,
projectId,
projectPermission,
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: projectPermission.type,
actorId: projectPermission.id,
projectId,
actorAuthMethod: projectPermission.authMethod,
actorOrgId: projectPermission.orgId,
actionProjectType: ActionProjectType.Any
});
if (actorType === 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: actorType,
actorId,
projectId,
actorAuthMethod: projectPermission.authMethod,
actorOrgId: projectPermission.orgId,
actionProjectType: ActionProjectType.Any
});
const appCfg = getConfig();
const assumePrivilegesToken = jwt.sign(
{
tokenVersionId,
actorType,
actorId,
projectId,
userId: projectPermission.id
},
appCfg.AUTH_SECRET,
{ expiresIn: "1hr" }
);
return { actorType, actorId, projectId, assumePrivilegesToken };
};
const verifyAssumePrivilegeToken = (token: string, tokenVersionId: string) => {
const appCfg = getConfig();
const decodedToken = jwt.verify(token, appCfg.AUTH_SECRET) as {
tokenVersionId: string;
projectId: string;
userId: string;
actorType: ActorType;
actorId: string;
};
if (decodedToken.tokenVersionId !== tokenVersionId) return;
return decodedToken;
};
return {
assumeProjectPrivileges,
verifyAssumePrivilegeToken
};
};
@@ -0,0 +1,10 @@
import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
export type TAssumeProjectPrivilegeDTO = {
actorType: ActorType.USER | ActorType.IDENTITY;
actorId: string;
projectId: string;
tokenVersionId: string;
projectPermission: OrgServiceActor;
};
@@ -551,13 +551,26 @@ export const permissionServiceFactory = ({
}; };
const getProjectPermission = async <T extends ActorType>({ const getProjectPermission = async <T extends ActorType>({
actor, actor: inputActor,
actorId, actorId: inputActorId,
projectId, projectId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
actionProjectType actionProjectType
}: TGetProjectPermissionArg): Promise<TProjectPermissionRT<T>> => { }: TGetProjectPermissionArg): Promise<TProjectPermissionRT<T>> => {
let actor = inputActor;
let actorId = inputActorId;
const projectAssumeRole = requestContext.get("projectAssumeRole");
if (
projectAssumeRole &&
actor === ActorType.USER &&
actorId === projectAssumeRole.userId &&
projectId === projectAssumeRole.projectId
) {
actor = projectAssumeRole.actorType;
actorId = projectAssumeRole.actorId;
}
switch (actor) { switch (actor) {
case ActorType.USER: case ActorType.USER:
return getUserProjectPermission({ return getUserProjectPermission({
@@ -50,7 +50,8 @@ export enum ProjectPermissionIdentityActions {
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete", Delete = "delete",
GrantPrivileges = "grant-privileges" GrantPrivileges = "grant-privileges",
AssumePrivileges = "assume-privileges"
} }
export enum ProjectPermissionMemberActions { export enum ProjectPermissionMemberActions {
@@ -58,7 +59,8 @@ export enum ProjectPermissionMemberActions {
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete", Delete = "delete",
GrantPrivileges = "grant-privileges" GrantPrivileges = "grant-privileges",
AssumePrivileges = "assume-privileges"
} }
export enum ProjectPermissionGroupActions { export enum ProjectPermissionGroupActions {
@@ -714,7 +716,8 @@ const buildAdminPermissionRules = () => {
ProjectPermissionMemberActions.Edit, ProjectPermissionMemberActions.Edit,
ProjectPermissionMemberActions.Delete, ProjectPermissionMemberActions.Delete,
ProjectPermissionMemberActions.Read, ProjectPermissionMemberActions.Read,
ProjectPermissionMemberActions.GrantPrivileges ProjectPermissionMemberActions.GrantPrivileges,
ProjectPermissionMemberActions.AssumePrivileges
], ],
ProjectPermissionSub.Member ProjectPermissionSub.Member
); );
@@ -736,7 +739,8 @@ const buildAdminPermissionRules = () => {
ProjectPermissionIdentityActions.Edit, ProjectPermissionIdentityActions.Edit,
ProjectPermissionIdentityActions.Delete, ProjectPermissionIdentityActions.Delete,
ProjectPermissionIdentityActions.Read, ProjectPermissionIdentityActions.Read,
ProjectPermissionIdentityActions.GrantPrivileges ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionIdentityActions.AssumePrivileges
], ],
ProjectPermissionSub.Identity ProjectPermissionSub.Identity
); );
@@ -0,0 +1,19 @@
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) => {
const assumeRoleCookie = req.cookies["infisical-project-assume-privileges"];
if (req?.auth?.authMode === AuthMode.JWT && assumeRoleCookie) {
const decodedToken = server.services.assumePrivileges.verifyAssumePrivilegeToken(
assumeRoleCookie,
req.auth.tokenVersionId
);
if (decodedToken) {
requestContext.set("projectAssumeRole", decodedToken);
}
}
});
});
+13 -2
View File
@@ -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 { 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 { 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 { 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 { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue"; import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue";
import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; 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 { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
import { injectAuditLogInfo } from "../plugins/audit-log"; import { injectAuditLogInfo } from "../plugins/audit-log";
import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege";
import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectIdentity } from "../plugins/auth/inject-identity";
import { injectPermission } from "../plugins/auth/inject-permission"; import { injectPermission } from "../plugins/auth/inject-permission";
import { injectRateLimits } from "../plugins/inject-rate-limits"; import { injectRateLimits } from "../plugins/inject-rate-limits";
@@ -427,6 +429,11 @@ export const registerRoutes = async (
serviceTokenDAL, serviceTokenDAL,
projectDAL projectDAL
}); });
const assumePrivilegeService = assumePrivilegeServiceFactory({
projectDAL,
permissionService
});
const licenseService = licenseServiceFactory({ const licenseService = licenseServiceFactory({
permissionService, permissionService,
orgDAL, orgDAL,
@@ -1015,7 +1022,9 @@ export const registerRoutes = async (
projectRoleDAL, projectRoleDAL,
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL, identityProjectMembershipRoleDAL,
projectDAL projectDAL,
identityDAL,
userDAL
}); });
const snapshotService = secretSnapshotServiceFactory({ const snapshotService = secretSnapshotServiceFactory({
@@ -1667,7 +1676,8 @@ export const registerRoutes = async (
kmip: kmipService, kmip: kmipService,
kmipOperation: kmipOperationService, kmipOperation: kmipOperationService,
gateway: gatewayService, gateway: gatewayService,
secretRotationV2: secretRotationV2Service secretRotationV2: secretRotationV2Service,
assumePrivileges: assumePrivilegeService
}); });
const cronJobs: CronJob[] = []; const cronJobs: CronJob[] = [];
@@ -1688,6 +1698,7 @@ export const registerRoutes = async (
}); });
await server.register(injectIdentity, { userDAL, serviceTokenDAL }); await server.register(injectIdentity, { userDAL, serviceTokenDAL });
await server.register(injectAssumePrivilege);
await server.register(injectPermission); await server.register(injectPermission);
await server.register(injectRateLimits); await server.register(injectRateLimits);
await server.register(injectAuditLogInfo); await server.register(injectAuditLogInfo);
@@ -33,6 +33,13 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
secure: appCfg.HTTPS_ENABLED secure: appCfg.HTTPS_ENABLED
}); });
void res.cookie("infisical-project-assume-privileges", "", {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
return { message: "Successfully logged out" }; return { message: "Successfully logged out" };
} }
}); });
@@ -77,6 +77,13 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
secure: cfg.HTTPS_ENABLED secure: cfg.HTTPS_ENABLED
}); });
void res.cookie("infisical-project-assume-privileges", "", {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: cfg.HTTPS_ENABLED
});
return { token: tokens.access, isMfaEnabled: false }; return { token: tokens.access, isMfaEnabled: false };
} }
}); });
@@ -131,6 +138,13 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
secure: appCfg.HTTPS_ENABLED secure: appCfg.HTTPS_ENABLED
}); });
void res.cookie("infisical-project-assume-privileges", "", {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
return { return {
encryptionVersion: data.user.encryptionVersion, encryptionVersion: data.user.encryptionVersion,
token: data.token.access, token: data.token.access,
@@ -1,5 +1,6 @@
import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability";
import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
import { requestContext } from "@fastify/request-context";
import { ActionProjectType, ProjectMembershipRole, TableName } from "@app/db/schemas"; import { ActionProjectType, ProjectMembershipRole, TableName } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; 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 { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; 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 { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TUserDALFactory } from "../user/user-dal";
import { TProjectRoleDALFactory } from "./project-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal";
import { getPredefinedRoles } from "./project-role-fns"; import { getPredefinedRoles } from "./project-role-fns";
import { import {
@@ -29,6 +32,8 @@ import {
type TProjectRoleServiceFactoryDep = { type TProjectRoleServiceFactoryDep = {
projectRoleDAL: TProjectRoleDALFactory; projectRoleDAL: TProjectRoleDALFactory;
identityDAL: Pick<TIdentityDALFactory, "findById">;
userDAL: Pick<TUserDALFactory, "findById">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">; projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory;
@@ -47,7 +52,9 @@ export const projectRoleServiceFactory = ({
permissionService, permissionService,
identityProjectMembershipRoleDAL, identityProjectMembershipRoleDAL,
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL,
projectDAL projectDAL,
identityDAL,
userDAL
}: TProjectRoleServiceFactoryDep) => { }: TProjectRoleServiceFactoryDep) => {
const createRole = async ({ data, actor, actorId, actorAuthMethod, actorOrgId, filter }: TCreateRoleDTO) => { const createRole = async ({ data, actor, actorId, actorAuthMethod, actorOrgId, filter }: TCreateRoleDTO) => {
let projectId = ""; let projectId = "";
@@ -220,14 +227,36 @@ export const projectRoleServiceFactory = ({
actorAuthMethod: ActorAuthMethod, actorAuthMethod: ActorAuthMethod,
actorOrgId: string | undefined actorOrgId: string | undefined
) => { ) => {
const { permission, membership } = await permissionService.getUserProjectPermission({ const { permission, membership } = await permissionService.getProjectPermission({
userId, actor: ActorType.USER,
actorId: userId,
projectId, projectId,
authMethod: actorAuthMethod, actorAuthMethod,
userOrgId: actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any 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 projectAssumeRole = requestContext.get("projectAssumeRole");
const isImpersonating = projectAssumeRole?.projectId === projectId;
const impersonation = isImpersonating
? {
actorId: projectAssumeRole?.actorId,
actorType: projectAssumeRole?.actorType,
actorName: "",
actorEmail: ""
}
: undefined;
if (impersonation?.actorType === ActorType.IDENTITY) {
const identityDetails = await identityDAL.findById(impersonation.actorId);
impersonation.actorName = identityDetails.name;
} else if (impersonation?.actorType === ActorType.USER) {
const userDetails = await userDAL.findById(impersonation?.actorId);
impersonation.actorName = `${userDetails?.firstName} ${userDetails?.lastName || ""}`;
impersonation.actorEmail = userDetails?.email || "";
}
return { permissions: packRules(permission.rules), membership, impersonation };
}; };
return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug }; return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug };
@@ -0,0 +1,115 @@
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<void>;
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();
} catch {
setIsLoading.off();
} finally {
setIsLoading.off();
}
};
return (
<Modal
isOpen={isOpen}
onOpenChange={(isOpenState) => {
setInputData("");
if (onChange) onChange(isOpenState);
}}
>
<ModalContent
title={title}
subTitle={subTitle}
footerContent={
<div className="mx-2 flex items-center">
<Button
className="mr-4"
isDisabled={!(confirmKey === inputData) || isLoading}
onClick={onDelete}
isLoading={isLoading}
>
{buttonText}
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary" onClick={onClose}>
Cancel
</Button>
</ModalClose>{" "}
</div>
}
onClose={onClose}
>
{formContent}
<form
onSubmit={(evt) => {
evt.preventDefault();
if (confirmKey === inputData) onDelete();
}}
>
<FormControl
label={
<div className="break-words pb-2 text-sm">
{confirmationMessage || (
<>
Type <span className="font-bold">{confirmKey}</span> to perform this action
</>
)}
</div>
}
className="mb-0"
>
<Input
value={inputData}
onChange={(e) => setInputData(e.target.value)}
placeholder={`Type ${confirmKey} here`}
/>
</FormControl>
{children}
</form>
</ModalContent>
</Modal>
);
};
@@ -0,0 +1 @@
export { ConfirmActionModal } from "./ConfirmActionModal";
@@ -14,7 +14,7 @@ export const PageHeader = ({ title, description, children, className }: Props) =
<div className="w-full"> <div className="w-full">
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1> <h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
</div> </div>
<div className="flex items-center">{children}</div> <div className="flex items-center gap-2">{children}</div>
</div> </div>
<div className="mt-2 text-gray-400">{description}</div> <div className="mt-2 text-gray-400">{description}</div>
</div> </div>
+1
View File
@@ -6,6 +6,7 @@ export * from "./Breadcrumb";
export * from "./Button"; export * from "./Button";
export * from "./Card"; export * from "./Card";
export * from "./Checkbox"; export * from "./Checkbox";
export * from "./ConfirmActionModal";
export * from "./ContentLoader"; export * from "./ContentLoader";
export * from "./DatePicker"; export * from "./DatePicker";
export * from "./DeleteActionModal"; export * from "./DeleteActionModal";
@@ -14,12 +14,13 @@ export const useProjectPermission = () => {
strict: false, strict: false,
select: (el) => el?.projectId select: (el) => el?.projectId
}); });
if (!projectId) { if (!projectId) {
throw new Error("useProjectPermission to be used within <ProjectPermissionContext>"); throw new Error("useProjectPermission to be used within <ProjectPermissionContext>");
} }
const { const {
data: { permission, membership } data: { permission, membership, impersonation }
} = useSuspenseQuery({ } = useSuspenseQuery({
queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId: projectId }), queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId: projectId }),
queryFn: () => fetchUserProjectPermissions({ workspaceId: projectId }), queryFn: () => fetchUserProjectPermissions({ workspaceId: projectId }),
@@ -29,6 +30,7 @@ export const useProjectPermission = () => {
const ability = evaluatePermissionsAbility(rule); const ability = evaluatePermissionsAbility(rule);
return { return {
permission: ability, permission: ability,
impersonation: data.impersonation,
membership: { membership: {
...data.membership, ...data.membership,
roles: data.membership.roles.map(({ role }) => role) roles: data.membership.roles.map(({ role }) => role)
@@ -42,5 +44,5 @@ export const useProjectPermission = () => {
[] []
); );
return { permission, membership, hasProjectRole }; return { permission, membership, hasProjectRole, impersonation };
}; };
@@ -58,7 +58,8 @@ export enum ProjectPermissionIdentityActions {
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete", Delete = "delete",
GrantPrivileges = "grant-privileges" GrantPrivileges = "grant-privileges",
AssumePrivileges = "assume-privileges"
} }
export enum ProjectPermissionMemberActions { export enum ProjectPermissionMemberActions {
@@ -66,7 +67,8 @@ export enum ProjectPermissionMemberActions {
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete", Delete = "delete",
GrantPrivileges = "grant-privileges" GrantPrivileges = "grant-privileges",
AssumePrivileges = "assume-privileges"
} }
export enum ProjectPermissionGroupActions { export enum ProjectPermissionGroupActions {
@@ -247,7 +249,7 @@ export type ProjectPermissionSet =
] ]
| [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Role]
| [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Tags]
| [ProjectPermissionActions, ProjectPermissionSub.Member] | [ProjectPermissionMemberActions, ProjectPermissionSub.Member]
| [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Groups]
| [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Integrations]
| [ProjectPermissionActions, ProjectPermissionSub.Webhooks] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks]
@@ -258,7 +260,7 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ | [
ProjectPermissionActions, ProjectPermissionIdentityActions,
( (
| ProjectPermissionSub.Identity | ProjectPermissionSub.Identity
| (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields) | (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
@@ -0,0 +1 @@
export { useAssumeProjectPrivileges, useRemoveAssumeProjectPrivilege } from "./mutations";
@@ -0,0 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { TProjectAssumePrivilegesDTO } from "./types";
import { apiRequest } from "@app/config/request";
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;
}
});
@@ -0,0 +1,7 @@
import { ActorType } from "../auditLogs/enums";
export type TProjectAssumePrivilegesDTO = {
projectId: string;
actorType: ActorType;
actorId: string;
};
+1
View File
@@ -1,4 +1,5 @@
export * from "./accessApproval"; export * from "./accessApproval";
export * from "./assumePrivileges";
export * from "./admin"; export * from "./admin";
export * from "./apiKeys"; export * from "./apiKeys";
export * from "./auditLogs"; export * from "./auditLogs";
+7
View File
@@ -18,6 +18,7 @@ import {
TPermission, TPermission,
TProjectRole TProjectRole
} from "./types"; } from "./types";
import { ActorType } from "../auditLogs/enums";
const $glob: FieldInstruction<string> = { const $glob: FieldInstruction<string> = {
type: "field", type: "field",
@@ -137,6 +138,12 @@ export const fetchUserProjectPermissions = async ({
data: { data: {
permissions: PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[]; permissions: PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[];
membership: Omit<TProjectMembership, "roles"> & { roles: { role: string }[] }; membership: Omit<TProjectMembership, "roles"> & { roles: { role: string }[] };
impersonation?: {
actorId: string;
actorType: ActorType;
actorEmail: string;
actorName: string;
};
}; };
}>(`/api/v1/workspace/${workspaceId}/permissions`, {}); }>(`/api/v1/workspace/${workspaceId}/permissions`, {});
@@ -11,7 +11,7 @@ import {
MenuItem, MenuItem,
TBreadcrumbFormat TBreadcrumbFormat
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context"; import { useProjectPermission, useSubscription, useWorkspace } from "@app/context";
import { import {
useGetAccessRequestsCount, useGetAccessRequestsCount,
useGetSecretApprovalRequestCount, useGetSecretApprovalRequestCount,
@@ -20,6 +20,7 @@ import {
import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProjectSelect } from "./components/ProjectSelect"; import { ProjectSelect } from "./components/ProjectSelect";
import { AssumePrivilegeModeBanner } from "./components/AssumePrivilegeModeBanner";
// This is a generic layout shared by all types of projects. // This is a generic layout shared by all types of projects.
// If the product layout differs significantly, create a new layout as needed. // If the product layout differs significantly, create a new layout as needed.
@@ -29,6 +30,7 @@ export const ProjectLayout = () => {
const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined;
const { t } = useTranslation(); const { t } = useTranslation();
const { impersonation } = useProjectPermission();
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || ""; const projectSlug = currentWorkspace?.slug || "";
@@ -267,6 +269,7 @@ export const ProjectLayout = () => {
</div> </div>
</div> </div>
</div> </div>
{impersonation && <AssumePrivilegeModeBanner />}
<div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden"> <div className="z-[200] flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" /> <FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
<p className="max-w-sm px-6 text-center text-lg text-gray-200"> <p className="max-w-sm px-6 text-center text-lg text-gray-200">
@@ -0,0 +1,68 @@
import { faIdCard, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, HoverCard, HoverCardContent, HoverCardTrigger } from "@app/components/v2";
import { useProjectPermission, useWorkspace } from "@app/context";
import { useRemoveAssumeProjectPrivilege } from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
export const AssumePrivilegeModeBanner = () => {
const { currentWorkspace } = useWorkspace();
const exitAssumePrivilegeMode = useRemoveAssumeProjectPrivilege();
const { impersonation } = useProjectPermission();
if (!impersonation) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded border border-mineshaft-600 bg-mineshaft-800 p-2 shadow">
<HoverCard openDelay={200}>
<HoverCardTrigger asChild>
<Button
leftIcon={
<FontAwesomeIcon
icon={impersonation?.actorType === ActorType.USER ? faUser : faIdCard}
/>
}
onClick={() => {
exitAssumePrivilegeMode.mutate(
{
projectId: currentWorkspace.id
},
{
onSuccess: () => {
window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`;
}
}
);
}}
>
Exit Assume Privilege Mode
</Button>
</HoverCardTrigger>
<HoverCardContent
className="bg-mineshaft-800 pt-4"
sideOffset={10}
style={{ width: "360px" }}
>
<div className="flex gap-2">
<div className="flex px-2 pt-2">
<FontAwesomeIcon
className="text-3xl text-mineshaft-400"
icon={impersonation?.actorType === ActorType.USER ? faUser : faIdCard}
/>
</div>
<div className="mb-4">
<div className="text-lg">{impersonation?.actorName}</div>
{impersonation?.actorEmail && (
<div className="text-xs">{impersonation?.actorEmail}</div>
)}
{impersonation?.actorType === ActorType.IDENTITY && (
<div className="text-xs">{impersonation?.actorId}</div>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
</div>
);
};
@@ -0,0 +1 @@
export { AssumePrivilegeModeBanner } from "./AssumePrivilegeModeBanner";
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useSubscription, useSubscription,
@@ -68,7 +69,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props)
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot( const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit, ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member ProjectPermissionSub.Member
); );
@@ -37,6 +37,7 @@ import {
import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
@@ -88,7 +89,8 @@ export const SpecificPrivilegeSecretForm = ({
] as const); ] as const);
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const isMemberEditDisabled = const isMemberEditDisabled =
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege; permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) &&
Boolean(privilege);
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
const requestAccess = useCreateAccessRequest(); const requestAccess = useCreateAccessRequest();
@@ -6,13 +6,27 @@ import { formatRelative } from "date-fns";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2"; import {
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; Button,
ConfirmActionModal,
DeleteActionModal,
EmptyState,
PageHeader,
Spinner
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
useAssumeProjectPrivileges,
useDeleteIdentityFromWorkspace, useDeleteIdentityFromWorkspace,
useGetWorkspaceIdentityMembershipDetails useGetWorkspaceIdentityMembershipDetails
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection"; import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection";
import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection"; import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection";
@@ -35,8 +49,29 @@ const Page = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteIdentity", "deleteIdentity",
"upgradePlan" "upgradePlan",
"assumePrivileges"
] as const); ] 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 () => { const onRemoveIdentitySubmit = async () => {
try { try {
@@ -100,6 +135,23 @@ const Page = () => {
> >
Copy Membership ID Copy Membership ID
</Button> </Button>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={ProjectPermissionSub.Identity}
renderTooltip
allowedLabel="Assume or impersonate privileges of the user"
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("assumePrivileges")}
>
Impersonate Identity
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, { a={subject(ProjectPermissionSub.Identity, {
@@ -137,6 +189,15 @@ const Page = () => {
deleteKey="remove" deleteKey="remove"
onDeleteApproved={() => onRemoveIdentitySubmit()} onDeleteApproved={() => onRemoveIdentitySubmit()}
/> />
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
title="Do you want to impersonate privileges of this identity?"
subTitle="This will set your privileges to those of the identity for the next hour."
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"
/>
</> </>
) : ( ) : (
<EmptyState title="Error: Unable to find the identity." className="py-12" /> <EmptyState title="Error: Unable to find the identity." className="py-12" />
@@ -32,7 +32,7 @@ import {
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionIdentityActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
@@ -97,7 +97,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
}); });
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot( const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId }) subject(ProjectPermissionSub.Identity, { identityId })
); );
@@ -22,7 +22,12 @@ import {
Tooltip, Tooltip,
Tr Tr
} from "@app/components/v2"; } 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 { usePopUp } from "@app/hooks";
import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api"; import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types"; import { IdentityMembership } from "@app/hooks/api/identities/types";
@@ -83,7 +88,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
identityId={identityId} identityId={identityId}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={permission.cannot( isDisabled={permission.cannot(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { subject(ProjectPermissionSub.Identity, {
identityId identityId
}) })
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
@@ -66,7 +67,7 @@ export const IdentityRoleModify = ({ identityProjectMembership }: Props) => {
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot( const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit, ProjectPermissionIdentityActions.Edit,
ProjectPermissionSub.Identity ProjectPermissionSub.Identity
); );
@@ -6,15 +6,28 @@ import { formatRelative } from "date-fns";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; 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 { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useOrganization, useOrganization,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; 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 { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection";
import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection"; import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection";
@@ -35,12 +48,34 @@ export const Page = () => {
const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } = const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } =
useDeleteUserFromWorkspace(); useDeleteUserFromWorkspace();
const assumePrivileges = useAssumeProjectPrivileges();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeMember", "removeMember",
"upgradePlan" "upgradePlan",
"assumePrivileges"
] as const); ] 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 () => { const handleRemoveUser = async () => {
if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return; if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return;
@@ -91,7 +126,28 @@ export const Page = () => {
description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`} description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`}
> >
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Assume or impersonate privileges of the user"
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={isRemovingUserFromWorkspace}
onClick={() =>
handlePopUpOpen("assumePrivileges", { userId: membershipDetails?.user?.id })
}
>
Impersonate User
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.Delete}
a={ProjectPermissionSub.Member} a={ProjectPermissionSub.Member}
renderTooltip renderTooltip
allowedLabel="Remove from project" allowedLabel="Remove from project"
@@ -128,6 +184,15 @@ export const Page = () => {
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)} onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={handleRemoveUser} onDeleteApproved={handleRemoveUser}
/> />
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
title="Do you want to impersonate privileges of this user?"
subTitle="This will set your privileges to those of the user for the next hour."
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"
/>
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
@@ -23,6 +23,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useUser useUser
@@ -90,7 +91,7 @@ export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: P
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id} privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={ isDisabled={
isOwnProjectMembershipDetails || isOwnProjectMembershipDetails ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member)
} }
/> />
</motion.div> </motion.div>
@@ -31,7 +31,7 @@ import {
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
@@ -95,7 +95,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot( const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit, ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member ProjectPermissionSub.Member
); );
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useSubscription, useSubscription,
@@ -69,7 +70,7 @@ export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) =
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId); const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot( const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit, ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member ProjectPermissionSub.Member
); );
@@ -577,6 +577,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canEdit = action.includes(ProjectPermissionMemberActions.Edit); const canEdit = action.includes(ProjectPermissionMemberActions.Edit);
const canDelete = action.includes(ProjectPermissionMemberActions.Delete); const canDelete = action.includes(ProjectPermissionMemberActions.Delete);
const canGrantPrivileges = action.includes(ProjectPermissionMemberActions.GrantPrivileges); const canGrantPrivileges = action.includes(ProjectPermissionMemberActions.GrantPrivileges);
const canAssumePrivileges = action.includes(ProjectPermissionMemberActions.AssumePrivileges);
if (!formVal[subject]) formVal[subject] = [{}]; if (!formVal[subject]) formVal[subject] = [{}];
@@ -587,6 +588,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0][ProjectPermissionMemberActions.Delete] = true; if (canDelete) formVal[subject]![0][ProjectPermissionMemberActions.Delete] = true;
if (canGrantPrivileges) if (canGrantPrivileges)
formVal[subject]![0][ProjectPermissionMemberActions.GrantPrivileges] = true; formVal[subject]![0][ProjectPermissionMemberActions.GrantPrivileges] = true;
if (canAssumePrivileges)
formVal[subject]![0][ProjectPermissionMemberActions.AssumePrivileges] = true;
return; return;
} }
@@ -596,6 +599,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canEdit = action.includes(ProjectPermissionIdentityActions.Edit); const canEdit = action.includes(ProjectPermissionIdentityActions.Edit);
const canDelete = action.includes(ProjectPermissionIdentityActions.Delete); const canDelete = action.includes(ProjectPermissionIdentityActions.Delete);
const canGrantPrivileges = action.includes(ProjectPermissionIdentityActions.GrantPrivileges); const canGrantPrivileges = action.includes(ProjectPermissionIdentityActions.GrantPrivileges);
const canAssumePrivileges = action.includes(
ProjectPermissionIdentityActions.AssumePrivileges
);
if (!formVal[subject]) formVal[subject] = [{ conditions: [] }]; if (!formVal[subject]) formVal[subject] = [{ conditions: [] }];
@@ -606,6 +612,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0][ProjectPermissionIdentityActions.Delete] = true; if (canDelete) formVal[subject]![0][ProjectPermissionIdentityActions.Delete] = true;
if (canGrantPrivileges) if (canGrantPrivileges)
formVal[subject]![0][ProjectPermissionIdentityActions.GrantPrivileges] = true; formVal[subject]![0][ProjectPermissionIdentityActions.GrantPrivileges] = true;
if (canAssumePrivileges)
formVal[subject]![0][ProjectPermissionIdentityActions.AssumePrivileges] = true;
return; return;
} }
@@ -904,7 +912,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Add", value: ProjectPermissionMemberActions.Create }, { label: "Add", value: ProjectPermissionMemberActions.Create },
{ label: "Modify", value: ProjectPermissionMemberActions.Edit }, { label: "Modify", value: ProjectPermissionMemberActions.Edit },
{ label: "Remove", value: ProjectPermissionMemberActions.Delete }, { 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]: { [ProjectPermissionSub.Identity]: {
@@ -914,7 +923,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Add", value: ProjectPermissionIdentityActions.Create }, { label: "Add", value: ProjectPermissionIdentityActions.Create },
{ label: "Modify", value: ProjectPermissionIdentityActions.Edit }, { label: "Modify", value: ProjectPermissionIdentityActions.Edit },
{ label: "Remove", value: ProjectPermissionIdentityActions.Delete }, { 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]: { [ProjectPermissionSub.Groups]: {
@@ -25,7 +25,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge"; import { Badge } from "@app/components/v2/Badge";
import { import {
ProjectPermissionActions, ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useSubscription, useSubscription,
@@ -289,7 +289,10 @@ export const AccessApprovalRequest = ({
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( {!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button <Button
@@ -22,7 +22,7 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes"; import { ROUTE_PATHS } from "@app/const/routes";
import { import {
ProjectPermissionActions, ProjectPermissionMemberActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useUser, useUser,
@@ -171,7 +171,10 @@ export const SecretApprovalRequest = () => {
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && ( {!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button <Button