diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c2cd2675..e4a7239c 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -51,7 +51,6 @@ export const app = new Hono() if (isPublicCorsPath(c.req.path)) { c.set('user', null); c.set('session', null); - c.set('orgRoles', {}); return next(); } @@ -67,7 +66,6 @@ export const app = new Hono() if (!session) { c.set('user', null); c.set('session', null); - c.set('orgRoles', {}); await next(); return; @@ -75,7 +73,6 @@ export const app = new Hono() c.set('user', session.user); c.set('session', session.session); - c.set('orgRoles', (session as { orgRoles?: Record }).orgRoles ?? {}); await next(); }) diff --git a/apps/api/src/middlewares/org-admin-or-automation-key.ts b/apps/api/src/middlewares/org-admin-or-automation-key.ts index 615260c9..f1405fd9 100644 --- a/apps/api/src/middlewares/org-admin-or-automation-key.ts +++ b/apps/api/src/middlewares/org-admin-or-automation-key.ts @@ -3,6 +3,7 @@ import type { TOrganizationApiKeyScope } from '@cio/utils/validation/organizatio import { ErrorCodes } from '@api/utils/errors'; import { ROLE } from '@cio/utils/constants'; +import { getOrgRolesForUser } from '@api/utils/redis/org-roles-cache'; import { organizationApiKeyHasScopes } from '@api/services/organization/automation-key'; export const orgAdminOrAutomationKeyMiddleware = @@ -55,7 +56,7 @@ export const orgAdminOrAutomationKeyMiddleware = ); } - const orgRoles = (c.get('orgRoles') as Record | undefined) ?? {}; + const orgRoles = await getOrgRolesForUser(user.id); if (orgRoles[orgId] !== ROLE.ADMIN) { return c.json( { diff --git a/apps/api/src/middlewares/org-admin.ts b/apps/api/src/middlewares/org-admin.ts index f5e50bb2..9fe86154 100644 --- a/apps/api/src/middlewares/org-admin.ts +++ b/apps/api/src/middlewares/org-admin.ts @@ -2,11 +2,11 @@ import { Context, Next } from 'hono'; import { ErrorCodes } from '@api/utils/errors'; import { ROLE } from '@cio/utils/constants'; +import { getOrgRolesForUser } from '@api/utils/redis/org-roles-cache'; /** * Middleware to check if the authenticated user is an admin of the organization. - * Reads the role from the Better Auth session (populated by the customSession plugin), - * so no per-request DB query is performed. + * Reads `orgRoles` from the Redis-backed cache (DB fallback on miss). * * Requires authMiddleware to be applied first. * Expects orgId in the 'cio-org-id' header. @@ -37,7 +37,7 @@ export const orgAdminMiddleware = async (c: Context, next: Next) => { ); } - const orgRoles = (c.get('orgRoles') as Record | undefined) ?? {}; + const orgRoles = await getOrgRolesForUser(user.id); const roleId = orgRoles[orgId]; if (roleId !== ROLE.ADMIN) { diff --git a/apps/api/src/middlewares/org-member.ts b/apps/api/src/middlewares/org-member.ts index 8b3e91c7..3ed66533 100644 --- a/apps/api/src/middlewares/org-member.ts +++ b/apps/api/src/middlewares/org-member.ts @@ -1,9 +1,11 @@ import { Context, Next } from 'hono'; +import { getOrgRolesForUser } from '@api/utils/redis/org-roles-cache'; /** * Middleware to check if the authenticated user is a member of the organization. - * Reads the role from the Better Auth session (populated by the customSession plugin), - * so no per-request DB query is performed. + * Loads `orgRoles` from the Redis-backed cache (DB fallback on miss) so the + * lookup stays out of the Better Auth session cookie — keeping that cookie + * small enough for the oauth-proxy redirect to fit in a Location header. * * Requires authMiddleware to be applied first. * Expects orgId in the 'cio-org-id' header. @@ -35,7 +37,7 @@ export const orgMemberMiddleware = async (c: Context, next: Next) => { ); } - const orgRoles = (c.get('orgRoles') as Record | undefined) ?? {}; + const orgRoles = await getOrgRolesForUser(user.id); const roleId = orgRoles[orgId]; if (roleId === undefined) { diff --git a/apps/api/src/types/auth.ts b/apps/api/src/types/auth.ts index 8270767e..2fd1974e 100644 --- a/apps/api/src/types/auth.ts +++ b/apps/api/src/types/auth.ts @@ -6,7 +6,6 @@ export type AuthSession = { actorId: string | null; automationKey: TOrganizationApiKey | null; orgId: string | null; - orgRoles: Record; session: typeof auth.$Infer.Session.session | null; user: typeof auth.$Infer.Session.user | null; userRole: number | null; diff --git a/apps/api/src/utils/redis/org-roles-cache.ts b/apps/api/src/utils/redis/org-roles-cache.ts new file mode 100644 index 00000000..7f863700 --- /dev/null +++ b/apps/api/src/utils/redis/org-roles-cache.ts @@ -0,0 +1,39 @@ +import { getUserOrgRolesMap } from '@cio/db/queries/organization'; +import { logRedisUnavailableOnce, redis } from './redis'; + +const TTL_SECONDS = 60 * 60; // 1 hour, matches the prior cookieCache.maxAge. + +const cacheKey = (userId: string) => `org-roles:${userId}`; + +export async function getOrgRolesForUser(userId: string): Promise> { + try { + const cached = await redis.get(cacheKey(userId)); + if (cached) { + try { + return JSON.parse(cached) as Record; + } catch { + // Stale/corrupt entry — fall through to DB. + } + } + } catch (error) { + logRedisUnavailableOnce('Redis GET org-roles failed, falling back to DB', error); + } + + const orgRoles = await getUserOrgRolesMap(userId); + + try { + await redis.set(cacheKey(userId), JSON.stringify(orgRoles), { EX: TTL_SECONDS }); + } catch (error) { + logRedisUnavailableOnce('Redis SET org-roles failed, cache disabled', error); + } + + return orgRoles; +} + +export async function invalidateOrgRolesCache(userId: string): Promise { + try { + await redis.del(cacheKey(userId)); + } catch (error) { + logRedisUnavailableOnce('Redis DEL org-roles failed, falling back to TTL', error); + } +} diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index 6ccec09c..aca8dbff 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -6,11 +6,9 @@ import { sendChangeEmailConfirmation, sendVerificationEmail } from './auth/email import { betterAuth } from 'better-auth/minimal'; import { createProfileHook } from './auth/hooks/create-profile'; -import { customSession } from 'better-auth/plugins/custom-session'; import { db } from '@db/drizzle'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { config as emailAndPassword } from './auth/email-password'; -import { getUserOrgRolesMap } from './queries/organization/organization'; import { loginLink } from './auth/plugins/login-link'; import { oAuthProxy } from 'better-auth/plugins/oauth-proxy'; import { resolveTrustedBrowserOrigin } from './utils'; @@ -121,20 +119,6 @@ export const auth: ReturnType = betterAuth({ productionURL: CONSTANTS.BASE_URL }), loginLink(), - tokenExchange(), - // Attaches the user's org memberships ({ [orgId]: roleId }) to the session - // so org-scoped middlewares can authorize without a per-request DB query. - // Refreshes when the session cookie cache expires (see session.cookieCache.maxAge). - customSession(async ({ user, session }) => { - let orgRoles: Record = {}; - try { - if (user?.id) { - orgRoles = await getUserOrgRolesMap(user.id); - } - } catch (error) { - console.error('customSession: failed to load orgRoles', error); - } - return { user, session, orgRoles }; - }) + tokenExchange() ] });