Skip to content

Latest commit

 

History

History
188 lines (142 loc) · 10.4 KB

File metadata and controls

188 lines (142 loc) · 10.4 KB

Gatekeeper Cloudflare

This package provides Cloudflare OAuth integration for Gadgets. It serves three purposes:

  • Sign-in: when cloudflare is in the deployment's AUTH_GATEKEEPERS allowlist, "Continue with Cloudflare" appears on the login page. The grant reads the account email (verified by Cloudflare, via the /user API), which becomes the user's identity. Cloudflare sign-in also establishes the persistent billing-only connection described below.
  • AI Gateway billing: when a user connects Cloudflare or signs in with it, the billing scopes are requested and the connection persists. The Workshop then reads a usable access token from it (getUsableAccessToken) to power the AI Gateway billing flow — reading the credit balance and routing BYOK inference through the account's default AI Gateway.
  • Workers Observability: gadgets can receive read-only access to logs, events, invocations, aggregate metrics, and traces either across an account or restricted to one Worker. Every result is authorized as an observation, and Worker bindings inject an immutable service filter and 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.

Observability connections request workers-observability.read. The OAuth client must allow that 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 boundary after connection.

Workers telemetry is retained by Cloudflare for at most seven days. Queries default to the last hour, and the Worker picker searches the full retention window. Suggested bindings are CLOUDFLARE_OBSERVABILITY for account access and WORKER_OBSERVABILITY for one Worker.

Sharing a gadget that reads telemetry

A binding is not transferable. When a gadget with an observability binding is shared, each collaborator is admitted only if their own connected Cloudflare account can read that resource (addObserver checks it against their credentials, not the owner's). A collaborator without access is refused, and a verification that fails for any other reason — a 5xx, a transport error — is also a refusal rather than an admission.

What a Worker binding trusts, and what it re-checks

The scope filter is prepended to every query, but a filter the provider accepted is not evidence it applied it — three separate behaviours here return wrong-but-plausible data with no error. So events are re-filtered on the way out, and a single foreign event is treated as proof the filter was dropped: the provider's count is withheld (it would be a count of the whole account's matching telemetry) and the event is logged at error. statistics is kept, because it describes what our query cost rather than how much matched, and callers are told to read it for cost.

The re-check is deliberately not a hard failure. The events returned are filtered and therefore safe, and this provider has surprised us often enough that turning a hypothetical disclosure into a guaranteed outage would be the worse trade.

calculate() is the exception, and knowingly so: an aggregate cannot be un-mixed, so there is no second line of defence to add. It rests entirely on the injected filter. The fix that would work — grouping by $metadata.service and keeping this binding's own group, whose value is the correctly scoped answer even for a median — changes limit and orderBy semantics for every caller, so it is a follow-up rather than a footnote.

Why discovery sometimes costs a query

Cloudflare's telemetry/keys and telemetry/values endpoints ignore the filters array they accept: verified against a live account, they answer for the whole account no matter what is passed. A Worker-scoped binding therefore cannot use them — they would disclose every field name and value in the account. Whenever a discovery call has to be constrained (a Worker binding, or any caller-supplied filter), the answer is derived instead from a filtered telemetry/query events sample, which the provider does filter correctly. Unconstrained account-wide discovery still uses the cheap endpoints.

Unresolved: whether the workers-observability.read scope alone is accepted, or whether Cloudflare requires the Workers Observability Write permission to read telemetry. This has not been verified against a live token; if reads 403 on a correctly-scoped grant, that is the first thing to check.

Two names for one field

A log's own structured fields are returned nested under source but are indexed under their bare name: logger.warn(msg, {event: "x"}) comes back as source.event and is queried as event. Confirmed both ways — telemetry/keys on a live account reports event/component/level and no source.* key at all, and the Workers Logs docs show console.log({user_id: 123}) being filtered as user_id.

This matters because the provider accepts an unknown filter key, matches nothing, and returns an empty page with no error. So discovery reports indexed names (observability-discovery.ts), and observabilityFieldKey additionally accepts a source.-prefixed key as an alias wherever a caller names a field — filters, calculations, groupBys, listValues — because copying a path out of a listEvents result is the obvious thing to do and used to fail silently.

openid is intentionally not requested — the Cloudflare dashboard OAuth client isn't permitted that scope; identity comes from the /user API (user-details.read).

Why an error's text never reaches the log

summarizeFilter deliberately keeps filter values out of the audit trail — they are caller text. But a provider error message can quote that same value straight back, so logging the message would readmit through the error path exactly what the audit path excludes. CloudflareObservabilityApiError therefore carries Cloudflare's numeric codes alongside the message: the request log names the codes, and the message travels only to the caller who caused it.

The codes are not the discriminator, though — Cloudflare can return a message with no numeric code at all, so the error records separately whether the message is its or ours, and only ours is logged. A provider failure carrying no codes is therefore logged as a bare status, which is the fail-closed answer: the status still says what happened without quoting anyone's filter back.

Listing accounts is a walk, not a request

/accounts is paginated and defaults to 20 per page, so a single GET silently returns a truncated list — an account past the first page simply cannot be picked, with nothing to indicate why. The account picker's substring match also stays client-side: name is the only documented server-side filter and whether it matches exactly or by substring is not specified, so pushing it down would trade a visible truncation for an invisible one.

Setting Up Cloudflare OAuth Credentials

You need a Cloudflare dashboard OAuth client (client id + secret). The dashboard OAuth endpoints and scopes are hardcoded in src/oauth.ts, so you only configure the client id/secret and register the redirect URI. Ensure the client's scope allowlist includes workers-observability.read when this deployment offers Workers Observability resources.

Step 1: Register the redirect URI

The gatekeeper's OAuth redirect URI is:

${BASE_URL}/oauth

where BASE_URL defaults to http://localhost:8787/gatekeeper/cloudflare in dev — i.e. the full redirect URI is:

http://localhost:8787/gatekeeper/cloudflare/oauth

Register exactly this (replace the host with your PUBLIC_BASE_URL when not running locally) as an allowed/pre-registered redirect URL on the Cloudflare OAuth client. If it isn't registered you'll get an invalid_request error: "the 'redirect_uri' parameter does not match any of the OAuth 2.0 Client's pre-registered redirect urls."

Step 2: Configure Your Local Environment

Create a .env file in this package's directory (packages/gatekeeper-cloudflare/.env):

CLIENT_ID=your-client-id-here
CLIENT_SECRET=your-client-secret-here

In local dev, run-dev-server.ts will also seed these from CLOUDFLARE_OAUTH_CLIENT_ID / CLOUDFLARE_OAUTH_CLIENT_SECRET if you'd rather set them in the root .dev.vars. A per-package .env takes precedence and keeps the credential with the gatekeeper that uses it.

Note: The .env file is gitignored and should never be committed.

Step 3: (Optional) Enable Cloudflare sign-in / billing

To offer "Continue with Cloudflare" on the login page, add cloudflare to the deployment's AUTH_GATEKEEPERS allowlist (e.g. in the root .dev.vars):

AUTH_GATEKEEPERS=cloudflare,google,github

The order controls the order of the login buttons. For the AI Gateway billing / top-up flow, also set ENABLE_CLOUDFLARE_LIMITS=true (see AI Gateway billing); a user enables billing by connecting Cloudflare, which requests the billing scopes (offline_access aig.read aig.run user-details.read account-settings.read). Connecting all Cloudflare gadget resources also requests workers-observability.read.

Step 4: Verify Setup

  1. Start the application in dev mode (see the root README.md).
  2. On the login page, click Continue with Cloudflare.
  3. A pop-up opens to the Cloudflare authorization page; approve it.
  4. The pop-up closes and you're signed in, identified by your Cloudflare account email.
  5. To use AI Gateway credits, open Usage & billing in settings and Connect Cloudflare (this requests billing scopes only).

Troubleshooting

"redirect_uri ... does not match any of the ... pre-registered redirect urls"

The redirect URI isn't registered on the OAuth client. Register exactly http://localhost:8787/gatekeeper/cloudflare/oauth (or your PUBLIC_BASE_URL equivalent) — no trailing slash, http not https for localhost.

"Not configured" page during authorization

CLIENT_ID / CLIENT_SECRET are missing. Ensure they're set (per-package .env or seeded from the root .dev.vars), then restart the dev server.