diff --git a/.cursor/rules/ci-safety.mdc b/.cursor/rules/ci-safety.mdc new file mode 100644 index 000000000..ff77f0b84 --- /dev/null +++ b/.cursor/rules/ci-safety.mdc @@ -0,0 +1,12 @@ +--- +description: CI safety checks before shipping +globs: "*" +alwaysApply: true +--- +### CI safety checklist + +- Run `npm run typecheck` after TypeScript changes. +- Run `npx prisma generate --sql` if Prisma SQL imports fail. +- Run `npm run test:e2e:run` for auth/session/permission changes. +- If a check cannot run due to environment limits, document the reason and + avoid declaring the change ready without CI confirmation. diff --git a/app/routes/settings/profile/index.tsx b/app/routes/settings/profile/index.tsx index 777471053..b8fb940d8 100644 --- a/app/routes/settings/profile/index.tsx +++ b/app/routes/settings/profile/index.tsx @@ -9,7 +9,11 @@ import { ErrorList, Field } from '#app/components/forms.tsx' import { Button } from '#app/components/ui/button.tsx' import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' -import { requireUserId, sessionKey } from '#app/utils/auth.server.ts' +import { + invalidateSessionCache, + requireUserId, + sessionKey, +} from '#app/utils/auth.server.ts' import { prisma } from '#app/utils/db.server.ts' import { getUserImgSrc, useDoubleCheck } from '#app/utils/misc.tsx' import { authSessionStorage } from '#app/utils/session.server.ts' @@ -287,12 +291,22 @@ async function signOutOfSessionsAction({ request, userId }: ProfileActionArgs) { sessionId, 'You must be authenticated to sign out of other sessions', ) + const sessionsToInvalidate = await prisma.session.findMany({ + select: { id: true }, + where: { + userId, + id: { not: sessionId }, + }, + }) await prisma.session.deleteMany({ where: { userId, id: { not: sessionId }, }, }) + await Promise.all( + sessionsToInvalidate.map((session) => invalidateSessionCache(session.id)), + ) return { status: 'success' } as const } @@ -337,7 +351,14 @@ function SignOutOfSessions({ } async function deleteDataAction({ userId }: ProfileActionArgs) { + const sessionsToInvalidate = await prisma.session.findMany({ + select: { id: true }, + where: { userId }, + }) await prisma.user.delete({ where: { id: userId } }) + await Promise.all( + sessionsToInvalidate.map((session) => invalidateSessionCache(session.id)), + ) return redirectWithToast('/', { type: 'success', title: 'Data Deleted', diff --git a/app/utils/auth.server.ts b/app/utils/auth.server.ts index f785ea88d..c54d71741 100644 --- a/app/utils/auth.server.ts +++ b/app/utils/auth.server.ts @@ -4,6 +4,7 @@ import bcrypt from 'bcryptjs' import { redirect } from 'react-router' import { Authenticator } from 'remix-auth' import { safeRedirect } from 'remix-utils/safe-redirect' +import { cache, cachified } from './cache.server.ts' import { providers } from './connections.server.ts' import { prisma } from './db.server.ts' import { combineHeaders, downloadFile } from './misc.tsx' @@ -19,6 +20,46 @@ export const sessionKey = 'sessionId' export const authenticator = new Authenticator() +const sessionCacheKey = (sessionId: string) => `session-user-id:${sessionId}` + +export async function invalidateSessionCache(sessionId: string) { + await cache.delete(sessionCacheKey(sessionId)) +} + +type SessionUserIdCacheEntry = { + userId: string + expirationDate: string +} + +async function getCachedSessionEntry(sessionId: string) { + return cachified({ + key: sessionCacheKey(sessionId), + cache, + ttl: SESSION_EXPIRATION_TIME, + async getFreshValue(context) { + const session = await prisma.session.findUnique({ + select: { userId: true, expirationDate: true }, + where: { id: sessionId }, + }) + if (!session) { + context.metadata.ttl = 0 + return null + } + const now = Date.now() + const expiresAt = session.expirationDate.getTime() + if (expiresAt <= now) { + context.metadata.ttl = 0 + return null + } + context.metadata.ttl = expiresAt - now + return { + userId: session.userId, + expirationDate: session.expirationDate.toISOString(), + } + }, + }) +} + for (const [providerName, provider] of Object.entries(providers)) { const strategy = provider.getAuthStrategy() if (strategy) { @@ -32,18 +73,24 @@ export async function getUserId(request: Request) { ) const sessionId = authSession.get(sessionKey) if (!sessionId) return null - const session = await prisma.session.findUnique({ - select: { userId: true }, - where: { id: sessionId, expirationDate: { gt: new Date() } }, - }) - if (!session?.userId) { + const cachedSession = await getCachedSessionEntry(sessionId) + if (!cachedSession) { + throw redirect('/', { + headers: { + 'set-cookie': await authSessionStorage.destroySession(authSession), + }, + }) + } + const expirationDate = new Date(cachedSession.expirationDate) + if (expirationDate <= new Date()) { + await invalidateSessionCache(sessionId) throw redirect('/', { headers: { 'set-cookie': await authSessionStorage.destroySession(authSession), }, }) } - return session.userId + return cachedSession.userId } export async function requireUserId( @@ -217,9 +264,10 @@ export async function logout( // if this fails, we still need to delete the session from the user's browser // and it doesn't do any harm staying in the db anyway. if (sessionId) { - // the .catch is important because that's what triggers the query. - // learn more about PrismaPromise: https://www.prisma.io/docs/orm/reference/prisma-client-reference#prismapromise-behavior - void prisma.session.deleteMany({ where: { id: sessionId } }).catch(() => {}) + await prisma.session + .deleteMany({ where: { id: sessionId } }) + .catch(() => {}) + await invalidateSessionCache(sessionId).catch(() => {}) } throw redirect(safeRedirect(redirectTo), { ...responseInit, diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts index 9f0ff6601..71a776fdf 100644 --- a/app/utils/cache.server.ts +++ b/app/utils/cache.server.ts @@ -152,7 +152,6 @@ export const cache: CachifiedCache = { }) if (!parsedEntry.success) return null const { metadata, value } = parsedEntry.data - if (!value) return null return { metadata, value } }, async set(key, entry) { diff --git a/app/utils/permissions.server.ts b/app/utils/permissions.server.ts index 1c1d92f45..4b276ed0f 100644 --- a/app/utils/permissions.server.ts +++ b/app/utils/permissions.server.ts @@ -1,33 +1,58 @@ import { data } from 'react-router' +import { cache, cachified } from './cache.server.ts' import { requireUserId } from './auth.server.ts' import { prisma } from './db.server.ts' import { type PermissionString, parsePermissionString } from './user.ts' +const permissionCacheKey = (userId: string, permission: PermissionString) => + `permission-check:${userId}:${permission}` +const roleCacheKey = (userId: string, name: string) => + `role-check:${userId}:${name}` + +export async function invalidatePermissionCache( + userId: string, + permission: PermissionString, +) { + await cache.delete(permissionCacheKey(userId, permission)) +} + +export async function invalidateRoleCache(userId: string, name: string) { + await cache.delete(roleCacheKey(userId, name)) +} + export async function requireUserWithPermission( request: Request, permission: PermissionString, ) { const userId = await requireUserId(request) const permissionData = parsePermissionString(permission) - const user = await prisma.user.findFirst({ - select: { id: true }, - where: { - id: userId, - roles: { - some: { - permissions: { + const allowed = await cachified({ + key: permissionCacheKey(userId, permission), + cache, + ttl: 1000 * 60 * 2, + async getFreshValue() { + const user = await prisma.user.findFirst({ + select: { id: true }, + where: { + id: userId, + roles: { some: { - ...permissionData, - access: permissionData.access - ? { in: permissionData.access } - : undefined, + permissions: { + some: { + ...permissionData, + access: permissionData.access + ? { in: permissionData.access } + : undefined, + }, + }, }, }, }, - }, + }) + return Boolean(user) }, }) - if (!user) { + if (!allowed) { throw data( { error: 'Unauthorized', @@ -37,16 +62,24 @@ export async function requireUserWithPermission( { status: 403 }, ) } - return user.id + return userId } export async function requireUserWithRole(request: Request, name: string) { const userId = await requireUserId(request) - const user = await prisma.user.findFirst({ - select: { id: true }, - where: { id: userId, roles: { some: { name } } }, + const allowed = await cachified({ + key: roleCacheKey(userId, name), + cache, + ttl: 1000 * 60 * 2, + async getFreshValue() { + const user = await prisma.user.findFirst({ + select: { id: true }, + where: { id: userId, roles: { some: { name } } }, + }) + return Boolean(user) + }, }) - if (!user) { + if (!allowed) { throw data( { error: 'Unauthorized', @@ -56,5 +89,5 @@ export async function requireUserWithRole(request: Request, name: string) { { status: 403 }, ) } - return user.id + return userId }