mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-25 00:22:43 -05:00
Restrict full organization roster access
This commit is contained in:
@@ -716,6 +716,21 @@ To retrieve the active organization for the user, you can call the `useActiveOrg
|
||||
|
||||
To get the full details of an organization, you can use the `getFullOrganization` function.
|
||||
By default, if you don't pass any properties, it will use the active organization.
|
||||
Only users with the `owner` or `admin` role can access this endpoint unless you opt in to broader access.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
import { organization } from "better-auth/plugins";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [
|
||||
organization({
|
||||
// Allow members to read the full org roster as well.
|
||||
fullOrganizationAccessRoles: ["owner", "admin", "member"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<APIMethod
|
||||
path="/organization/get-full-organization"
|
||||
|
||||
@@ -14,6 +14,8 @@ export const ORGANIZATION_ERROR_CODES = defineErrorCodes({
|
||||
"You are not allowed to update this organization",
|
||||
YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_ORGANIZATION:
|
||||
"You are not allowed to delete this organization",
|
||||
YOU_ARE_NOT_ALLOWED_TO_READ_THIS_ORGANIZATION:
|
||||
"You are not allowed to read this organization",
|
||||
NO_ACTIVE_ORGANIZATION: "No active organization",
|
||||
USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION:
|
||||
"User is already a member of this organization",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAuthClient } from "../../../client";
|
||||
import { getTestInstance } from "../../../test-utils/test-instance";
|
||||
@@ -164,6 +165,54 @@ describe("get-full-organization", async () => {
|
||||
expect(invitation?.role).toBe("member");
|
||||
});
|
||||
|
||||
it("should forbid member role from reading full organization by default", async () => {
|
||||
const isolatedOrg = await client.organization.create({
|
||||
name: `forbid-${randomUUID()}`,
|
||||
slug: `forbid-${randomUUID()}`,
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
const memberEmail = `member-default-${randomUUID()}@test.com`;
|
||||
const memberPassword = "password";
|
||||
const newUser = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: memberEmail,
|
||||
password: memberPassword,
|
||||
name: "Member Default",
|
||||
},
|
||||
});
|
||||
await auth.api.addMember({
|
||||
body: {
|
||||
userId: newUser.user.id,
|
||||
role: "member",
|
||||
organizationId: isolatedOrg.data?.id as string,
|
||||
},
|
||||
});
|
||||
const memberHeaders = new Headers();
|
||||
await client.signIn.email(
|
||||
{
|
||||
email: memberEmail,
|
||||
password: memberPassword,
|
||||
},
|
||||
{
|
||||
onSuccess: cookieSetter(memberHeaders),
|
||||
},
|
||||
);
|
||||
const result = await client.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationId: isolatedOrg.data?.id as string,
|
||||
},
|
||||
fetchOptions: {
|
||||
headers: memberHeaders,
|
||||
},
|
||||
});
|
||||
expect(result.error?.status).toBe(403);
|
||||
expect(result.error?.message).toBe(
|
||||
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_READ_THIS_ORGANIZATION,
|
||||
);
|
||||
});
|
||||
|
||||
it("should prioritize organizationSlug over organizationId when both are provided", async () => {
|
||||
const result = await client.organization.getFullOrganization({
|
||||
query: {
|
||||
@@ -253,6 +302,76 @@ describe("get-full-organization", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("get-full-organization role overrides", async () => {
|
||||
const { auth, signInWithTestUser, cookieSetter } = await getTestInstance({
|
||||
plugins: [
|
||||
organization({
|
||||
fullOrganizationAccessRoles: ["owner", "admin", "member"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const { headers } = await signInWithTestUser();
|
||||
const client = createAuthClient({
|
||||
plugins: [organizationClient()],
|
||||
baseURL: "http://localhost:3000/api/auth",
|
||||
fetchOptions: {
|
||||
customFetchImpl: async (url, init) => {
|
||||
return auth.handler(new Request(url, init));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const org = await client.organization.create({
|
||||
name: "role-override-org",
|
||||
slug: "role-override-org",
|
||||
fetchOptions: {
|
||||
headers,
|
||||
},
|
||||
});
|
||||
|
||||
it("should allow configured member role to read full organization", async () => {
|
||||
const memberEmail = `member-override-${randomUUID()}@test.com`;
|
||||
const memberPassword = "password";
|
||||
const member = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: memberEmail,
|
||||
password: memberPassword,
|
||||
name: "Member Override",
|
||||
},
|
||||
});
|
||||
await auth.api.addMember({
|
||||
body: {
|
||||
userId: member.user.id,
|
||||
role: "member",
|
||||
organizationId: org.data?.id as string,
|
||||
},
|
||||
});
|
||||
const memberHeaders = new Headers();
|
||||
await client.signIn.email(
|
||||
{
|
||||
email: memberEmail,
|
||||
password: memberPassword,
|
||||
},
|
||||
{
|
||||
onSuccess: cookieSetter(memberHeaders),
|
||||
},
|
||||
);
|
||||
const result = await client.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationId: org.data?.id as string,
|
||||
},
|
||||
fetchOptions: {
|
||||
headers: memberHeaders,
|
||||
},
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data?.id).toBe(org.data?.id);
|
||||
expect(result.data?.members?.some((m: any) => m.userId === member.user.id)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization hooks", async () => {
|
||||
it("should apply beforeCreateOrganization hook", async () => {
|
||||
const beforeCreateOrganization = vi.fn();
|
||||
|
||||
@@ -724,11 +724,11 @@ export const getFullOrganization = <O extends OrganizationOptions>(
|
||||
message: ORGANIZATION_ERROR_CODES.ORGANIZATION_NOT_FOUND,
|
||||
});
|
||||
}
|
||||
const isMember = await adapter.checkMembership({
|
||||
const member = await adapter.checkMembership({
|
||||
userId: session.user.id,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
if (!isMember) {
|
||||
if (!member) {
|
||||
await adapter.setActiveOrganization(session.session.token, null, ctx);
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message:
|
||||
@@ -736,6 +736,19 @@ export const getFullOrganization = <O extends OrganizationOptions>(
|
||||
});
|
||||
}
|
||||
|
||||
const allowedRoles =
|
||||
Array.isArray(ctx.context.orgOptions.fullOrganizationAccessRoles) &&
|
||||
ctx.context.orgOptions.fullOrganizationAccessRoles.length > 0
|
||||
? ctx.context.orgOptions.fullOrganizationAccessRoles
|
||||
: ["owner", "admin"];
|
||||
|
||||
if (!allowedRoles.includes(member.role)) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message:
|
||||
ORGANIZATION_ERROR_CODES.YOU_ARE_NOT_ALLOWED_TO_READ_THIS_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
|
||||
type OrganizationReturn = O["teams"] extends { enabled: true }
|
||||
? {
|
||||
members: InferMember<O, false>[];
|
||||
|
||||
@@ -65,6 +65,12 @@ export interface OrganizationOptions {
|
||||
[key in string]?: Role<any>;
|
||||
}
|
||||
| undefined;
|
||||
/**
|
||||
* Allowed roles for `/organization/get-full-organization`.
|
||||
*
|
||||
* @default ["owner", "admin"]
|
||||
*/
|
||||
fullOrganizationAccessRoles?: string[] | undefined;
|
||||
/**
|
||||
* Dynamic access control for the organization plugin.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user