mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-29 18:36:34 -05:00
refactor(admin): allow flexible admin impersonation (#8045)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -7,6 +7,7 @@ export const defaultStatements = {
|
||||
"set-role",
|
||||
"ban",
|
||||
"impersonate",
|
||||
"impersonate-admins",
|
||||
"delete",
|
||||
"set-password",
|
||||
"get",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user