Team Impersonation (#3450)

* Team Impersonation

* Refactor - Disable Imeprsonate button

* Change copy

* Add .env toggle

* Fix eslint

* Update role selection

Co-authored-by: Omar López <zomars@me.com>

* New 'admin' seed user, improve flow ImpersonationProvider

* Fix impersonation string

* Adds fullstop

Co-authored-by: Sean Brydon <seanbrydon.me@gmail.ocm>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
pull/3452/head^2
sean-brydon 2022-07-21 18:02:20 +01:00 committed by GitHub
parent 471420c1d4
commit d3fcb8bf7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 305 additions and 42 deletions

View File

@ -127,3 +127,6 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
## @see https://support.google.com/accounts/answer/185833
# EMAIL_SERVER_PASSWORD='<gmail_app_password>'
# **********************************************************************************************************
# Set the following value to true if you wish to enable Team Impersonation
NEXT_PUBLIC_TEAM_IMPERSONATION=false

View File

@ -0,0 +1,63 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/Button";
import { trpc } from "@lib/trpc";
import Badge from "@components/ui/Badge";
const DisableTeamImpersonation = ({ teamId, memberId }: { teamId: number; memberId: number }) => {
const { t } = useLocale();
const utils = trpc.useContext();
const query = trpc.useQuery(["viewer.teams.getMembershipbyUser", { teamId, memberId }]);
const mutation = trpc.useMutation("viewer.teams.updateMembership", {
onSuccess: async () => {
showToast(t("your_user_profile_updated_successfully"), "success");
await utils.invalidateQueries(["viewer.teams.getMembershipbyUser"]);
},
async onSettled() {
await utils.invalidateQueries(["viewer.public.i18n"]);
},
});
if (query.isLoading) return <></>;
return (
<>
<h3 className="font-cal mt-7 pb-4 text-xl leading-6 text-gray-900">{t("settings")}</h3>
<div className="-mx-0 rounded-sm border border-neutral-200 bg-white px-4 pb-4 sm:px-6">
<div className="flex flex-col justify-between pt-4 sm:flex-row">
<div>
<div className="flex flex-row items-center">
<h2 className="font-cal font-bold leading-6 text-gray-900">
{t("user_impersonation_heading")}
</h2>
<Badge
className="ml-2 text-xs"
variant={!query.data?.disableImpersonation ? "success" : "gray"}>
{!query.data?.disableImpersonation ? t("enabled") : t("disabled")}
</Badge>
</div>
<p className="text-sm text-gray-700">{t("team_impersonation_description")}</p>
</div>
<div className="mt-5 sm:mt-0 sm:self-center">
<Button
type="submit"
color="secondary"
onClick={() =>
!query.data?.disableImpersonation
? mutation.mutate({ teamId, memberId, disableImpersonation: true })
: mutation.mutate({ teamId, memberId, disableImpersonation: false })
}>
{!query.data?.disableImpersonation ? t("disable") : t("enable")}
</Button>
</div>
</div>
</div>
</>
);
};
export default DisableTeamImpersonation;

View File

@ -1,6 +1,7 @@
import { PencilIcon, UserRemoveIcon } from "@heroicons/react/outline";
import { LockClosedIcon, PencilIcon, UserRemoveIcon } from "@heroicons/react/outline";
import { ClockIcon, DotsHorizontalIcon, ExternalLinkIcon } from "@heroicons/react/solid";
import { MembershipRole } from "@prisma/client";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useState } from "react";
@ -39,6 +40,7 @@ export default function MemberListItem(props: Props) {
const utils = trpc.useContext();
const [showChangeMemberRoleModal, setShowChangeMemberRoleModal] = useState(false);
const [showTeamAvailabilityModal, setShowTeamAvailabilityModal] = useState(false);
const [showImpersonateModal, setShowImpersonateModal] = useState(false);
const removeMemberMutation = trpc.useMutation("viewer.teams.removeMember", {
async onSuccess() {
@ -147,6 +149,24 @@ export default function MemberListItem(props: Props) {
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
{/* Only show impersonate box if - The user has impersonation enabled,
They have accepted the team invite, and it is enabled for this instance */}
{!props.member.disableImpersonation &&
props.member.accepted &&
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true" && (
<>
<DropdownMenuItem>
<Button
onClick={() => setShowImpersonateModal(true)}
color="minimal"
StartIcon={LockClosedIcon}
className="w-full flex-shrink-0 font-normal">
{t("impersonate")}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator className="h-px bg-gray-200" />
</>
)}
<DropdownMenuItem>
<Dialog>
<DialogTrigger asChild>
@ -185,6 +205,39 @@ export default function MemberListItem(props: Props) {
onExit={() => setShowChangeMemberRoleModal(false)}
/>
)}
{showImpersonateModal && props.member.username && (
<ModalContainer isOpen={showImpersonateModal} onExit={() => setShowImpersonateModal(false)}>
<>
<div className="mb-4 sm:flex sm:items-start">
<div className="text-center sm:text-left">
<h3 className="text-lg font-medium leading-6 text-gray-900" id="modal-title">
{t("impersonate")}
</h3>
</div>
</div>
<form
onSubmit={async (e) => {
e.preventDefault();
await signIn("impersonation-auth", {
username: props.member.username,
teamId: props.team.id,
});
}}>
<p className="mt-2 text-sm text-gray-500" id="email-description">
{t("impersonate_user_tip")}
</p>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<Button type="submit" color="primary" className="ltr:ml-2 rtl:mr-2">
{t("impersonate")}
</Button>
<Button type="button" color="secondary" onClick={() => setShowImpersonateModal(false)}>
{t("cancel")}
</Button>
</div>
</form>
</>
</ModalContainer>
)}
{showTeamAvailabilityModal && (
<ModalContainer
wide

View File

@ -1,66 +1,136 @@
import { User } from "@prisma/client";
import CredentialsProvider from "next-auth/providers/credentials";
import { getSession } from "next-auth/react";
import { asNumberOrThrow } from "@lib/asStringOrNull";
import prisma from "@lib/prisma";
const auditAndReturnNextUser = async (
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role">,
impersonatedByUID: number
) => {
// Log impersonations for audit purposes
await prisma.impersonations.create({
data: {
impersonatedBy: {
connect: {
id: impersonatedByUID,
},
},
impersonatedUser: {
connect: {
id: impersonatedUser.id,
},
},
},
});
const obj = {
id: impersonatedUser.id,
username: impersonatedUser.username,
email: impersonatedUser.email,
name: impersonatedUser.name,
role: impersonatedUser.role,
impersonatedByUID,
};
return obj;
};
const ImpersonationProvider = CredentialsProvider({
id: "impersonation-auth",
name: "Impersonation",
type: "credentials",
credentials: {
username: { label: "Username", type: "text " },
username: { type: "text" },
teamId: { type: "text" },
},
async authorize(creds, req) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore need to figure out how to correctly type this
const session = await getSession({ req });
if (session?.user.role !== "ADMIN") {
throw new Error("You do not have permission to do this.");
}
const teamId = creds?.teamId ? asNumberOrThrow(creds.teamId) : undefined;
if (session?.user.username === creds?.username) {
throw new Error("You cannot impersonate yourself.");
}
const user = await prisma.user.findUnique({
if (!creds?.username) throw new Error("Username must be present");
// If you are an ADMIN we return way before team impersonation logic is executed, so NEXT_PUBLIC_TEAM_IMPERSONATION certainly true
if (session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") {
throw new Error("You do not have permission to do this.");
}
// Get user who is being impersonated
const impersonatedUser = await prisma.user.findUnique({
where: {
username: creds?.username,
},
});
if (!user) {
throw new Error("This user does not exist");
}
if (user.disableImpersonation) {
throw new Error("This user has disabled Impersonation.");
}
// Log impersonations for audit purposes
await prisma.impersonations.create({
data: {
impersonatedBy: {
connect: {
id: session.user.id,
select: {
id: true,
username: true,
role: true,
name: true,
email: true,
disableImpersonation: true,
teams: {
where: {
disableImpersonation: false, // Ensure they have impersonation enabled
accepted: true, // Ensure they are apart of the team and not just invited.
team: {
id: teamId, // Bring back only the right team
},
},
},
impersonatedUser: {
connect: {
id: user.id,
select: {
teamId: true,
disableImpersonation: true,
},
},
},
});
const obj = {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: user.role,
impersonatedByUID: session?.user.id,
};
return obj;
// Check if impersonating is allowed for this user
if (!impersonatedUser) {
throw new Error("This user does not exist");
}
if (session?.user.role === "ADMIN") {
if (impersonatedUser.disableImpersonation) {
throw new Error("This user has disabled Impersonation.");
}
return auditAndReturnNextUser(impersonatedUser, session?.user.id as number);
}
// Check session
const sessionUserFromDb = await prisma.user.findUnique({
where: {
id: session?.user.id,
},
include: {
teams: {
where: {
AND: [
{
role: {
in: ["ADMIN", "OWNER"],
},
},
{
team: {
id: teamId,
},
},
],
},
},
},
});
if (sessionUserFromDb?.teams.length === 0 || impersonatedUser.teams.length === 0) {
throw new Error("You do not have permission to do this.");
}
return auditAndReturnNextUser(impersonatedUser, session?.user.id as number);
},
});

View File

@ -63,6 +63,7 @@ export async function getTeamWithMembers(id?: number, slug?: string) {
isMissingSeat: obj.user.plan === UserPlan.FREE,
role: membership?.role,
accepted: membership?.accepted,
disableImpersonation: membership?.disableImpersonation,
};
});

View File

@ -11,10 +11,12 @@ import SAMLConfiguration from "@ee/components/saml/Configuration";
import { QueryCell } from "@lib/QueryCell";
import { getPlaceholderAvatar } from "@lib/getPlaceholderAvatar";
import useCurrentUserId from "@lib/hooks/useCurrentUserId";
import { useLocale } from "@lib/hooks/useLocale";
import { trpc } from "@lib/trpc";
import Shell from "@components/Shell";
import DisableTeamImpersonation from "@components/team/DisableTeamImpersonation";
import MemberInvitationModal from "@components/team/MemberInvitationModal";
import MemberList from "@components/team/MemberList";
import TeamSettings from "@components/team/TeamSettings";
@ -25,6 +27,7 @@ import Avatar from "@components/ui/Avatar";
export function TeamSettingsPage() {
const { t } = useLocale();
const router = useRouter();
const userId = useCurrentUserId();
const upgraded = router.query.upgraded as string;
@ -165,6 +168,7 @@ export function TeamSettingsPage() {
</div>
<MemberList team={team} members={team.members || []} />
{isAdmin && <SAMLConfiguration teamsView={true} teamId={team.id} />}
{userId && <DisableTeamImpersonation teamId={team.id} memberId={userId} />}
</div>
<div className="min-w-32 mt-8 w-full px-2 ltr:ml-2 rtl:mr-2 sm:mt-0 md:w-3/12">
<TeamSettingsRightSidebar role={team.membership.role} team={team} />

View File

@ -838,6 +838,7 @@
"impersonate": "Impersonate",
"user_impersonation_heading": "User Impersonation",
"user_impersonation_description": "Allows our support team to temporarily sign in as you to help us quickly resolve any issues you report to us.",
"team_impersonation_description": "Allows your team admins to temporarily sign in as you.",
"impersonate_user_tip": "All uses of this feature is audited.",
"impersonating_user_warning": "Impersonating username \"{{user}}\".",
"impersonating_stop_instructions": "<0>Click Here to stop</0>.",

View File

@ -1,5 +1,6 @@
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import { randomBytes } from "crypto";
import { resolve } from "path";
import { z } from "zod";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
@ -460,4 +461,54 @@ export const viewerTeamsRouter = createProtectedRouter()
async resolve({ ctx, input }) {
return await ensureSubscriptionQuantityCorrectness(ctx.user.id, input.teamId);
},
})
.query("getMembershipbyUser", {
input: z.object({
teamId: z.number(),
memberId: z.number(),
}),
async resolve({ ctx, input }) {
if (ctx.user.id !== input.memberId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You cannot view memberships that are not your own.",
});
}
return await ctx.prisma.membership.findUnique({
where: {
userId_teamId: {
userId: input.memberId,
teamId: input.teamId,
},
},
});
},
})
.mutation("updateMembership", {
input: z.object({
teamId: z.number(),
memberId: z.number(),
disableImpersonation: z.boolean(),
}),
async resolve({ ctx, input }) {
if (ctx.user.id !== input.memberId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You cannot edit memberships that are not your own.",
});
}
return await ctx.prisma.membership.update({
where: {
userId_teamId: {
userId: input.memberId,
teamId: input.teamId,
},
},
data: {
disableImpersonation: input.disableImpersonation,
},
});
},
});

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "disableImpersonation" BOOLEAN NOT NULL DEFAULT false;

View File

@ -206,12 +206,13 @@ enum MembershipRole {
}
model Membership {
teamId Int
userId Int
accepted Boolean @default(false)
role MembershipRole
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int
userId Int
accepted Boolean @default(false)
role MembershipRole
team Team @relation(fields: [teamId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
disableImpersonation Boolean @default(false)
@@id([userId, teamId])
}

View File

@ -1,4 +1,4 @@
import { BookingStatus, MembershipRole, Prisma, UserPlan } from "@prisma/client";
import { BookingStatus, MembershipRole, Prisma, UserPermissionRole, UserPlan } from "@prisma/client";
import { uuid } from "short-uuid";
import dayjs from "@calcom/dayjs";
@ -18,6 +18,7 @@ async function createUserAndEventType(opts: {
name: string;
completedOnboarding?: boolean;
timeZone?: string;
role?: UserPermissionRole;
};
eventTypes: Array<
Prisma.EventTypeCreateInput & {
@ -468,6 +469,18 @@ async function main() {
eventTypes: [],
});
await createUserAndEventType({
user: {
email: "admin@example.com",
password: "admin",
username: "admin",
name: "Admin Example",
plan: "PRO",
role: "ADMIN",
},
eventTypes: [],
});
const pro2UserTeam = await createUserAndEventType({
user: {
email: "teampro2@example.com",
@ -536,6 +549,7 @@ async function main() {
{
id: pro2UserTeam.id,
username: pro2UserTeam.name || "Unknown",
role: "MEMBER",
},
{
id: pro3UserTeam.id,