Compare commits

...

14 Commits

Author SHA1 Message Date
sean-brydon 805ddfaf87
Merge branch 'main' into feat/impersonation-improvements 2024-01-12 20:35:28 +13:00
sean-brydon 90efde5c5d
Merge branch 'main' into feat/impersonation-improvements 2024-01-11 20:55:59 +10:00
sean-brydon d755635a01 fix: typecheck 2024-01-11 21:45:11 +13:00
Udit Takkar 7b98ff4078
Merge branch 'main' into feat/impersonation-improvements 2024-01-09 19:03:54 +05:30
sean-brydon 296f185329
Update apps/web/playwright/impersonation.e2e.ts 2024-01-06 22:57:45 +10:00
sean-brydon 5c4cb68966 fix: can return to self 2024-01-06 20:27:04 +10:00
sean-brydon 2e9e341f80 chore: move to impersonatedBy in session 2024-01-06 19:20:30 +10:00
sean-brydon e41ba360e5 nit: cleanup logs 2024-01-06 18:14:58 +10:00
sean-brydon 3ac362a6d1 fix: fix fixtures 2024-01-06 18:14:30 +10:00
sean-brydon 37b9476398 test: Add e2e 2024-01-06 18:05:51 +10:00
sean-brydon 68895f9b57 cleanup 2024-01-05 17:31:41 +10:00
sean-brydon d905cdf056 nits: cleanup plus comments 2024-01-05 16:58:05 +10:00
sean-brydon 26e87ff086 feat:return to user 2024-01-05 16:55:36 +10:00
sean-brydon dd0fd5b5cc fix: rename checkPermission to be more fitting 2024-01-05 16:09:57 +10:00
11 changed files with 210 additions and 23 deletions

View File

@ -31,8 +31,11 @@ function AdminView() {
ref={usernameRef}
hint={t("impersonate_user_tip")}
defaultValue={undefined}
data-testid="admin-impersonation-input"
/>
<Button type="submit">{t("impersonate")}</Button>
<Button type="submit" data-testid="impersonation-submit">
{t("impersonate")}
</Button>
</div>
</form>
</>

View File

@ -563,6 +563,8 @@ type CustomUserOptsKeys =
| "name"
| "email"
| "organizationId"
| "twoFactorEnabled"
| "disableImpersonation"
| "role";
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
timeZone?: TimeZoneEnum;
@ -594,6 +596,8 @@ const createUser = (
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
locale: opts?.locale ?? "en",
role: opts?.role ?? "USER",
twoFactorEnabled: opts?.twoFactorEnabled ?? false,
disableImpersonation: opts?.disableImpersonation ?? false,
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
schedules:
opts?.completedOnboarding ?? true

View File

@ -0,0 +1,50 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.describe("Users can impersonate", async () => {
test.afterAll(async ({ users }) => {
await users.deleteAll();
});
test("App Admin can impersonate users with impersonation enabled", async ({ page, users }) => {
// log in trail user
const user = await users.create({
role: "ADMIN",
password: "ADMINadmin2022!",
});
const userToImpersonate = await users.create({ disableImpersonation: false });
await user.apiLogin();
await page.waitForLoadState();
await page.goto("/settings/admin/impersonation");
await page.waitForLoadState();
const adminInput = page.getByTestId("admin-impersonation-input");
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore the username does exist
await adminInput.fill(userToImpersonate.username);
await page.getByTestId("impersonation-submit").click();
// // Wait for sign in to complete
await page.waitForURL("/settings/my-account/profile");
const stopImpersonatingButton = page.getByTestId("stop-impersonating-button");
const impersonatedUsernameInput = page.locator("input[name='username']");
const impersonatedUser = await impersonatedUsernameInput.inputValue();
await expect(stopImpersonatingButton).toBeVisible();
await expect(impersonatedUser).toBe(userToImpersonate.username);
await stopImpersonatingButton.click();
await page.waitForLoadState("networkidle");
// Return to user
const ogUser = await impersonatedUsernameInput.inputValue();
expect(ogUser).toBe(user.username);
});
});

View File

@ -72,13 +72,30 @@ export async function getServerSession(options: {
email_verified: user.emailVerified !== null,
role: user.role,
image: `${CAL_URL}/${user.username}/avatar.png`,
impersonatedByUID: token.impersonatedByUID ?? undefined,
belongsToActiveTeam: token.belongsToActiveTeam,
org: token.org,
locale: user.locale ?? undefined,
},
};
if (token?.impersonatedBy?.id) {
const impersonatedByUser = await prisma.user.findUnique({
where: {
id: token.impersonatedBy.id,
},
select: {
id: true,
role: true,
},
});
if (impersonatedByUser) {
session.user.impersonatedBy = {
id: impersonatedByUser?.id,
role: impersonatedByUser.role,
};
}
}
CACHE.set(JSON.stringify(token), session);
return session;

View File

@ -508,7 +508,7 @@ export const AUTH_OPTIONS: AuthOptions = {
username: user.username,
email: user.email,
role: user.role,
impersonatedByUID: user?.impersonatedByUID,
impersonatedBy: user.impersonatedBy,
belongsToActiveTeam: user?.belongsToActiveTeam,
org: user?.org,
locale: user?.locale,
@ -547,7 +547,7 @@ export const AUTH_OPTIONS: AuthOptions = {
username: existingUser.username,
email: existingUser.email,
role: existingUser.role,
impersonatedByUID: token.impersonatedByUID as number,
impersonatedBy: token.impersonatedBy,
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
org: token?.org,
locale: existingUser.locale,
@ -567,7 +567,7 @@ export const AUTH_OPTIONS: AuthOptions = {
name: token.name,
username: token.username as string,
role: token.role as UserPermissionRole,
impersonatedByUID: token.impersonatedByUID as number,
impersonatedBy: token.impersonatedBy,
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
org: token?.org,
locale: token.locale,

View File

@ -1,4 +1,5 @@
import type { SessionContextValue } from "next-auth/react";
import { signIn } from "next-auth/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { TopBanner } from "@calcom/ui";
@ -8,7 +9,10 @@ export type ImpersonatingBannerProps = { data: SessionContextValue["data"] };
function ImpersonatingBanner({ data }: ImpersonatingBannerProps) {
const { t } = useLocale();
if (!data?.user.impersonatedByUID) return null;
if (!data?.user.impersonatedBy) return null;
const returnToId = data.user.impersonatedBy.id;
const canReturnToSelf = data.user.impersonatedBy.role == "ADMIN" || data.user?.org?.id;
return (
<>
@ -16,9 +20,21 @@ function ImpersonatingBanner({ data }: ImpersonatingBannerProps) {
text={t("impersonating_user_warning", { user: data.user.username })}
variant="warning"
actions={
<a className="border-b border-b-black" href="/auth/logout">
{t("impersonating_stop_instructions")}
</a>
canReturnToSelf ? (
<form
onSubmit={(e) => {
e.preventDefault();
signIn("impersonation-auth", { returnToId });
}}>
<button className="text-emphasis hover:underline" data-testid="stop-impersonating-button">
{t("impersonating_stop_instructions")}
</button>
</form>
) : (
<a className="border-b border-b-black" href="/auth/logout">
{t("impersonating_stop_instructions")}
</a>
)
}
/>
</>

View File

@ -7,7 +7,7 @@ import {
parseTeamId,
checkSelfImpersonation,
checkUserIdentifier,
checkPermission,
checkGlobalPermission,
} from "./ImpersonationProvider";
const session: Session = {
@ -65,17 +65,17 @@ describe("checkUserIdentifier", () => {
describe("checkPermission", () => {
it("should throw an error if the user is not an admin and team impersonation is disabled", () => {
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
expect(() => checkPermission(session)).toThrow();
expect(() => checkGlobalPermission(session)).toThrow();
});
it("should not throw an error if the user is an admin and team impersonation is disabled", () => {
const modifiedSession = { ...session, user: { ...session.user, role: UserPermissionRole.ADMIN } };
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
expect(() => checkPermission(modifiedSession)).not.toThrow();
expect(() => checkGlobalPermission(modifiedSession)).not.toThrow();
});
it("should not throw an error if the user is not an admin but team impersonation is enabled", () => {
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "true";
expect(() => checkPermission(session)).not.toThrow();
expect(() => checkGlobalPermission(session)).not.toThrow();
});
});

View File

@ -14,7 +14,8 @@ const teamIdschema = z.object({
const auditAndReturnNextUser = async (
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "organizationId" | "locale">,
impersonatedByUID: number,
hasTeam?: boolean
hasTeam?: boolean,
isReturningToSelf?: boolean
) => {
// Log impersonations for audit purposes
await prisma.impersonations.create({
@ -38,16 +39,36 @@ const auditAndReturnNextUser = async (
email: impersonatedUser.email,
name: impersonatedUser.name,
role: impersonatedUser.role,
impersonatedByUID,
belongsToActiveTeam: hasTeam,
organizationId: impersonatedUser.organizationId,
locale: impersonatedUser.locale,
};
if (!isReturningToSelf) {
const impersonatedByUser = await prisma.user.findUnique({
where: {
id: impersonatedByUID,
},
select: {
id: true,
role: true,
},
});
if (!impersonatedByUser) throw new Error("This user does not exist.");
return {
...obj,
impersonatedBy: {
id: impersonatedByUser?.id,
role: impersonatedByUser?.role,
},
};
}
return obj;
};
type Credentials = Record<"username" | "teamId", string> | undefined;
type Credentials = Record<"username" | "teamId" | "returnToId", string> | undefined;
export function parseTeamId(creds: Partial<Credentials>) {
return creds?.teamId ? teamIdschema.parse({ teamId: creds.teamId }).teamId : undefined;
@ -60,10 +81,13 @@ export function checkSelfImpersonation(session: Session | null, creds: Partial<C
}
export function checkUserIdentifier(creds: Partial<Credentials>) {
if (!creds?.username) throw new Error("User identifier must be present");
if (!creds?.username) {
if (creds?.returnToId) return;
throw new Error("User identifier must be present");
}
}
export function checkPermission(session: Session | null) {
export function checkGlobalPermission(session: Session | null) {
if (
(session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") ||
!session?.user
@ -133,6 +157,60 @@ async function getImpersonatedUser({
return impersonatedUser;
}
async function isReturningToSelf({ session, creds }: { session: Session | null; creds: Credentials | null }) {
const impersonatedByUID = session?.user.impersonatedBy?.id;
if (!impersonatedByUID || !creds?.returnToId) return;
const returnToId = parseInt(creds?.returnToId, 10);
// Ensure session impersonatedUID + the returnToId is the same so we cant take over a random account
if (impersonatedByUID !== returnToId) return;
const returningUser = await prisma.user.findUnique({
where: {
id: returnToId,
},
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
organizationId: true,
locale: true,
teams: {
where: {
accepted: true, // Ensure they are apart of the team and not just invited.
},
select: {
teamId: true,
disableImpersonation: true,
role: true,
},
},
},
});
if (returningUser) {
// Skip for none org users
if (returningUser.role !== "ADMIN" && !returningUser.organizationId) return;
const hasTeams = returningUser.teams.length >= 1;
return {
user: {
id: returningUser.id,
email: returningUser.email,
locale: returningUser.locale,
name: returningUser.name,
organizationId: returningUser.organizationId,
role: returningUser.role,
username: returningUser.username,
},
impersonatedByUID,
hasTeams,
};
}
}
const ImpersonationProvider = CredentialsProvider({
id: "impersonation-auth",
name: "Impersonation",
@ -140,6 +218,7 @@ const ImpersonationProvider = CredentialsProvider({
credentials: {
username: { type: "text" },
teamId: { type: "text" },
returnToId: { type: "text" },
},
async authorize(creds, req) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -148,7 +227,19 @@ const ImpersonationProvider = CredentialsProvider({
const teamId = parseTeamId(creds);
checkSelfImpersonation(session, creds);
checkUserIdentifier(creds);
checkPermission(session);
// Returning to target and UID is self without having to do perm checks.
const returnToUser = await isReturningToSelf({ session, creds });
if (returnToUser) {
return auditAndReturnNextUser(
returnToUser.user,
returnToUser.impersonatedByUID,
returnToUser.hasTeams,
true
);
}
checkGlobalPermission(session);
const impersonatedUser = await getImpersonatedUser({ session, teamId, creds });

View File

@ -56,7 +56,7 @@ export default function TimezoneChangeDialog() {
const { data } = useSession();
if (data?.user.impersonatedByUID) return null;
if (data?.user.impersonatedBy) return null;
const ONE_DAY = 60 * 60 * 24; // 1 day in seconds (60 seconds * 60 minutes * 24 hours)
const THREE_MONTHS = ONE_DAY * 90; // 90 days in seconds (90 days * 1 day in seconds)

View File

@ -200,7 +200,7 @@ const useBanners = () => {
if (isLoading || !userSession) return null;
const isUserInactiveAdmin = userSession?.user.role === "INACTIVE_ADMIN";
const userImpersonatedByUID = userSession?.user.impersonatedByUID;
const userImpersonatedByUID = userSession?.user.impersonatedBy?.id;
const userSessionBanners = {
adminPasswordBanner: isUserInactiveAdmin ? userSession : null,

View File

@ -14,7 +14,10 @@ declare module "next-auth" {
id: PrismaUser["id"];
emailVerified?: PrismaUser["emailVerified"];
email_verified?: boolean;
impersonatedByUID?: number;
impersonatedBy?: {
id: number;
role: PrismaUser["role"];
};
belongsToActiveTeam?: boolean;
org?: {
id: number;
@ -36,7 +39,10 @@ declare module "next-auth/jwt" {
username?: string | null;
email?: string | null;
role?: UserPermissionRole | "INACTIVE_ADMIN" | null;
impersonatedByUID?: number | null;
impersonatedBy?: {
id: number;
role: PrismaUser["role"];
};
belongsToActiveTeam?: boolean;
org?: {
id: number;