Skip to content

Commit ba81e5e

Browse files
committed
feat(webapp): gate additional API key creation
Require both the global issuance switch and organization rollout flag before creating additional keys, while leaving existing credentials available for use and revocation. Show nullable creators and identify SDK v4.5.8 as the first compatible public-token version.
1 parent d8c0282 commit ba81e5e

7 files changed

Lines changed: 220 additions & 25 deletions

File tree

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { customAlphabet } from "nanoid";
99
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
1010
import { prisma } from "~/db.server";
1111
import { RuntimeEnvironmentType } from "~/database-types";
12+
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
1213
import { rbac } from "~/services/rbac.server";
1314
import { generateAdditionalApiKey, generateRootApiKey } from "~/utils/apiKeys";
1415
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
@@ -129,9 +130,14 @@ export async function createEnvironmentApiKey(
129130
{
130131
prismaClient = prisma,
131132
rbacController = rbac,
133+
issuanceAllowed,
132134
}: {
133-
prismaClient?: Pick<PrismaClient, "runtimeEnvironment" | "taskIdentifier" | "apiKey">;
135+
prismaClient?: Pick<
136+
PrismaClient,
137+
"apiKey" | "featureFlag" | "organization" | "runtimeEnvironment" | "taskIdentifier"
138+
>;
134139
rbacController?: Pick<HostRbacController, "prepareApiKeyPolicy">;
140+
issuanceAllowed?: (organizationId: string) => Promise<boolean>;
135141
} = {}
136142
) {
137143
const environment = await prismaClient.runtimeEnvironment.findFirst({
@@ -146,6 +152,13 @@ export async function createEnvironmentApiKey(
146152
throw new Error("Environment not found");
147153
}
148154

155+
const canIssue =
156+
issuanceAllowed ??
157+
((organizationId) => canIssueAdditionalApiKeys(organizationId, prismaClient));
158+
if (!(await canIssue(environment.organizationId))) {
159+
throw new Error("Creating additional API keys is not enabled.");
160+
}
161+
149162
if (expiresAt && expiresAt.getTime() <= Date.now()) {
150163
throw new Error("Expiration must be in the future");
151164
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import { findProjectBySlug } from "~/models/project.server";
7676
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
7777
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
7878
import { FULL_ACCESS_PRESET_ID } from "@trigger.dev/rbac";
79+
import { canIssueAdditionalApiKeys } from "~/services/additionalApiKeyIssuance.server";
7980
import { rbac } from "~/services/rbac.server";
8081
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
8182
import { cn } from "~/utils/cn";
@@ -180,16 +181,21 @@ export const loader = dashboardLoader(
180181
return organizationId ? { organizationId } : {};
181182
},
182183
},
183-
async ({ params, searchParams, user, ability }) => {
184+
async ({ params, searchParams, user, ability, context }) => {
184185
try {
185186
const presenter = new ApiKeysPresenter();
186-
const data = await presenter.call({
187-
userId: user.id,
188-
organizationSlug: params.organizationSlug,
189-
projectSlug: params.projectParam,
190-
environmentSlug: params.envParam,
191-
showRevoked: searchParams.showRevoked,
192-
});
187+
const [data, additionalApiKeyIssuanceEnabled] = await Promise.all([
188+
presenter.call({
189+
userId: user.id,
190+
organizationSlug: params.organizationSlug,
191+
projectSlug: params.projectParam,
192+
environmentSlug: params.envParam,
193+
showRevoked: searchParams.showRevoked,
194+
}),
195+
context.organizationId
196+
? canIssueAdditionalApiKeys(context.organizationId)
197+
: Promise.resolve(false),
198+
]);
193199

194200
const canReadApiKeys = ability.can("read", {
195201
type: "apiKeys",
@@ -214,6 +220,7 @@ export const loader = dashboardLoader(
214220
apiKeys: canReadApiKeys ? data.apiKeys : [],
215221
canReadApiKeys,
216222
canWriteApiKeys,
223+
additionalApiKeyIssuanceEnabled,
217224
showRevoked: searchParams.showRevoked ?? false,
218225
});
219226
} catch (error) {
@@ -276,6 +283,15 @@ export const action = dashboardAction(
276283
try {
277284
switch (submission.data.action) {
278285
case "create": {
286+
if (!(await canIssueAdditionalApiKeys(project.organizationId))) {
287+
const message = "Creating additional API keys is not enabled.";
288+
return typedJsonWithErrorMessage(
289+
{ ok: false as const, error: message },
290+
request,
291+
message
292+
);
293+
}
294+
279295
const presets = await rbac.apiKeyPresets(project.organizationId);
280296
const preset = validateCreateApiKeyPreset({
281297
presets,
@@ -335,6 +351,7 @@ export default function Page() {
335351
apiKeys,
336352
canReadApiKeys,
337353
canWriteApiKeys,
354+
additionalApiKeyIssuanceEnabled,
338355
showRevoked,
339356
hasVercelIntegration,
340357
availableTasks,
@@ -370,7 +387,7 @@ export default function Page() {
370387
API keys docs
371388
</LinkButton>
372389

373-
{canReadApiKeys ? (
390+
{canReadApiKeys && additionalApiKeyIssuanceEnabled ? (
374391
<NewApiKeyDialog
375392
canWrite={canWriteApiKeys}
376393
availableTasks={availableTasks}
@@ -503,7 +520,7 @@ export default function Page() {
503520
apiKey.createdBy?.displayName ??
504521
apiKey.createdBy?.name ??
505522
apiKey.createdBy?.email ??
506-
"Deleted user";
523+
"";
507524

508525
return (
509526
<TableRow key={apiKey.id} disabled={cannotAuthenticate}>
@@ -653,10 +670,10 @@ function NewApiKeyDialog({
653670
className="w-full"
654671
/>
655672
<Callout variant="warning">
656-
Requires a version of <InlineCode variant="extra-small">@trigger.dev/sdk</InlineCode>{" "}
657-
that supports additional environment API keys. On older versions,{" "}
658-
<InlineCode variant="extra-small">auth.createPublicToken()</InlineCode> will return a
659-
token the server rejects, because only the root API key can sign one locally.
673+
Use <InlineCode variant="extra-small">@trigger.dev/sdk</InlineCode> v4.5.8 or later.
674+
Older SDK versions mint an unusable token when{" "}
675+
<InlineCode variant="extra-small">auth.createPublicToken()</InlineCode> is called with
676+
this API key.
660677
</Callout>
661678
<FormButtons
662679
confirmButton={
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { type PrismaClient } from "@trigger.dev/database";
2+
import { prisma } from "~/db.server";
3+
import { resolveAdditionalApiKeyIssuance } from "~/services/additionalApiKeyIssuance";
4+
import { FEATURE_FLAG } from "~/v3/featureFlags";
5+
6+
type IssuancePrismaClient = Pick<PrismaClient, "featureFlag" | "organization">;
7+
8+
export async function canIssueAdditionalApiKeys(
9+
organizationId: string,
10+
prismaClient: IssuancePrismaClient = prisma
11+
): Promise<boolean> {
12+
const [organization, globalFlags] = await Promise.all([
13+
prismaClient.organization.findUnique({
14+
where: { id: organizationId },
15+
select: { featureFlags: true },
16+
}),
17+
prismaClient.featureFlag.findMany({
18+
where: {
19+
key: {
20+
in: [FEATURE_FLAG.additionalApiKeysEnabled, FEATURE_FLAG.additionalApiKeyIssuanceEnabled],
21+
},
22+
},
23+
select: { key: true, value: true },
24+
}),
25+
]);
26+
27+
if (!organization) {
28+
return false;
29+
}
30+
31+
return resolveAdditionalApiKeyIssuance(
32+
Object.fromEntries(globalFlags.map((featureFlag) => [featureFlag.key, featureFlag.value])),
33+
(organization.featureFlags as Record<string, unknown> | null) ?? undefined
34+
);
35+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { FEATURE_FLAG, type FeatureFlagCatalog } from "~/v3/featureFlags";
2+
3+
export function resolveAdditionalApiKeyIssuance(
4+
globalFlags: Partial<FeatureFlagCatalog> | Record<string, unknown> | undefined,
5+
organizationFlags: Record<string, unknown> | undefined
6+
): boolean {
7+
if (globalFlags?.[FEATURE_FLAG.additionalApiKeyIssuanceEnabled] !== true) {
8+
return false;
9+
}
10+
11+
const organizationOverride = organizationFlags?.[FEATURE_FLAG.additionalApiKeysEnabled];
12+
if (organizationOverride === true || organizationOverride === false) {
13+
return organizationOverride;
14+
}
15+
16+
return globalFlags?.[FEATURE_FLAG.additionalApiKeysEnabled] === true;
17+
}

apps/webapp/app/v3/featureFlags.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export const FEATURE_FLAG = {
2222
// Grace-linger stamp carried alongside runOpsMintKind on flip. See mintFlipGrace.ts.
2323
runOpsMintKindPrev: "runOpsMintKindPrev",
2424
runOpsMintKindFlippedAt: "runOpsMintKindFlippedAt",
25+
// Per-organization rollout for creating additional environment API keys.
26+
additionalApiKeysEnabled: "additionalApiKeysEnabled",
27+
// System-wide kill switch for issuing additional environment API keys.
28+
additionalApiKeyIssuanceEnabled: "additionalApiKeyIssuanceEnabled",
2529
// System-wide kill switch for additional (scoped) environment API-key lookup.
2630
// Defaults off; enable during rollout once the new lookup path is trusted.
2731
additionalApiKeyLookupEnabled: "additionalApiKeyLookupEnabled",
@@ -64,9 +68,10 @@ export const FeatureFlagCatalog = {
6468
// by stampMintKindFlip on a genuine flip. Display-only (see ORG_LOCKED_FLAGS).
6569
[FEATURE_FLAG.runOpsMintKindPrev]: z.enum(["cuid", "runOpsId"]),
6670
[FEATURE_FLAG.runOpsMintKindFlippedAt]: z.string().datetime(),
67-
// Strict z.boolean() (not z.coerce.boolean()): coercion turns the string
68-
// "false" into true, which would silently enable this kill switch the wrong
69-
// way if written as a string. Cold/absent resolves to the safe `false`.
71+
// Strict booleans prevent a stringified "false" from silently enabling API-key
72+
// creation or lookup. Cold/absent values resolve to the safe `false`.
73+
[FEATURE_FLAG.additionalApiKeysEnabled]: z.boolean(),
74+
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: z.boolean(),
7075
[FEATURE_FLAG.additionalApiKeyLookupEnabled]: z.boolean(),
7176
};
7277

@@ -86,7 +91,8 @@ export const ORG_LOCKED_FLAGS: FeatureFlagKey[] = [
8691
FEATURE_FLAG.taskEventRepository,
8792
FEATURE_FLAG.runOpsMintKindPrev,
8893
FEATURE_FLAG.runOpsMintKindFlippedAt,
89-
// System-wide only — an org must not be able to override the rollout switch.
94+
// System-wide only — orgs must not be able to override these kill switches.
95+
FEATURE_FLAG.additionalApiKeyIssuanceEnabled,
9096
FEATURE_FLAG.additionalApiKeyLookupEnabled,
9197
];
9298

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveAdditionalApiKeyIssuance } from "~/services/additionalApiKeyIssuance";
3+
import { FEATURE_FLAG, FeatureFlagCatalog, ORG_LOCKED_FLAGS } from "~/v3/featureFlags";
4+
5+
describe("additional API key issuance controls", () => {
6+
it("registers strict rollout and system-wide flags", () => {
7+
expect(
8+
FeatureFlagCatalog[FEATURE_FLAG.additionalApiKeysEnabled].safeParse("false").success
9+
).toBe(false);
10+
expect(
11+
FeatureFlagCatalog[FEATURE_FLAG.additionalApiKeyIssuanceEnabled].safeParse("false").success
12+
).toBe(false);
13+
expect(ORG_LOCKED_FLAGS).not.toContain(FEATURE_FLAG.additionalApiKeysEnabled);
14+
expect(ORG_LOCKED_FLAGS).toContain(FEATURE_FLAG.additionalApiKeyIssuanceEnabled);
15+
});
16+
17+
it("defaults to disabled", () => {
18+
expect(resolveAdditionalApiKeyIssuance(undefined, undefined)).toBe(false);
19+
});
20+
21+
it("requires the system-wide issuance gate", () => {
22+
expect(
23+
resolveAdditionalApiKeyIssuance(
24+
{ [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: false },
25+
{ [FEATURE_FLAG.additionalApiKeysEnabled]: true }
26+
)
27+
).toBe(false);
28+
});
29+
30+
it("allows an organization override when issuance is enabled", () => {
31+
expect(
32+
resolveAdditionalApiKeyIssuance(
33+
{ [FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true },
34+
{ [FEATURE_FLAG.additionalApiKeysEnabled]: true }
35+
)
36+
).toBe(true);
37+
});
38+
39+
it("uses the global rollout value when the organization has no override", () => {
40+
expect(
41+
resolveAdditionalApiKeyIssuance(
42+
{
43+
[FEATURE_FLAG.additionalApiKeysEnabled]: true,
44+
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true,
45+
},
46+
undefined
47+
)
48+
).toBe(true);
49+
});
50+
51+
it("allows an organization to opt out of a global rollout", () => {
52+
expect(
53+
resolveAdditionalApiKeyIssuance(
54+
{
55+
[FEATURE_FLAG.additionalApiKeysEnabled]: true,
56+
[FEATURE_FLAG.additionalApiKeyIssuanceEnabled]: true,
57+
},
58+
{ [FEATURE_FLAG.additionalApiKeysEnabled]: false }
59+
)
60+
).toBe(false);
61+
});
62+
});

apps/webapp/test/createEnvironmentApiKey.test.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import rbacPlugin, { type RoleBaseAccessController } from "@trigger.dev/rbac";
44
import { expect, vi } from "vitest";
55
import { MAX_API_KEY_TASK_IDENTIFIERS } from "~/consts";
66
import { createEnvironmentApiKey } from "~/models/api-key.server";
7+
import { FEATURE_FLAG } from "~/v3/featureFlags";
78
import {
89
createRuntimeEnvironment,
910
createTestOrgProjectWithMember,
@@ -20,15 +21,59 @@ function policyController(
2021

2122
async function setup(prisma: PrismaClient) {
2223
const { organization, project, user } = await createTestOrgProjectWithMember(prisma);
23-
const environment = await createRuntimeEnvironment(prisma, {
24-
projectId: project.id,
25-
organizationId: organization.id,
26-
type: "PRODUCTION",
27-
slug: uniqueId("prod"),
28-
});
24+
const [environment] = await Promise.all([
25+
createRuntimeEnvironment(prisma, {
26+
projectId: project.id,
27+
organizationId: organization.id,
28+
type: "PRODUCTION",
29+
slug: uniqueId("prod"),
30+
}),
31+
prisma.organization.update({
32+
where: { id: organization.id },
33+
data: { featureFlags: { [FEATURE_FLAG.additionalApiKeysEnabled]: true } },
34+
}),
35+
prisma.featureFlag.upsert({
36+
where: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled },
37+
create: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled, value: true },
38+
update: { value: true },
39+
}),
40+
]);
2941
return { organization, project, user, environment };
3042
}
3143

44+
containerTest(
45+
"rejects creation when the system-wide issuance gate is disabled",
46+
async ({ prisma }) => {
47+
const { user, environment } = await setup(prisma);
48+
await prisma.featureFlag.update({
49+
where: { key: FEATURE_FLAG.additionalApiKeyIssuanceEnabled },
50+
data: { value: false },
51+
});
52+
const controller = policyController(async () => ({
53+
ok: true,
54+
policy: { presetId: null, scopes: ["admin"] },
55+
}));
56+
57+
await expect(
58+
createEnvironmentApiKey(
59+
{
60+
environmentId: environment.id,
61+
taskEnvironmentId: environment.id,
62+
userId: user.id,
63+
name: "Disabled",
64+
presetId: "FULL_ACCESS",
65+
},
66+
{ prismaClient: prisma, rbacController: controller }
67+
)
68+
).rejects.toThrow("Creating additional API keys is not enabled");
69+
70+
expect(controller.prepareApiKeyPolicy).not.toHaveBeenCalled();
71+
await expect(
72+
prisma.apiKey.count({ where: { runtimeEnvironmentId: environment.id } })
73+
).resolves.toBe(0);
74+
}
75+
);
76+
3277
containerTest("standalone fallback creates one explicit full-access key", async ({ prisma }) => {
3378
const { user, environment } = await setup(prisma);
3479
const fallback = rbacPlugin.create({ primary: prisma, replica: prisma }, { forceFallback: true });

0 commit comments

Comments
 (0)