Skip to content

Commit 70559be

Browse files
committed
fix(webapp): attribute env-authed API calls to the acting user (TRI-11095)
The env JWT exchange now stamps a signed `act` claim (acting user + client kind), auth surfaces it as `actor`, and the tenant context prefers it over `orgMember`, which only exists on dev environments. Identity only — the JWT still authorizes as the environment.
1 parent 17758f7 commit 70559be

8 files changed

Lines changed: 273 additions & 6 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Actions taken through the API, like resolving an error, now record which team member made them in every environment, not just development.

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,17 +99,23 @@ export async function action({ request, params }: ActionFunctionArgs) {
9999
// authentication result. Either way downstream handlers read `act.sub`
100100
// (e.g. the errors API records who resolved/ignored an error). An org
101101
// access token has no user, so `act` is omitted.
102+
//
103+
// `act.client` names the kind of caller that did the exchange, for
104+
// attribution only. A UAT already carries its own `client` (e.g.
105+
// "dashboard-agent"), so pass it through; a PAT exchange has none, and
106+
// gets the same default the UAT mint route uses.
102107
const actorUserId =
103108
userActorId ??
104109
(authenticationResult.type === "personalAccessToken"
105110
? authenticationResult.result.userId
106111
: undefined);
112+
const actorClient = userActor?.client ?? "personal-access-token";
107113

108114
const claims = {
109115
sub: runtimeEnv.id,
110116
pub: true,
111117
...(scopes ? { scopes } : {}),
112-
...(actorUserId ? { act: { sub: actorUserId } } : {}),
118+
...(actorUserId ? { act: { sub: actorUserId, client: actorClient } } : {}),
113119
};
114120

115121
const jwt = await internal_generateJWT({

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ const ClaimsSchema = z.object({
4444
skipColumns: z.array(z.string()).optional(),
4545
})
4646
.optional(),
47+
// Delegation stamped at env-JWT exchange time: who the exchange was done for
48+
// (`sub`) and what kind of caller did it (`client`). Identity only — it must
49+
// never feed the ability. Authorization comes from `sub` (the environment)
50+
// and `scopes` alone, so `act` can't widen what a token can do.
51+
act: z
52+
.object({
53+
sub: z.string(),
54+
client: z.string().optional(),
55+
})
56+
.optional(),
4757
});
4858

4959
// Re-export the slim shape defined in @trigger.dev/core. Single source of
@@ -74,6 +84,7 @@ export type ApiAuthenticationResultSuccess = {
7484
// API keys (no user) and JWTs minted without delegation.
7585
actor?: {
7686
sub: string;
87+
client?: string;
7788
};
7889
};
7990

@@ -187,6 +198,7 @@ export async function authenticateApiKey(
187198
environment: validationResults.environment,
188199
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
189200
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
201+
actor: parsedClaims.success ? parsedClaims.data.act : undefined,
190202
};
191203
}
192204
}
@@ -279,6 +291,7 @@ async function authenticateApiKeyWithFailure(
279291
environment: validationResults.environment,
280292
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
281293
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
294+
actor: parsedClaims.success ? parsedClaims.data.act : undefined,
282295
};
283296
}
284297
}

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,10 @@ export function createLoaderApiRoute<
422422
const apiVersion = getApiVersion(request);
423423

424424
const result = await tenantContext.run(
425-
tenantContextFromAuthEnvironment(authenticationResult.environment),
425+
tenantContextFromAuthEnvironment(
426+
authenticationResult.environment,
427+
authenticationResult.actor
428+
),
426429
() =>
427430
handler({
428431
params: parsedParams,
@@ -1278,7 +1281,10 @@ export function createActionApiRoute<
12781281
}
12791282

12801283
const result = await tenantContext.run(
1281-
tenantContextFromAuthEnvironment(authenticationResult.environment),
1284+
tenantContextFromAuthEnvironment(
1285+
authenticationResult.environment,
1286+
authenticationResult.actor
1287+
),
12821288
() =>
12831289
handler({
12841290
params: parsedParams,
@@ -1543,7 +1549,10 @@ export function createMultiMethodApiRoute<
15431549

15441550
// Dispatch to method handler
15451551
const result = await tenantContext.run(
1546-
tenantContextFromAuthEnvironment(authenticationResult.environment),
1552+
tenantContextFromAuthEnvironment(
1553+
authenticationResult.environment,
1554+
authenticationResult.actor
1555+
),
15471556
() =>
15481557
methodConfig.handler({
15491558
params: parsedParams,

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,15 @@ export const tenantContext = {
3535
},
3636
};
3737

38-
export function tenantContextFromAuthEnvironment(env: AuthenticatedEnvironment): TenantContext {
38+
// `actor` is the env JWT's delegation claim, when the request carried one. It
39+
// wins over `orgMember`: that only exists on dev environments, so an env-authed
40+
// call against a shared prod/staging env is otherwise attributed to nobody.
41+
export function tenantContextFromAuthEnvironment(
42+
env: AuthenticatedEnvironment,
43+
actor?: { sub: string }
44+
): TenantContext {
3945
return {
40-
userId: env.orgMember?.userId,
46+
userId: actor?.sub ?? env.orgMember?.userId,
4147
orgSlug: env.organization.slug,
4248
projectSlug: env.project.slug,
4349
envSlug: env.slug,
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { buildJwtAbility } from "@trigger.dev/rbac";
3+
4+
const jwtMocks = vi.hoisted(() => ({
5+
validatePublicJwtKey: vi.fn<(...args: any[]) => Promise<any>>(),
6+
}));
7+
8+
vi.mock("@internal/tracing", () => ({
9+
getMeter: () => ({
10+
createCounter: () => ({ add: vi.fn() }),
11+
createHistogram: () => ({ record: vi.fn() }),
12+
createObservableGauge: () => ({ addCallback: vi.fn() }),
13+
}),
14+
}));
15+
vi.mock("~/services/rbac.server", () => ({ rbac: { authenticateBearer: vi.fn() } }));
16+
vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} }));
17+
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
18+
vi.mock("~/models/project.server", () => ({ findProjectByRef: vi.fn() }));
19+
vi.mock("~/models/runtimeEnvironment.server", () => ({
20+
authIncludeBase: {},
21+
authIncludeWithParent: {},
22+
findEnvironmentByApiKey: vi.fn(),
23+
findEnvironmentByApiKeyWithResolution: vi.fn(),
24+
findEnvironmentByPublicApiKey: vi.fn(),
25+
toAuthenticated: vi.fn(),
26+
}));
27+
vi.mock("~/services/personalAccessToken.server", () => ({
28+
authenticateApiRequestWithPersonalAccessToken: vi.fn(),
29+
isPersonalAccessToken: () => false,
30+
}));
31+
vi.mock("~/services/organizationAccessToken.server", () => ({
32+
authenticateApiRequestWithOrganizationAccessToken: vi.fn(),
33+
isOrganizationAccessToken: () => false,
34+
}));
35+
vi.mock("~/services/realtime/jwtAuth.server", () => ({
36+
isPublicJWT: (token: string) => token.startsWith("jwt_"),
37+
validatePublicJwtKey: jwtMocks.validatePublicJwtKey,
38+
}));
39+
vi.mock("~/services/logger.server", () => ({
40+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
41+
}));
42+
43+
import { authenticateApiKey } from "~/services/apiAuth.server";
44+
45+
const environment = { id: "env_1", apiKey: "tr_prod_abc" };
46+
47+
function claims(extra: Record<string, unknown> = {}) {
48+
return { sub: environment.id, pub: true, scopes: ["read:runs"], ...extra };
49+
}
50+
51+
async function authenticate(jwtClaims: Record<string, unknown>) {
52+
jwtMocks.validatePublicJwtKey.mockResolvedValue({
53+
ok: true,
54+
environment,
55+
claims: jwtClaims,
56+
});
57+
const result = await authenticateApiKey("jwt_token", { allowJWT: true });
58+
if (!result) throw new Error("expected authentication to succeed");
59+
return result;
60+
}
61+
62+
describe("PUBLIC_JWT authentication — actor claim", () => {
63+
beforeEach(() => {
64+
jwtMocks.validatePublicJwtKey.mockReset();
65+
});
66+
67+
it("surfaces actor when the JWT carries act", async () => {
68+
const result = await authenticate(
69+
claims({ act: { sub: "usr_42", client: "dashboard-agent" } })
70+
);
71+
72+
expect(result.actor).toEqual({ sub: "usr_42", client: "dashboard-agent" });
73+
});
74+
75+
it("accepts act without a client", async () => {
76+
const result = await authenticate(claims({ act: { sub: "usr_42" } }));
77+
78+
expect(result.actor).toEqual({ sub: "usr_42" });
79+
});
80+
81+
it("leaves actor undefined when the JWT has no act", async () => {
82+
const result = await authenticate(claims());
83+
84+
expect(result.actor).toBeUndefined();
85+
});
86+
87+
it("ignores a malformed act rather than failing the request", async () => {
88+
const result = await authenticate(claims({ act: { client: "dashboard-agent" } }));
89+
90+
expect(result.ok).toBe(true);
91+
expect(result.actor).toBeUndefined();
92+
});
93+
94+
it("does not let act widen authorization", async () => {
95+
// Authorization comes from sub + scopes only. An act claim stuffed with
96+
// extra scopes is identity data and grants nothing.
97+
const forged = claims({
98+
act: { sub: "usr_42", client: "dashboard-agent", scopes: ["admin"] },
99+
});
100+
const result = await authenticate(forged);
101+
102+
expect(result.environment.id).toBe(environment.id);
103+
expect(result.actor).toEqual({ sub: "usr_42", client: "dashboard-agent" });
104+
105+
const ability = buildJwtAbility(forged.scopes);
106+
expect(ability.rules).toEqual(buildJwtAbility(["read:runs"]).rules);
107+
expect(ability.can("read", { type: "runs" })).toBe(true);
108+
expect(ability.can("write", { type: "runs" })).toBe(false);
109+
});
110+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mocks = vi.hoisted(() => ({
4+
authenticateUatOrApiRequest: vi.fn<(...args: any[]) => Promise<any>>(),
5+
authorizePatEnvironmentAccess: vi.fn<(...args: any[]) => Promise<any>>(),
6+
}));
7+
8+
vi.mock("~/services/uatRoutePreamble.server", () => ({
9+
authenticateUatOrApiRequest: mocks.authenticateUatOrApiRequest,
10+
}));
11+
vi.mock("~/services/environmentVariableApiAccess.server", () => ({
12+
authorizePatEnvironmentAccess: mocks.authorizePatEnvironmentAccess,
13+
}));
14+
vi.mock("~/services/apiAuth.server", () => ({
15+
authenticatedEnvironmentForAuthentication: vi.fn(async () => environment),
16+
branchNameFromRequest: () => undefined,
17+
}));
18+
vi.mock("~/services/logger.server", () => ({
19+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
20+
}));
21+
22+
import { validateJWT } from "@trigger.dev/core/v3/jwt";
23+
import { action } from "~/routes/api.v1.projects.$projectRef.$env.jwt";
24+
25+
const environment = {
26+
id: "env_1234",
27+
apiKey: "tr_prod_abcdefghijklmnop",
28+
organizationId: "org_1234",
29+
type: "PRODUCTION" as const,
30+
project: { id: "proj_1234" },
31+
};
32+
33+
const params = { projectRef: "proj_abc", env: "prod" };
34+
35+
function request(body: unknown = {}) {
36+
return new Request("https://example.com/api/v1/projects/proj_abc/prod/jwt", {
37+
method: "POST",
38+
headers: { "Content-Type": "application/json" },
39+
body: JSON.stringify(body),
40+
});
41+
}
42+
43+
async function mintedClaims(body?: unknown) {
44+
const response = await action({ request: request(body), params, context: {} as any });
45+
const { token } = (await response.json()) as { token: string };
46+
const result = await validateJWT(token, environment.apiKey);
47+
if (!result.ok) throw new Error("minted token failed validation");
48+
return result.payload as Record<string, any>;
49+
}
50+
51+
describe("env JWT exchange — act claim", () => {
52+
beforeEach(() => {
53+
mocks.authenticateUatOrApiRequest.mockReset();
54+
mocks.authorizePatEnvironmentAccess.mockReset();
55+
mocks.authorizePatEnvironmentAccess.mockResolvedValue(undefined);
56+
});
57+
58+
it("stamps the PAT's user with the personal-access-token client", async () => {
59+
mocks.authenticateUatOrApiRequest.mockResolvedValue({
60+
authenticationResult: { type: "personalAccessToken", result: { userId: "usr_42" } },
61+
});
62+
63+
const claims = await mintedClaims();
64+
65+
expect(claims.act).toEqual({ sub: "usr_42", client: "personal-access-token" });
66+
// Authorization is unchanged: the JWT still authorizes as the environment.
67+
expect(claims.sub).toBe(environment.id);
68+
});
69+
70+
it("passes through a user-actor token's own client", async () => {
71+
mocks.authenticateUatOrApiRequest.mockResolvedValue({
72+
authenticationResult: { type: "personalAccessToken", result: { userId: "usr_7" } },
73+
userActor: { userId: "usr_7", client: "dashboard-agent", cap: ["read:runs"] },
74+
});
75+
76+
const claims = await mintedClaims({ claims: { scopes: ["read:runs"] } });
77+
78+
expect(claims.act).toEqual({ sub: "usr_7", client: "dashboard-agent" });
79+
expect(claims.scopes).toEqual(["read:runs"]);
80+
});
81+
82+
it("omits act for an org access token (no user)", async () => {
83+
mocks.authenticateUatOrApiRequest.mockResolvedValue({
84+
authenticationResult: {
85+
type: "organizationAccessToken",
86+
result: { organizationId: "org_1" },
87+
},
88+
});
89+
90+
const claims = await mintedClaims();
91+
92+
expect(claims.act).toBeUndefined();
93+
expect(claims.sub).toBe(environment.id);
94+
});
95+
96+
it("401s without a token", async () => {
97+
mocks.authenticateUatOrApiRequest.mockResolvedValue(undefined);
98+
99+
const response = await action({ request: request(), params, context: {} as any });
100+
101+
expect(response.status).toBe(401);
102+
});
103+
});

apps/webapp/test/tenantContextFromAuthEnvironment.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,18 @@ describe("tenantContextFromAuthEnvironment", () => {
4545
it("does not propagate impersonating (auth environments are real, not impersonated)", () => {
4646
expect(tenantContextFromAuthEnvironment(envWithOrgMember).impersonating).toBeUndefined();
4747
});
48+
49+
it("prefers the JWT actor over orgMember", () => {
50+
const ctx = tenantContextFromAuthEnvironment(envWithOrgMember, { sub: "usr_99" });
51+
expect(ctx.userId).toBe("usr_99");
52+
});
53+
54+
it("attributes a shared env with no orgMember to the JWT actor", () => {
55+
const ctx = tenantContextFromAuthEnvironment(envWithoutOrgMember, { sub: "usr_99" });
56+
expect(ctx.userId).toBe("usr_99");
57+
});
58+
59+
it("falls back to orgMember when there is no actor", () => {
60+
expect(tenantContextFromAuthEnvironment(envWithOrgMember, undefined).userId).toBe("usr_42");
61+
});
4862
});

0 commit comments

Comments
 (0)