From fd8a76527bd4d2af10cf8c2396e34a415e3d5bd7 Mon Sep 17 00:00:00 2001 From: Vinicius Garcia Date: Fri, 9 Oct 2026 22:34:54 +0000 Subject: [PATCH] fix(auth): sair do workspace por conta propria encerra a participacao no dominio (auditoria COMP-008) O endpoint /organization/leave apaga o membro do Better Auth mas nao chama hook de organizacao; workspace_members.removed_at ficava nulo e a pessoa seguia 'ativa' em candidatos de projeto, plano de anonimizacao e relatorios. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01VDxbrneS3TEAUmdgVmkKpQ --- src/server/auth/auth.ts | 31 +++++++---- src/server/auth/member-leave.test.ts | 78 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 src/server/auth/member-leave.test.ts diff --git a/src/server/auth/auth.ts b/src/server/auth/auth.ts index f1ca2c8..bcaefac 100644 --- a/src/server/auth/auth.ts +++ b/src/server/auth/auth.ts @@ -38,6 +38,18 @@ export function createAuth({ ? { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, requireEmailVerification: true } } : {}; + /** Logical removal (LGPD): the person loses access but their time entries stay. Idempotent. */ + async function markMemberRemoved(authOrgId: string, authUserId: string) { + const [ws] = await db.select({ id: workspaces.id }).from(workspaces).where(eq(workspaces.authOrgId, authOrgId)); + const [appUser] = await db.select({ id: users.id }).from(users).where(eq(users.authId, authUserId)); + if (!ws || !appUser) return; + const now = new Date(); + await db + .update(workspaceMembers) + .set({ removedAt: now, updatedAt: now }) + .where(and(eq(workspaceMembers.workspaceId, ws.id), eq(workspaceMembers.userId, appUser.id), isNull(workspaceMembers.removedAt))); + } + return betterAuth({ baseURL, secret: env.BETTER_AUTH_SECRET, @@ -67,6 +79,14 @@ export function createAuth({ throw new APIError("TOO_MANY_REQUESTS", { message: "Muitos códigos pedidos para este e-mail. Tente de novo mais tarde." }); } }), + // `/organization/leave` deletes the auth member but, unlike `remove-member`, runs no organization hook (COMP-008): without + // this the person would stay "active" in the domain tables (project candidates, anonymization plan, reports). + after: createAuthMiddleware(async (ctx) => { + if (ctx.path !== "/organization/leave") return; + const left = ctx.context.returned as { userId?: unknown; organizationId?: unknown } | Error | undefined; + if (!left || left instanceof Error || typeof left.userId !== "string" || typeof left.organizationId !== "string") return; + await markMemberRemoved(left.organizationId, left.userId); + }), }, databaseHooks: { user: { @@ -125,16 +145,7 @@ export function createAuth({ }, organizationHooks: { // Logical removal (LGPD): the member loses access but their time entries stay. - afterRemoveMember: async ({ user, organization: org }) => { - const [ws] = await db.select({ id: workspaces.id }).from(workspaces).where(eq(workspaces.authOrgId, org.id)); - const [appUser] = await db.select({ id: users.id }).from(users).where(eq(users.authId, user.id)); - if (!ws || !appUser) return; - const now = new Date(); - await db - .update(workspaceMembers) - .set({ removedAt: now, updatedAt: now }) - .where(and(eq(workspaceMembers.workspaceId, ws.id), eq(workspaceMembers.userId, appUser.id), isNull(workspaceMembers.removedAt))); - }, + afterRemoveMember: async ({ user, organization: org }) => markMemberRemoved(org.id, user.id), // Deleting an organization archives its workspace: every record stays, access is blocked (ADR-034). Terminal. afterDeleteOrganization: async ({ organization: org }) => { const now = new Date(); diff --git a/src/server/auth/member-leave.test.ts b/src/server/auth/member-leave.test.ts new file mode 100644 index 0000000..faf61f7 --- /dev/null +++ b/src/server/auth/member-leave.test.ts @@ -0,0 +1,78 @@ +import { and, eq } from "drizzle-orm"; +import { beforeAll, describe, expect, it } from "vitest"; +import type { Db } from "@/db"; +import { users, workspaceMembers, workspaces } from "@/db/schema"; +import { createTestDb, type TestDb } from "@/test/db"; +import { createAuth } from "./auth"; +import { getAuthEnv } from "./env"; +import { resolveWorkspaceContext } from "./workspace-context"; + +/** COMP-008: leaving a workspace on one's own must retire the domain membership, like removal by an admin does. */ +describe("voluntary leave", () => { + let db: TestDb; + let auth: ReturnType; + const mails: { text: string }[] = []; + const asDb = () => db as unknown as Db; + + beforeAll(async () => { + db = await createTestDb(); + auth = createAuth({ + db: asDb(), + nextJsCookies: false, + env: getAuthEnv({ NODE_ENV: "test", BETTER_AUTH_SECRET: "test-secret-test-secret-test-secret-123" }), + send: async (m) => void mails.push(m), + }); + }); + + 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 { headers: new Headers({ cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ") }), user: res.response.user }; + } + + const sessionOf = (u: { id: string; email: string }, orgId: string) => ({ + user: { id: u.id, email: u.email, name: "Pessoa", image: null }, + session: { activeOrganizationId: orgId }, + }); + + const removedAt = async (orgId: string, authUserId: string) => { + const [row] = await db + .select({ removedAt: workspaceMembers.removedAt }) + .from(workspaceMembers) + .innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId)) + .innerJoin(users, eq(users.id, workspaceMembers.userId)) + .where(and(eq(workspaces.authOrgId, orgId), eq(users.authId, authUserId))); + return row.removedAt; + }; + + it("marks the domain membership as removed, and only for the person who left", async () => { + const owner = await signIn("leave-owner@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Leave", slug: "leave" } }); + const invitation = await auth.api.createInvitation({ headers: owner.headers, body: { email: "leaver@example.com", role: "member", organizationId: org.id } }); + const leaver = await signIn("leaver@example.com"); + await auth.api.acceptInvitation({ headers: leaver.headers, body: { invitationId: invitation.id } }); + await auth.api.setActiveOrganization({ headers: leaver.headers, body: { organizationId: org.id } }); + + // both people exist in the domain tables (first access) + expect((await resolveWorkspaceContext(asDb(), sessionOf(owner.user, org.id))).kind).toBe("ok"); + expect((await resolveWorkspaceContext(asDb(), sessionOf(leaver.user, org.id))).kind).toBe("ok"); + expect(await removedAt(org.id, leaver.user.id)).toBeNull(); + + await auth.api.leaveOrganization({ headers: leaver.headers, body: { organizationId: org.id } }); + + expect(await removedAt(org.id, leaver.user.id)).toBeInstanceOf(Date); + expect(await removedAt(org.id, owner.user.id)).toBeNull(); + expect((await resolveWorkspaceContext(asDb(), sessionOf(leaver.user, org.id))).kind).toBe("no-membership"); + }); + + it("a refused leave (sole owner) changes nothing", async () => { + const owner = await signIn("sole-owner@example.com"); + const org = await auth.api.createOrganization({ headers: owner.headers, body: { name: "Sole", slug: "sole" } }); + await resolveWorkspaceContext(asDb(), sessionOf(owner.user, org.id)); + await expect(auth.api.leaveOrganization({ headers: owner.headers, body: { organizationId: org.id } })).rejects.toThrow(); + expect(await removedAt(org.id, owner.user.id)).toBeNull(); + }); +});