Skip to content

Commit f520c53

Browse files
committed
fix(webapp,rbac): render API key scopes from policy presets
1 parent 8092e9e commit f520c53

8 files changed

Lines changed: 222 additions & 308 deletions

File tree

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

Lines changed: 99 additions & 300 deletions
Large diffs are not rendered by default.

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type ApiKeyPreset = {
55
available: boolean;
66
label: string;
77
description: string;
8+
scopes?: string[];
89
usesTaskSelection?: boolean;
910
};
1011

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
export type ApiKeyScopePreviewPreset = {
2+
label: string;
3+
scopes?: string[];
4+
usesTaskSelection?: boolean;
5+
};
6+
7+
export type ApiKeyScopePreview = {
8+
fullAccess: boolean;
9+
scopes: string[];
10+
};
11+
12+
export function getApiKeyScopePreview({
13+
preset,
14+
taskScope,
15+
selectedTasks,
16+
}: {
17+
preset?: ApiKeyScopePreviewPreset;
18+
taskScope?: "all" | "selected";
19+
selectedTasks: string[];
20+
}): ApiKeyScopePreview {
21+
const scopes = preset?.scopes;
22+
if (!scopes) {
23+
return { fullAccess: false, scopes: [] };
24+
}
25+
26+
const fullAccess = scopes.includes("admin");
27+
const selectedTaskScope =
28+
preset?.usesTaskSelection && taskScope === "selected" && selectedTasks.length > 0;
29+
30+
return {
31+
fullAccess,
32+
scopes: fullAccess
33+
? []
34+
: scopes.flatMap((scope) => expandTaskScope(scope, selectedTaskScope, selectedTasks)),
35+
};
36+
}
37+
38+
function expandTaskScope(scope: string, selectedTaskScope: boolean, selectedTasks: string[]): string[] {
39+
const parts = scope.split(":");
40+
if (!selectedTaskScope || parts.length !== 2 || parts[1] !== "tasks") {
41+
return [scope];
42+
}
43+
44+
const shown = selectedTasks.slice(0, 3).map((task) => `${scope}:${task}`);
45+
if (selectedTasks.length > 3) {
46+
shown.push(`… +${selectedTasks.length - 3} more`);
47+
}
48+
return shown;
49+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getApiKeyScopePreview } from "~/utils/apiKeyScopePreview";
3+
4+
describe("getApiKeyScopePreview", () => {
5+
it("renders a plugin-defined preset from its scopes without treating it as full access", () => {
6+
const preview = getApiKeyScopePreview({
7+
preset: {
8+
label: "Log viewer",
9+
scopes: ["read:logs"],
10+
usesTaskSelection: false,
11+
},
12+
selectedTasks: [],
13+
});
14+
15+
expect(preview).toEqual({
16+
fullAccess: false,
17+
scopes: ["read:logs"],
18+
});
19+
});
20+
21+
it("expands selected task scopes from the plugin-generated template", () => {
22+
const preview = getApiKeyScopePreview({
23+
preset: {
24+
label: "Task operator",
25+
scopes: ["trigger:tasks", "read:tasks", "batchTrigger:batch"],
26+
usesTaskSelection: true,
27+
},
28+
taskScope: "selected",
29+
selectedTasks: ["send-email", "sync-customer"],
30+
});
31+
32+
expect(preview.scopes).toEqual([
33+
"trigger:tasks:send-email",
34+
"trigger:tasks:sync-customer",
35+
"read:tasks:send-email",
36+
"read:tasks:sync-customer",
37+
"batchTrigger:batch",
38+
]);
39+
});
40+
});

apps/webapp/test/apiKeysPresenter.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ containerTest(
105105
id: "RESTRICTED_TEST_PRESET",
106106
label: "Restricted access",
107107
description: "Restricted test access",
108+
scopes: ["read:deployments"],
108109
usesTaskSelection: false,
109110
available: true,
110111
},

internal-packages/rbac/src/apiKeyPolicies.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,10 +264,19 @@ describe("API-key policy controller composition", () => {
264264
});
265265

266266
describe("API-key policy fallback", () => {
267-
it("prepares explicit standalone full access and exposes no preset catalogue", async () => {
267+
it("prepares explicit standalone full access and exposes its scope preview", async () => {
268268
const controller = loader.create(prismaPlaceholder, { forceFallback: true });
269269

270-
await expect(controller.apiKeyPresets("org_123")).resolves.toBeNull();
270+
await expect(controller.apiKeyPresets("org_123")).resolves.toEqual([
271+
{
272+
id: "FULL_ACCESS",
273+
label: "No restrictions",
274+
description: "Full access to this environment, matching the root API key.",
275+
scopes: ["admin"],
276+
usesTaskSelection: false,
277+
available: true,
278+
},
279+
]);
271280
await expect(
272281
controller.prepareApiKeyPolicy({ organizationId: "org_123", presetId: "FULL_ACCESS" })
273282
).resolves.toEqual({

internal-packages/rbac/src/fallback.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,23 +223,32 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
223223
}
224224

225225
async apiKeyPresets(_organizationId: string) {
226-
return null;
226+
return [
227+
{
228+
id: FULL_ACCESS_PRESET_ID,
229+
label: "No restrictions",
230+
description: "Full access to this environment, matching the root API key.",
231+
scopes: ["admin"],
232+
usesTaskSelection: false,
233+
available: true,
234+
},
235+
];
227236
}
228237

229238
async prepareApiKeyPolicy(params: {
230239
organizationId: string;
231240
presetId: string;
232241
taskIdentifiers?: string[];
233242
}) {
234-
// Without a plugin there is no preset catalogue, so full access is the only
235-
// policy on offer — but the caller still has to ask for it by name. Any
236-
// other preset, or any task selection, is a restricted key and unavailable.
243+
// Full access is the only policy on offer without a plugin, but the caller
244+
// still has to ask for it by name. Any other preset, or any task selection,
245+
// is a restricted key and unavailable.
237246
if (params.presetId !== FULL_ACCESS_PRESET_ID || (params.taskIdentifiers?.length ?? 0) > 0) {
238247
return { ok: false as const, error: "API key access presets are not available" };
239248
}
240249

241-
// `presetId: null` because this install has no catalogue to reference — the
242-
// key is full-access, not an instance of a named preset.
250+
// Persist no preset ID: this is the standalone full-access policy rather
251+
// than an enterprise plugin-defined preset.
243252
return {
244253
ok: true as const,
245254
policy: { presetId: null, scopes: ["admin"] },

packages/plugins/src/rbac.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export type ApiKeyPreset = {
2323
id: string;
2424
label: string;
2525
description: string;
26+
/**
27+
* The plugin-generated, all-task scope template for this preset. The host
28+
* uses it only to preview the policy before creation; `prepareApiKeyPolicy`
29+
* remains the authorization source of truth.
30+
*/
31+
scopes: string[];
2632
usesTaskSelection: boolean;
2733
available: boolean;
2834
};

0 commit comments

Comments
 (0)