Skip to content

Commit 177d171

Browse files
committed
fix(webapp): let an org-wide user-actor token list a project's environments
1 parent 63738d4 commit 177d171

4 files changed

Lines changed: 223 additions & 7 deletions

File tree

apps/webapp/app/routes/api.v1.projects.$projectRef.environments.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export const loader = createLoaderPATApiRoute(
2828
return project ? { organizationId: project.organizationId } : {};
2929
},
3030
authorization: { action: "read", resource: () => ({ type: "environments" }) },
31+
// An org-wide delegated token lists any project of its org, so the agent can sweep
32+
// sibling projects. The org binding is the context above; membership is `findProjectByRef`.
33+
organizationScoped: true,
3134
},
3235
async ({ params, authentication }) => {
3336
const project = await findProjectByRef(params.projectRef, authentication.userId);
@@ -37,9 +40,11 @@ export const loader = createLoaderPATApiRoute(
3740
}
3841

3942
// A delegated token signed for one environment only ever lists that one.
40-
const scope = await resolveUserActorEnvironmentScope(authentication.userActor, {
41-
projectId: project.id,
42-
});
43+
const scope = await resolveUserActorEnvironmentScope(
44+
authentication.userActor,
45+
{ projectId: project.id },
46+
{ organizationScoped: true }
47+
);
4348

4449
const environments = await $replica.runtimeEnvironment.findMany({
4550
where: {

apps/webapp/app/services/routeBuilders/apiBuilder.server.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,10 @@ type PATLoaderRouteBuilderOptions<
528528
// which mutate nothing — otherwise such a token is refused for want of anything to check.
529529
// Loaders only: an action mutates by definition, so the action options forbid it.
530530
identityOnly?: true;
531+
// Opts a route into being reachable by an org-scoped user-actor token, bound to the
532+
// organization its `context` names. Only for routes that resolve their target scoped to the
533+
// caller's membership, since the claim itself proves none.
534+
organizationScoped?: true;
531535
};
532536

533537
type PATHandlerFunction<
@@ -568,6 +572,7 @@ export function createLoaderPATApiRoute<
568572
corsStrategy = "none",
569573
context: contextFn,
570574
identityOnly,
575+
organizationScoped,
571576
authorization,
572577
} = options;
573578

@@ -671,7 +676,7 @@ export function createLoaderPATApiRoute<
671676
corsStrategy !== "none"
672677
);
673678
}
674-
await assertUserActorScope(claims, ctx, { identityOnly });
679+
await assertUserActorScope(claims, ctx, { identityOnly, organizationScoped });
675680
authenticationResult = { userId: uatAuth.userId, userActor: claims };
676681
ability = uatAuth.ability;
677682
} else {
@@ -768,8 +773,9 @@ type PATActionRouteBuilderOptions<
768773
method?: PATActionMethod | PATActionMethod[];
769774
body?: TBodySchema;
770775
// `identityOnly` waives the contextless refusal for reads that mutate nothing. An action
771-
// never qualifies, so it cannot be declared here.
776+
// never qualifies, so it cannot be declared here. Same for the org-scoped read opt-in.
772777
identityOnly?: never;
778+
organizationScoped?: never;
773779
};
774780

775781
type PATActionHandlerFunction<

apps/webapp/app/services/userActorEnvironment.server.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,15 @@ export async function assertUserActorEnvironmentAccess(
6767
export async function assertUserActorScope(
6868
userActor: UserActorClaims | undefined,
6969
scope: { organizationId?: string; projectId?: string; environmentId?: string },
70-
route?: { identityOnly?: boolean }
70+
route?: { identityOnly?: boolean; organizationScoped?: boolean }
7171
): Promise<void> {
7272
if (!userActor) return;
7373

7474
if (!userActor.environmentId) {
75+
if (route?.organizationScoped && userActor.organizationId) {
76+
assertUserActorOrganization(userActor.organizationId, scope.organizationId);
77+
return;
78+
}
7579
assertClaimIsOptional(userActor);
7680
return;
7781
}
@@ -116,11 +120,15 @@ export type UserActorEnvironmentScope =
116120
*/
117121
export async function resolveUserActorEnvironmentScope(
118122
userActor: UserActorClaims | undefined,
119-
target: { projectId: string; requestedEnvironmentSlugs?: string[] }
123+
target: { projectId: string; requestedEnvironmentSlugs?: string[] },
124+
route?: { organizationScoped?: boolean }
120125
): Promise<UserActorEnvironmentScope> {
121126
if (!userActor) return { scoped: false };
122127

123128
if (!userActor.environmentId) {
129+
// An org claim spans every environment of its org, so it narrows nothing here. The org
130+
// binding itself is `assertUserActorScope`'s, against the organization the route named.
131+
if (route?.organizationScoped && userActor.organizationId) return { scoped: false };
124132
assertClaimIsOptional(userActor);
125133
return { scoped: false };
126134
}
@@ -148,6 +156,12 @@ export async function resolveUserActorEnvironmentScope(
148156
};
149157
}
150158

159+
/** An org claim only reaches a route that names its own organization. */
160+
function assertUserActorOrganization(claimed: string, named: string | undefined): void {
161+
if (named === claimed) return;
162+
throw forbiddenEnvironment("This token isn't scoped to that organization.");
163+
}
164+
151165
function assertClaimIsOptional(userActor: UserActorClaims): void {
152166
if (userActor.client !== DASHBOARD_AGENT_CLIENT) return;
153167
throw forbiddenEnvironment("This token isn't scoped to an environment.");
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/**
2+
* The environments route is the agent's cross-project sweep: an org-wide user-actor token lists
3+
* any project of its own organization, and nothing outside it. Driven through the real loader
4+
* against a real database, because membership — not the claim — is the tenant floor.
5+
*/
6+
7+
import { postgresTest } from "@internal/testcontainers";
8+
import type { PrismaClient } from "@trigger.dev/database";
9+
import { buildJwtAbility, 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+
24+
const mocks = vi.hoisted(() => ({ authenticateUserActor: vi.fn() }));
25+
26+
vi.mock("~/services/rbac.server", () => ({
27+
rbac: { authenticateUserActor: mocks.authenticateUserActor, authenticatePat: vi.fn() },
28+
}));
29+
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
30+
vi.mock("~/services/personalAccessToken.server", async () => {
31+
const { verifyUserActorToken } = await import("@trigger.dev/rbac");
32+
return {
33+
updateLastAccessedAtIfStale: vi.fn(),
34+
resolveAndRecheckUserActorClaims: async (claims: unknown, bearer: string) =>
35+
claims ?? (await verifyUserActorToken(SESSION_SECRET, bearer)),
36+
};
37+
});
38+
vi.mock("~/services/authTelemetry.server", () => ({
39+
authenticateBearerWithTelemetry: vi.fn(),
40+
}));
41+
vi.mock("~/services/logger.server", () => ({
42+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
43+
}));
44+
vi.mock("~/services/tenantContext.server", () => ({
45+
tenantContext: { enrich: vi.fn() },
46+
tenantContextFromAuthEnvironment: vi.fn(),
47+
}));
48+
vi.mock("~/v3/services/worker/workerGroupTokenService.server", () => ({
49+
WorkerGroupTokenService: class {},
50+
}));
51+
vi.mock("~/v3/services/common.server", () => ({
52+
ServiceValidationError: class extends Error {},
53+
}));
54+
vi.mock("@internal/run-engine", () => ({
55+
EngineServiceValidationError: class extends Error {},
56+
}));
57+
58+
const { loader } = await import("~/routes/api.v1.projects.$projectRef.environments");
59+
60+
function suffix() {
61+
return Math.random().toString(36).slice(2, 10);
62+
}
63+
64+
/** An org with one project, prod/staging/dev environments, a member and an outsider. */
65+
async function seedOrg(prisma: PrismaClient) {
66+
const slug = `orgenvs_${suffix()}`;
67+
const member = await prisma.user.create({
68+
data: { email: `${slug}-member@example.com`, authenticationMethod: "MAGIC_LINK" },
69+
});
70+
const outsider = await prisma.user.create({
71+
data: { email: `${slug}-outsider@example.com`, authenticationMethod: "MAGIC_LINK" },
72+
});
73+
const organization = await prisma.organization.create({ data: { title: slug, slug } });
74+
const orgMember = await prisma.orgMember.create({
75+
data: { organizationId: organization.id, userId: member.id, role: "ADMIN" },
76+
});
77+
const project = await prisma.project.create({
78+
data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` },
79+
});
80+
const environmentFor = (envSlug: string, type: "PRODUCTION" | "STAGING" | "DEVELOPMENT") =>
81+
prisma.runtimeEnvironment.create({
82+
data: {
83+
slug: envSlug,
84+
type,
85+
projectId: project.id,
86+
organizationId: organization.id,
87+
apiKey: `tr_${envSlug}_${slug}`,
88+
pkApiKey: `pk_${envSlug}_${slug}`,
89+
shortcode: `${envSlug}${suffix()}`,
90+
...(type === "DEVELOPMENT" ? { orgMemberId: orgMember.id } : {}),
91+
},
92+
});
93+
94+
return {
95+
member,
96+
outsider,
97+
organization,
98+
project,
99+
prod: await environmentFor("prod", "PRODUCTION"),
100+
staging: await environmentFor("stg", "STAGING"),
101+
dev: await environmentFor("dev", "DEVELOPMENT"),
102+
};
103+
}
104+
105+
async function callLoader(opts: {
106+
projectRef: string;
107+
userId: string;
108+
organizationId?: string;
109+
environmentId?: string;
110+
}): Promise<{ status: number; body: any }> {
111+
const claims = {
112+
userId: opts.userId,
113+
client: "dashboard-agent",
114+
...(opts.organizationId ? { organizationId: opts.organizationId } : {}),
115+
...(opts.environmentId ? { environmentId: opts.environmentId } : {}),
116+
};
117+
const token = await signUserActorToken(SESSION_SECRET, {
118+
...claims,
119+
cap: ["read:environments"],
120+
});
121+
mocks.authenticateUserActor.mockImplementation(async () => ({
122+
ok: true,
123+
userId: opts.userId,
124+
claims,
125+
subject: { type: "userActor", userId: opts.userId },
126+
ability: buildJwtAbility(["read:environments"]),
127+
}));
128+
129+
const response = await loader({
130+
request: new Request(
131+
`https://api.trigger.dev/api/v1/projects/${opts.projectRef}/environments`,
132+
{ headers: { Authorization: `Bearer ${token}` } }
133+
),
134+
params: { projectRef: opts.projectRef },
135+
context: {},
136+
} as any);
137+
138+
return { status: response.status, body: await response.json() };
139+
}
140+
141+
postgresTest(
142+
"org-wide user-actor token lists a sibling project's environments",
143+
async ({ prisma }) => {
144+
ctx.prisma = prisma;
145+
const orgA = await seedOrg(prisma);
146+
const orgB = await seedOrg(prisma);
147+
148+
// Its own org's project: every environment, dev included — the whole point of the sweep.
149+
const own = await callLoader({
150+
projectRef: orgA.project.externalRef,
151+
userId: orgA.member.id,
152+
organizationId: orgA.organization.id,
153+
});
154+
expect(own.status).toBe(200);
155+
expect(own.body.map((env: any) => env.slug).sort()).toEqual(["dev", "prod", "stg"]);
156+
157+
// A project outside the claimed organization is refused on the claim alone.
158+
const foreign = await callLoader({
159+
projectRef: orgB.project.externalRef,
160+
userId: orgA.member.id,
161+
organizationId: orgA.organization.id,
162+
});
163+
expect(foreign.status).toBe(403);
164+
expect(foreign.body.code).toBe("forbidden_environment");
165+
166+
// A claim naming the right org still needs membership of it.
167+
const outsider = await callLoader({
168+
projectRef: orgA.project.externalRef,
169+
userId: orgB.outsider.id,
170+
organizationId: orgA.organization.id,
171+
});
172+
expect(outsider.status).toBe(404);
173+
174+
// The environment-claim path is unchanged: exactly its own environment, and nothing elsewhere.
175+
const scoped = await callLoader({
176+
projectRef: orgA.project.externalRef,
177+
userId: orgA.member.id,
178+
environmentId: orgA.staging.id,
179+
});
180+
expect(scoped.status).toBe(200);
181+
expect(scoped.body.map((env: any) => env.slug)).toEqual(["stg"]);
182+
183+
const scopedForeign = await callLoader({
184+
projectRef: orgB.project.externalRef,
185+
userId: orgA.member.id,
186+
environmentId: orgA.staging.id,
187+
});
188+
expect(scopedForeign.status).toBe(403);
189+
expect(scopedForeign.body.code).toBe("forbidden_environment");
190+
}
191+
);

0 commit comments

Comments
 (0)