Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/gatekeeper-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ This package provides Cloudflare OAuth integration for Gadgets. It serves three
defensively discard foreign-service events. Distributed trace summaries are account-only because
their names, timing, services, and counts describe the whole cross-service trace; a Worker binding
can still retrieve its own events for a known trace ID.
- **Notifications destinations:** gadgets can submit an approval-gated request to configure a
generic webhook destination in one account. The receiving webhook owns delivery and callbacks;
this gatekeeper only holds the Cloudflare OAuth token and calls the Notifications API after
approval.

Observability connections request `workers-observability.read`. The OAuth client must allow that
Observability connections request `workers-observability.read`; notification destination
connections request `notifications.write`. The OAuth client must allow the selected
scope or Cloudflare will omit/reject it. Existing billing-only connections can add the grant when the
user first selects an observability resource. Cloudflare exposes account and Worker resource choices,
but both map to this one indivisible OAuth scope; resource bindings provide the finer capability
Expand Down
69 changes: 69 additions & 0 deletions packages/gatekeeper-cloudflare/__tests__/notifications-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, expect, it, vi } from "vitest";
import { provisionNotificationInstallation } from "../src/notifications-api.js";

const account = "a".repeat(32);
const webhookId = "b".repeat(32);
const url = "https://webhook.example/hooks/endpoint";

afterEach(() => vi.unstubAllGlobals());

function mock(results: unknown[]) {
const calls: { url: string; init: RequestInit }[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: string, init: RequestInit) => {
calls.push({ url: input, init });
const result = results.shift();
if (result instanceof Response) return result;
return Response.json({ success: true, result });
}),
);
return calls;
}

it("creates a generic webhook destination", async () => {
const calls = mock([[], { id: webhookId }]);
await expect(provisionNotificationInstallation("token", account, url, "secret")).resolves.toEqual(
{ webhookId },
);
expect(JSON.parse(calls[1]!.init.body as string)).toEqual({
name: "Cloudflare OS",
type: "generic",
url,
secret: "secret",
});
expect(calls.every((call) => call.init.redirect === "manual")).toBe(true);
});

it("reconciles the exact URL and replaces its secret", async () => {
const calls = mock([[{ id: webhookId, url }], { id: webhookId }]);
await provisionNotificationInstallation("token", account, url, "new-secret", "Investigator");
expect(calls.map((call) => call.init.method)).toEqual(["GET", "PUT"]);
expect(JSON.parse(calls[1]!.init.body as string)).toMatchObject({
name: "Investigator",
url,
secret: "new-secret",
});
});

it("refuses ambiguous destinations", async () => {
mock([
[
{ id: webhookId, url },
{ id: "c".repeat(32), url },
],
]);
await expect(provisionNotificationInstallation("token", account, url, "secret")).rejects.toThrow(
"Multiple",
);
});

it("never follows redirects with the account token", async () => {
const calls = mock([
new Response(null, { status: 302, headers: { Location: "https://evil.test" } }),
]);
await expect(provisionNotificationInstallation("token", account, url, "secret")).rejects.toThrow(
"302",
);
expect(calls[0]!.init.redirect).toBe("manual");
});
19 changes: 19 additions & 0 deletions packages/gatekeeper-cloudflare/__tests__/resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import { describe, expect, it } from "vitest";
import {
ACCOUNT_OBSERVABILITY_RESOURCE,
WORKER_OBSERVABILITY_RESOURCE,
NOTIFICATIONS_RESOURCE,
accountNotificationsUrl,
cloudflareScopesForResources,
grantedCloudflareResourcePatterns,
accountObservabilityUrl,
grantedObservabilityResourcePatterns,
observabilityScopesForResources,
parseObservabilityResourceUrl,
parseNotificationsResourceUrl,
workerObservabilityUrl,
} from "../src/resources";
import { BILLING_SCOPES, persistentScopesForResources } from "../src/oauth";
Expand Down Expand Up @@ -55,6 +60,20 @@ describe("Cloudflare observability resources", () => {
});
});

describe("Cloudflare Notifications resources", () => {
it("round-trips account notification URLs", () => {
const url = accountNotificationsUrl(ACCOUNT_ID);
expect(parseNotificationsResourceUrl(url)).toEqual({ accountId: ACCOUNT_ID });
});

it("maps the resource to its least-privilege OAuth scope", () => {
expect(cloudflareScopesForResources([NOTIFICATIONS_RESOURCE.urlPattern]))
.toEqual(["notifications.write"]);
expect(grantedCloudflareResourcePatterns(["notifications.write"]))
.toEqual([NOTIFICATIONS_RESOURCE.urlPattern]);
});
});

describe("Cloudflare observability OAuth scopes", () => {
it("keeps billing-only connections free of observability access", () => {
expect(persistentScopesForResources([])).toEqual(BILLING_SCOPES);
Expand Down
38 changes: 31 additions & 7 deletions packages/gatekeeper-cloudflare/src/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@ import {
} from "./oauth";
import { fetchIdentity } from "./cloudflare-api";
import {
OBSERVABILITY_RESOURCES,
CLOUDFLARE_RESOURCES,
ACCOUNT_OBSERVABILITY_RESOURCE,
WORKER_OBSERVABILITY_RESOURCE,
grantedObservabilityResourcePatterns,
NOTIFICATIONS_RESOURCE,
NOTIFICATIONS_SCOPE,
grantedCloudflareResourcePatterns,
accountObservabilityUrl,
workerObservabilityUrl,
parseObservabilityResourceUrl,
parseNotificationsResourceUrl,
} from "./resources.js";
import { CloudflareObservabilityApi, deniesAccess } from "./observability-api.js";
import { CloudflareObservabilitySessionImpl } from "./observability-session.js";
Expand All @@ -31,10 +34,12 @@ import {
} from "./cloudflare-configurators.js";
import ACCOUNT_CONFIGURATOR_HTML from "./generated/cloudflare-account-configurator-ui.txt";
import WORKER_CONFIGURATOR_HTML from "./generated/cloudflare-worker-configurator-ui.txt";
import NOTIFICATIONS_CONFIGURATOR_HTML from "./generated/cloudflare-notifications-configurator-ui.txt";
import type { CloudflareObservabilitySession } from "./types.js";
import { VENDOR_ID } from "./vendor.js";
import TYPES_CODE from "./types.txt";
import { obsContext } from "./observability.js";
export { CloudflareNotificationsGatekeeper } from "./notifications.js";

const logger = obsContext.createLogger({
component: "gatekeeper.cloudflare", vendorId: VENDOR_ID,
Expand Down Expand Up @@ -181,7 +186,7 @@ export class GatekeeperVendor extends WorkerEntrypoint<Env> implements Gatekeepe
url: "https://cloudflare.com",
logo: { url: CLOUDFLARE_LOGO_URL },
color: "#fbece0",
tagline: "Sign in, use AI Gateway, and inspect Workers Observability",
tagline: "Sign in, use AI Gateway, inspect Workers, and configure Notifications",
description:
"Sign in with your Cloudflare account and use your own Cloudflare AI Gateway credits for " +
"usage beyond the free tier. You can also connect Workers Observability to inspect logs, " +
Expand All @@ -204,7 +209,7 @@ export class GatekeeperVendor extends WorkerEntrypoint<Env> implements Gatekeepe
}

async getSupportedResources(): Promise<SupportedResource[]> {
return OBSERVABILITY_RESOURCES;
return CLOUDFLARE_RESOURCES;
}

async getTypeScriptTypes(): Promise<string> {
Expand Down Expand Up @@ -421,7 +426,7 @@ export class GatekeeperUserImpl extends WorkerEntrypoint<Env, GatekeeperUserImpl
displayName: identity?.displayName,
uniqueName: identity?.email,
avatar: { url: CLOUDFLARE_LOGO_URL },
grantedResourceUrlPatterns: grantedObservabilityResourcePatterns(grantedScopes),
grantedResourceUrlPatterns: grantedCloudflareResourcePatterns(grantedScopes),
};
}

Expand All @@ -434,7 +439,7 @@ export class GatekeeperUserImpl extends WorkerEntrypoint<Env, GatekeeperUserImpl

async ensureResources(resourceUrlPatterns: string[]): Promise<{url?: string}> {
const account = this.#account();
const grantedPatterns = new Set(grantedObservabilityResourcePatterns(await account.getGrantedScopes()));
const grantedPatterns = new Set(grantedCloudflareResourcePatterns(await account.getGrantedScopes()));
if (resourceUrlPatterns.every(pattern => grantedPatterns.has(pattern))) return {};

const union = [...new Set([...grantedPatterns, ...resourceUrlPatterns])];
Expand All @@ -448,13 +453,26 @@ export class GatekeeperUserImpl extends WorkerEntrypoint<Env, GatekeeperUserImpl
}

async getSupportedResources(): Promise<SupportedResource[]> {
return OBSERVABILITY_RESOURCES;
return CLOUDFLARE_RESOURCES;
}

async getGatekeeperClassFor(url: string): Promise<{
class: DurableObjectClass<Gatekeeper<any>>;
resource: SupportedResource;
}> {
let notifications: { accountId: string } | undefined;
try { notifications = parseNotificationsResourceUrl(url); } catch { /* Try observability. */ }
if (notifications) {
if (!(await this.#account().getGrantedScopes()).includes(NOTIFICATIONS_SCOPE)) {
throw new Error("Reconnect Cloudflare with Notifications access first.");
}
return {
class: this.ctx.exports.CloudflareNotificationsGatekeeper({
props: { userObjectId: this.ctx.props.userObjectId, accountId: notifications.accountId },
}),
resource: NOTIFICATIONS_RESOURCE,
};
}
const parsed = parseObservabilityResourceUrl(url);
return {
class: this.ctx.exports.CloudflareObservabilityGatekeeper({
Expand All @@ -466,6 +484,12 @@ export class GatekeeperUserImpl extends WorkerEntrypoint<Env, GatekeeperUserImpl

async startResourceConfigurator(resourceUrlPattern: string): Promise<ResourceConfiguratorFrame> {
const getToken = () => this.#account().getAccessToken();
if (resourceUrlPattern === NOTIFICATIONS_RESOURCE.urlPattern) {
return {
iframeHtml: NOTIFICATIONS_CONFIGURATOR_HTML,
ui: new RpcStub(new CloudflareAccountConfiguratorUI(getToken)),
};
}
if (resourceUrlPattern === ACCOUNT_OBSERVABILITY_RESOURCE.urlPattern) {
return {
iframeHtml: ACCOUNT_CONFIGURATOR_HTML,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export type CloudflareWorkerConfiguratorValues = {
workerName?: string | null;
};

export type CloudflareNotificationsConfiguratorValues = CloudflareAccountConfiguratorValues;

export interface CloudflareAccountConfiguratorRpc {
listAccounts(query: string): Promise<ConfiguratorUIOption[]>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Autocomplete, Field, h, Section, type ConfiguratorUISpec } from "@gadgets/configurator-ui";
import type {
CloudflareAccountConfiguratorRpc,
CloudflareNotificationsConfiguratorValues,
} from "./cloudflare-configurator-types";

export default {
initial: { accountId: null },
initialValuesFromResourceUrl({ resourceUrl }) {
const url = new URL(resourceUrl);
const accountId = url.pathname.split("/")[1];
return url.origin === "https://dash.cloudflare.com" && /^[a-f0-9]{32}$/i.test(accountId ?? "")
? { accountId }
: {};
},
isReady: ({ values }) => /^[a-f0-9]{32}$/i.test(values.accountId ?? ""),
resourceUrl: ({ values }) =>
`https://dash.cloudflare.com/${encodeURIComponent(values.accountId!)}/notifications`,
render({ values, setValues, ui }) {
return (
<Section>
<Field
label="Cloudflare account"
description="Choose the account where this app may configure a notification destination."
>
<Autocomplete
name="accountId"
value={values.accountId}
placeholder="Choose an account"
loadOptions={(query) => ui.listAccounts(query)}
onChange={(accountId) => setValues({ accountId })}
/>
</Field>
</Section>
);
},
} satisfies ConfiguratorUISpec<
CloudflareAccountConfiguratorRpc,
CloudflareNotificationsConfiguratorValues
>;
81 changes: 81 additions & 0 deletions packages/gatekeeper-cloudflare/src/notifications-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { readTextCapped } from "@gadgets/gatekeeper-kit/response-body";
import { assertCloudflareAccountId } from "./resources.js";

const API = "https://api.cloudflare.com/client/v4";

function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Malformed Cloudflare Notifications response.");
}
return value as Record<string, unknown>;
}

function id(value: unknown): string {
if (
typeof value !== "string" ||
!/^(?:[a-f\d]{32}|[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12})$/i.test(value)
) {
throw new Error("Invalid Cloudflare Notifications resource ID.");
}
return value;
}

async function request(
token: string,
path: string,
method = "GET",
body?: unknown,
): Promise<unknown> {
const response = await fetch(`${API}${path}`, {
method,
redirect: "manual",
signal: AbortSignal.timeout(15_000),
headers: {
Accept: "application/json",
Authorization: `Bearer ${token}`,
...(body === undefined ? {} : { "Content-Type": "application/json" }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(
`Cloudflare Notifications request failed (${response.status}). ` +
"Check Notifications Write access and webhook eligibility.",
);
}
const envelope = object(JSON.parse(await readTextCapped(response)));
if (envelope.success !== true || !("result" in envelope)) {
throw new Error("Cloudflare Notifications rejected the request.");
}
return envelope.result;
}

async function list(token: string, path: string): Promise<Record<string, unknown>[]> {
const result = await request(token, path);
if (!Array.isArray(result)) throw new Error("Cloudflare Notifications returned an invalid list.");
return result.map(object);
}

/** Create or reconcile the destination identified by its exact callback URL. */
export async function provisionNotificationInstallation(
token: string,
accountId: string,
webhookUrl: string,
secret: string,
name = "Cloudflare OS",
): Promise<{ webhookId: string }> {
const root = `/accounts/${assertCloudflareAccountId(accountId)}/alerting/v3`;
const matches = (await list(token, `${root}/destinations/webhooks`)).filter(
(item) => item.url === webhookUrl,
);
if (matches.length > 1) {
throw new Error("Multiple notification destinations use this webhook URL.");
}
const body = { name, type: "generic", url: webhookUrl, secret };
const webhookId = matches[0]
? id(matches[0].id)
: id(object(await request(token, `${root}/destinations/webhooks`, "POST", body)).id);
if (matches[0]) await request(token, `${root}/destinations/webhooks/${webhookId}`, "PUT", body);
return { webhookId };
}
Loading
Loading