Skip to content

Commit ca744a5

Browse files
committed
feat(webapp): add API key lifecycle metrics
Record bounded outcomes for additional key creation, policy preparation, revocation, and public-token minting.
1 parent ba81e5e commit ca744a5

5 files changed

Lines changed: 228 additions & 52 deletions

File tree

apps/webapp/app/models/api-key.server.ts

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
1010
import { prisma } from "~/db.server";
1111
import { RuntimeEnvironmentType } from "~/database-types";
1212
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
13+
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
1314
import { rbac } from "~/services/rbac.server";
1415
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
1516
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
@@ -131,13 +132,15 @@ export async function createEnvironmentApiKey(
131132
prismaClient = prisma,
132133
rbacController = rbac,
133134
issuanceAllowed,
135+
telemetryRecorder = apiKeyTelemetry,
134136
}: {
135137
prismaClient?: Pick<
136138
PrismaClient,
137139
"apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier"
138140
>;
139141
rbacController?: Pick<HostRbacController, "prepareApiKeyPolicy">;
140142
issuanceAllowed?: (organizationId: string) => Promise<boolean>;
143+
telemetryRecorder?: ApiKeyTelemetry;
141144
} = {}
142145
) {
143146
const environment = await prismaClient.runtimeEnvironment.findFirst({
@@ -184,29 +187,45 @@ export async function createEnvironmentApiKey(
184187
}
185188
}
186189

187-
const prepared = await rbacController.prepareApiKeyPolicy({
188-
organizationId: environment.organizationId,
189-
presetId,
190-
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
191-
});
190+
let prepared: Awaited<ReturnType<typeof rbacController.prepareApiKeyPolicy>>;
191+
try {
192+
prepared = await rbacController.prepareApiKeyPolicy({
193+
organizationId: environment.organizationId,
194+
presetId,
195+
taskIdentifiers: selectedTasks.length > 0 ? selectedTasks : undefined,
196+
});
197+
} catch (error) {
198+
telemetryRecorder.recordOperation("prepare_policy", "error", "policy_error");
199+
throw error;
200+
}
192201

193202
if (!prepared.ok) {
203+
telemetryRecorder.recordOperation("prepare_policy", "rejected", "policy_rejected");
194204
throw new Error(prepared.error);
195205
}
206+
telemetryRecorder.recordOperation("prepare_policy", "success");
196207

197208
const generated = generateAdditionalApiKey(environment.type);
198-
const apiKey = await prismaClient.apiKey.create({
199-
data: {
200-
name,
201-
keyHash: generated.keyHash,
202-
lastFour: generated.lastFour,
203-
runtimeEnvironmentId: environment.id,
204-
createdByUserId: userId,
205-
expiresAt,
206-
presetId: prepared.policy.presetId,
207-
scopes: prepared.policy.scopes,
208-
},
209-
});
209+
const apiKey = await (async () => {
210+
try {
211+
return await prismaClient.apiKey.create({
212+
data: {
213+
name,
214+
keyHash: generated.keyHash,
215+
lastFour: generated.lastFour,
216+
runtimeEnvironmentId: environment.id,
217+
createdByUserId: userId,
218+
expiresAt,
219+
presetId: prepared.policy.presetId,
220+
scopes: prepared.policy.scopes,
221+
},
222+
});
223+
} catch (error) {
224+
telemetryRecorder.recordOperation("create", "error", "database_error");
225+
throw error;
226+
}
227+
})();
228+
telemetryRecorder.recordOperation("create", "success");
210229

211230
crumb("environment API key created", {
212231
apiKeyId: apiKey.id,
@@ -217,26 +236,44 @@ export async function createEnvironmentApiKey(
217236
return { apiKey, plaintext: generated.apiKey };
218237
}
219238

220-
export async function revokeEnvironmentApiKey({
221-
environmentId,
222-
apiKeyId,
223-
}: {
224-
environmentId: string;
225-
apiKeyId: string;
226-
}) {
227-
const result = await prisma.apiKey.updateMany({
228-
where: {
229-
id: apiKeyId,
230-
runtimeEnvironmentId: environmentId,
231-
revokedAt: null,
232-
},
233-
data: { revokedAt: new Date() },
234-
});
239+
export async function revokeEnvironmentApiKey(
240+
{
241+
environmentId,
242+
apiKeyId,
243+
}: {
244+
environmentId: string;
245+
apiKeyId: string;
246+
},
247+
{
248+
prismaClient = prisma,
249+
telemetryRecorder = apiKeyTelemetry,
250+
}: {
251+
prismaClient?: Pick<PrismaClient, "apiKey">;
252+
telemetryRecorder?: ApiKeyTelemetry;
253+
} = {}
254+
) {
255+
const result = await (async () => {
256+
try {
257+
return await prismaClient.apiKey.updateMany({
258+
where: {
259+
id: apiKeyId,
260+
runtimeEnvironmentId: environmentId,
261+
revokedAt: null,
262+
},
263+
data: { revokedAt: new Date() },
264+
});
265+
} catch (error) {
266+
telemetryRecorder.recordOperation("revoke", "error", "database_error");
267+
throw error;
268+
}
269+
})();
235270

236271
if (result.count !== 1) {
272+
telemetryRecorder.recordOperation("revoke", "rejected", "not_found_or_revoked");
237273
throw new Error("API key not found or already revoked");
238274
}
239275

276+
telemetryRecorder.recordOperation("revoke", "success");
240277
crumb("environment API key revoked", { apiKeyId, environmentId }); // @crumbs
241278
}
242279

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { getMeter } from "@internal/tracing";
2+
import { singleton } from "~/utils/singleton";
3+
4+
export type ApiKeyOperation = "create" | "prepare_policy" | "revoke";
5+
export type ApiKeyOperationResult = "success" | "rejected" | "error";
6+
export type ApiKeyOperationReason =
7+
| "none"
8+
| "database_error"
9+
| "not_found_or_revoked"
10+
| "policy_rejected"
11+
| "policy_error";
12+
13+
export type PublicTokenMintResult = "success" | "rejected" | "error";
14+
export type PublicTokenMintReason =
15+
| "none"
16+
| "invalid_body"
17+
| "scope_not_allowed"
18+
| "invalid_expiration"
19+
| "expiration_not_future"
20+
| "expiration_too_long"
21+
| "signing_failed";
22+
23+
const telemetry = singleton("apiKeyTelemetry", () => {
24+
const meter = getMeter("api-key");
25+
26+
return {
27+
operations: meter.createCounter("api_key.operations", {
28+
description: "Additional environment API key management operations",
29+
}),
30+
publicTokenMintAttempts: meter.createCounter("public_token.mint_attempts", {
31+
description: "Public access token mint attempts using environment API keys",
32+
}),
33+
};
34+
});
35+
36+
export const apiKeyTelemetry = {
37+
recordOperation(
38+
operation: ApiKeyOperation,
39+
result: ApiKeyOperationResult,
40+
reason: ApiKeyOperationReason = "none"
41+
) {
42+
telemetry.operations.add(1, { operation, result, reason });
43+
},
44+
recordPublicTokenMint(result: PublicTokenMintResult, reason: PublicTokenMintReason = "none") {
45+
telemetry.publicTokenMintAttempts.add(1, { result, reason });
46+
},
47+
};
48+
49+
export type ApiKeyTelemetry = typeof apiKeyTelemetry;

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

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { RoleBaseAccessController } from "@trigger.dev/rbac";
33
import { resolveJwtSigningKey, scopesWithinAbility } from "@trigger.dev/rbac";
44
import { json } from "@remix-run/server-runtime";
55
import { z } from "zod";
6+
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
67
import { rbac } from "~/services/rbac.server";
78

89
// Public access tokens may be valid for at most 30 days.
@@ -53,7 +54,8 @@ function expirationTimestamp(expirationTime: string | number, now: number): numb
5354

5455
export async function handlePublicTokenRequest(
5556
request: Request,
56-
controller: Pick<RoleBaseAccessController, "authenticateBearer"> = rbac
57+
controller: Pick<RoleBaseAccessController, "authenticateBearer"> = rbac,
58+
telemetryRecorder: ApiKeyTelemetry = apiKeyTelemetry
5759
) {
5860
// Public JWTs are intentionally not enabled here. Only API keys may mint tokens.
5961
const authResult = await controller.authenticateBearer(request);
@@ -65,11 +67,13 @@ export async function handlePublicTokenRequest(
6567
try {
6668
body = await request.json();
6769
} catch {
70+
telemetryRecorder.recordPublicTokenMint("rejected", "invalid_body");
6871
return json({ error: "Invalid request body" }, { status: 400 });
6972
}
7073

7174
const parsedBody = RequestBodySchema.safeParse(body);
7275
if (!parsedBody.success) {
76+
telemetryRecorder.recordPublicTokenMint("rejected", "invalid_body");
7377
return json(
7478
{ error: "Invalid request body", issues: parsedBody.error.issues },
7579
{ status: 400 }
@@ -78,6 +82,7 @@ export async function handlePublicTokenRequest(
7882

7983
const scopeCheck = scopesWithinAbility(parsedBody.data.scopes, authResult.ability);
8084
if (!scopeCheck.ok) {
85+
telemetryRecorder.recordPublicTokenMint("rejected", "scope_not_allowed");
8186
return json(
8287
{
8388
error: "Requested scopes exceed the API key's access",
@@ -92,32 +97,42 @@ export async function handlePublicTokenRequest(
9297
const now = Math.floor(Date.now() / 1000);
9398
const expiresAt = expirationTimestamp(expirationTime, now);
9499
if (expiresAt === undefined) {
100+
telemetryRecorder.recordPublicTokenMint("rejected", "invalid_expiration");
95101
return json({ error: "Invalid expiration time" }, { status: 400 });
96102
}
97103
// `expirationTimestamp` accepts past values ("-5m", "5m ago"), which would
98104
// otherwise mint an already-expired token behind a 200.
99105
if (expiresAt <= now) {
106+
telemetryRecorder.recordPublicTokenMint("rejected", "expiration_not_future");
100107
return json({ error: "Expiration time must be in the future" }, { status: 400 });
101108
}
102109
if (expiresAt - now > MAX_PUBLIC_TOKEN_LIFETIME_SECONDS) {
110+
telemetryRecorder.recordPublicTokenMint("rejected", "expiration_too_long");
103111
return json({ error: "Expiration time cannot exceed 30 days" }, { status: 400 });
104112
}
105113

106-
const token = await generateJWT({
107-
secretKey: resolveJwtSigningKey(authResult.environment),
108-
payload: {
109-
sub: authResult.environment.id,
110-
pub: true,
111-
scopes: parsedBody.data.scopes,
112-
...(parsedBody.data.oneTimeUse ? { otu: true } : {}),
113-
...(parsedBody.data.realtime ? { realtime: parsedBody.data.realtime } : {}),
114-
},
115-
// Pass the absolute `exp` validated above, not the original string.
116-
// `generateJWT` hands the string to jose's own parser, which would leave
117-
// the 30-day cap enforced against a different computation than the one
118-
// that actually sets the claim.
119-
expirationTime: expiresAt,
120-
});
114+
let token: string;
115+
try {
116+
token = await generateJWT({
117+
secretKey: resolveJwtSigningKey(authResult.environment),
118+
payload: {
119+
sub: authResult.environment.id,
120+
pub: true,
121+
scopes: parsedBody.data.scopes,
122+
...(parsedBody.data.oneTimeUse ? { otu: true } : {}),
123+
...(parsedBody.data.realtime ? { realtime: parsedBody.data.realtime } : {}),
124+
},
125+
// Pass the absolute `exp` validated above, not the original string.
126+
// `generateJWT` hands the string to jose's own parser, which would leave
127+
// the 30-day cap enforced against a different computation than the one
128+
// that actually sets the claim.
129+
expirationTime: expiresAt,
130+
});
131+
} catch (error) {
132+
telemetryRecorder.recordPublicTokenMint("error", "signing_failed");
133+
throw error;
134+
}
121135

136+
telemetryRecorder.recordPublicTokenMint("success");
122137
return json({ token });
123138
}

apps/webapp/test/createEnvironmentApiKey.test.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import type { PrismaClient } from "@trigger.dev/database";
33
import rbacPlugin, { type RoleBaseAccessController } from "@trigger.dev/rbac";
44
import { expect, vi } from "vitest";
55
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
6-
import { createEnvironmentApiKey } from "~/models/api-key.server";
6+
import { createEnvironmentApiKey, revokeEnvironmentApiKey } from "~/models/api-key.server";
7+
import type { ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
78
import { FEATURE_FLAG } from "~/v3/featureFlags";
89
import {
910
createRuntimeEnvironment,
@@ -19,6 +20,13 @@ function policyController(
1920
return { prepareApiKeyPolicy: vi.fn(implementation) };
2021
}
2122

23+
function telemetryRecorder(): ApiKeyTelemetry {
24+
return {
25+
recordOperation: vi.fn(),
26+
recordPublicTokenMint: vi.fn(),
27+
};
28+
}
29+
2230
async function setup(prisma: PrismaClient) {
2331
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
2432
const [environment] = await Promise.all([
@@ -77,6 +85,7 @@ containerTest(
7785
containerTest("standalone fallback creates one explicit full-access key", async ({ prisma }) => {
7886
const { user, environment } = await setup(prisma);
7987
const fallback = rbacPlugin.create({ primary: prisma, replica: prisma }, { forceFallback: true });
88+
const telemetry = telemetryRecorder();
8089
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
8190

8291
const result = await createEnvironmentApiKey(
@@ -88,9 +97,11 @@ containerTest("standalone fallback creates one explicit full-access key", async
8897
expiresAt,
8998
presetId: "FULL_ACCESS",
9099
},
91-
{ prismaClient: prisma, rbacController: fallback }
100+
{ prismaClient: prisma, rbacController: fallback, telemetryRecorder: telemetry }
92101
);
93102

103+
expect(telemetry.recordOperation).toHaveBeenNthCalledWith(1, "prepare_policy", "success");
104+
expect(telemetry.recordOperation).toHaveBeenNthCalledWith(2, "create", "success");
94105
expect(result.plaintext).toMatch(/^tr_prod_sk_[A-Za-z0-9]{24}$/);
95106
expect(result.apiKey).toMatchObject({
96107
presetId: null,
@@ -102,6 +113,31 @@ containerTest("standalone fallback creates one explicit full-access key", async
102113
).resolves.toBe(1);
103114
});
104115

116+
containerTest("records successful API key revocation", async ({ prisma }) => {
117+
const { user, environment } = await setup(prisma);
118+
const apiKey = await prisma.apiKey.create({
119+
data: {
120+
name: "Revoke me",
121+
keyHash: uniqueId("hash"),
122+
lastFour: "last",
123+
runtimeEnvironmentId: environment.id,
124+
createdByUserId: user.id,
125+
scopes: ["admin"],
126+
},
127+
});
128+
const telemetry = telemetryRecorder();
129+
130+
await revokeEnvironmentApiKey(
131+
{ environmentId: environment.id, apiKeyId: apiKey.id },
132+
{ prismaClient: prisma, telemetryRecorder: telemetry }
133+
);
134+
135+
expect(telemetry.recordOperation).toHaveBeenCalledWith("revoke", "success");
136+
await expect(prisma.apiKey.findUnique({ where: { id: apiKey.id } })).resolves.toMatchObject({
137+
revokedAt: expect.any(Date),
138+
});
139+
});
140+
105141
containerTest("persists trusted full-access and restricted cloud policies", async ({ prisma }) => {
106142
const { organization, user, environment } = await setup(prisma);
107143
const fullAccessController = policyController(async () => ({
@@ -155,6 +191,7 @@ containerTest("policy preparation failure inserts no credential", async ({ prism
155191
ok: false,
156192
error: "This API key access preset is not available on your plan",
157193
}));
194+
const telemetry = telemetryRecorder();
158195

159196
await expect(
160197
createEnvironmentApiKey(
@@ -165,10 +202,15 @@ containerTest("policy preparation failure inserts no credential", async ({ prism
165202
name: "Unavailable",
166203
presetId: "RESTRICTED",
167204
},
168-
{ prismaClient: prisma, rbacController: controller }
205+
{ prismaClient: prisma, rbacController: controller, telemetryRecorder: telemetry }
169206
)
170207
).rejects.toThrow("not available on your plan");
171208

209+
expect(telemetry.recordOperation).toHaveBeenCalledWith(
210+
"prepare_policy",
211+
"rejected",
212+
"policy_rejected"
213+
);
172214
await expect(
173215
prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } })
174216
).resolves.toBe(0);

0 commit comments

Comments
 (0)