feat: implemented user role impersonation

This commit is contained in:
=
2025-04-18 14:49:45 +05:30
parent eba12912f8
commit 7219ba3b46
40 changed files with 750 additions and 51 deletions

View File

@@ -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<string, unknown>; // filled by permission service
projectAssumeRole?: { userId: 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

View File

@@ -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" });
}
});
};

View File

@@ -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" }
);

View File

@@ -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()
}),
impersonation: 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, impersonation } = 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,
impersonation
}
};
}
});
};

View File

@@ -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
};
};

View File

@@ -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;
};

View File

@@ -551,13 +551,26 @@ export const permissionServiceFactory = ({
};
const getProjectPermission = async <T extends ActorType>({
actor,
actorId,
actor: inputActor,
actorId: inputActorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType
}: 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) {
case ActorType.USER:
return getUserProjectPermission({

View File

@@ -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
);

View File

@@ -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);
}
}
});
});

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 { 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,
@@ -1015,7 +1022,9 @@ export const registerRoutes = async (
projectRoleDAL,
projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL,
projectDAL
projectDAL,
identityDAL,
userDAL
});
const snapshotService = secretSnapshotServiceFactory({
@@ -1667,7 +1676,8 @@ export const registerRoutes = async (
kmip: kmipService,
kmipOperation: kmipOperationService,
gateway: gatewayService,
secretRotationV2: secretRotationV2Service
secretRotationV2: secretRotationV2Service,
assumePrivileges: assumePrivilegeService
});
const cronJobs: CronJob[] = [];
@@ -1688,6 +1698,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);

View File

@@ -33,6 +33,13 @@ 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
});
return { message: "Successfully logged out" };
}
});

View File

@@ -77,6 +77,13 @@ 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
});
return { token: tokens.access, isMfaEnabled: false };
}
});
@@ -131,6 +138,13 @@ 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
});
return {
encryptionVersion: data.user.encryptionVersion,
token: data.token.access,

View File

@@ -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<TIdentityDALFactory, "findById">;
userDAL: Pick<TUserDALFactory, "findById">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getUserProjectPermission">;
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,36 @@ 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 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 };

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { ConfirmActionModal } from "./ConfirmActionModal";

View File

@@ -14,7 +14,7 @@ export const PageHeader = ({ title, description, children, className }: Props) =
<div className="w-full">
<h1 className="mr-4 text-3xl font-semibold capitalize text-white">{title}</h1>
</div>
<div className="flex items-center">{children}</div>
<div className="flex items-center gap-2">{children}</div>
</div>
<div className="mt-2 text-gray-400">{description}</div>
</div>

View File

@@ -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";

View File

@@ -14,12 +14,13 @@ export const useProjectPermission = () => {
strict: false,
select: (el) => el?.projectId
});
if (!projectId) {
throw new Error("useProjectPermission to be used within <ProjectPermissionContext>");
}
const {
data: { permission, membership }
data: { permission, membership, impersonation }
} = useSuspenseQuery({
queryKey: roleQueryKeys.getUserProjectPermissions({ workspaceId: projectId }),
queryFn: () => fetchUserProjectPermissions({ workspaceId: projectId }),
@@ -29,6 +30,7 @@ export const useProjectPermission = () => {
const ability = evaluatePermissionsAbility(rule);
return {
permission: ability,
impersonation: data.impersonation,
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, impersonation };
};

View File

@@ -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<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)

View File

@@ -0,0 +1 @@
export { useAssumeProjectPrivileges, useRemoveAssumeProjectPrivilege } from "./mutations";

View File

@@ -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;
}
});

View File

@@ -0,0 +1,7 @@
import { ActorType } from "../auditLogs/enums";
export type TProjectAssumePrivilegesDTO = {
projectId: string;
actorType: ActorType;
actorId: string;
};

View File

@@ -1,4 +1,5 @@
export * from "./accessApproval";
export * from "./assumePrivileges";
export * from "./admin";
export * from "./apiKeys";
export * from "./auditLogs";

View File

@@ -18,6 +18,7 @@ import {
TPermission,
TProjectRole
} from "./types";
import { ActorType } from "../auditLogs/enums";
const $glob: FieldInstruction<string> = {
type: "field",
@@ -137,6 +138,12 @@ export const fetchUserProjectPermissions = async ({
data: {
permissions: PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[];
membership: Omit<TProjectMembership, "roles"> & { roles: { role: string }[] };
impersonation?: {
actorId: string;
actorType: ActorType;
actorEmail: string;
actorName: string;
};
};
}>(`/api/v1/workspace/${workspaceId}/permissions`, {});

View File

@@ -11,7 +11,7 @@ import {
MenuItem,
TBreadcrumbFormat
} from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import { useProjectPermission, useSubscription, useWorkspace } from "@app/context";
import {
useGetAccessRequestsCount,
useGetSecretApprovalRequestCount,
@@ -20,6 +20,7 @@ import {
import { ProjectType } from "@app/hooks/api/workspace/types";
import { ProjectSelect } from "./components/ProjectSelect";
import { AssumePrivilegeModeBanner } from "./components/AssumePrivilegeModeBanner";
// This is a generic layout shared by all types of projects.
// 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 { t } = useTranslation();
const { impersonation } = useProjectPermission();
const workspaceId = currentWorkspace?.id || "";
const projectSlug = currentWorkspace?.slug || "";
@@ -267,6 +269,7 @@ export const ProjectLayout = () => {
</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">
<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">

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { AssumePrivilegeModeBanner } from "./AssumePrivilegeModeBanner";

View File

@@ -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
);

View File

@@ -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();

View File

@@ -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,29 @@ const Page = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteIdentity",
"upgradePlan"
"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 +135,23 @@ const Page = () => {
>
Copy Membership ID
</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
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
@@ -137,6 +189,15 @@ const Page = () => {
deleteKey="remove"
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" />

View File

@@ -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 })
);

View File

@@ -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
})

View File

@@ -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
);

View File

@@ -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,28 @@ export const Page = () => {
description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`}
>
<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}
renderTooltip
allowedLabel="Remove from project"
@@ -128,6 +184,15 @@ export const Page = () => {
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
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
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}

View File

@@ -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)
}
/>
</motion.div>

View File

@@ -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
);

View File

@@ -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
);

View File

@@ -577,6 +577,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 +588,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 +599,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 +612,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 +912,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 +923,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]: {

View File

@@ -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 = ({
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button

View File

@@ -22,7 +22,7 @@ import {
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useUser,
@@ -171,7 +171,10 @@ export const SecretApprovalRequest = () => {
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button