Skip to content

Commit bd4d4a0

Browse files
committed
Merge branch 'feat/dashboard-agent-flows' into feat/dashboard-agent-ui
2 parents 7be3df7 + 5126bae commit bd4d4a0

17 files changed

Lines changed: 662 additions & 81 deletions

apps/webapp/app/routes/api.v1.orgs.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,38 +11,43 @@ import {
1111
import { extractDomain, faviconUrl } from "~/utils/favicon";
1212

1313
// Identity-only: lists the caller's own orgs, so no authorization gate.
14-
export const loader = createLoaderPATApiRoute({}, async ({ authentication }) => {
15-
const orgs = await prisma.organization.findMany({
16-
where: {
17-
deletedAt: null,
18-
members: {
19-
some: {
20-
userId: authentication.userId,
14+
export const loader = createLoaderPATApiRoute(
15+
{ identityOnly: true },
16+
async ({ authentication }) => {
17+
const orgs = await prisma.organization.findMany({
18+
where: {
19+
deletedAt: null,
20+
members: {
21+
some: {
22+
userId: authentication.userId,
23+
},
2124
},
2225
},
23-
},
24-
});
26+
});
2527

26-
if (!orgs) {
27-
return json({ error: "Orgs not found" }, { status: 404 });
28-
}
28+
if (!orgs) {
29+
return json({ error: "Orgs not found" }, { status: 404 });
30+
}
2931

30-
const result: GetOrgsResponseBody = orgs.map((org) => ({
31-
id: org.id,
32-
title: org.title,
33-
slug: org.slug,
34-
createdAt: org.createdAt,
35-
}));
32+
const result: GetOrgsResponseBody = orgs.map((org) => ({
33+
id: org.id,
34+
title: org.title,
35+
slug: org.slug,
36+
createdAt: org.createdAt,
37+
}));
3638

37-
return json(result);
38-
});
39+
return json(result);
40+
}
41+
);
3942

40-
// No org exists yet, so no authorization gate; any authenticated user can
41-
// create an org and becomes its ADMIN.
43+
// No org exists yet, so there is nothing to scope the gate to; any authenticated user can create
44+
// an org and becomes its ADMIN. The gate is still declared so a narrowly-capped delegated token
45+
// (which cannot `manage`) is refused rather than inheriting its user's full reach.
4246
export const action = createActionPATApiRoute(
4347
{
4448
method: "POST",
4549
body: CreateOrgRequestBody,
50+
authorization: { action: "manage", resource: () => ({ type: "organization" }) },
4651
},
4752
async ({ body, authentication }) => {
4853
if (env.ORG_CREATION_API_ENABLED !== "1") {

apps/webapp/app/routes/api.v1.projects.$projectRef.$env.runs.$runId.commit.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type AuthenticatedEnvironment,
77
} from "~/services/apiAuth.server";
88
import { resolveRunCommit } from "~/services/dashboardAgent.server";
9+
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
910
import { logger } from "~/services/logger.server";
1011
import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server";
1112

@@ -51,6 +52,18 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
5152
triggerBranch
5253
);
5354

55+
// The answer is a deployment's git metadata, so it's gated like the deployments list.
56+
const denied = await authorizePatEnvironmentAccess({
57+
request,
58+
authType: authentication.authenticationResult.type,
59+
organizationId: runtimeEnv.organizationId,
60+
projectId: runtimeEnv.project.id,
61+
envType: runtimeEnv.type,
62+
resource: "deployments",
63+
action: "read",
64+
});
65+
if (denied) return denied;
66+
5467
const commit = await resolveRunCommit(runtimeEnv.id, runId);
5568
if (!commit) {
5669
return json(

apps/webapp/app/routes/api.v1.projects.ts

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,44 +4,47 @@ import { prisma } from "~/db.server";
44
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
55

66
// Identity-only: lists projects across the caller's orgs, so no authorization gate.
7-
export const loader = createLoaderPATApiRoute({}, async ({ authentication }) => {
8-
const projects = await prisma.project.findMany({
9-
where: {
10-
organization: {
11-
deletedAt: null,
12-
members: {
13-
some: {
14-
userId: authentication.userId,
7+
export const loader = createLoaderPATApiRoute(
8+
{ identityOnly: true },
9+
async ({ authentication }) => {
10+
const projects = await prisma.project.findMany({
11+
where: {
12+
organization: {
13+
deletedAt: null,
14+
members: {
15+
some: {
16+
userId: authentication.userId,
17+
},
1518
},
1619
},
20+
version: "V3",
21+
deletedAt: null,
22+
},
23+
include: {
24+
organization: true,
25+
defaultWorkerGroup: { select: { name: true } },
1726
},
18-
version: "V3",
19-
deletedAt: null,
20-
},
21-
include: {
22-
organization: true,
23-
defaultWorkerGroup: { select: { name: true } },
24-
},
25-
});
27+
});
2628

27-
if (!projects) {
28-
return json({ error: "Projects not found" }, { status: 404 });
29-
}
29+
if (!projects) {
30+
return json({ error: "Projects not found" }, { status: 404 });
31+
}
3032

31-
const result: GetProjectsResponseBody = projects.map((project) => ({
32-
id: project.id,
33-
externalRef: project.externalRef,
34-
name: project.name,
35-
slug: project.slug,
36-
createdAt: project.createdAt,
37-
defaultRegion: project.defaultWorkerGroup?.name ?? null,
38-
organization: {
39-
id: project.organization.id,
40-
title: project.organization.title,
41-
slug: project.organization.slug,
42-
createdAt: project.organization.createdAt,
43-
},
44-
}));
33+
const result: GetProjectsResponseBody = projects.map((project) => ({
34+
id: project.id,
35+
externalRef: project.externalRef,
36+
name: project.name,
37+
slug: project.slug,
38+
createdAt: project.createdAt,
39+
defaultRegion: project.defaultWorkerGroup?.name ?? null,
40+
organization: {
41+
id: project.organization.id,
42+
title: project.organization.title,
43+
slug: project.organization.slug,
44+
createdAt: project.organization.createdAt,
45+
},
46+
}));
4547

46-
return json(result);
47-
});
48+
return json(result);
49+
}
50+
);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from "~/services/apiAuth.server";
1010
import { rbac } from "~/services/rbac.server";
1111

12-
type EnvironmentScopedResource = "envvars" | "apiKeys";
12+
type EnvironmentScopedResource = "envvars" | "apiKeys" | "deployments";
1313

1414
type EnvironmentScopedAuthentication =
1515
| { ok: true; authentication: AuthenticationResult }
@@ -78,6 +78,7 @@ export function authenticateEnvVarApiRequest(
7878
const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
7979
envvars: "environment variables",
8080
apiKeys: "API keys",
81+
deployments: "deployments",
8182
};
8283

8384
/**

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,10 @@ type PATRouteBuilderOptions<
500500
: undefined,
501501
request: Request
502502
) => PATRouteContext | Promise<PATRouteContext>;
503+
// Opts a contextless route into being reachable by an environment-scoped user-actor token.
504+
// Only for routes whose answer is the caller's own identity (their orgs, their projects) and
505+
// which mutate nothing — otherwise such a token is refused for want of anything to check.
506+
identityOnly?: true;
503507
authorization?: {
504508
action: string;
505509
resource: (
@@ -555,6 +559,7 @@ export function createLoaderPATApiRoute<
555559
headers: headersSchema,
556560
corsStrategy = "none",
557561
context: contextFn,
562+
identityOnly,
558563
authorization,
559564
} = options;
560565

@@ -658,7 +663,7 @@ export function createLoaderPATApiRoute<
658663
corsStrategy !== "none"
659664
);
660665
}
661-
await assertUserActorScope(claims, ctx);
666+
await assertUserActorScope(claims, ctx, { identityOnly });
662667
authenticationResult = { userId: uatAuth.userId, userActor: claims };
663668
ability = uatAuth.ability;
664669
} else {
@@ -798,6 +803,7 @@ export function createActionPATApiRoute<
798803
body: bodySchema,
799804
corsStrategy = "none",
800805
context: contextFn,
806+
identityOnly,
801807
authorization,
802808
method,
803809
} = options;
@@ -930,7 +936,7 @@ export function createActionPATApiRoute<
930936
corsStrategy !== "none"
931937
);
932938
}
933-
await assertUserActorScope(claims, ctx);
939+
await assertUserActorScope(claims, ctx, { identityOnly });
934940
authenticationResult = { userId: uatAuth.userId, userActor: claims };
935941
ability = uatAuth.ability;
936942
} else {

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
* MCP and the CLI may use their existing ones. Both normalize into the same authorized
44
* capability context, so every route calls in here rather than deriving the rule itself.
55
*
6-
* The rule: a token signed for one environment may only act inside it. Anything with no claim
7-
* is environment-agnostic and unaffected — except a dashboard-agent token, which always carries
8-
* one, so its absence is a failed mint rather than a flow. Mismatches throw a 403 Response.
6+
* The rule: a token signed for one environment may only act inside it. A route that names nothing
7+
* to check the claim against is refused too, unless it declares itself identity-only. Anything
8+
* with no claim is environment-agnostic and unaffected — except a dashboard-agent token, which
9+
* always carries one, so its absence is a failed mint rather than a flow. Mismatches throw 403.
910
*/
1011

1112
import { json } from "@remix-run/server-runtime";
@@ -33,7 +34,8 @@ export function assertUserActorEnvironment(
3334
/** The same check for a route that names an org/project rather than one environment. */
3435
export async function assertUserActorScope(
3536
userActor: UserActorClaims | undefined,
36-
scope: { organizationId?: string; projectId?: string; environmentId?: string }
37+
scope: { organizationId?: string; projectId?: string; environmentId?: string },
38+
route?: { identityOnly?: boolean }
3739
): Promise<void> {
3840
if (!userActor) return;
3941

@@ -47,7 +49,12 @@ export async function assertUserActorScope(
4749
return;
4850
}
4951

50-
if (!scope.organizationId && !scope.projectId) return;
52+
// A route that names nothing offers no way to honour the claim, so it isn't reachable unless it
53+
// has declared itself identity-only.
54+
if (!scope.organizationId && !scope.projectId) {
55+
if (route?.identityOnly) return;
56+
throw forbiddenEnvironment("This token is scoped to an environment this route doesn't name.");
57+
}
5158

5259
const environment = await $replica.runtimeEnvironment.findFirst({
5360
where: { id: userActor.environmentId },

0 commit comments

Comments
 (0)