Compare commits
14 Commits
main
...
feat/imper
Author | SHA1 | Date | |
---|---|---|---|
805ddfaf87 | |||
90efde5c5d | |||
d755635a01 | |||
7b98ff4078 | |||
296f185329 | |||
5c4cb68966 | |||
2e9e341f80 | |||
e41ba360e5 | |||
3ac362a6d1 | |||
37b9476398 | |||
68895f9b57 | |||
d905cdf056 | |||
26e87ff086 | |||
dd0fd5b5cc |
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue
Block a user