Skip to content

Commit f4bb92f

Browse files
committed
fix(webapp): scope the run stream lookup to the user's organizations
The SSE stream route resolved runs by friendly id alone. The lookup now applies the same organization membership scoping as the rest of the run page presenters, on both the database lookup and the buffered-run fallback, with unauthorized indistinguishable from missing.
1 parent e5e89ec commit f4bb92f

1 file changed

Lines changed: 24 additions & 1 deletion

File tree

apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type PrismaClient, prisma } from "~/db.server";
22
import { logger } from "~/services/logger.server";
3+
import { requireUserId } from "~/services/session.server";
34
import { singleton } from "~/utils/singleton";
45
import { ABORT_REASON_SEND_ERROR, createSSELoader, SendFunction } from "~/utils/sse";
56
import { throttle } from "~/utils/throttle";
@@ -30,9 +31,23 @@ export class RunStreamPresenter {
3031
throw new Response("Missing runParam", { status: 400 });
3132
}
3233

34+
const userId = await requireUserId(context.request);
35+
36+
// Scope the lookup to organizations the requesting user is a member
37+
// of, matching RunPresenter's run lookup. Unauthorized and missing
38+
// runs are indistinguishable (both 404).
3339
const run = await prismaClient.taskRun.findFirst({
3440
where: {
3541
friendlyId: runFriendlyId,
42+
project: {
43+
organization: {
44+
members: {
45+
some: {
46+
userId,
47+
},
48+
},
49+
},
50+
},
3651
},
3752
select: {
3853
traceId: true,
@@ -51,7 +66,15 @@ export class RunStreamPresenter {
5166
if (buffer) {
5267
try {
5368
const entry = await buffer.getEntry(runFriendlyId);
54-
if (entry) {
69+
// Same membership scoping as the PG lookup above — the buffer
70+
// entry carries the owning org's id.
71+
const isMember = entry
72+
? (await prismaClient.orgMember.findFirst({
73+
where: { organizationId: entry.orgId, userId },
74+
select: { id: true },
75+
})) !== null
76+
: false;
77+
if (entry && isMember) {
5578
// Go through the webapp wrapper so this read-side module
5679
// shares a single deserialisation path with readFallback —
5780
// see the contract comment in syntheticRedirectInfo.server.ts.

0 commit comments

Comments
 (0)