From 832afdb389e910ed3bf06bdfdffebfe420c650a8 Mon Sep 17 00:00:00 2001 From: Mariano Date: Tue, 5 May 2026 17:32:30 +0100 Subject: [PATCH] fix(api): pass explicit permission:undefined to better-auth hasPermission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every cookie-authenticated request was 403'ing with "Unable to verify permissions" because better-auth 1.4.x's `hasPermission` body schema is a discriminated union that requires both keys present, with the unused side set to an explicit `undefined`. zod 4 (which better-auth's plugin uses internally) rejects "key is absent" — only "key is undefined" passes the `z.undefined()` branch: z.union([ z.object({ permission: z.record(...), permissions: z.undefined() }), z.object({ permission: z.undefined(), permissions: z.record(...) }), ]) Without `permission: undefined` in the body, the schema rejects every request with "[body] Invalid input"; the catch in canActivate turns that into a generic 403 and customers can't load the tasks page, findings, or any other endpoint protected by PermissionGuard. API key requests aren't affected because they go through the scope-checking path earlier in the guard, which is why this only showed up for cookie sessions on customer accounts. Test pins the body shape so a future refactor that drops the field gets caught. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/src/auth/permission.guard.spec.ts | 7 +++++++ apps/api/src/auth/permission.guard.ts | 12 +++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/api/src/auth/permission.guard.spec.ts b/apps/api/src/auth/permission.guard.spec.ts index 3bf4c6ed6a..46ac2ec522 100644 --- a/apps/api/src/auth/permission.guard.spec.ts +++ b/apps/api/src/auth/permission.guard.spec.ts @@ -197,10 +197,17 @@ describe('PermissionGuard', () => { const result = await guard.canActivate(context); expect(result).toBe(true); + // The body MUST include `permission: undefined` explicitly — zod 4 in + // better-auth's hasPermission schema requires both discriminated-union + // keys to be present, and treats omitted-vs-undefined as different. + // Without the explicit undefined, every cookie-authenticated request + // 403s with "Unable to verify permissions". Pin this in the test so + // a future refactor that drops the field gets caught. expect(mockHasPermission).toHaveBeenCalledWith({ headers: expect.any(Headers), body: { permissions: { control: ['delete'] }, + permission: undefined, }, }); }); diff --git a/apps/api/src/auth/permission.guard.ts b/apps/api/src/auth/permission.guard.ts index 0f19f79498..fee0efac3b 100644 --- a/apps/api/src/auth/permission.guard.ts +++ b/apps/api/src/auth/permission.guard.ts @@ -179,9 +179,19 @@ export class PermissionGuard implements CanActivate { return false; } + // better-auth 1.4.x's `hasPermission` body schema is a discriminated + // union that requires BOTH keys present, with the unused side set to + // an explicit `undefined`. zod 4 (which better-auth's plugin uses + // internally) rejects "key is absent" — only "key is undefined" passes + // the `z.undefined()` branch. Without the explicit `permission: undefined`, + // the schema rejects every request with `[body] Invalid input`, the + // catch in canActivate turns that into a generic "Unable to verify + // permissions" 403, and EVERY cookie-authenticated request returns 403. + // Reproduced repo-side via `bun run zod-repro.mjs`. Discovered on + // ENG-221 and the same fix applies here. const result = await auth.api.hasPermission({ headers, - body: { permissions }, + body: { permissions, permission: undefined }, }); return result.success === true;