Restrict full organization roster access

This commit is contained in:
sivaratrisrinivas
2025-11-18 04:52:44 +05:30
parent d19b1b2e55
commit 9027a8af55
5 changed files with 157 additions and 2 deletions

View File

@@ -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"

View File

@@ -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",

View File

@@ -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();

View File

@@ -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>[];

View File

@@ -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.
*/