Skip to content

Commit c8e7e0d

Browse files
committed
feat(webapp): enforce env-tier access on environment credential endpoints
The endpoints that hand a personal access token an environment's secret key or a key-signed JWT now apply the caller's role for that environment tier. A restricted role can't pull deployed-environment credentials, which is what stops it deploying via the CLI (deploy authenticates with the environment secret key). Environment API keys are scoped to a single environment already, so they are unaffected.
1 parent 2346a72 commit c8e7e0d

3 files changed

Lines changed: 67 additions & 7 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
authenticateRequest,
77
} from "~/services/apiAuth.server";
88
import { logger } from "~/services/logger.server";
9+
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
910

1011
const ParamsSchema = z.object({
1112
projectRef: z.string(),
@@ -49,6 +50,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
4950
triggerBranch
5051
);
5152

53+
// This mints a JWT signed with the environment's secret key. For a PAT
54+
// (a user), gate it on env-tier read:apiKeys so a restricted role can't
55+
// obtain deployed-environment credentials (and therefore can't deploy).
56+
const denied = await authorizePatEnvironmentAccess({
57+
request,
58+
authType: authenticationResult.type,
59+
organizationId: runtimeEnv.organizationId,
60+
projectId: runtimeEnv.project.id,
61+
envType: runtimeEnv.type,
62+
resource: "apiKeys",
63+
action: "read",
64+
});
65+
if (denied) return denied;
66+
5267
const parsedBody = RequestBodySchema.safeParse(await request.json());
5368

5469
if (!parsedBody.success) {

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

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
branchNameFromRequest,
99
} from "~/services/apiAuth.server";
1010
import { logger } from "~/services/logger.server";
11+
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
1112

1213
const ParamsSchema = z.object({
1314
projectRef: z.string(),
@@ -26,7 +27,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
2627
const { projectRef, env } = parsedParams.data;
2728

2829
try {
29-
const authenticationResult = await authenticateRequest(request);
30+
const authenticationResult = await authenticateRequest(request, {
31+
personalAccessToken: true,
32+
organizationAccessToken: true,
33+
apiKey: true,
34+
});
3035

3136
if (!authenticationResult) {
3237
return json({ error: "Invalid or Missing API key" }, { status: 401 });
@@ -39,6 +44,20 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
3944
branchNameFromRequest(request)
4045
);
4146

47+
// This endpoint hands the caller the environment's secret key. For a PAT
48+
// (a user), gate it on env-tier read:apiKeys — so a restricted role can't
49+
// pull deployed credentials (and therefore can't deploy) via the CLI.
50+
const denied = await authorizePatEnvironmentAccess({
51+
request,
52+
authType: authenticationResult.type,
53+
organizationId: environment.organizationId,
54+
projectId: environment.project.id,
55+
envType: environment.type,
56+
resource: "apiKeys",
57+
action: "read",
58+
});
59+
if (denied) return denied;
60+
4261
const result: GetProjectEnvResponse = {
4362
apiKey: environment.apiKey,
4463
name: environment.project.name,

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,43 @@ import { json } from "@remix-run/server-runtime";
22
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
33
import { rbac } from "~/services/rbac.server";
44

5+
type EnvironmentScopedResource = "envvars" | "apiKeys";
6+
7+
const RESOURCE_LABELS: Record<EnvironmentScopedResource, string> = {
8+
envvars: "environment variables",
9+
apiKeys: "API keys",
10+
};
11+
512
/**
6-
* Env-tier RBAC for the environment-variable API routes.
13+
* Env-tier RBAC for environment-scoped API routes (env vars, and the endpoints
14+
* that hand out an environment's secret credentials).
715
*
816
* Machine credentials (an environment's secret/public API key) are already
917
* scoped to a single environment, so they pass through unchanged. A personal
1018
* access token carries a user, so enforce that user's role for the targeted
11-
* environment tier — e.g. a Developer can't read or write deployed env vars
12-
* via the API, matching the dashboard restriction.
19+
* environment tier — e.g. a Developer can't read deployed env vars or API keys
20+
* via the API, matching the dashboard restriction. Blocking the credential read
21+
* for deployed tiers is also what stops a restricted role deploying via the CLI
22+
* (deploy needs the environment's secret key).
1323
*
1424
* Returns a `Response` to short-circuit with when access is denied, or
1525
* `undefined` when the request may proceed.
1626
*/
17-
export async function authorizeEnvVarApiRequest({
27+
export async function authorizePatEnvironmentAccess({
1828
request,
1929
authType,
2030
organizationId,
2131
projectId,
2232
envType,
33+
resource,
2334
action,
2435
}: {
2536
request: Request;
2637
authType: "personalAccessToken" | "organizationAccessToken" | "apiKey";
2738
organizationId: string;
2839
projectId: string;
2940
envType: RuntimeEnvironmentType;
41+
resource: EnvironmentScopedResource;
3042
action: "read" | "write";
3143
}): Promise<Response | undefined> {
3244
if (authType !== "personalAccessToken") {
@@ -38,12 +50,26 @@ export async function authorizeEnvVarApiRequest({
3850
return json({ error: patAuth.error }, { status: patAuth.status });
3951
}
4052

41-
if (!patAuth.ability.can(action, { type: "envvars", envType })) {
53+
if (!patAuth.ability.can(action, { type: resource, envType })) {
4254
return json(
43-
{ error: "You don't have permission to manage environment variables in this environment." },
55+
{
56+
error: `You don't have permission to access this environment's ${RESOURCE_LABELS[resource]}.`,
57+
},
4458
{ status: 403 }
4559
);
4660
}
4761

4862
return undefined;
4963
}
64+
65+
/** Env-tier env var access for the env var API routes. */
66+
export function authorizeEnvVarApiRequest(opts: {
67+
request: Request;
68+
authType: "personalAccessToken" | "organizationAccessToken" | "apiKey";
69+
organizationId: string;
70+
projectId: string;
71+
envType: RuntimeEnvironmentType;
72+
action: "read" | "write";
73+
}): Promise<Response | undefined> {
74+
return authorizePatEnvironmentAccess({ ...opts, resource: "envvars" });
75+
}

0 commit comments

Comments
 (0)