refactor(admin): allow flexible admin impersonation (#8045)

This commit is contained in:
Joél Solano
2026-02-28 22:15:34 +01:00
committed by GitHub
parent 0deaaa4e67
commit 4f1326ef18
5 changed files with 223 additions and 21 deletions

View File

@@ -437,6 +437,19 @@ type impersonateUser = {
```
</APIMethod>
By default, admins cannot impersonate other admin users. To allow this, grant the `impersonate-admins` permission to a role:
```ts title="auth.ts"
const superAdmin = ac.newRole({
...adminAc.statements,
user: ["impersonate-admins", ...adminAc.statements.user],
});
```
<Callout type="info">
The legacy `allowImpersonatingAdmins` option is still supported, but is deprecated and will be removed in a future version.
</Callout>
### Stop Impersonating User
To stop impersonating a user and continue with the admin account, you can use `stopImpersonating`
@@ -489,7 +502,7 @@ By default, there are two roles:
By default, there are two resources with up to six permissions.
**user**:
`create` `list` `set-role` `ban` `impersonate` `delete` `set-password`
`create` `list` `set-role` `ban` `impersonate` `impersonate-admins` `delete` `set-password`
**session**:
`list` `revoke` `delete`
@@ -865,12 +878,3 @@ admin({
});
```
### allowImpersonatingAdmins
Whether to allow impersonating other admin users. Defaults to `false`.
```ts title="auth.ts"
admin({
allowImpersonatingAdmins: true,
});
```

View File

@@ -7,6 +7,7 @@ export const defaultStatements = {
"set-role",
"ban",
"impersonate",
"impersonate-admins",
"delete",
"set-password",
"get",

View File

@@ -738,7 +738,7 @@ describe("Admin plugin", async () => {
expect(res.error?.status).toBe(403);
});
it("should not allow to impersonate admins", async () => {
it("should not allow to impersonate admins without impersonate-admins permission", async () => {
const userToImpersonate = await client.signUp.email({
email: "impersonate-admin@mail.com",
password: "password",
@@ -762,6 +762,190 @@ describe("Admin plugin", async () => {
expect(res.error?.status).toBe(403);
});
it("should allow impersonating admins with impersonate-admins permission", async () => {
const ac = createAccessControl({
user: [
"create",
"list",
"set-role",
"ban",
"impersonate",
"impersonate-admins",
"delete",
"set-password",
"get",
"update",
],
session: ["list", "revoke", "delete"],
} as const);
const superAdminRole = ac.newRole({
user: [
"create",
"list",
"set-role",
"ban",
"impersonate",
"impersonate-admins",
"delete",
"set-password",
"get",
"update",
],
session: ["list", "revoke", "delete"],
});
const regularAdminRole = ac.newRole({
user: [
"create",
"list",
"set-role",
"ban",
"impersonate",
"delete",
"set-password",
"get",
"update",
],
session: ["list", "revoke", "delete"],
});
const userRole = ac.newRole({
user: [],
session: [],
});
const {
signInWithTestUser: signInSuperAdmin,
signInWithUser: signInAsUser,
customFetchImpl: fetchImpl,
} = await getTestInstance(
{
plugins: [
admin({
ac,
roles: {
"super-admin": superAdminRole,
admin: regularAdminRole,
user: userRole,
},
}),
],
databaseHooks: {
user: {
create: {
before: async (user) => {
if (user.name === "Super Admin") {
return { data: { ...user, role: "super-admin" } };
}
},
},
},
},
},
{ testUser: { name: "Super Admin" } },
);
const c = createAuthClient({
fetchOptions: { customFetchImpl: fetchImpl },
plugins: [
adminClient({
ac,
roles: {
"super-admin": superAdminRole,
admin: regularAdminRole,
user: userRole,
},
}),
],
baseURL: "http://localhost:3000",
});
const { headers: superAdminHeaders } = await signInSuperAdmin();
const { data: targetAdmin } = await c.signUp.email({
email: "target-admin-perm@test.com",
password: "password",
name: "Target Admin Perm",
});
await c.admin.setRole(
{ userId: targetAdmin!.user.id, role: "admin" },
{ headers: superAdminHeaders },
);
// super-admin has impersonate-admins permission, should succeed
const res = await c.admin.impersonateUser(
{ userId: targetAdmin!.user.id },
{ headers: superAdminHeaders },
);
expect(res.data?.session).toBeDefined();
expect(res.data?.user?.id).toBe(targetAdmin!.user.id);
// regular admin does NOT have impersonate-admins permission, should fail
const { data: regularAdmin } = await c.signUp.email({
email: "regular-admin-perm@test.com",
password: "password",
name: "Regular Admin Perm",
});
await c.admin.setRole(
{ userId: regularAdmin!.user.id, role: "admin" },
{ headers: superAdminHeaders },
);
const { headers: regularAdminHeaders } = await signInAsUser(
"regular-admin-perm@test.com",
"password",
);
const res2 = await c.admin.impersonateUser(
{ userId: targetAdmin!.user.id },
{ headers: regularAdminHeaders },
);
expect(res2.error?.status).toBe(403);
});
it("should allow impersonating admins with legacy allowImpersonatingAdmins option", async () => {
const { signInWithTestUser: signInAdmin, customFetchImpl: fetchImpl } =
await getTestInstance(
{
plugins: [
admin({
allowImpersonatingAdmins: true,
}),
],
databaseHooks: {
user: {
create: {
before: async (user) => {
if (user.name === "Admin") {
return { data: { ...user, role: "admin" } };
}
},
},
},
},
},
{ testUser: { name: "Admin" } },
);
const c = createAuthClient({
fetchOptions: { customFetchImpl: fetchImpl },
plugins: [adminClient()],
baseURL: "http://localhost:3000",
});
const { headers: aHeaders } = await signInAdmin();
const { data: targetAdmin } = await c.signUp.email({
email: "target-admin-legacy@test.com",
password: "password",
name: "Target Admin Legacy",
});
await c.admin.setRole(
{ userId: targetAdmin!.user.id, role: "admin" },
{ headers: aHeaders },
);
const res = await c.admin.impersonateUser(
{ userId: targetAdmin!.user.id },
{ headers: aHeaders },
);
expect(res.data?.session).toBeDefined();
expect(res.data?.user?.id).toBe(targetAdmin!.user.id);
});
it("should filter impersonated sessions", async () => {
const { headers } = await signInWithUser(data.email, data.password);
const res = await client.listSessions({

View File

@@ -1079,15 +1079,26 @@ export const impersonateUser = (opts: AdminOptions) =>
opts.defaultRole ||
"user"
).split(",");
if (
opts.allowImpersonatingAdmins !== true &&
(targetUserRole.some((role) => adminRoles.includes(role)) ||
opts.adminUserIds?.includes(targetUser.id))
) {
throw APIError.from(
"FORBIDDEN",
ADMIN_ERROR_CODES.YOU_CANNOT_IMPERSONATE_ADMINS,
);
const isTargetAdmin =
targetUserRole.some((role) => adminRoles.includes(role)) ||
!!opts.adminUserIds?.includes(targetUser.id);
if (isTargetAdmin) {
const canImpersonateAdmins =
opts.allowImpersonatingAdmins === true ||
hasPermission({
userId: ctx.context.session.user.id,
role: ctx.context.session.user.role,
options: opts,
permissions: {
user: ["impersonate-admins"],
},
});
if (!canImpersonateAdmins) {
throw APIError.from(
"FORBIDDEN",
ADMIN_ERROR_CODES.YOU_CANNOT_IMPERSONATE_ADMINS,
);
}
}
const session = await ctx.context.internalAdapter.createSession(

View File

@@ -77,7 +77,9 @@ export interface AdminOptions {
*/
bannedUserMessage?: string | undefined;
/**
* Whether to allow impersonating other admins
* Whether to allow impersonating other admins.
*
* @deprecated Use the `impersonate-admins` permission instead.
*
* @default false
*/