diff --git a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/authentication.mdx b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/authentication.mdx new file mode 100644 index 00000000000..f74682aa390 --- /dev/null +++ b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/authentication.mdx @@ -0,0 +1,210 @@ +--- +title: Authentication and authorization +description: Authenticate requests in a dispatch Worker and authorize data access outside customer app code with Workers for Platforms. +pcx_content_type: design-guide +sidebar: + order: 4 +products: + - cloudflare-for-platforms +--- + +import { Render, TypeScriptExample } from "~/components"; + +When you build a platform that runs customer apps, you need to control who can use each app and which data they can access. An app may have both public and private endpoints. + +Customers can add routes and change app code independently of your platform. Keep access rules in platform-owned code, so they do not depend on every app implementing authentication and authorization correctly. + +Enforce access at two points: + +- **Before the app runs:** require a list of public and private routes when the app is published. Validate that list and enforce its rules before invoking app code. +- **When the app accesses data:** check permissions inside platform-owned operations. Signing in must not let Bob read Alice's private report. + +With Workers for Platforms, check requests in the dispatch Worker and pass scoped data operations to user Workers. + +```mermaid +flowchart TB + Request --> Gate[Dispatch Worker
Authenticate and check route] + Gate -->|Denied| Reject[Return 401, 403, or 404
App does not run] + Gate -->|Allowed| App[User Worker] + App --> Capability[Platform capability
Check access to this record] + Capability --> Data[(Private data)] +``` + +Authentication establishes identity. Authorization determines which routes, actions, and records that identity can access. + +## Choose where to enforce access + +Choose checks according to what needs protection: + +| Requirement | Enforcement point | +| -------------------------------------------------- | -------------------------------------------- | +| The whole app requires sign-in | [Dispatch Worker, before invocation](/cloudflare-for-platforms/workers-for-platforms/configuration/authentication/#authenticate-before-invoking-the-app) | +| Routes mix public and private access | [Platform-owned method/path policy](/cloudflare-for-platforms/workers-for-platforms/configuration/authentication/#publish-code-and-route-policy-together) | +| Routes are unknown, but data permissions are known | [Platform-owned data operations](/cloudflare-for-platforms/workers-for-platforms/configuration/authentication/#protect-data-even-when-routes-change) | +| Routing and policy must stay together | [Registered handlers with access metadata](/cloudflare-for-platforms/workers-for-platforms/configuration/authentication/#adapt-the-pattern) | +| Public code needs fewer privileges | [Separate Workers with different capabilities](/cloudflare-for-platforms/workers-for-platforms/configuration/authentication/#adapt-the-pattern) | +| App libraries call HTTP APIs | [Outbound Worker](/cloudflare-for-platforms/workers-for-platforms/configuration/outbound-workers/) | + +Combine route checks with data-operation checks for mixed-access apps. + +## Publish code and route policy together + +Your platform does not need to predict every future route. Require each app to declare its routes when it publishes: + +```json title="routes.json" +[ + { "method": "GET", "path": "/", "access": "public" }, + { "method": "GET", "path": "/profile", "access": "member" }, + { "method": "GET", "path": "/admin", "access": "reports:admin" } +] +``` + +This manifest is a policy format your platform implements. Workers does not infer intended access from app code. + +```mermaid +flowchart LR + Upload[Customer submits code + policy] --> Validate[Platform validates policy] + Validate --> Release[Version code + policy together] + Release --> Publish[Publish the matching release] +``` + +The platform owns these publication rules: + +- Match the tenant to the authenticated app owner. +- Reject duplicate or ambiguous routes. +- Restrict which routes can become public. +- Prevent changes that weaken required permissions. +- Keep code and policy versions consistent during rollouts. + +Upload a new script name first. Then switch the dispatch Worker to that script and its matching policy. + +### Deny undeclared routes + +Match both method and path. A new route stays inaccessible until the platform accepts its policy. + + + +Here, `member` means membership in the app tenant. Add an app-membership check if only some tenant members may enter. + +Use one path interpretation across the gate and app. This example matches exact URL paths; it grants no wildcard access. + +## Authenticate before invoking the app + +Implement `authenticate(request)` in `auth.ts` using your existing identity provider or session service. This integration must: + +- Verify the session or token, including expiry and intended audience. +- Return a verified identity, or `null` when credentials are absent. +- Reject invalid credentials rather than trust caller-supplied identity headers. + +Resolve `deployment` from your platform's app registry. Its tenant, routes, and script name must not come from app code. + +Place this gate in the dispatch Worker request handler: + + +```ts +import { checkRoute, type Identity } from "./policy"; +import { authenticate } from "./auth"; + +let identity: Identity | null; +try { + identity = await authenticate(request); +} catch { + return new Response("Invalid session", { status: 401 }); +} + +const denied = checkRoute( + request, + deployment.routes, + deployment.tenant, + identity, +); +if (denied) return denied; + +// Forward only what this GET-only example needs. Keep session credentials out. +const appRequest = new Request(request.url, { method: "GET" }); +``` + + + +After the gate succeeds, invoke the app through the [dispatch namespace binding](/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch/): + + +```ts +const app = env.DISPATCHER.get(deployment.scriptName, { + props: { identity }, +}); +return app.fetch(appRequest); +``` + + +All app ingress must pass through the dispatch Worker. + +For a wholly private app, require membership on every request. For mixed-access apps, use the route policy to select public exceptions. + +## Protect data even when routes change + +A route check can require Bob to sign in, but it cannot prevent app code from requesting Alice's report. Keep permission checks inside the platform operations that read private data, so every new route uses the same checks. + +An app can omit `checkAuth()` or ignore its result. Give it an operation that performs authorization and data access together. + +```mermaid +sequenceDiagram + participant Caller as Bob + participant Platform as Dispatch Worker + participant App as User Worker + participant Reports as Platform Reports capability + Caller->>Platform: Request with Bob's session + Platform->>App: Capability scoped to Bob + App->>Reports: read("alice") + Reports-->>App: 404 — record does not belong to Bob + App-->>Caller: 404 +``` + +Keep the data and implementation in the dispatch Worker. This example uses synthetic records: + + + +Create the capability using verified platform context. The app can choose a record ID, but cannot choose the capability's identity: + + +```ts +const reports = ctx.exports.Reports({ + props: { identity, tenant: deployment.tenant }, +}); +``` + + +Pass the capability with the invocation: + + +```ts +const app = env.DISPATCHER.get(deployment.scriptName, { + props: { reports }, +}); +return app.fetch(appRequest); +``` + + +The customer app calls the capability through [RPC](/workers/runtime-apis/rpc/): + + +```ts +const result = await this.ctx.props.reports.read(reportId); +return Response.json(result, { status: result.status }); +``` + + +Use [Outbound Workers](/cloudflare-for-platforms/workers-for-platforms/configuration/outbound-workers/) to restrict network access to authorized destinations and operations. Otherwise, another data-access path could bypass the capability. + +## Adapt the pattern + +Use these variations when the app interface requires them: + +| Pattern | Example | Why | +| --------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| Registered handlers | `{ method: "GET", path: "/profile", access: "member", handler: "profile" }` | Select policy and handler from the same platform-owned entry. A handler name does not grant data access. | +| Public app shell | Public HTML; protected report reads through `reports.read(id)` | The platform can protect data without enumerating every frontend route. | +| Separate components | Public Worker receives `{}`; private Worker receives `{ reports }` | Public code has no private capability. Keep their storage separate too. | +| Outbound HTTP gateway | Allow `GET https://reports.internal/reports/:id`, then check record access | Existing HTTP clients can use the same authorization model. Ignore identity headers supplied by app code. | + +If the outbound gateway injects credentials, authorize the destination and operation first. Hiding a token does not limit what that token can do. diff --git a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/bindings.mdx b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/bindings.mdx index 9c115608604..3f66b9ed93b 100644 --- a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/bindings.mdx +++ b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/bindings.mdx @@ -23,6 +23,8 @@ With bindings, each User Worker can extend functionality to: - **Connect to private networks** with [VPC Services](/workers-vpc/configuration/vpc-services/), [VPC Networks](/workers-vpc/configuration/vpc-networks/), and [Hyperdrive](/hyperdrive/) - **Collect metrics** with [Analytics Engine](/analytics/analytics-engine/) +For an example that checks both tenant and record ownership, refer to [Protect data even when routes change](/cloudflare-for-platforms/workers-for-platforms/configuration/authentication/#protect-data-even-when-routes-change). + #### Resource isolation Each User Worker can only access the bindings that are explicitly attached to it. For complete isolation, you can create and attach a unique resource (like a D1 database or KV namespace) to every User Worker. diff --git a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch.mdx b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch.mdx index ddc9334f525..afef90a1043 100644 --- a/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch.mdx +++ b/src/content/docs/cloudflare-for-platforms/workers-for-platforms/configuration/dynamic-dispatch.mdx @@ -18,6 +18,8 @@ A [dynamic dispatch Worker](/cloudflare-for-platforms/workers-for-platforms/how- You can also create a dispatch Worker from the Cloudflare dashboard. Go to **Workers for Platforms**, select your namespace, and click **Create** > **Dispatch Worker**. The dashboard provides templates for path-based and subdomain-based routing. ::: +For apps with public and private routes, refer to [Authentication and authorization](/cloudflare-for-platforms/workers-for-platforms/configuration/authentication/). The guide combines route policies with scoped data access. + #### Why use a dynamic dispatch Worker? - **Scale**: Route requests to millions of hostnames to different Workers, without defining [Workers Routes](/workers/configuration/routing/routes/) configuration for each one diff --git a/src/content/docs/dynamic-workers/index.mdx b/src/content/docs/dynamic-workers/index.mdx index d4cc569b9fa..ea3af625ad1 100644 --- a/src/content/docs/dynamic-workers/index.mdx +++ b/src/content/docs/dynamic-workers/index.mdx @@ -40,6 +40,7 @@ Use Dynamic Workers when code needs to run quickly in a secure, isolated environ Because you compose the Worker that runs the code at runtime, you control how that Worker is configured and what it can access. +- **[Authentication and authorization](/dynamic-workers/usage/authentication/)**: Enforce route policies and protect data access outside app code. - **[Bindings](/dynamic-workers/usage/bindings/)**: Decide which bindings and structured data the dynamic Worker receives. - **[Observability](/dynamic-workers/usage/observability/)**: Attach Tail Workers and capture logs for each run. - **[Network access](/dynamic-workers/usage/egress-control/)**: Intercept or block Internet access for outbound requests. diff --git a/src/content/docs/dynamic-workers/usage/authentication.mdx b/src/content/docs/dynamic-workers/usage/authentication.mdx new file mode 100644 index 00000000000..555823887d0 --- /dev/null +++ b/src/content/docs/dynamic-workers/usage/authentication.mdx @@ -0,0 +1,210 @@ +--- +title: Authentication and authorization +description: Authenticate requests in a loader Worker and authorize data access outside customer app code with Dynamic Workers. +pcx_content_type: design-guide +sidebar: + order: 2 +products: + - dynamic-workers +--- + +import { Render, TypeScriptExample } from "~/components"; + +When you build a platform that runs customer apps, you need to control who can use each app and which data they can access. An app may have both public and private endpoints. + +Customers can add routes and change app code independently of your platform. Keep access rules in platform-owned code, so they do not depend on every app implementing authentication and authorization correctly. + +Enforce access at two points: + +- **Before the app runs:** require a list of public and private routes when the app is published. Validate that list and enforce its rules before invoking app code. +- **When the app accesses data:** check permissions inside platform-owned operations. Signing in must not let Bob read Alice's private report. + +With Dynamic Workers, check requests in the loader Worker and pass scoped data operations to the Worker it loads. + +```mermaid +flowchart TB + Request --> Gate[Loader Worker
Authenticate and check route] + Gate -->|Denied| Reject[Return 401, 403, or 404
App does not run] + Gate -->|Allowed| App[Dynamic Worker] + App --> Capability[Platform capability
Check access to this record] + Capability --> Data[(Private data)] +``` + +Authentication establishes identity. Authorization determines which routes, actions, and records that identity can access. + +## Choose where to enforce access + +Choose checks according to what needs protection: + +| Requirement | Enforcement point | +| -------------------------------------------------- | -------------------------------------------- | +| The whole app requires sign-in | [Loader Worker, before invocation](/dynamic-workers/usage/authentication/#authenticate-before-invoking-the-app) | +| Routes mix public and private access | [Platform-owned method/path policy](/dynamic-workers/usage/authentication/#publish-code-and-route-policy-together) | +| Routes are unknown, but data permissions are known | [Platform-owned data operations](/dynamic-workers/usage/authentication/#protect-data-even-when-routes-change) | +| Routing and policy must stay together | [Registered handlers with access metadata](/dynamic-workers/usage/authentication/#adapt-the-pattern) | +| Public code needs fewer privileges | [Separate Workers with different capabilities](/dynamic-workers/usage/authentication/#adapt-the-pattern) | +| App libraries call HTTP APIs | [`globalOutbound` gateway](/dynamic-workers/usage/egress-control/#intercept-outbound-requests) | + +Combine route checks with data-operation checks for mixed-access apps. + +## Publish code and route policy together + +Your platform does not need to predict every future route. Require each app to declare its routes when it publishes: + +```json title="routes.json" +[ + { "method": "GET", "path": "/", "access": "public" }, + { "method": "GET", "path": "/profile", "access": "member" }, + { "method": "GET", "path": "/admin", "access": "reports:admin" } +] +``` + +This manifest is a policy format your platform implements. Workers does not infer intended access from app code. + +```mermaid +flowchart LR + Upload[Customer submits code + policy] --> Validate[Platform validates policy] + Validate --> Release[Version code + policy together] + Release --> Publish[Publish the matching release] +``` + +The platform owns these publication rules: + +- Match the tenant to the authenticated app owner. +- Reject duplicate or ambiguous routes. +- Restrict which routes can become public. +- Prevent changes that weaken required permissions. +- Keep code and policy versions consistent during rollouts. + +Store each app's code and route policy as one versioned deployment. Resolve both from that deployment before calling the loader. + +### Deny undeclared routes + +Match both method and path. A new route stays inaccessible until the platform accepts its policy. + + + +Here, `member` means membership in the app tenant. Add an app-membership check if only some tenant members may enter. + +Use one path interpretation across the gate and app. This example matches exact URL paths; it grants no wildcard access. + +## Authenticate before invoking the app + +Implement `authenticate(request)` in `auth.ts` using your existing identity provider or session service. This integration must: + +- Verify the session or token, including expiry and intended audience. +- Return a verified identity, or `null` when credentials are absent. +- Reject invalid credentials rather than trust caller-supplied identity headers. + +Resolve `deployment` from your platform's app registry. Its tenant, routes, and code must not come from app code. + +Place this gate in the loader Worker request handler: + + +```ts +import { checkRoute, type Identity } from "./policy"; +import { authenticate } from "./auth"; + +let identity: Identity | null; +try { + identity = await authenticate(request); +} catch { + return new Response("Invalid session", { status: 401 }); +} + +const denied = checkRoute( + request, + deployment.routes, + deployment.tenant, + identity, +); +if (denied) return denied; + +// Forward only what this GET-only example needs. Keep session credentials out. +const appRequest = new Request(request.url, { method: "GET" }); +``` + + + +After the gate succeeds, load and invoke the app with the [Worker Loader](/dynamic-workers/api-reference/): + + +```ts +const app = env.LOADER.load({ + compatibilityDate: "$today", + mainModule: "app.js", + modules: { "app.js": deployment.code }, + globalOutbound: null, +}); +return app.getEntrypoint(undefined, { props: { identity } }).fetch(appRequest); +``` + + +`load()` creates a fresh Worker. With `get()`, keep code and configuration identical for each cache ID; do not cache one visitor's credentials for other visitors. + +For a wholly private app, require membership on every request. For mixed-access apps, use the route policy to select public exceptions. + +## Protect data even when routes change + +A route check can require Bob to sign in, but it cannot prevent app code from requesting Alice's report. Keep permission checks inside the platform operations that read private data, so every new route uses the same checks. + +An app can omit `checkAuth()` or ignore its result. Give it an operation that performs authorization and data access together. + +```mermaid +sequenceDiagram + participant Caller as Bob + participant Platform as Loader Worker + participant App as Dynamic Worker + participant Reports as Platform Reports capability + Caller->>Platform: Request with Bob's session + Platform->>App: Capability scoped to Bob + App->>Reports: read("alice") + Reports-->>App: 404 — record does not belong to Bob + App-->>Caller: 404 +``` + +Keep the data and implementation in the loader Worker. This example uses synthetic records: + + + +Create the capability using verified platform context. The app can choose a record ID, but cannot choose the capability's identity: + + +```ts +const reports = ctx.exports.Reports({ + props: { identity, tenant: deployment.tenant }, +}); +``` + + +Pass the capability with the invocation: + + +```ts +return app.getEntrypoint(undefined, { props: { reports } }).fetch(appRequest); +``` + + +The customer app calls the capability through [RPC](/workers/runtime-apis/rpc/): + + +```ts +const result = await this.ctx.props.reports.read(reportId); +return Response.json(result, { status: result.status }); +``` + + +Keep `globalOutbound: null` to block network access, or mediate requests through a [gateway](/dynamic-workers/usage/egress-control/#intercept-outbound-requests). Otherwise, another data-access path could bypass the capability. + +## Adapt the pattern + +Use these variations when the app interface requires them: + +| Pattern | Example | Why | +| --------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| Registered handlers | `{ method: "GET", path: "/profile", access: "member", handler: "profile" }` | Select policy and handler from the same platform-owned entry. A handler name does not grant data access. | +| Public app shell | Public HTML; protected report reads through `reports.read(id)` | The platform can protect data without enumerating every frontend route. | +| Separate components | Public Dynamic Worker receives `{}`; private Dynamic Worker receives `{ reports }` | Public code has no private capability. Keep their storage separate too. | +| Outbound HTTP gateway | Allow `GET https://reports.internal/reports/:id`, then check record access | Existing HTTP clients can use the same authorization model. Ignore identity headers supplied by app code. | + +If the outbound gateway injects credentials, authorize the destination and operation first. Hiding a token does not limit what that token can do. diff --git a/src/content/docs/dynamic-workers/usage/bindings.mdx b/src/content/docs/dynamic-workers/usage/bindings.mdx index 62357ba21ab..c4dbe9ed4b2 100644 --- a/src/content/docs/dynamic-workers/usage/bindings.mdx +++ b/src/content/docs/dynamic-workers/usage/bindings.mdx @@ -18,6 +18,8 @@ This allows you to: - **Expose custom capabilities**: Define your own methods that Dynamic Workers can call, such as posting to a chat room, sending an email, or querying an internal service. You design the interface, and the Dynamic Worker calls it. - **Restrict and control access**: Inspect, transform, or reject calls before they reach the underlying resource. +For apps with public and private routes, refer to [Authentication and authorization](/dynamic-workers/usage/authentication/). The guide checks requests before invocation and permissions inside platform-owned operations. + ## Custom bindings with Dynamic Workers With custom bindings, you: diff --git a/src/content/partials/workers/platform-auth-reports.mdx b/src/content/partials/workers/platform-auth-reports.mdx new file mode 100644 index 00000000000..68b017d05b2 --- /dev/null +++ b/src/content/partials/workers/platform-auth-reports.mdx @@ -0,0 +1,39 @@ +--- +{} +--- + +import { TypeScriptExample } from "~/components"; + + +```ts +import { WorkerEntrypoint } from "cloudflare:workers"; + +const records = [ + { id: "alice", tenant: "acme", owner: "alice", text: "Alice's report" }, + { id: "bob", tenant: "acme", owner: "bob", text: "Bob's report" }, +]; + +type Scope = { identity: Identity | null; tenant: string }; + +export class Reports extends WorkerEntrypoint { + read(id: string) { + const { identity, tenant } = this.ctx.props; + if (!identity) return { status: 401, error: "Sign in" }; + if ( + identity.tenant !== tenant || + !identity.permissions.includes("reports:read") + ) { + return { status: 403, error: "Forbidden" }; + } + const report = records.find( + (record) => + record.id === id && + record.tenant === tenant && + record.owner === identity.userId, + ); + if (!report) return { status: 404, error: "Report not found" }; + return { status: 200, report: { text: report.text } }; + } +} +``` + diff --git a/src/content/partials/workers/platform-auth-route-policy.mdx b/src/content/partials/workers/platform-auth-route-policy.mdx new file mode 100644 index 00000000000..d95e8177b8a --- /dev/null +++ b/src/content/partials/workers/platform-auth-route-policy.mdx @@ -0,0 +1,43 @@ +--- +{} +--- + +import { TypeScriptExample } from "~/components"; + + +```ts +export type Identity = { + userId: string; + tenant: string; + permissions: string[]; +}; + +export type Route = { + method: string; + path: string; + access: "public" | "member" | "reports:admin"; +}; + +export function checkRoute( + request: Request, + routes: Route[], + tenant: string, + identity: Identity | null, +): Response | null { + const path = new URL(request.url).pathname; + const route = routes.find( + (route) => route.method === request.method && route.path === path, + ); + if (!route) return new Response("Route not published", { status: 404 }); + if (route.access === "public") return null; + if (!identity) return new Response("Sign in", { status: 401 }); + if ( + identity.tenant !== tenant || + (route.access !== "member" && !identity.permissions.includes(route.access)) + ) { + return new Response("Forbidden", { status: 403 }); + } + return null; +} +``` +