Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .cursor/rules/ci-safety.mdc
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion app/routes/settings/profile/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 },
},
})
for (const session of sessionsToInvalidate) {
invalidateSessionCache(session.id)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
return { status: 'success' } as const
}

Expand Down
60 changes: 54 additions & 6 deletions app/utils/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { cachified, lruCache } from './cache.server.ts'
import { providers } from './connections.server.ts'
import { prisma } from './db.server.ts'
import { combineHeaders, downloadFile } from './misc.tsx'
Expand All @@ -19,6 +20,46 @@ export const sessionKey = 'sessionId'

export const authenticator = new Authenticator<ProviderUser>()

const sessionCacheKey = (sessionId: string) => `session-user-id:${sessionId}`

export function invalidateSessionCache(sessionId: string) {
lruCache.delete(sessionCacheKey(sessionId))
}

type SessionUserIdCacheEntry = {
userId: string
expirationDate: string
}

async function getCachedSessionEntry(sessionId: string) {
return cachified<SessionUserIdCacheEntry | null>({
key: sessionCacheKey(sessionId),
cache: lruCache,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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(),
}
},
})
}
Comment thread
cursor[bot] marked this conversation as resolved.

for (const [providerName, provider] of Object.entries(providers)) {
const strategy = provider.getAuthStrategy()
if (strategy) {
Expand All @@ -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()) {
invalidateSessionCache(sessionId)
throw redirect('/', {
headers: {
'set-cookie': await authSessionStorage.destroySession(authSession),
},
})
}
return session.userId
return cachedSession.userId
}

export async function requireUserId(
Expand Down Expand Up @@ -217,6 +264,7 @@ 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) {
invalidateSessionCache(sessionId)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
// 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(() => {})
Expand Down
60 changes: 41 additions & 19 deletions app/utils/permissions.server.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,47 @@
import { data } from 'react-router'
import { cachified, lruCache } from './cache.server.ts'
import { requireUserId } from './auth.server.ts'

Check warning on line 3 in app/utils/permissions.server.ts

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

`./auth.server.ts` import should occur before import of `./cache.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 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: lruCache,
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)
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
},
})
if (!user) {
if (!allowed) {
throw data(
{
error: 'Unauthorized',
Expand All @@ -37,16 +51,24 @@
{ 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: lruCache,
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',
Expand All @@ -56,5 +78,5 @@
{ status: 403 },
)
}
return user.id
return userId
}
Loading