Skip to content

Commit 07ff270

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(webapp): disable root API key visibility
New environments no longer display root API keys. Existing environments can permanently disable root key visibility while retaining standard key rotation behavior. Mono-RevId: 521947a794d7bf82803a97fad014b497a8c9ca26
1 parent 1133ad4 commit 07ff270

40 files changed

Lines changed: 817 additions & 266 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: feature
4+
---
5+
6+
Additional API keys are now enabled by default. New environments no longer display root API keys, and existing environments can permanently disable their visibility

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

Lines changed: 156 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
import type { PrismaClient, RuntimeEnvironment } from "@trigger.dev/database";
1+
import type {
2+
PrismaClient,
3+
PrismaTransactionClient,
4+
RuntimeEnvironment,
5+
} from "@trigger.dev/database";
26
import type { HostRbacController } from "@trigger.dev/rbac";
37
import { customAlphabet } from "nanoid";
48
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
5-
import { boundedIn, prisma } from "~/db.server";
9+
import { $transaction, boundedIn, prisma } from "~/db.server";
610
import { RuntimeEnvironmentType } from "~/database-types";
711
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
812
import { apiKeyTelemetry, type ApiKeyTelemetry } from "~/services/apiKeyTelemetry.server";
@@ -17,87 +21,187 @@ const apiKeyId = customAlphabet(
1721

1822
const REVOKED_API_KEY_GRACE_PERIOD_MS = 24 * 60 * 60 * 1000;
1923

20-
type RegenerateAPIKeyInput = {
24+
type RootApiKeyMutationInput = {
2125
userId: string;
2226
environmentId: string;
2327
};
2428

25-
export async function regenerateApiKey({ userId, environmentId }: RegenerateAPIKeyInput) {
26-
const environment = await prisma.runtimeEnvironment.findUnique({
29+
export class RootApiKeyNotVisibleError extends Error {
30+
constructor() {
31+
super("The root API key is no longer visible");
32+
this.name = "RootApiKeyNotVisibleError";
33+
}
34+
}
35+
36+
async function findRootApiKeyEnvironment(
37+
{ userId, environmentId }: RootApiKeyMutationInput,
38+
prismaClient: PrismaClient
39+
) {
40+
const requestedEnvironment = await prismaClient.runtimeEnvironment.findFirst({
2741
where: {
2842
id: environmentId,
43+
organization: { members: { some: { userId } } },
44+
OR: [
45+
{ type: { not: RuntimeEnvironmentType.DEVELOPMENT } },
46+
{
47+
type: RuntimeEnvironmentType.DEVELOPMENT,
48+
orgMember: { userId },
49+
},
50+
],
2951
},
30-
include: {
31-
organization: true,
32-
project: true,
33-
},
52+
select: { id: true, parentEnvironmentId: true },
3453
});
3554

36-
if (!environment) {
37-
throw new Error("Environment does not exist");
55+
if (!requestedEnvironment) {
56+
throw new Error("User does not have permission to manage this root API key");
3857
}
3958

40-
// check if the user is part of the org
41-
const organization = await prisma.organization.findFirst({
59+
const environment = await prismaClient.runtimeEnvironment.findFirst({
4260
where: {
43-
id: environment.organization.id,
44-
members: { some: { userId } },
61+
id: requestedEnvironment.parentEnvironmentId ?? requestedEnvironment.id,
62+
organization: { members: { some: { userId } } },
63+
OR: [
64+
{ type: { not: RuntimeEnvironmentType.DEVELOPMENT } },
65+
{
66+
type: RuntimeEnvironmentType.DEVELOPMENT,
67+
orgMember: { userId },
68+
},
69+
],
70+
},
71+
select: {
72+
id: true,
73+
apiKey: true,
74+
pkApiKey: true,
75+
rootApiKeyHiddenAt: true,
76+
type: true,
77+
projectId: true,
78+
branchName: true,
4579
},
4680
});
4781

48-
if (!organization) {
49-
throw new Error("User does not have permission to regenerate API key");
82+
if (!environment) {
83+
throw new Error("User does not have permission to manage this root API key");
5084
}
5185

52-
// check if it is the user's dev environment
53-
if (environment.type === RuntimeEnvironmentType.DEVELOPMENT) {
54-
if (!environment.orgMemberId) {
55-
throw new Error("User does not have permission to regenerate API key");
56-
}
86+
if (environment.rootApiKeyHiddenAt) {
87+
throw new RootApiKeyNotVisibleError();
88+
}
5789

58-
const orgMember = await prisma.orgMember.findFirst({
59-
where: {
60-
organizationId: organization.id,
61-
userId: userId,
62-
id: environment.orgMemberId,
63-
},
64-
});
90+
return environment;
91+
}
92+
93+
async function lockVisibleRootApiKeyEnvironment(
94+
prismaClient: PrismaTransactionClient,
95+
environmentId: string
96+
) {
97+
const [environment] = await prismaClient.$queryRaw<
98+
Array<{ apiKey: string; rootApiKeyHiddenAt: Date | null }>
99+
>`
100+
SELECT "apiKey", "rootApiKeyHiddenAt"
101+
FROM "public"."RuntimeEnvironment"
102+
WHERE "id" = ${environmentId}
103+
FOR UPDATE
104+
`;
105+
106+
if (!environment || environment.rootApiKeyHiddenAt) {
107+
throw new RootApiKeyNotVisibleError();
108+
}
109+
110+
return environment;
111+
}
65112

66-
if (!orgMember) {
67-
throw new Error("User does not have permission to regenerate API key");
113+
export async function regenerateApiKey(
114+
input: RootApiKeyMutationInput,
115+
{ prismaClient = prisma }: { prismaClient?: PrismaClient } = {}
116+
) {
117+
const environment = await findRootApiKeyEnvironment(input, prismaClient);
118+
const newApiKey = createApiKeyForEnv(environment.type);
119+
const newPkApiKey = createPkApiKeyForEnv(environment.type);
120+
const revokedApiKeyExpiresAt = new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS);
121+
122+
const updatedEnvironment = await $transaction(
123+
prismaClient,
124+
"regenerate root API key",
125+
async (tx) => {
126+
const currentEnvironment = await lockVisibleRootApiKeyEnvironment(tx, environment.id);
127+
128+
await tx.runtimeEnvironment.update({
129+
where: { id: environment.id },
130+
data: {
131+
apiKey: newApiKey,
132+
pkApiKey: newPkApiKey,
133+
},
134+
});
135+
136+
await tx.revokedApiKey.create({
137+
data: {
138+
apiKey: currentEnvironment.apiKey,
139+
runtimeEnvironmentId: environment.id,
140+
expiresAt: revokedApiKeyExpiresAt,
141+
},
142+
});
143+
144+
return { ...environment, apiKey: newApiKey, pkApiKey: newPkApiKey };
68145
}
146+
);
147+
148+
if (!updatedEnvironment) {
149+
throw new Error("The root API key could not be regenerated");
69150
}
70151

71-
// generate and store new keys
152+
controlPlaneResolver.invalidateEnvironment(environment.id);
153+
154+
return updatedEnvironment;
155+
}
156+
157+
export async function disableRootApiKeyVisibility(
158+
input: RootApiKeyMutationInput,
159+
{ prismaClient = prisma }: { prismaClient?: PrismaClient } = {}
160+
) {
161+
const environment = await findRootApiKeyEnvironment(input, prismaClient);
72162
const newApiKey = createApiKeyForEnv(environment.type);
73163
const newPkApiKey = createPkApiKeyForEnv(environment.type);
164+
const rootApiKeyHiddenAt = new Date();
74165

75-
const revokedApiKeyExpiresAt = new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS);
166+
const updatedEnvironment = await $transaction(
167+
prismaClient,
168+
"disable root API key visibility",
169+
async (tx) => {
170+
const currentEnvironment = await lockVisibleRootApiKeyEnvironment(tx, environment.id);
76171

77-
const updatedEnviroment = await prisma.$transaction(async (tx) => {
78-
await tx.revokedApiKey.create({
79-
data: {
80-
apiKey: environment.apiKey,
81-
runtimeEnvironmentId: environment.id,
82-
expiresAt: revokedApiKeyExpiresAt,
83-
},
84-
});
172+
await tx.runtimeEnvironment.update({
173+
where: { id: environment.id },
174+
data: {
175+
apiKey: newApiKey,
176+
pkApiKey: newPkApiKey,
177+
rootApiKeyHiddenAt,
178+
},
179+
});
180+
181+
await tx.revokedApiKey.create({
182+
data: {
183+
apiKey: currentEnvironment.apiKey,
184+
runtimeEnvironmentId: environment.id,
185+
expiresAt: new Date(Date.now() + REVOKED_API_KEY_GRACE_PERIOD_MS),
186+
},
187+
});
85188

86-
return tx.runtimeEnvironment.update({
87-
data: {
189+
return {
190+
...environment,
88191
apiKey: newApiKey,
89192
pkApiKey: newPkApiKey,
90-
},
91-
where: {
92-
id: environmentId,
93-
},
94-
});
95-
});
193+
rootApiKeyHiddenAt,
194+
};
195+
}
196+
);
197+
198+
if (!updatedEnvironment) {
199+
throw new Error("Root API key visibility could not be disabled");
200+
}
96201

97-
// The env's apiKey changed in the control-plane; drop any cached copy.
98-
controlPlaneResolver.invalidateEnvironment(environmentId);
202+
controlPlaneResolver.invalidateEnvironment(environment.id);
99203

100-
return updatedEnviroment;
204+
return updatedEnvironment;
101205
}
102206

103207
export async function createEnvironmentApiKey(

apps/webapp/app/models/organization.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ export async function createEnvironment({
227227
slug,
228228
apiKey,
229229
pkApiKey,
230+
rootApiKeyHiddenAt: new Date(),
230231
shortcode,
231232
autoEnableInternalSources: type !== "DEVELOPMENT",
232233
maximumConcurrencyLimit: limit,

apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export class ApiKeysPresenter {
7070
select: {
7171
id: true,
7272
apiKey: true,
73+
rootApiKeyHiddenAt: true,
7374
type: true,
7475
apiKeys: {
7576
where: showRevoked ? undefined : { revokedAt: null },
@@ -119,20 +120,23 @@ export class ApiKeysPresenter {
119120
]);
120121
const presetsById = new Map(presets?.map((preset) => [preset.id, preset]));
121122
const { taskIdentifiers, organizationId: _organizationId, ...environmentData } = environment;
123+
const rootApiKey = keyEnvironment.rootApiKeyHiddenAt
124+
? null
125+
: {
126+
id: keyEnvironment.id,
127+
name: "Root API key",
128+
value: keyEnvironment.apiKey,
129+
obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)),
130+
};
122131

123132
return {
124133
environment: {
125134
...environmentData,
126-
apiKey: keyEnvironment.apiKey,
135+
apiKey: rootApiKey?.value ?? null,
127136
keyEnvironmentId,
128137
},
129138
availableTasks: taskIdentifiers.map((task) => task.slug),
130-
rootApiKey: {
131-
id: keyEnvironment.id,
132-
name: "Root API key",
133-
value: keyEnvironment.apiKey,
134-
obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)),
135-
},
139+
rootApiKey,
136140
apiKeys: keyEnvironment.apiKeys.map((apiKey, index) => {
137141
const { presetId, scopes, ...apiKeyData } = apiKey;
138142
const description = policyDescriptions[index];

0 commit comments

Comments
 (0)