fix: read-after-write issues (#215)

* fix: read-after-write issues

* fix: coderabbit comments

* fix: clear cache on invite

* fix: use primary after a read
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-31 09:56:07 +01:00
committed by GitHub
parent abacf66155
commit f454449365
19 changed files with 470 additions and 167 deletions

View File

@@ -1,93 +1,5 @@
import { db, getProjectById } from '@openpanel/db';
import { cacheable } from '@openpanel/redis';
export const getProjectAccessCached = cacheable(getProjectAccess, 60 * 5);
export async function getProjectAccess({
userId,
projectId,
}: {
userId: string;
projectId: string;
}) {
try {
// Check if user has access to the project
const project = await getProjectById(projectId);
if (!project?.organizationId) {
return false;
}
const [projectAccess, member] = await Promise.all([
db.projectAccess.findMany({
where: {
userId,
organizationId: project.organizationId,
},
}),
db.member.findFirst({
where: {
organizationId: project.organizationId,
userId,
},
}),
]);
if (projectAccess.length === 0 && member) {
return true;
}
return projectAccess.find((item) => item.projectId === projectId);
} catch (err) {
return false;
}
}
export const getOrganizationAccessCached = cacheable(
export {
getOrganizationAccess,
60 * 5,
);
export async function getOrganizationAccess({
userId,
organizationId,
}: {
userId: string;
organizationId: string;
}) {
return db.member.findFirst({
where: {
userId,
organizationId,
},
});
}
export const getClientAccessCached = cacheable(getClientAccess, 60 * 5);
export async function getClientAccess({
userId,
clientId,
}: {
userId: string;
clientId: string;
}) {
const client = await db.client.findFirst({
where: {
id: clientId,
},
});
if (!client) {
return false;
}
if (client.projectId) {
return getProjectAccess({ userId, projectId: client.projectId });
}
if (client.organizationId) {
return getOrganizationAccess({
userId,
organizationId: client.organizationId,
});
}
return false;
}
getProjectAccess,
getClientAccess,
} from '@openpanel/db';

View File

@@ -20,6 +20,7 @@ import {
getUserAccount,
} from '@openpanel/db';
import { sendEmail } from '@openpanel/email';
import { deleteCache } from '@openpanel/redis';
import {
zRequestResetPassword,
zResetPassword,
@@ -74,6 +75,7 @@ export const authRouter = createTRPCRouter({
deleteSessionTokenCookie(ctx.setCookie);
if (ctx.session?.session?.id) {
await invalidateSession(ctx.session.session.id);
await deleteCache(`validateSession:${ctx.session.session.id}`);
}
}),
signInOAuth: publicProcedure
@@ -333,6 +335,7 @@ export const authRouter = createTRPCRouter({
const session = await validateSessionToken(token);
if (session.session) {
await deleteCache(`validateSession:${session.session.id}`);
// Re-set the cookie with updated expiration
setSessionTokenCookie(ctx.setCookie, token, session.session.expiresAt);
return {

View File

@@ -32,7 +32,7 @@ import {
differenceInWeeks,
formatISO,
} from 'date-fns';
import { getProjectAccessCached } from '../access';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import {
cacheMiddleware,
@@ -367,7 +367,7 @@ export const chartRouter = createTRPCRouter({
.input(zChartInput)
.query(async ({ input, ctx }) => {
if (ctx.session.userId) {
const access = await getProjectAccessCached({
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});

View File

@@ -27,7 +27,7 @@ import {
} from '@openpanel/validation';
import { clone } from 'ramda';
import { getProjectAccessCached } from '../access';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
@@ -266,7 +266,7 @@ export const eventRouter = createTRPCRouter({
)
.query(async ({ input: { projectId, cursor, limit }, ctx }) => {
if (ctx.session.userId) {
const access = await getProjectAccessCached({
const access = await getProjectAccess({
projectId,
userId: ctx.session.userId,
});

View File

@@ -9,7 +9,7 @@ import {
zCreateSlackIntegration,
zCreateWebhookIntegration,
} from '@openpanel/validation';
import { getOrganizationAccessCached } from '../access';
import { getOrganizationAccess } from '../access';
import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -23,7 +23,7 @@ export const integrationRouter = createTRPCRouter({
},
});
const access = await getOrganizationAccessCached({
const access = await getOrganizationAccess({
userId: ctx.session.userId,
organizationId: integration.organizationId,
});
@@ -122,7 +122,7 @@ export const integrationRouter = createTRPCRouter({
},
});
const access = await getOrganizationAccessCached({
const access = await getOrganizationAccess({
userId: ctx.session.userId,
organizationId: integration.organizationId,
});

View File

@@ -4,18 +4,15 @@ import { has } from 'ramda';
import superjson from 'superjson';
import { ZodError } from 'zod';
import {
COOKIE_OPTIONS,
EMPTY_SESSION,
validateSessionToken,
} from '@openpanel/auth';
import { getCache, getRedisCache } from '@openpanel/redis';
import { COOKIE_OPTIONS, type SessionValidationResult } from '@openpanel/auth';
import { runWithAlsSession } from '@openpanel/db';
import { getRedisCache } from '@openpanel/redis';
import type { ISetCookie } from '@openpanel/validation';
import {
createTrpcRedisLimiter,
defaultFingerPrint,
} from '@trpc-limiter/redis';
import { getOrganizationAccessCached, getProjectAccessCached } from './access';
import { getOrganizationAccess, getProjectAccess } from './access';
import { TRPCAccessError } from './errors';
export const rateLimitMiddleware = ({
@@ -44,10 +41,6 @@ export async function createContext({ req, res }: CreateFastifyContextOptions) {
});
};
const session = cookies?.session
? await validateSessionToken(cookies.session!)
: EMPTY_SESSION;
if (process.env.NODE_ENV !== 'production') {
await new Promise((res) =>
setTimeout(() => res(1), Math.min(Math.random() * 500, 200)),
@@ -57,7 +50,7 @@ export async function createContext({ req, res }: CreateFastifyContextOptions) {
return {
req,
res,
session,
session: (req as any).session as SessionValidationResult,
// we do not get types for `setCookie` from fastify
// so define it here and be safe in routers
setCookie,
@@ -102,37 +95,39 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
// Only used on protected routes
const enforceAccess = t.middleware(async ({ ctx, next, type, getRawInput }) => {
const rawInput = await getRawInput();
if (type === 'mutation' && process.env.DEMO_USER_ID) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You are not allowed to do this in demo mode',
});
}
if (has('projectId', rawInput)) {
const access = await getProjectAccessCached({
userId: ctx.session.userId!,
projectId: rawInput.projectId as string,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
return runWithAlsSession(ctx.session.session?.id, async () => {
const rawInput = await getRawInput();
if (type === 'mutation' && process.env.DEMO_USER_ID) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You are not allowed to do this in demo mode',
});
}
}
if (has('organizationId', rawInput)) {
const access = await getOrganizationAccessCached({
userId: ctx.session.userId!,
organizationId: rawInput.organizationId as string,
});
if (has('projectId', rawInput)) {
const access = await getProjectAccess({
userId: ctx.session.userId!,
projectId: rawInput.projectId as string,
});
if (!access) {
throw TRPCAccessError('You do not have access to this organization');
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
}
}
return next();
if (has('organizationId', rawInput)) {
const access = await getOrganizationAccess({
userId: ctx.session.userId!,
organizationId: rawInput.organizationId as string,
});
if (!access) {
throw TRPCAccessError('You do not have access to this organization');
}
}
return next();
});
});
export const createTRPCRouter = t.router;
@@ -157,11 +152,21 @@ const loggerMiddleware = t.middleware(
},
);
export const publicProcedure = t.procedure.use(loggerMiddleware);
const sessionScopeMiddleware = t.middleware(async ({ ctx, next }) => {
const sessionId = ctx.session.session?.id ?? null;
return runWithAlsSession(sessionId, async () => {
return next();
});
});
export const publicProcedure = t.procedure
.use(loggerMiddleware)
.use(sessionScopeMiddleware);
export const protectedProcedure = t.procedure
.use(enforceUserIsAuthed)
.use(enforceAccess)
.use(loggerMiddleware);
.use(loggerMiddleware)
.use(sessionScopeMiddleware);
const middlewareMarker = 'middlewareMarker' as 'middlewareMarker' & {
__brand: 'middlewareMarker';