Skip to content

Commit 21f1dcc

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
feat(webapp): webhook and waitpoint access for scoped API keys
The access summary in the New API key dialog now shows Errors, Webhooks and Waitpoints rows, so presets that grant those scopes show them: | Preset | Errors | Webhooks | Waitpoints | | -- | -- | -- | -- | | Observer | Read | Read | No access | | Operator | Read & write | Read & write | No access | | Webhooks only | No access | Read & write | No access | | Waitpoints only | No access | No access | Read & write | The Webhooks only and Waitpoints only presets are listed under Environment capabilities next to Observer and Operator. Waitpoint token routes now authorize by scope, so a key with waitpoint access can use them, not just a full-access key: ```bash curl -X POST "$TRIGGER_API_URL/api/v1/waitpoints/tokens" \ -H "Authorization: Bearer $WAITPOINTS_KEY" \ -H "Content-Type: application/json" \ -d '{"timeout": "1h"}' ``` Creating a token needs `write:waitpoints`; listing and retrieving tokens need `read:waitpoints`. Mono-RevId: eb4673818ce46613d1b11a50b89fdb09d616e922
1 parent 45e40a8 commit 21f1dcc

6 files changed

Lines changed: 206 additions & 86 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
The new API key dialog's access summary now shows Errors, Webhooks and Waitpoints access, and API keys with waitpoint access can create, list and retrieve waitpoint tokens.

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

Lines changed: 9 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
import { resolveProjectAuthScope } from "~/services/projectAuthScope.server";
7878
import { rbac } from "~/services/rbac.server";
7979
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
80+
import { SCOPE_CAPABILITIES, scopeDetailForPreset } from "~/utils/apiKeyScopeDetail";
8081
import { cn } from "~/utils/cn";
8182
import { docsPath, EnvironmentParamSchema, v3BillingPath } from "~/utils/pathBuilder";
8283
import { sectionAgentPageContext } from "~/components/dashboard-agent/suggested-prompts";
@@ -798,7 +799,12 @@ function NewApiKeyDialog({
798799
<PresetGroup
799800
title="Environment capabilities"
800801
presets={presets}
801-
ids={["ENVIRONMENT_OBSERVER", "ENVIRONMENT_OPERATOR"]}
802+
ids={[
803+
"ENVIRONMENT_OBSERVER",
804+
"ENVIRONMENT_OPERATOR",
805+
"WEBHOOKS_ONLY",
806+
"WAITPOINTS_ONLY",
807+
]}
802808
/>
803809
<PresetGroup
804810
title="Deployment and configuration"
@@ -880,6 +886,8 @@ const KNOWN_PRESET_IDS = new Set([
880886
"ENVIRONMENT_OPERATOR",
881887
"DEPLOY_ONLY",
882888
"ENV_VARS_ONLY",
889+
"WEBHOOKS_ONLY",
890+
"WAITPOINTS_ONLY",
883891
]);
884892

885893
const API_KEY_EXPIRATIONS = [
@@ -889,97 +897,12 @@ const API_KEY_EXPIRATIONS = [
889897
{ value: "never", label: "Never" },
890898
];
891899

892-
type CapId =
893-
| "tasks"
894-
| "runs"
895-
| "batches"
896-
| "queues"
897-
| "sessions"
898-
| "tags"
899-
| "deployments"
900-
| "branches"
901-
| "envvars";
902-
903-
// Capability rows shown in the scope pane, in a fixed order so two presets read
904-
// as a diff of the same list rather than a reshuffled one.
905-
const SCOPE_CAPABILITIES: [CapId, string][] = [
906-
["tasks", "Tasks"],
907-
["runs", "Runs"],
908-
["batches", "Batches"],
909-
["queues", "Queues"],
910-
["sessions", "Sessions (all tasks)"],
911-
["tags", "Tagged runs"],
912-
["deployments", "Deployments"],
913-
["branches", "Preview branches"],
914-
["envvars", "Environment variables"],
915-
];
916-
917900
// 0 none · 1 read · 2 read & write · 3 allowed (an action) · 4 full
918901
const SCOPE_LEVEL_WORDS = ["No access", "Read", "Read & write", "Allowed", "Full access"] as const;
919902
const SCOPE_LEVEL_TONES = ["none", "read", "write", "write", "write"] as const;
920903

921904
type ScopeTone = (typeof SCOPE_LEVEL_TONES)[number];
922905

923-
// The second entry retains the plugin-provided raw scope strings.
924-
type PresetCapability = [level: number, rawScopes: string[]];
925-
926-
type PresetScopeDetail = {
927-
/** A single `admin` scope grants everything, so every row reads "Full access". */
928-
admin?: boolean;
929-
/** Task-scopable presets expand task scopes into the selected task identifiers. */
930-
scopable?: boolean;
931-
/** Shown in the task-access panel for presets that aren't task-scopable. */
932-
taskLabel?: string;
933-
caps: Partial<Record<CapId, PresetCapability>>;
934-
};
935-
936-
const SCOPE_CAPABILITY_BY_SCOPE: Record<string, [CapId, number]> = {
937-
"trigger:tasks": ["tasks", 3],
938-
"batchTrigger:tasks": ["batches", 3],
939-
"batchTrigger:batch": ["batches", 3],
940-
"read:tasks": ["tasks", 1],
941-
"write:tasks": ["tasks", 2],
942-
"read:runs": ["runs", 1],
943-
"write:runs": ["runs", 2],
944-
"read:batch": ["batches", 1],
945-
"write:batch": ["batches", 2],
946-
"read:queues": ["queues", 1],
947-
"write:queues": ["queues", 2],
948-
"read:sessions": ["sessions", 1],
949-
"write:sessions": ["sessions", 2],
950-
"read:tags": ["tags", 1],
951-
"read:deployments": ["deployments", 1],
952-
"write:deployments": ["deployments", 2],
953-
"write:branches": ["branches", 3],
954-
"read:envvars": ["envvars", 1],
955-
"write:envvars": ["envvars", 2],
956-
};
957-
958-
function scopeDetailForPreset(preset?: ApiKeyPreset): PresetScopeDetail | undefined {
959-
const scopes = preset?.scopes;
960-
if (!scopes) return;
961-
if (scopes.includes("admin")) {
962-
return { admin: true, taskLabel: "All tasks", caps: {} };
963-
}
964-
965-
const caps: PresetScopeDetail["caps"] = {};
966-
for (const scope of scopes) {
967-
const [action, resource] = scope.split(":");
968-
const capability = SCOPE_CAPABILITY_BY_SCOPE[`${action}:${resource}`];
969-
if (!capability) continue;
970-
971-
const [key, level] = capability;
972-
const current = caps[key];
973-
caps[key] = [Math.max(current?.[0] ?? 0, level), [...(current?.[1] ?? []), scope]];
974-
}
975-
976-
return {
977-
scopable: preset.usesTaskSelection,
978-
taskLabel: scopes.some((scope) => scope.split(":")[1] === "tasks") ? "All tasks" : "No tasks",
979-
caps,
980-
};
981-
}
982-
983906
function expandScopeString(raw: string, scoped: boolean, tasks: string[]): string[] {
984907
const parts = raw.split(":");
985908
if (!scoped || parts.length !== 2 || parts[1] !== "tasks") {

‎apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export const loader = createLoaderApiRoute(
1212
waitpointFriendlyId: z.string(),
1313
}),
1414
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
15+
authorization: {
16+
action: "read",
17+
resource: (_, params) => ({ type: "waitpoints", id: params.waitpointFriendlyId }),
18+
},
1519
},
1620
async ({ params, authentication }) => {
1721
const presenter = new ApiWaitpointPresenter(undefined, undefined, {

‎apps/webapp/app/routes/api.v1.waitpoints.tokens.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export const loader = createLoaderApiRoute(
3333
{
3434
searchParams: ApiWaitpointListSearchParams,
3535
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
36+
authorization: {
37+
action: "read",
38+
resource: () => ({ type: "waitpoints" }),
39+
},
3640
},
3741
async ({ searchParams, authentication }) => {
3842
const presenter = new ApiWaitpointListPresenter(undefined, undefined, {
@@ -51,6 +55,10 @@ const { action } = createActionApiRoute(
5155
body: CreateWaitpointTokenRequestBody,
5256
maxContentLength: 1024 * 10, // 10KB
5357
method: "POST",
58+
authorization: {
59+
action: "write",
60+
resource: () => ({ type: "waitpoints" }),
61+
},
5462
},
5563
async ({ authentication, body }) => {
5664
try {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test } from "vitest";
2+
import { SCOPE_CAPABILITIES, scopeDetailForPreset } from "./apiKeyScopeDetail";
3+
4+
function preset(scopes: string[]) {
5+
return { id: "PRESET", available: true, label: "Preset", description: "", scopes };
6+
}
7+
8+
describe("scopeDetailForPreset", () => {
9+
test("shows a row for every capability a restricted preset can grant", () => {
10+
expect(SCOPE_CAPABILITIES.map(([key]) => key)).toEqual([
11+
"tasks",
12+
"runs",
13+
"batches",
14+
"queues",
15+
"sessions",
16+
"tags",
17+
"errors",
18+
"webhooks",
19+
"waitpoints",
20+
"deployments",
21+
"branches",
22+
"envvars",
23+
]);
24+
});
25+
26+
test("reads webhooks and errors for an observer", () => {
27+
const detail = scopeDetailForPreset(
28+
preset([
29+
"read:runs",
30+
"read:tasks",
31+
"read:batch",
32+
"read:queues",
33+
"read:errors",
34+
"read:webhooks",
35+
])
36+
);
37+
38+
expect(detail?.caps.webhooks).toEqual([1, ["read:webhooks"]]);
39+
expect(detail?.caps.errors).toEqual([1, ["read:errors"]]);
40+
expect(detail?.caps.waitpoints).toBeUndefined();
41+
});
42+
43+
test("reads and writes webhooks and errors for an operator", () => {
44+
const detail = scopeDetailForPreset(
45+
preset(["read:errors", "read:webhooks", "write:errors", "write:webhooks", "trigger:tasks"])
46+
);
47+
48+
expect(detail?.caps.webhooks).toEqual([2, ["read:webhooks", "write:webhooks"]]);
49+
expect(detail?.caps.errors).toEqual([2, ["read:errors", "write:errors"]]);
50+
});
51+
52+
test("grants no tasks to a webhooks-only key", () => {
53+
const detail = scopeDetailForPreset(preset(["read:webhooks", "write:webhooks"]));
54+
55+
expect(detail?.caps).toEqual({ webhooks: [2, ["read:webhooks", "write:webhooks"]] });
56+
expect(detail?.taskLabel).toBe("No tasks");
57+
});
58+
59+
test("reads and writes waitpoints for a waitpoints-only key", () => {
60+
const detail = scopeDetailForPreset(preset(["read:waitpoints", "write:waitpoints"]));
61+
62+
expect(detail?.caps).toEqual({ waitpoints: [2, ["read:waitpoints", "write:waitpoints"]] });
63+
expect(detail?.taskLabel).toBe("No tasks");
64+
});
65+
66+
test("ignores task identifiers when mapping task scopes", () => {
67+
const detail = scopeDetailForPreset(preset(["trigger:tasks:send-email"]));
68+
69+
expect(detail?.caps.tasks).toEqual([3, ["trigger:tasks:send-email"]]);
70+
});
71+
72+
test("marks admin presets as full access", () => {
73+
expect(scopeDetailForPreset(preset(["admin"]))).toEqual({
74+
admin: true,
75+
taskLabel: "All tasks",
76+
caps: {},
77+
});
78+
});
79+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { ApiKeyPreset } from "~/services/apiKeyPresetValidation.server";
2+
3+
export type CapId =
4+
| "tasks"
5+
| "runs"
6+
| "batches"
7+
| "queues"
8+
| "sessions"
9+
| "tags"
10+
| "errors"
11+
| "webhooks"
12+
| "waitpoints"
13+
| "deployments"
14+
| "branches"
15+
| "envvars";
16+
17+
/**
18+
* Capability rows shown in the scope pane, in a fixed order so two presets read
19+
* as a diff of the same list rather than a reshuffled one.
20+
*/
21+
export const SCOPE_CAPABILITIES: [CapId, string][] = [
22+
["tasks", "Tasks"],
23+
["runs", "Runs"],
24+
["batches", "Batches"],
25+
["queues", "Queues"],
26+
["sessions", "Sessions (all tasks)"],
27+
["tags", "Tagged runs"],
28+
["errors", "Errors"],
29+
["webhooks", "Webhooks"],
30+
["waitpoints", "Waitpoints"],
31+
["deployments", "Deployments"],
32+
["branches", "Preview branches"],
33+
["envvars", "Environment variables"],
34+
];
35+
36+
/** The second entry retains the plugin-provided raw scope strings. */
37+
type PresetCapability = [level: number, rawScopes: string[]];
38+
39+
export type PresetScopeDetail = {
40+
/** A single `admin` scope grants everything, so every row reads "Full access". */
41+
admin?: boolean;
42+
/** Task-scopable presets expand task scopes into the selected task identifiers. */
43+
scopable?: boolean;
44+
/** Shown in the task-access panel for presets that aren't task-scopable. */
45+
taskLabel?: string;
46+
caps: Partial<Record<CapId, PresetCapability>>;
47+
};
48+
49+
const SCOPE_CAPABILITY_BY_SCOPE: Record<string, [CapId, number]> = {
50+
"trigger:tasks": ["tasks", 3],
51+
"batchTrigger:tasks": ["batches", 3],
52+
"batchTrigger:batch": ["batches", 3],
53+
"read:tasks": ["tasks", 1],
54+
"write:tasks": ["tasks", 2],
55+
"read:runs": ["runs", 1],
56+
"write:runs": ["runs", 2],
57+
"read:batch": ["batches", 1],
58+
"write:batch": ["batches", 2],
59+
"read:queues": ["queues", 1],
60+
"write:queues": ["queues", 2],
61+
"read:sessions": ["sessions", 1],
62+
"write:sessions": ["sessions", 2],
63+
"read:tags": ["tags", 1],
64+
"read:errors": ["errors", 1],
65+
"write:errors": ["errors", 2],
66+
"read:webhooks": ["webhooks", 1],
67+
"write:webhooks": ["webhooks", 2],
68+
"read:waitpoints": ["waitpoints", 1],
69+
"write:waitpoints": ["waitpoints", 2],
70+
"read:deployments": ["deployments", 1],
71+
"write:deployments": ["deployments", 2],
72+
"write:branches": ["branches", 3],
73+
"read:envvars": ["envvars", 1],
74+
"write:envvars": ["envvars", 2],
75+
};
76+
77+
export function scopeDetailForPreset(preset?: ApiKeyPreset): PresetScopeDetail | undefined {
78+
const scopes = preset?.scopes;
79+
if (!scopes) return;
80+
if (scopes.includes("admin")) {
81+
return { admin: true, taskLabel: "All tasks", caps: {} };
82+
}
83+
84+
const caps: PresetScopeDetail["caps"] = {};
85+
for (const scope of scopes) {
86+
const [action, resource] = scope.split(":");
87+
const capability = SCOPE_CAPABILITY_BY_SCOPE[`${action}:${resource}`];
88+
if (!capability) continue;
89+
90+
const [key, level] = capability;
91+
const current = caps[key];
92+
caps[key] = [Math.max(current?.[0] ?? 0, level), [...(current?.[1] ?? []), scope]];
93+
}
94+
95+
return {
96+
scopable: preset.usesTaskSelection,
97+
taskLabel: scopes.some((scope) => scope.split(":")[1] === "tasks") ? "All tasks" : "No tasks",
98+
caps,
99+
};
100+
}

0 commit comments

Comments
 (0)