feat(organization): support multiple permissions check (#2227)

* feat: remove the artificial resource limit so that code can check

Also change `permission` to `permissions` (clearer for end user). `permission` is left for backwards compatibility.

* docs: add examples for multiple perms checking

* refactor: check `permissions` first, then legacy one

* feat: use union types for `permission` & `permissions`

* fix: properly use union types

* fix: remove accidental `@deprecated` comment

* chore: lint

* fix test

* chore: add oneTimeToken plugin to client barrel exports (#2224)

* docs(expo): add id token usage

* feat(oauth2): override user info on provider sign-in (#2148)

* feat(oauth2): override user info on provider sign-in

* improve email verification handling

* resolve mrge

* fix(sso): update overrideUserInfo handling to use provider configuration

* fix param

* chore: change plugin interface middleware type (#2195)

* fix: delete from session table when stopImpersonate called (#2230)

* chore: fix active organization inferred type

* chore: fix admin test

---------

Co-authored-by: Bereket Engida <bekacru@gmail.com>
Co-authored-by: Wade Fletcher <3798059+wadefletch@users.noreply.github.com>
Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com>
Co-authored-by: KinfeMichael Tariku <65047246+Kinfe123@users.noreply.github.com>
This commit is contained in:
ririxi
2025-04-12 21:00:58 +02:00
committed by GitHub
parent de91c26708
commit cb900f9594
15 changed files with 551 additions and 288 deletions

View File

@@ -19,6 +19,7 @@ import { MysqlDialect } from "kysely";
import { createPool } from "mysql2/promise"; import { createPool } from "mysql2/promise";
import { nextCookies } from "better-auth/next-js"; import { nextCookies } from "better-auth/next-js";
import { passkey } from "better-auth/plugins/passkey"; import { passkey } from "better-auth/plugins/passkey";
import { expo } from "@better-auth/expo";
import { stripe } from "@better-auth/stripe"; import { stripe } from "@better-auth/stripe";
import { Stripe } from "stripe"; import { Stripe } from "stripe";
@@ -197,5 +198,7 @@ export const auth = betterAuth({
], ],
}, },
}), }),
expo(),
], ],
trustedOrigins: ["exp://"],
}); });

View File

@@ -416,10 +416,18 @@ To check a user's permissions, you can use the `hasPermission` function provided
```ts title="auth-client.ts" ```ts title="auth-client.ts"
const canCreateProject = await authClient.admin.hasPermission({ const canCreateProject = await authClient.admin.hasPermission({
permission: { permissions: {
project: ["create"], project: ["create"],
}, },
}); });
// You can also check multiple resource permissions at the same time
const canCreateProjectAndCreateSale = await authClient.admin.hasPermission({
permissions: {
project: ["create"],
sale: ["create"]
},
});
``` ```
If you want to check a user's permissions server-side, you can use the `userHasPermission` action provided by the `api` to check the user's permissions. If you want to check a user's permissions server-side, you can use the `userHasPermission` action provided by the `api` to check the user's permissions.
@@ -429,21 +437,32 @@ import { auth } from "@/auth";
auth.api.userHasPermission({ auth.api.userHasPermission({
body: { body: {
userId: 'id', //the user id userId: 'id', //the user id
permission: { permissions: {
project: ["create"], // This must match the structure in your access control project: ["create"], // This must match the structure in your access control
}, },
}, },
}); });
//you can also just pass the role directly // You can also just pass the role directly
auth.api.userHasPermission({ auth.api.userHasPermission({
body: { body: {
role: "admin", role: "admin",
permission: { permissions: {
project: ["create"], // This must match the structure in your access control project: ["create"], // This must match the structure in your access control
}, },
}, },
}); });
// You can also check multiple resource permissions at the same time
auth.api.userHasPermission({
body: {
role: "admin",
permissions: {
project: ["create"], // This must match the structure in your access control
sale: ["create"]
},
},
});
``` ```
@@ -453,11 +472,20 @@ Once you have defined the roles and permissions to avoid checking the permission
```ts title="auth-client.ts" ```ts title="auth-client.ts"
const canCreateProject = client.admin.checkRolePermission({ const canCreateProject = client.admin.checkRolePermission({
permission: { permissions: {
user: ["delete"], user: ["delete"],
}, },
role: "admin", role: "admin",
}); });
// You can also check multiple resource permissions at the same time
const canCreateProjectAndRevokeSession = client.admin.checkRolePermission({
permissions: {
user: ["delete"],
session: ["revoke"]
},
role: "admin",
});
``` ```
## Schema ## Schema
@@ -584,4 +612,3 @@ admin({
bannedUserMessage: "Custom banned user message", bannedUserMessage: "Custom banned user message",
}); });
``` ```

View File

@@ -800,24 +800,43 @@ You can use the `hasPermission` action provided by the `api` to check the permis
```ts title="api.ts" ```ts title="api.ts"
import { auth } from "@/auth"; import { auth } from "@/auth";
auth.api.hasPermission({ auth.api.hasPermission({
headers: await headers(), headers: await headers(),
body: { body: {
permission: { permissions: {
project: ["create"] // This must match the structure in your access control project: ["create"] // This must match the structure in your access control
} }
} }
}); });
// You can also check multiple resource permissions at the same time
auth.api.hasPermission({
headers: await headers(),
body: {
permissions: {
project: ["create"], // This must match the structure in your access control
sale: ["create"]
}
}
});
``` ```
If you want to check the permission of the user on the client from the server you can use the `hasPermission` function provided by the client. If you want to check the permission of the user on the client from the server you can use the `hasPermission` function provided by the client.
```ts title="auth-client.ts" ```ts title="auth-client.ts"
const canCreateProject = await authClient.organization.hasPermission({ const canCreateProject = await authClient.organization.hasPermission({
permission: { permissions: {
project: ["create"] project: ["create"]
} }
}) })
// You can also check multiple resource permissions at the same time
const canCreateProjectAndCreateSale = await authClient.organization.hasPermission({
permissions: {
project: ["create"],
sale: ["create"]
}
})
``` ```
**Check Role Permission**: **Check Role Permission**:
@@ -826,11 +845,20 @@ Once you have defined the roles and permissions to avoid checking the permission
```ts title="auth-client.ts" ```ts title="auth-client.ts"
const canCreateProject = client.organization.checkRolePermission({ const canCreateProject = client.organization.checkRolePermission({
permission: { permissions: {
organization: ["delete"], organization: ["delete"],
}, },
role: "admin", role: "admin",
}); });
// You can also check multiple resource permissions at the same time
const canCreateProjectAndCreateSale = client.organization.checkRolePermission({
permissions: {
organization: ["delete"],
member: ["delete"]
},
role: "admin",
});
``` ```
## Teams ## Teams

View File

@@ -624,7 +624,7 @@ describe("Admin plugin", async () => {
describe("access control", async (it) => { describe("access control", async (it) => {
const ac = createAccessControl({ const ac = createAccessControl({
user: ["create", "read", "update", "delete", "list"], user: ["create", "read", "update", "delete", "list", "bulk-delete"],
order: ["create", "read", "update", "delete", "update-many"], order: ["create", "read", "update", "delete", "update-many"],
}); });
@@ -699,61 +699,136 @@ describe("access control", async (it) => {
it("should validate on the client", async () => { it("should validate on the client", async () => {
const canCreateOrder = client.admin.checkRolePermission({ const canCreateOrder = client.admin.checkRolePermission({
role: "admin", role: "admin",
permission: { permissions: {
order: ["create"], order: ["create"],
}, },
}); });
expect(canCreateOrder).toBe(true); expect(canCreateOrder).toBe(true);
// To be removed when `permission` will be removed entirely
const canCreateOrderLegacy = client.admin.checkRolePermission({
role: "admin",
permission: {
order: ["create"],
user: ["read"],
},
});
expect(canCreateOrderLegacy).toBe(true);
const canCreateOrderAndReadUser = client.admin.checkRolePermission({
role: "admin",
permissions: {
order: ["create"],
user: ["read"],
},
});
expect(canCreateOrderAndReadUser).toBe(true);
const canCreateUser = client.admin.checkRolePermission({ const canCreateUser = client.admin.checkRolePermission({
role: "user", role: "user",
permission: { permissions: {
user: ["create"], user: ["create"],
}, },
}); });
expect(canCreateUser).toBe(false); expect(canCreateUser).toBe(false);
const canCreateOrderAndCreateUser = client.admin.checkRolePermission({
role: "user",
permissions: {
order: ["create"],
user: ["create"],
},
});
expect(canCreateOrderAndCreateUser).toBe(false);
}); });
it("should validate using userId", async () => { it("should validate using userId", async () => {
const canCreateUser = await auth.api.userHasPermission({ const canCreateUser = await auth.api.userHasPermission({
body: { body: {
userId: user.id, userId: user.id,
permission: { permissions: {
user: ["create"], user: ["create"],
}, },
}, },
}); });
expect(canCreateUser.success).toBe(true); expect(canCreateUser.success).toBe(true);
const canCreateUserAndCreateOrder = await auth.api.userHasPermission({
body: {
userId: user.id,
permissions: {
user: ["create"],
order: ["create"],
},
},
});
expect(canCreateUserAndCreateOrder.success).toBe(true);
const canUpdateManyOrder = await auth.api.userHasPermission({ const canUpdateManyOrder = await auth.api.userHasPermission({
body: { body: {
userId: user.id, userId: user.id,
permission: { permissions: {
order: ["update-many"], order: ["update-many"],
}, },
}, },
}); });
expect(canUpdateManyOrder.success).toBe(false); expect(canUpdateManyOrder.success).toBe(false);
const canUpdateManyOrderAndBulkDeleteUser =
await auth.api.userHasPermission({
body: {
userId: user.id,
permissions: {
user: ["bulk-delete"],
order: ["update-many"],
},
},
});
expect(canUpdateManyOrderAndBulkDeleteUser.success).toBe(false);
}); });
it("should validate using role", async () => { it("should validate using role", async () => {
const canCreateUser = await auth.api.userHasPermission({ const canCreateUser = await auth.api.userHasPermission({
body: { body: {
role: "admin", role: "admin",
permission: { permissions: {
user: ["create"], user: ["create"],
}, },
}, },
}); });
expect(canCreateUser.success).toBe(true); expect(canCreateUser.success).toBe(true);
const canCreateUserAndCreateOrder = await auth.api.userHasPermission({
body: {
role: "admin",
permissions: {
user: ["create"],
order: ["create"],
},
},
});
expect(canCreateUserAndCreateOrder.success).toBe(true);
const canUpdateOrder = await auth.api.userHasPermission({ const canUpdateOrder = await auth.api.userHasPermission({
body: { body: {
role: "user", role: "user",
permission: { permissions: {
order: ["update"], order: ["update"],
}, },
}, },
}); });
expect(canUpdateOrder.success).toBe(false); expect(canUpdateOrder.success).toBe(false);
const canUpdateOrderAndUpdateUser = await auth.api.userHasPermission({
body: {
role: "user",
permissions: {
order: ["update"],
user: ["update"],
},
},
});
expect(canUpdateOrderAndUpdateUser.success).toBe(false);
}); });
it("shouldn't allow to list users", async () => { it("shouldn't allow to list users", async () => {

View File

@@ -119,6 +119,26 @@ export const admin = <O extends AdminOptions>(options?: O) => {
? S ? S
: DefaultStatements; : DefaultStatements;
type PermissionType = {
[key in keyof Statements]?: Array<
Statements[key] extends readonly unknown[]
? Statements[key][number]
: never
>;
};
type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: PermissionType;
permissions?: never;
}
| {
permissions: PermissionType;
permission?: never;
};
const adminMiddleware = createAuthMiddleware(async (ctx) => { const adminMiddleware = createAuthMiddleware(async (ctx) => {
const session = await getSessionFromCtx(ctx); const session = await getSessionFromCtx(ctx);
if (!session) { if (!session) {
@@ -265,7 +285,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: ctx.context.session.user.role, role: ctx.context.session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["set-role"], user: ["set-role"],
}, },
}); });
@@ -370,7 +390,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: session.user.id, userId: session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["create"], user: ["create"],
}, },
}); });
@@ -528,7 +548,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["list"], user: ["list"],
}, },
}); });
@@ -629,7 +649,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
session: ["list"], session: ["list"],
}, },
}); });
@@ -689,7 +709,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["ban"], user: ["ban"],
}, },
}); });
@@ -769,7 +789,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["ban"], user: ["ban"],
}, },
}); });
@@ -848,7 +868,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: ctx.context.session.user.role, role: ctx.context.session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["impersonate"], user: ["impersonate"],
}, },
}); });
@@ -1001,7 +1021,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
session: ["revoke"], session: ["revoke"],
}, },
}); });
@@ -1061,7 +1081,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
session: ["revoke"], session: ["revoke"],
}, },
}); });
@@ -1120,7 +1140,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: session.user.role, role: session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["delete"], user: ["delete"],
}, },
}); });
@@ -1178,7 +1198,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: ctx.context.session.user.id, userId: ctx.context.session.user.id,
role: ctx.context.session.user.role, role: ctx.context.session.user.role,
options: opts, options: opts,
permission: { permissions: {
user: ["set-password"], user: ["set-password"],
}, },
}); });
@@ -1204,11 +1224,23 @@ export const admin = <O extends AdminOptions>(options?: O) => {
"/admin/has-permission", "/admin/has-permission",
{ {
method: "POST", method: "POST",
body: z.object({ body: z
permission: z.record(z.string(), z.array(z.string())), .object({
userId: z.coerce.string().optional(), userId: z.coerce.string().optional(),
role: z.string().optional(), role: z.string().optional(),
})
.and(
z.union([
z.object({
permission: z.record(z.string(), z.array(z.string())),
permissions: z.undefined(),
}), }),
z.object({
permission: z.undefined(),
permissions: z.record(z.string(), z.array(z.string())),
}),
]),
),
metadata: { metadata: {
openapi: { openapi: {
description: "Check if the user has permission", description: "Check if the user has permission",
@@ -1221,9 +1253,14 @@ export const admin = <O extends AdminOptions>(options?: O) => {
permission: { permission: {
type: "object", type: "object",
description: "The permission to check", description: "The permission to check",
deprecated: true,
},
permissions: {
type: "object",
description: "The permission to check",
}, },
}, },
required: ["permission"], required: ["permissions"],
}, },
}, },
}, },
@@ -1251,11 +1288,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
}, },
}, },
$Infer: { $Infer: {
body: {} as { body: {} as PermissionExclusive & {
permission: {
//@ts-expect-error
[key in keyof Statements]?: Array<Statements[key][number]>;
};
userId?: string; userId?: string;
role?: InferAdminRolesFromOption<O>; role?: InferAdminRolesFromOption<O>;
}, },
@@ -1263,13 +1296,10 @@ export const admin = <O extends AdminOptions>(options?: O) => {
}, },
}, },
async (ctx) => { async (ctx) => {
if ( if (!ctx.body?.permission && !ctx.body?.permissions) {
!ctx.body.permission ||
Object.keys(ctx.body.permission).length > 1
) {
throw new APIError("BAD_REQUEST", { throw new APIError("BAD_REQUEST", {
message: message:
"invalid permission check. you can only check one resource permission at a time.", "invalid permission check. no permission(s) were passed.",
}); });
} }
const session = await getSessionFromCtx(ctx); const session = await getSessionFromCtx(ctx);
@@ -1297,7 +1327,7 @@ export const admin = <O extends AdminOptions>(options?: O) => {
userId: user.id, userId: user.id,
role: user.role, role: user.role,
options: options as AdminOptions, options: options as AdminOptions,
permission: ctx.body.permission as any, permissions: (ctx.body.permissions ?? ctx.body.permission) as any,
}); });
return ctx.json({ return ctx.json({
error: null, error: null,

View File

@@ -1,4 +1,3 @@
import { BetterAuthError } from "../../error";
import type { BetterAuthClientPlugin } from "../../types"; import type { BetterAuthClientPlugin } from "../../types";
import { type AccessControl, type Role } from "../access"; import { type AccessControl, type Role } from "../access";
import { adminAc, defaultStatements, userAc } from "./access"; import { adminAc, defaultStatements, userAc } from "./access";
@@ -17,6 +16,26 @@ export const adminClient = <O extends AdminClientOptions>(options?: O) => {
type Statements = O["ac"] extends AccessControl<infer S> type Statements = O["ac"] extends AccessControl<infer S>
? S ? S
: DefaultStatements; : DefaultStatements;
type PermissionType = {
[key in keyof Statements]?: Array<
Statements[key] extends readonly unknown[]
? Statements[key][number]
: never
>;
};
type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: PermissionType;
permissions?: never;
}
| {
permissions: PermissionType;
permission?: never;
};
const roles = { const roles = {
admin: adminAc, admin: adminAc,
user: userAc, user: userAc,
@@ -44,25 +63,18 @@ export const adminClient = <O extends AdminClientOptions>(options?: O) => {
R extends O extends { roles: any } R extends O extends { roles: any }
? keyof O["roles"] ? keyof O["roles"]
: "admin" | "user", : "admin" | "user",
>(data: { >(
data: PermissionExclusive & {
role: R; role: R;
permission: { },
//@ts-expect-error fix this later ) => {
[key in keyof Statements]?: Statements[key][number][];
};
}) => {
if (Object.keys(data.permission).length > 1) {
throw new BetterAuthError(
"you can only check one resource permission at a time.",
);
}
const isAuthorized = hasPermission({ const isAuthorized = hasPermission({
role: data.role as string, role: data.role as string,
options: { options: {
ac: options?.ac, ac: options?.ac,
roles: roles, roles: roles,
}, },
permission: data.permission as any, permissions: (data.permissions ?? data.permission) as any,
}); });
return isAuthorized; return isAuthorized;
}, },

View File

@@ -1,22 +1,37 @@
import { defaultRoles } from "./access"; import { defaultRoles } from "./access";
import type { AdminOptions } from "./admin"; import type { AdminOptions } from "./admin";
export const hasPermission = (input: { type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: { [key: string]: string[] };
permissions?: never;
}
| {
permissions: { [key: string]: string[] };
permission?: never;
};
export const hasPermission = (
input: {
userId?: string; userId?: string;
role?: string; role?: string;
options?: AdminOptions; options?: AdminOptions;
permission: { } & PermissionExclusive,
[key: string]: string[]; ) => {
};
}) => {
if (input.userId && input.options?.adminUserIds?.includes(input.userId)) { if (input.userId && input.options?.adminUserIds?.includes(input.userId)) {
return true; return true;
} }
if (!input.permissions && !input.permission) {
return false;
}
const roles = (input.role || input.options?.defaultRole || "user").split(","); const roles = (input.role || input.options?.defaultRole || "user").split(",");
const acRoles = input.options?.roles || defaultRoles; const acRoles = input.options?.roles || defaultRoles;
for (const role of roles) { for (const role of roles) {
const _role = acRoles[role as keyof typeof acRoles]; const _role = acRoles[role as keyof typeof acRoles];
const result = _role?.authorize(input.permission); const result = _role?.authorize(input.permission ?? input.permissions);
if (result?.success) { if (result?.success) {
return true; return true;
} }

View File

@@ -12,7 +12,6 @@ import { type AccessControl, type Role } from "../access";
import type { BetterAuthClientPlugin } from "../../client/types"; import type { BetterAuthClientPlugin } from "../../client/types";
import type { organization } from "./organization"; import type { organization } from "./organization";
import { useAuthQuery } from "../../client"; import { useAuthQuery } from "../../client";
import { BetterAuthError } from "../../error";
import { defaultStatements, adminAc, memberAc, ownerAc } from "./access"; import { defaultStatements, adminAc, memberAc, ownerAc } from "./access";
import { hasPermission } from "./has-permission"; import { hasPermission } from "./has-permission";
@@ -37,6 +36,26 @@ export const organizationClient = <O extends OrganizationClientOptions>(
type Statements = O["ac"] extends AccessControl<infer S> type Statements = O["ac"] extends AccessControl<infer S>
? S ? S
: DefaultStatements; : DefaultStatements;
type PermissionType = {
[key in keyof Statements]?: Array<
Statements[key] extends readonly unknown[]
? Statements[key][number]
: never
>;
};
type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: PermissionType;
permissions?: never;
}
| {
permissions: PermissionType;
permission?: never;
};
const roles = { const roles = {
admin: adminAc, admin: adminAc,
member: memberAc, member: memberAc,
@@ -86,25 +105,18 @@ export const organizationClient = <O extends OrganizationClientOptions>(
R extends O extends { roles: any } R extends O extends { roles: any }
? keyof O["roles"] ? keyof O["roles"]
: "admin" | "member" | "owner", : "admin" | "member" | "owner",
>(data: { >(
data: PermissionExclusive & {
role: R; role: R;
permission: { },
//@ts-expect-error fix this later ) => {
[key in keyof Statements]?: Statements[key][number][];
};
}) => {
if (Object.keys(data.permission).length > 1) {
throw new BetterAuthError(
"you can only check one resource permission at a time.",
);
}
const isAuthorized = hasPermission({ const isAuthorized = hasPermission({
role: data.role as string, role: data.role as string,
options: { options: {
ac: options?.ac, ac: options?.ac,
roles: roles, roles: roles,
}, },
permission: data.permission as any, permissions: (data.permissions ?? data.permission) as any,
}); });
return isAuthorized; return isAuthorized;
}, },

View File

@@ -1,18 +1,33 @@
import { defaultRoles } from "./access"; import { defaultRoles } from "./access";
import type { OrganizationOptions } from "./organization"; import type { OrganizationOptions } from "./organization";
export const hasPermission = (input: { type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: { [key: string]: string[] };
permissions?: never;
}
| {
permissions: { [key: string]: string[] };
permission?: never;
};
export const hasPermission = (
input: {
role: string; role: string;
options: OrganizationOptions; options: OrganizationOptions;
permission: { } & PermissionExclusive,
[key: string]: string[]; ) => {
}; if (!input.permissions && !input.permission) {
}) => { return false;
}
const roles = input.role.split(","); const roles = input.role.split(",");
const acRoles = input.options.roles || defaultRoles; const acRoles = input.options.roles || defaultRoles;
for (const role of roles) { for (const role of roles) {
const _role = acRoles[role as keyof typeof acRoles]; const _role = acRoles[role as keyof typeof acRoles];
const result = _role?.authorize(input.permission); const result = _role?.authorize(input.permissions ?? input.permission);
if (result?.success) { if (result?.success) {
return true; return true;
} }

View File

@@ -5,7 +5,6 @@ import { createAuthClient } from "../../client";
import { organizationClient } from "./client"; import { organizationClient } from "./client";
import { createAccessControl } from "../access"; import { createAccessControl } from "../access";
import { ORGANIZATION_ERROR_CODES } from "./error-codes"; import { ORGANIZATION_ERROR_CODES } from "./error-codes";
import { BetterAuthError } from "../../error";
import { APIError } from "better-call"; import { APIError } from "better-call";
describe("organization", async (it) => { describe("organization", async (it) => {
@@ -500,7 +499,7 @@ describe("organization", async (it) => {
}, },
}); });
const hasPermission = await client.organization.hasPermission({ const hasPermission = await client.organization.hasPermission({
permission: { permissions: {
member: ["update"], member: ["update"],
}, },
fetchOptions: { fetchOptions: {
@@ -508,6 +507,17 @@ describe("organization", async (it) => {
}, },
}); });
expect(hasPermission.data?.success).toBe(true); expect(hasPermission.data?.success).toBe(true);
const hasMultiplePermissions = await client.organization.hasPermission({
permissions: {
member: ["update"],
invitation: ["create"],
},
fetchOptions: {
headers,
},
});
expect(hasMultiplePermissions.data?.success).toBe(true);
}); });
it("should allow deleting organization", async () => { it("should allow deleting organization", async () => {
@@ -795,15 +805,25 @@ describe("access control", async (it) => {
it("should return success", async () => { it("should return success", async () => {
const canCreateProject = checkRolePermission({ const canCreateProject = checkRolePermission({
role: "admin", role: "admin",
permission: { permissions: {
project: ["create"], project: ["create"],
}, },
}); });
expect(canCreateProject).toBe(true); expect(canCreateProject).toBe(true);
const canCreateProjectServer = await hasPermission({
// To be removed when `permission` will be removed entirely
const canCreateProjectLegacy = checkRolePermission({
role: "admin",
permission: { permission: {
project: ["create"], project: ["create"],
}, },
});
expect(canCreateProjectLegacy).toBe(true);
const canCreateProjectServer = await hasPermission({
permissions: {
project: ["create"],
},
fetchOptions: { fetchOptions: {
headers, headers,
}, },
@@ -814,7 +834,7 @@ describe("access control", async (it) => {
it("should return not success", async () => { it("should return not success", async () => {
const canCreateProject = checkRolePermission({ const canCreateProject = checkRolePermission({
role: "admin", role: "admin",
permission: { permissions: {
project: ["delete"], project: ["delete"],
}, },
}); });
@@ -822,21 +842,14 @@ describe("access control", async (it) => {
}); });
it("should return not success", async () => { it("should return not success", async () => {
let error: BetterAuthError | null = null; const res = checkRolePermission({
try {
checkRolePermission({
role: "admin", role: "admin",
permission: { permissions: {
project: ["read"], project: ["read"],
sales: ["delete"], sales: ["delete"],
}, },
}); });
} catch (e) { expect(res).toBe(false);
if (e instanceof BetterAuthError) {
error = e;
}
}
expect(error).toBeInstanceOf(BetterAuthError);
}); });
}); });

View File

@@ -443,6 +443,26 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
type Statements = O["ac"] extends AccessControl<infer S> type Statements = O["ac"] extends AccessControl<infer S>
? S ? S
: DefaultStatements; : DefaultStatements;
type PermissionType = {
[key in keyof Statements]?: Array<
Statements[key] extends readonly unknown[]
? Statements[key][number]
: never
>;
};
type PermissionExclusive =
| {
/**
* @deprecated Use `permissions` instead
*/
permission: PermissionType;
permissions?: never;
}
| {
permissions: PermissionType;
permission?: never;
};
return { return {
id: "organization", id: "organization",
endpoints: { endpoints: {
@@ -454,18 +474,26 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
{ {
method: "POST", method: "POST",
requireHeaders: true, requireHeaders: true,
body: z.object({ body: z
.object({
organizationId: z.string().optional(), organizationId: z.string().optional(),
})
.and(
z.union([
z.object({
permission: z.record(z.string(), z.array(z.string())), permission: z.record(z.string(), z.array(z.string())),
permissions: z.undefined(),
}), }),
z.object({
permission: z.undefined(),
permissions: z.record(z.string(), z.array(z.string())),
}),
]),
),
use: [orgSessionMiddleware], use: [orgSessionMiddleware],
metadata: { metadata: {
$Infer: { $Infer: {
body: {} as { body: {} as PermissionExclusive & {
permission: {
//@ts-expect-error
[key in keyof Statements]?: Array<Statements[key][number]>;
};
organizationId?: string; organizationId?: string;
}, },
}, },
@@ -480,9 +508,14 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
permission: { permission: {
type: "object", type: "object",
description: "The permission to check", description: "The permission to check",
deprecated: true,
},
permissions: {
type: "object",
description: "The permission to check",
}, },
}, },
required: ["permission"], required: ["permissions"],
}, },
}, },
}, },
@@ -534,7 +567,7 @@ export const organization = <O extends OrganizationOptions>(options?: O) => {
const result = hasPermission({ const result = hasPermission({
role: member.role, role: member.role,
options: options as OrganizationOptions, options: options as OrganizationOptions,
permission: ctx.body.permission as any, permissions: (ctx.body.permissions ?? ctx.body.permission) as any,
}); });
return ctx.json({ return ctx.json({
error: null, error: null,

View File

@@ -153,7 +153,7 @@ export const createInvitation = <O extends OrganizationOptions | undefined>(
const canInvite = hasPermission({ const canInvite = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
invitation: ["create"], invitation: ["create"],
}, },
}); });
@@ -489,7 +489,7 @@ export const cancelInvitation = createAuthEndpoint(
const canCancel = hasPermission({ const canCancel = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
invitation: ["cancel"], invitation: ["cancel"],
}, },
}); });

View File

@@ -244,7 +244,7 @@ export const removeMember = createAuthEndpoint(
const canDeleteMember = hasPermission({ const canDeleteMember = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
member: ["delete"], member: ["delete"],
}, },
}); });
@@ -399,7 +399,7 @@ export const updateMemberRole = <O extends OrganizationOptions>(option: O) =>
const canUpdateMember = hasPermission({ const canUpdateMember = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
member: ["update"], member: ["update"],
}, },
}); });

View File

@@ -321,7 +321,7 @@ export const updateOrganization = createAuthEndpoint(
}); });
} }
const canUpdateOrg = hasPermission({ const canUpdateOrg = hasPermission({
permission: { permissions: {
organization: ["update"], organization: ["update"],
}, },
role: member.role, role: member.role,
@@ -403,7 +403,7 @@ export const deleteOrganization = createAuthEndpoint(
} }
const canDeleteOrg = hasPermission({ const canDeleteOrg = hasPermission({
role: member.role, role: member.role,
permission: { permissions: {
organization: ["delete"], organization: ["delete"],
}, },
options: ctx.context.orgOptions, options: ctx.context.orgOptions,

View File

@@ -99,7 +99,7 @@ export const createTeam = <O extends OrganizationOptions | undefined>(
const canCreate = hasPermission({ const canCreate = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
team: ["create"], team: ["create"],
}, },
}); });
@@ -208,7 +208,7 @@ export const removeTeam = createAuthEndpoint(
const canRemove = hasPermission({ const canRemove = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
team: ["delete"], team: ["delete"],
}, },
}); });
@@ -329,7 +329,7 @@ export const updateTeam = createAuthEndpoint(
const canUpdate = hasPermission({ const canUpdate = hasPermission({
role: member.role, role: member.role,
options: ctx.context.orgOptions, options: ctx.context.orgOptions,
permission: { permissions: {
team: ["update"], team: ["update"],
}, },
}); });