From 9027a8af5589768f7c7d40a41ea3438caa720bb3 Mon Sep 17 00:00:00 2001 From: sivaratrisrinivas Date: Tue, 18 Nov 2025 04:52:44 +0530 Subject: [PATCH] Restrict full organization roster access --- docs/content/docs/plugins/organization.mdx | 15 +++ .../src/plugins/organization/error-codes.ts | 2 + .../organization/routes/crud-org.test.ts | 119 ++++++++++++++++++ .../plugins/organization/routes/crud-org.ts | 17 ++- .../src/plugins/organization/types.ts | 6 + 5 files changed, 157 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/plugins/organization.mdx b/docs/content/docs/plugins/organization.mdx index 14dd08ed1c..928c9334b9 100644 --- a/docs/content/docs/plugins/organization.mdx +++ b/docs/content/docs/plugins/organization.mdx @@ -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"], + }), + ], +}); +``` { 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(); diff --git a/packages/better-auth/src/plugins/organization/routes/crud-org.ts b/packages/better-auth/src/plugins/organization/routes/crud-org.ts index ea479af981..7b07cd7a54 100644 --- a/packages/better-auth/src/plugins/organization/routes/crud-org.ts +++ b/packages/better-auth/src/plugins/organization/routes/crud-org.ts @@ -724,11 +724,11 @@ export const getFullOrganization = ( 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 = ( }); } + 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[]; diff --git a/packages/better-auth/src/plugins/organization/types.ts b/packages/better-auth/src/plugins/organization/types.ts index 918b21b0bb..6f22c0e18e 100644 --- a/packages/better-auth/src/plugins/organization/types.ts +++ b/packages/better-auth/src/plugins/organization/types.ts @@ -65,6 +65,12 @@ export interface OrganizationOptions { [key in string]?: Role; } | undefined; + /** + * Allowed roles for `/organization/get-full-organization`. + * + * @default ["owner", "admin"] + */ + fullOrganizationAccessRoles?: string[] | undefined; /** * Dynamic access control for the organization plugin. */