Skip to content

Commit b6e125c

Browse files
committed
fix(webapp): org-scope worker and repo snapshot routes for the agent
1 parent 7ec4f34 commit b6e125c

4 files changed

Lines changed: 559 additions & 2 deletions

‎apps/webapp/app/routes/api.v1.projects.$projectRef.$env.repo.snapshot.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3333
const { projectRef, env } = parsed.data;
3434

3535
const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined;
36+
// An org-claim token reads the repo of any project in its org, so the agent can open code
37+
// for a sibling project. Membership is `findProjectByRef` inside the resolve.
3638
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
3739
authentication.authenticationResult,
3840
projectRef,
3941
env,
40-
triggerBranch
42+
triggerBranch,
43+
{ organizationScoped: true }
4144
);
4245

4346
// The signed URL exposes the project's whole source tree, so gate it like the environment's

‎apps/webapp/app/routes/api.v1.projects.$projectRef.$env.workers.$tagName.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,14 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3939

4040
const triggerBranch = branchNameFromRequest(request);
4141

42+
// An org-claim token lists tasks in any project of its org, so the agent can read a sibling
43+
// project's worker. Membership is `findProjectByRef` inside the resolve.
4244
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
4345
authentication.authenticationResult,
4446
projectRef,
4547
env,
46-
triggerBranch
48+
triggerBranch,
49+
{ organizationScoped: true }
4750
);
4851

4952
const currentWorker = await findCurrentWorkerFromEnvironment(
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
/**
2+
* The repo-snapshot route feeds the agent's code tools, so an org-wide user-actor token has to
3+
* reach a sibling project and its preview branches. Another member's dev env, another
4+
* organization and an environment-only claim all stay refused.
5+
*/
6+
7+
import { postgresTest } from "@internal/testcontainers";
8+
import type { PrismaClient } from "@trigger.dev/database";
9+
import { signUserActorToken } from "@trigger.dev/rbac";
10+
import { expect, vi } from "vitest";
11+
12+
const SESSION_SECRET = "test-session-secret";
13+
14+
const ctx = vi.hoisted(() => ({ prisma: undefined as unknown as PrismaClient }));
15+
16+
vi.mock("~/db.server", () => {
17+
const proxy = new Proxy(
18+
{},
19+
{ get: (_target, prop) => (ctx.prisma as unknown as Record<string, unknown>)[prop as string] }
20+
);
21+
return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined };
22+
});
23+
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
24+
vi.mock("~/services/logger.server", () => ({
25+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
26+
}));
27+
// The real resolver talks to the GitHub app. Echo back the project the route resolved instead,
28+
// so the assertions can name it.
29+
vi.mock("~/services/dashboardAgent.server", () => ({
30+
resolveDashboardAgentRepoSnapshot: async (projectId: string) => ({
31+
owner: "acme",
32+
repo: projectId,
33+
sha: "a".repeat(40),
34+
tarballUrl: `https://codeload.example/${projectId}`,
35+
}),
36+
resolveRunCommit: async () => null,
37+
}));
38+
39+
const { loader } = await import("~/routes/api.v1.projects.$projectRef.$env.repo.snapshot");
40+
41+
function suffix() {
42+
return Math.random().toString(36).slice(2, 10);
43+
}
44+
45+
/** An org with two projects, each with prod, a preview branch, and a second member's dev env. */
46+
async function seedOrg(prisma: PrismaClient) {
47+
const slug = `reposnapshot_${suffix()}`;
48+
const member = await prisma.user.create({
49+
data: { email: `${slug}-member@example.com`, authenticationMethod: "MAGIC_LINK" },
50+
});
51+
const other = await prisma.user.create({
52+
data: { email: `${slug}-other@example.com`, authenticationMethod: "MAGIC_LINK" },
53+
});
54+
const organization = await prisma.organization.create({ data: { title: slug, slug } });
55+
await prisma.orgMember.create({
56+
data: { organizationId: organization.id, userId: member.id, role: "ADMIN" },
57+
});
58+
const otherMembership = await prisma.orgMember.create({
59+
data: { organizationId: organization.id, userId: other.id, role: "ADMIN" },
60+
});
61+
62+
async function projectWithEnvironments(name: string) {
63+
const projectSlug = `${slug}_${name}`;
64+
const project = await prisma.project.create({
65+
data: {
66+
name: projectSlug,
67+
slug: projectSlug,
68+
organizationId: organization.id,
69+
externalRef: `proj_${projectSlug}`,
70+
},
71+
});
72+
73+
const environmentFor = (data: {
74+
slug: string;
75+
type: "PRODUCTION" | "DEVELOPMENT" | "PREVIEW";
76+
branchName?: string;
77+
parentEnvironmentId?: string;
78+
orgMemberId?: string;
79+
}) =>
80+
prisma.runtimeEnvironment.create({
81+
data: {
82+
slug: data.slug,
83+
type: data.type,
84+
branchName: data.branchName,
85+
parentEnvironmentId: data.parentEnvironmentId,
86+
orgMemberId: data.orgMemberId,
87+
projectId: project.id,
88+
organizationId: organization.id,
89+
apiKey: `tr_${data.slug}_${projectSlug}`,
90+
pkApiKey: `pk_${data.slug}_${projectSlug}`,
91+
shortcode: `${suffix()}`,
92+
},
93+
});
94+
95+
const prod = await environmentFor({ slug: "prod", type: "PRODUCTION" });
96+
// Dev rows are per-member, so this one belongs to somebody else in the same org.
97+
await environmentFor({
98+
slug: "dev",
99+
type: "DEVELOPMENT",
100+
orgMemberId: otherMembership.id,
101+
});
102+
const previewParent = await environmentFor({ slug: "preview", type: "PREVIEW" });
103+
await environmentFor({
104+
slug: `preview-feat-${name}`,
105+
type: "PREVIEW",
106+
branchName: `feat/${name}`,
107+
parentEnvironmentId: previewParent.id,
108+
});
109+
110+
return { project, prod };
111+
}
112+
113+
return {
114+
member,
115+
organization,
116+
current: await projectWithEnvironments("current"),
117+
sibling: await projectWithEnvironments("sibling"),
118+
};
119+
}
120+
121+
async function callLoader(opts: {
122+
projectRef: string;
123+
env: string;
124+
branch?: string;
125+
userId: string;
126+
organizationId?: string;
127+
environmentId?: string;
128+
}): Promise<{ status: number; body: any }> {
129+
const token = await signUserActorToken(SESSION_SECRET, {
130+
userId: opts.userId,
131+
client: "dashboard-agent",
132+
...(opts.organizationId ? { organizationId: opts.organizationId } : {}),
133+
...(opts.environmentId ? { environmentId: opts.environmentId } : {}),
134+
cap: ["read:apiKeys"],
135+
});
136+
137+
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
138+
if (opts.branch) headers["x-trigger-branch"] = opts.branch;
139+
140+
try {
141+
const response = await loader({
142+
request: new Request(
143+
`https://api.trigger.dev/api/v1/projects/${opts.projectRef}/${opts.env}/repo/snapshot`,
144+
{ headers }
145+
),
146+
params: { projectRef: opts.projectRef, env: opts.env },
147+
context: {},
148+
} as any);
149+
return { status: response.status, body: await response.json() };
150+
} catch (thrown) {
151+
if (thrown instanceof Response) return { status: thrown.status, body: await thrown.json() };
152+
throw thrown;
153+
}
154+
}
155+
156+
postgresTest(
157+
"org-wide user-actor token reads a sibling project's repo snapshot",
158+
async ({ prisma }) => {
159+
ctx.prisma = prisma;
160+
const orgA = await seedOrg(prisma);
161+
const orgB = await seedOrg(prisma);
162+
163+
// The shape the dashboard agent mints: the turn's environment plus its organization.
164+
const minted = {
165+
userId: orgA.member.id,
166+
organizationId: orgA.organization.id,
167+
environmentId: orgA.current.prod.id,
168+
};
169+
170+
// A sibling project of the same org — the live repro. Its own snapshot, not a 403.
171+
const sibling = await callLoader({
172+
...minted,
173+
projectRef: orgA.sibling.project.externalRef,
174+
env: "prod",
175+
});
176+
expect(sibling.status).toBe(200);
177+
expect(sibling.body.repo).toBe(orgA.sibling.project.id);
178+
179+
// Its own environment still works.
180+
const own = await callLoader({
181+
...minted,
182+
projectRef: orgA.current.project.externalRef,
183+
env: "prod",
184+
});
185+
expect(own.status).toBe(200);
186+
expect(own.body.repo).toBe(orgA.current.project.id);
187+
188+
// A preview branch of the sibling project, addressed the way the agent addresses one.
189+
const branch = await callLoader({
190+
...minted,
191+
projectRef: orgA.sibling.project.externalRef,
192+
env: "preview",
193+
branch: "feat/sibling",
194+
});
195+
expect(branch.status).toBe(200);
196+
expect(branch.body.repo).toBe(orgA.sibling.project.id);
197+
198+
// Another member's dev environment: org membership doesn't hand over a personal dev env.
199+
const otherDev = await callLoader({
200+
...minted,
201+
projectRef: orgA.sibling.project.externalRef,
202+
env: "dev",
203+
});
204+
expect(otherDev.status).toBe(404);
205+
206+
// A project in another organization, reached by a user who belongs to both — so the refusal
207+
// is the org boundary itself, not a missing membership.
208+
const crossMember = await prisma.user.create({
209+
data: { email: `cross-${suffix()}@example.com`, authenticationMethod: "MAGIC_LINK" },
210+
});
211+
for (const organizationId of [orgA.organization.id, orgB.organization.id]) {
212+
await prisma.orgMember.create({
213+
data: { organizationId, userId: crossMember.id, role: "ADMIN" },
214+
});
215+
}
216+
217+
const foreign = await callLoader({
218+
userId: crossMember.id,
219+
organizationId: orgA.organization.id,
220+
environmentId: orgA.current.prod.id,
221+
projectRef: orgB.current.project.externalRef,
222+
env: "prod",
223+
});
224+
expect(foreign.status).toBe(403);
225+
expect(foreign.body.code).toBe("forbidden_environment");
226+
227+
// Same claim, its own org: the very same user is admitted, so the 403 above is the boundary.
228+
const crossOwn = await callLoader({
229+
userId: crossMember.id,
230+
organizationId: orgA.organization.id,
231+
environmentId: orgA.current.prod.id,
232+
projectRef: orgA.sibling.project.externalRef,
233+
env: "prod",
234+
});
235+
expect(crossOwn.status).toBe(200);
236+
237+
// An environment claim with no org claim is unchanged: its own environment only.
238+
const envOnly = { userId: orgA.member.id, environmentId: orgA.current.prod.id };
239+
const scoped = await callLoader({
240+
...envOnly,
241+
projectRef: orgA.current.project.externalRef,
242+
env: "prod",
243+
});
244+
expect(scoped.status).toBe(200);
245+
246+
const scopedSibling = await callLoader({
247+
...envOnly,
248+
projectRef: orgA.sibling.project.externalRef,
249+
env: "prod",
250+
});
251+
expect(scopedSibling.status).toBe(403);
252+
expect(scopedSibling.body.code).toBe("forbidden_environment");
253+
}
254+
);

0 commit comments

Comments
 (0)