From 3e266dff3650eec540f900f3ab4b22391896330c Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Fri, 9 Oct 2026 23:31:56 +0000 Subject: [PATCH] fix(auth): convites pendentes so para owner e admin (auditoria COMP-012) - hook before bloqueia /organization/list-invitations e /organization/get-full-organization para quem nao e owner/admin (get-full-organization devolve os convites embutidos) - menu da conta deixa de usar useActiveOrganization e le o nome do workspace da lista de organizacoes - list-members segue aberto a membros (decisao registrada no ADR-036) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VDxbrneS3TEAUmdgVmkKpQ --- src/components/account-menu.tsx | 8 +- src/server/auth/auth.ts | 16 +++- src/server/auth/invitation-access.test.ts | 97 +++++++++++++++++++++++ src/server/auth/invitation-access.ts | 31 ++++++++ 4 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 src/server/auth/invitation-access.test.ts create mode 100644 src/server/auth/invitation-access.ts diff --git a/src/components/account-menu.tsx b/src/components/account-menu.tsx index 83ff3c9..6648a09 100644 --- a/src/components/account-menu.tsx +++ b/src/components/account-menu.tsx @@ -17,7 +17,9 @@ export function AccountMenu() { const [open, setOpen] = useState(false); const session = authClient.useSession(); const orgs = authClient.useListOrganizations(); - const active = authClient.useActiveOrganization(); + // Not `useActiveOrganization()`: that endpoint returns every member and pending invitation to every member (COMP-012). + const activeId = session.data?.session.activeOrganizationId; + const active = (orgs.data ?? []).find((org) => org.id === activeId); async function switchTo(organizationId: string) { await authClient.organization.setActive({ organizationId }); @@ -35,7 +37,7 @@ export function AccountMenu() { diff --git a/src/server/auth/auth.ts b/src/server/auth/auth.ts index 44ccce4..68b6139 100644 --- a/src/server/auth/auth.ts +++ b/src/server/auth/auth.ts @@ -1,5 +1,5 @@ import { APIError, betterAuth } from "better-auth"; -import { createAuthMiddleware } from "better-auth/api"; +import { createAuthMiddleware, getSessionFromCtx } from "better-auth/api"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; import { after } from "next/server"; @@ -9,6 +9,7 @@ import { getDb, type Db } from "@/db"; import * as authSchema from "@/db/auth-schema"; import { users, workspaceMembers, workspaces } from "@/db/schema"; import { getAuthEnv } from "./env"; +import { INVITATION_LISTING_PATHS, mayListInvitations } from "./invitation-access"; import { invitationText, INVITATION_SUBJECT } from "./invite-mail"; import { consumeOtpQuota, hashEmail } from "./otp-limit"; import { sendMail, type Mailer } from "./mail"; @@ -84,6 +85,19 @@ export function createAuth({ // gets ~60 guesses per address per day. Every refusal is logged (hashed address) so a campaign is visible. An anti-bot // challenge on this endpoint is the real fix and is planned before launch. before: createAuthMiddleware(async (ctx) => { + // Pending invitations carry the e-mail of people who have not joined: admins only (COMP-012). `get-full-organization` + // returns them too, so the account menu must not call it (it reads the organization list instead). + if ((INVITATION_LISTING_PATHS as readonly string[]).includes(ctx.path)) { + const session = await getSessionFromCtx(ctx); + if (!session) return; // the endpoint answers 401 itself + const allowed = await mayListInvitations(db, { + userId: session.user.id, + activeOrganizationId: (session.session as { activeOrganizationId?: string | null }).activeOrganizationId, + query: ctx.query, + }); + if (!allowed) throw new APIError("FORBIDDEN", { message: "Apenas administradores veem os convites pendentes." }); + return; + } if (ctx.path !== "/email-otp/send-verification-otp") return; const email = (ctx.body as { email?: unknown } | undefined)?.email; if (typeof email !== "string") return; diff --git a/src/server/auth/invitation-access.test.ts b/src/server/auth/invitation-access.test.ts new file mode 100644 index 0000000..ee39525 --- /dev/null +++ b/src/server/auth/invitation-access.test.ts @@ -0,0 +1,97 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import type { Db } from "@/db"; +import { createTestDb, type TestDb } from "@/test/db"; +import { createAuth } from "./auth"; +import { getAuthEnv } from "./env"; + +/** COMP-012: pending invitations (e-mails of people who have not joined) are visible to owners and admins only. */ +describe("pending invitation visibility", () => { + let db: TestDb; + let auth: ReturnType; + const mails: { text: string }[] = []; + let ownerH: Headers; + let adminH: Headers; + let memberH: Headers; + let orgId: string; + + beforeAll(async () => { + db = await createTestDb(); + auth = createAuth({ + db: db as unknown as Db, + nextJsCookies: false, + env: getAuthEnv({ NODE_ENV: "test", BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-123", BETTER_AUTH_URL: "http://localhost:3000" }), + send: async (m) => void mails.push(m), + }); + ownerH = await signIn("vis-owner@example.com"); + const org = await auth.api.createOrganization({ headers: ownerH, body: { name: "Vis", slug: "vis" } }); + orgId = org.id; + const join = async (email: string, role: "member" | "admin") => { + const inv = await auth.api.createInvitation({ headers: ownerH, body: { email, role, organizationId: orgId } }); + const headers = await signIn(email); + await auth.api.acceptInvitation({ headers, body: { invitationId: inv.id } }); + await auth.api.setActiveOrganization({ headers, body: { organizationId: orgId } }); + return headers; + }; + adminH = await join("vis-admin@example.com", "admin"); + memberH = await join("vis-member@example.com", "member"); + // a pending invitation nobody accepted + await auth.api.createInvitation({ headers: ownerH, body: { email: "pending-secret@example.com", role: "member", organizationId: orgId } }); + }); + + async function signIn(email: string) { + mails.length = 0; + await auth.api.sendVerificationOTP({ body: { email, type: "sign-in" } }); + await new Promise((r) => setTimeout(r, 20)); + const otp = /(\d{6})/.exec(mails[0].text)![1]; + const res = await auth.api.signInEmailOTP({ body: { email, otp }, returnHeaders: true }); + return new Headers({ cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }); + } + + const get = (path: string, headers: Headers) => auth.handler(new Request(`http://localhost:3000/api/auth${path}`, { headers: { cookie: headers.get("cookie")! } })); + + it("lets the owner and an admin list pending invitations", async () => { + for (const h of [ownerH, adminH]) { + const res = await get("/organization/list-invitations", h); + expect(res.status).toBe(200); + expect(await res.text()).toContain("pending-secret@example.com"); + } + }); + + it("refuses list-invitations to a plain member, by session, id and over HTTP", async () => { + await expect(auth.api.listInvitations({ headers: memberH })).rejects.toThrow(); + await expect(auth.api.listInvitations({ headers: memberH, query: { organizationId: orgId } })).rejects.toThrow(); + const res = await get("/organization/list-invitations", memberH); + expect(res.status).toBe(403); + expect(await res.text()).not.toContain("pending-secret@example.com"); + }); + + it("refuses get-full-organization to a plain member (it embeds the invitations), including by slug", async () => { + for (const path of ["/organization/get-full-organization", "/organization/get-full-organization?organizationSlug=vis", `/organization/get-full-organization?organizationId=${orgId}`]) { + const res = await get(path, memberH); + expect(res.status).toBe(403); + expect(await res.text()).not.toContain("pending-secret@example.com"); + } + }); + + it("still serves get-full-organization to admins and the organization list to members", async () => { + const full = await get("/organization/get-full-organization", adminH); + expect(full.status).toBe(200); + expect(await full.text()).toContain("pending-secret@example.com"); + const list = await get("/organization/list", memberH); + expect(list.status).toBe(200); + expect(await list.text()).toContain("Vis"); + }); + + it("leaves the 401 to the endpoint when there is no session", async () => { + const res = await auth.handler(new Request("http://localhost:3000/api/auth/organization/list-invitations")); + expect(res.status).toBe(401); + }); + + it("refuses a member of another workspace who tries this one's id", async () => { + const outsider = await signIn("vis-outsider@example.com"); + const own = await auth.api.createOrganization({ headers: outsider, body: { name: "Other", slug: "vis-other" } }); + expect(own.id).not.toBe(orgId); + const res = await get(`/organization/list-invitations?organizationId=${orgId}`, outsider); + expect(res.status).toBe(403); + }); +}); diff --git a/src/server/auth/invitation-access.ts b/src/server/auth/invitation-access.ts new file mode 100644 index 0000000..3c7f514 --- /dev/null +++ b/src/server/auth/invitation-access.ts @@ -0,0 +1,31 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@/db"; +import * as authSchema from "@/db/auth-schema"; +import { strongestRole } from "./member-roles"; + +/** Endpoints that return the e-mail address of every pending invitation (COMP-012). */ +export const INVITATION_LISTING_PATHS = ["/organization/list-invitations", "/organization/get-full-organization"] as const; + +/** + * Pending invitations expose the e-mail of people who have not joined yet, so only owners and admins may read them. + * Resolves the organization the way Better Auth does (id, slug, then the session's active one); with none, the endpoint + * answers on its own. Returns false for a non-member too: the endpoint's own error is not worth leaking either way. + */ +export async function mayListInvitations( + db: Db, + { userId, activeOrganizationId, query }: { userId: string; activeOrganizationId?: string | null; query?: { organizationId?: unknown; organizationSlug?: unknown } }, +): Promise { + let organizationId: string | null | undefined = typeof query?.organizationId === "string" ? query.organizationId : undefined; + if (!organizationId && typeof query?.organizationSlug === "string") { + const [org] = await db.select({ id: authSchema.organization.id }).from(authSchema.organization).where(eq(authSchema.organization.slug, query.organizationSlug)); + organizationId = org?.id; + if (!organizationId) return true; // unknown slug: the endpoint itself answers "not found" + } + organizationId ??= activeOrganizationId; + if (!organizationId) return true; + const [row] = await db + .select({ role: authSchema.member.role }) + .from(authSchema.member) + .where(and(eq(authSchema.member.userId, userId), eq(authSchema.member.organizationId, organizationId))); + return !!row && strongestRole(row.role) !== "member"; +}