Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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.
23 changes: 22 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 },
},
})
await Promise.all(
sessionsToInvalidate.map((session) => invalidateSessionCache(session.id)),
)
return { status: 'success' } as const
}

Expand Down Expand Up @@ -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',
Expand Down
66 changes: 57 additions & 9 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 { cache, cachified } 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 async function invalidateSessionCache(sessionId: string) {
await cache.delete(sessionCacheKey(sessionId))
}

type SessionUserIdCacheEntry = {
userId: string
expirationDate: string
}

async function getCachedSessionEntry(sessionId: string) {
return cachified<SessionUserIdCacheEntry | null>({
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) {
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()) {
await 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,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,
Expand Down
1 change: 0 additions & 1 deletion app/utils/cache.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
71 changes: 52 additions & 19 deletions app/utils/permissions.server.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,58 @@
import { data } from 'react-router'
import { cache, cachified } 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 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',
Expand All @@ -37,16 +62,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,
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 +89,5 @@
{ status: 403 },
)
}
return user.id
return userId
}
Loading