Skip to content

Commit 1759618

Browse files
committed
fix(webapp): keep the project-wide answer for an environment-agnostic user-actor token
Binding a delegated token to its environment claim on the project-wide routes also refused any token that carries no claim at all, which the public PAT exchange used by MCP and the CLI is allowed to issue. Line the project-wide helper up with its neighbours: a claimless dashboard-agent token is still refused, everything else stays project-wide.
1 parent e62d8c6 commit 1759618

2 files changed

Lines changed: 46 additions & 10 deletions

File tree

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ export async function assertUserActorScope(
7373
}
7474

7575
/**
76-
* The environment a project-wide route must narrow to. `scoped: false` is every non-delegated
77-
* caller (session, PAT, org token) — project-wide, unchanged.
76+
* The environment a project-wide route must narrow to. `scoped: false` is every caller with no
77+
* environment claim to narrow to — a session, a PAT, an org token, or an environment-agnostic
78+
* UAT — and stays project-wide, unchanged.
7879
*/
7980
export type UserActorEnvironmentScope =
8081
| { scoped: false }
@@ -85,10 +86,11 @@ export type UserActorEnvironmentScope =
8586
* across a project. Without this, an environment-scoped token reads every environment its user
8687
* is a member of — the claim would only bind the per-environment routes.
8788
*
88-
* Throws a 403 Response when the claim can't be honoured: no claim (nothing to narrow to), a
89-
* claim outside the target project, or a request filter that names anything else. A conflicting
90-
* filter is refused rather than overridden, so a caller never gets another environment's shape
91-
* of answer under its own filter.
89+
* Throws a 403 Response when a claim can't be honoured: a claim outside the target project, or a
90+
* request filter that names anything else. A conflicting filter is refused rather than overridden,
91+
* so a caller never gets another environment's shape of answer under its own filter. A token with
92+
* no claim keeps the project-wide answer, as the public PAT exchange's MCP and CLI callers expect —
93+
* except a dashboard-agent token, which always carries one, so its absence is a bug.
9294
*/
9395
export async function resolveUserActorEnvironmentScope(
9496
userActor: UserActorClaims | undefined,
@@ -97,7 +99,8 @@ export async function resolveUserActorEnvironmentScope(
9799
if (!userActor) return { scoped: false };
98100

99101
if (!userActor.environmentId) {
100-
throw forbiddenEnvironment("This token isn't scoped to an environment.");
102+
assertClaimIsOptional(userActor);
103+
return { scoped: false };
101104
}
102105

103106
const environment = await $replica.runtimeEnvironment.findFirst({

apps/webapp/test/userActorProjectWideScope.test.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,16 +260,16 @@ postgresTest(
260260
);
261261

262262
postgresTest(
263-
"a claimless user-actor token gets nothing project-wide",
263+
"a claimless dashboard-agent token is refused",
264264
async ({ prisma }) => {
265265
ctx.prisma = prisma;
266266
const seeded = await seedProject(prisma);
267267
ctx.patUserId = seeded.user.id;
268268

269-
// Any delegated client, not just the agent: a project-wide route has no scope to narrow to.
269+
// The agent always mints per-environment, so a claimless one of its own is a bug, not a flow.
270270
const claimless = await call(environmentsLoader, {
271271
projectRef: seeded.project.externalRef,
272-
token: await agentToken(seeded.user.id, undefined, "mcp"),
272+
token: await agentToken(seeded.user.id, undefined),
273273
});
274274

275275
expect(claimless.status).toBe(403);
@@ -278,6 +278,39 @@ postgresTest(
278278
60_000
279279
);
280280

281+
postgresTest(
282+
"a claimless user-actor token from another client still gets the project-wide answer",
283+
async ({ prisma }) => {
284+
ctx.prisma = prisma;
285+
ctx.presenterEnvironments = [];
286+
const seeded = await seedProject(prisma);
287+
ctx.patUserId = seeded.user.id;
288+
289+
// The public PAT exchange used by MCP and the CLI may issue an environment-agnostic token.
290+
// Narrowing it would be a breaking change, so it reads the whole project as it always has.
291+
const environments = await call(environmentsLoader, {
292+
projectRef: seeded.project.externalRef,
293+
token: await agentToken(seeded.user.id, undefined, "mcp"),
294+
});
295+
296+
expect(environments.status).toBe(200);
297+
expect(environments.body.map((env: any) => env.id).sort()).toEqual(
298+
[seeded.envA.id, seeded.envB.id].sort()
299+
);
300+
301+
const runs = await call(runsLoader, {
302+
projectRef: seeded.project.externalRef,
303+
token: await agentToken(seeded.user.id, undefined, "mcp"),
304+
search: `?filter[env]=${seeded.envB.slug}`,
305+
});
306+
307+
// No forced environment, and its own filter is honoured rather than refused.
308+
expect(runs.status).toBe(200);
309+
expect(ctx.presenterEnvironments).toEqual([undefined]);
310+
},
311+
60_000
312+
);
313+
281314
postgresTest(
282315
"a personal access token still gets the project-wide answer",
283316
async ({ prisma }) => {

0 commit comments

Comments
 (0)