Skip to content

Commit 6fd9df4

Browse files
committed
fix(webapp): honour the org claim on a minted env-scoped agent token
1 parent 177d171 commit 6fd9df4

3 files changed

Lines changed: 112 additions & 45 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ export const loader = createLoaderPATApiRoute(
2929
},
3030
authorization: { action: "read", resource: () => ({ type: "environments" }) },
3131
// 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`.
32+
// sibling projects. The context above names no project, so the org is what binds the claim
33+
// here; `resolveUserActorEnvironmentScope` binds it to this project's org, and
34+
// `findProjectByRef` is the membership floor.
3335
organizationScoped: true,
3436
},
3537
async ({ params, authentication }) => {

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,15 @@ export async function assertUserActorScope(
101101
if (!environment) {
102102
throw forbiddenEnvironment("This token isn't scoped to an environment.");
103103
}
104-
if (scope.projectId && environment.projectId !== scope.projectId) {
104+
// An org claim makes the organization the boundary, so a route that opted in reads any project
105+
// of it. The org check below still runs, so the route's own organization does the binding.
106+
const orgWide =
107+
route?.organizationScoped &&
108+
!!userActor.organizationId &&
109+
userActor.organizationId === environment.organizationId &&
110+
scope.organizationId === environment.organizationId;
111+
112+
if (scope.projectId && environment.projectId !== scope.projectId && !orgWide) {
105113
throw forbiddenEnvironment("This token isn't scoped to that project.");
106114
}
107115
if (scope.organizationId && environment.organizationId !== scope.organizationId) {
@@ -125,10 +133,22 @@ export async function resolveUserActorEnvironmentScope(
125133
): Promise<UserActorEnvironmentScope> {
126134
if (!userActor) return { scoped: false };
127135

136+
// An org claim makes the organization the boundary, not the environment, so on a route that
137+
// opted in it narrows nothing — for any project of that org, the environment claim included.
138+
// The org binding is this query: a project outside the claimed org is refused.
139+
if (route?.organizationScoped && userActor.organizationId) {
140+
const project = await $replica.project.findFirst({
141+
where: { id: target.projectId, organizationId: userActor.organizationId },
142+
select: { id: true },
143+
});
144+
145+
if (!project) {
146+
throw forbiddenEnvironment("This token isn't scoped to that organization.");
147+
}
148+
return { scoped: false };
149+
}
150+
128151
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 };
132152
assertClaimIsOptional(userActor);
133153
return { scoped: false };
134154
}

apps/webapp/test/userActorOrgWideProjectEnvironments.test.ts

Lines changed: 85 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,14 @@ vi.mock("@internal/run-engine", () => ({
5656
}));
5757

5858
const { loader } = await import("~/routes/api.v1.projects.$projectRef.environments");
59+
const { assertUserActorScope, resolveUserActorEnvironmentScope } =
60+
await import("~/services/userActorEnvironment.server");
5961

6062
function suffix() {
6163
return Math.random().toString(36).slice(2, 10);
6264
}
6365

64-
/** An org with one project, prod/staging/dev environments, a member and an outsider. */
66+
/** An org with two projects, each with prod/staging/dev environments, a member and an outsider. */
6567
async function seedOrg(prisma: PrismaClient) {
6668
const slug = `orgenvs_${suffix()}`;
6769
const member = await prisma.user.create({
@@ -74,31 +76,45 @@ async function seedOrg(prisma: PrismaClient) {
7476
const orgMember = await prisma.orgMember.create({
7577
data: { organizationId: organization.id, userId: member.id, role: "ADMIN" },
7678
});
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({
79+
80+
async function projectWithEnvironments(name: string) {
81+
const projectSlug = `${slug}_${name}`;
82+
const project = await prisma.project.create({
8283
data: {
83-
slug: envSlug,
84-
type,
85-
projectId: project.id,
84+
name: projectSlug,
85+
slug: projectSlug,
8686
organizationId: organization.id,
87-
apiKey: `tr_${envSlug}_${slug}`,
88-
pkApiKey: `pk_${envSlug}_${slug}`,
89-
shortcode: `${envSlug}${suffix()}`,
90-
...(type === "DEVELOPMENT" ? { orgMemberId: orgMember.id } : {}),
87+
externalRef: `proj_${projectSlug}`,
9188
},
9289
});
90+
const environmentFor = (envSlug: string, type: "PRODUCTION" | "STAGING" | "DEVELOPMENT") =>
91+
prisma.runtimeEnvironment.create({
92+
data: {
93+
slug: envSlug,
94+
type,
95+
projectId: project.id,
96+
organizationId: organization.id,
97+
apiKey: `tr_${envSlug}_${projectSlug}`,
98+
pkApiKey: `pk_${envSlug}_${projectSlug}`,
99+
shortcode: `${envSlug}${suffix()}`,
100+
...(type === "DEVELOPMENT" ? { orgMemberId: orgMember.id } : {}),
101+
},
102+
});
103+
104+
return {
105+
project,
106+
prod: await environmentFor("prod", "PRODUCTION"),
107+
staging: await environmentFor("stg", "STAGING"),
108+
dev: await environmentFor("dev", "DEVELOPMENT"),
109+
};
110+
}
93111

94112
return {
95113
member,
96114
outsider,
97115
organization,
98-
project,
99-
prod: await environmentFor("prod", "PRODUCTION"),
100-
staging: await environmentFor("stg", "STAGING"),
101-
dev: await environmentFor("dev", "DEVELOPMENT"),
116+
current: await projectWithEnvironments("current"),
117+
sibling: await projectWithEnvironments("sibling"),
102118
};
103119
}
104120

@@ -138,54 +154,83 @@ async function callLoader(opts: {
138154
return { status: response.status, body: await response.json() };
139155
}
140156

157+
async function statusOf(promise: Promise<unknown>) {
158+
try {
159+
await promise;
160+
return 200;
161+
} catch (thrown) {
162+
if (thrown instanceof Response) return thrown.status;
163+
throw thrown;
164+
}
165+
}
166+
141167
postgresTest(
142168
"org-wide user-actor token lists a sibling project's environments",
143169
async ({ prisma }) => {
144170
ctx.prisma = prisma;
145171
const orgA = await seedOrg(prisma);
146172
const orgB = await seedOrg(prisma);
147173

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,
174+
// The shape the dashboard agent actually mints: the turn's environment plus its organization.
175+
const minted = {
151176
userId: orgA.member.id,
152177
organizationId: orgA.organization.id,
153-
});
178+
environmentId: orgA.current.dev.id,
179+
};
180+
181+
// A sibling project of the same org — the sweep this whole route exists for. Every
182+
// environment, dev included, none of them the one the token was minted for.
183+
const sibling = await callLoader({ ...minted, projectRef: orgA.sibling.project.externalRef });
184+
expect(sibling.status).toBe(200);
185+
expect(sibling.body.map((env: any) => env.slug).sort()).toEqual(["dev", "prod", "stg"]);
186+
187+
// Its own project answers the same way: with an org claim, the org is the boundary.
188+
const own = await callLoader({ ...minted, projectRef: orgA.current.project.externalRef });
154189
expect(own.status).toBe(200);
155190
expect(own.body.map((env: any) => env.slug).sort()).toEqual(["dev", "prod", "stg"]);
156191

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-
});
192+
// A project outside the claimed organization.
193+
const foreign = await callLoader({ ...minted, projectRef: orgB.current.project.externalRef });
163194
expect(foreign.status).toBe(403);
164195
expect(foreign.body.code).toBe("forbidden_environment");
165196

166197
// A claim naming the right org still needs membership of it.
167198
const outsider = await callLoader({
168-
projectRef: orgA.project.externalRef,
199+
projectRef: orgA.current.project.externalRef,
169200
userId: orgB.outsider.id,
170201
organizationId: orgA.organization.id,
171202
});
172203
expect(outsider.status).toBe(404);
173204

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-
});
205+
// An environment claim with no org claim is unchanged: that environment only, and nothing
206+
// in another project.
207+
const envOnly = { userId: orgA.member.id, environmentId: orgA.current.staging.id };
208+
const scoped = await callLoader({ ...envOnly, projectRef: orgA.current.project.externalRef });
180209
expect(scoped.status).toBe(200);
181210
expect(scoped.body.map((env: any) => env.slug)).toEqual(["stg"]);
182211

183-
const scopedForeign = await callLoader({
184-
projectRef: orgB.project.externalRef,
185-
userId: orgA.member.id,
186-
environmentId: orgA.staging.id,
212+
const scopedSibling = await callLoader({
213+
...envOnly,
214+
projectRef: orgA.sibling.project.externalRef,
187215
});
188-
expect(scopedForeign.status).toBe(403);
189-
expect(scopedForeign.body.code).toBe("forbidden_environment");
216+
expect(scopedSibling.status).toBe(403);
217+
expect(scopedSibling.body.code).toBe("forbidden_environment");
218+
219+
// The control: a route that hasn't opted in refuses the same org-wide token, so nothing is
220+
// loosened for the PAT routes at large.
221+
const claims = { ...minted, client: "dashboard-agent" };
222+
expect(
223+
await statusOf(
224+
resolveUserActorEnvironmentScope(claims, { projectId: orgA.sibling.project.id })
225+
)
226+
).toBe(403);
227+
expect(
228+
await statusOf(
229+
assertUserActorScope(claims, {
230+
organizationId: orgA.organization.id,
231+
projectId: orgA.sibling.project.id,
232+
})
233+
)
234+
).toBe(403);
190235
}
191236
);

0 commit comments

Comments
 (0)