From 2b094ea7e345540fcf67a69a29b3a407d85c8eb8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 30 Sep 2026 12:57:52 +0800 Subject: [PATCH 01/25] feat(platform): add project Zero Trust protection Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/.vitepress/config.ts | 1 + docs/guide/edge/revalidation.md | 2 +- docs/guide/edge/static-assets.md | 1 + docs/guide/platform-administration.md | 1 + .../platform/administration/zero-trust.md | 52 + docs/guide/platform/installation/domains.md | 2 +- .../platform/installation/first-deployment.md | 4 + docs/reference/cli.md | 60 +- packages/edge/src/access.ts | 4 + packages/edge/src/index.ts | 1 + packages/isr/README.md | 2 +- packages/isr/src/core.ts | 3 +- packages/platform/src/application.ts | 8 +- packages/platform/src/index.ts | 1 + packages/void/AGENTS.md | 2 +- packages/void/skills/void/SKILL.md | 2 + packages/void/src/cli/cli.ts | 4 +- packages/void/src/cli/client.ts | 55 + packages/void/src/cli/create-project.ts | 9 +- packages/void/src/cli/deploy.ts | 3 +- packages/void/src/cli/help.ts | 44 +- packages/void/src/cli/operator-args.ts | 111 +- packages/void/src/cli/operator-cmd.ts | 5 +- packages/void/src/cli/output.ts | 6 + packages/void/src/cli/platform-args.ts | 6 +- packages/void/src/cli/platform-domain.ts | 68 +- packages/void/src/cli/project-args.ts | 42 +- packages/void/src/cli/project-cmd.ts | 15 + packages/void/src/cli/project-zero-trust.ts | 94 + .../void/test/integration/cli-help.test.ts | 12 +- .../void/test/unit/create-project.test.ts | 27 +- packages/void/test/unit/isr-cache.test.ts | 23 + packages/void/test/unit/operator-cli.test.ts | 86 +- .../void/test/unit/platform-domain.test.ts | 28 + packages/void/test/unit/project-args.test.ts | 29 + .../unit/project-zero-trust-command.test.ts | 253 + .../design-docs/0083-project-zero-trust.md | 194 + platform/meta/design-docs/README.md | 2 +- platform/packages/api/README.md | 1 + .../api/drizzle/0053_project_zero_trust.sql | 22 + .../packages/api/drizzle/compatibility.json | 24 + .../api/drizzle/meta/0053_snapshot.json | 6157 +++++++++++++++++ .../packages/api/drizzle/meta/_journal.json | 7 + platform/packages/api/package.json | 1 + .../api/src/admin/routes/projects.tsx | 65 + .../api/src/admin/routes/zero-trust.tsx | 326 + platform/packages/api/src/api.ts | 2 + platform/packages/api/src/db.ts | 13 - platform/packages/api/src/index.ts | 181 +- .../api/src/middleware/platform-access.ts | 3 +- platform/packages/api/src/operator/audit.ts | 32 + platform/packages/api/src/operator/index.ts | 2 + .../api/src/operator/tenant-service.ts | 10 +- .../api/src/operator/zero-trust-routes.ts | 183 + .../api/src/platform-domain-activation.ts | 46 + .../api/src/provisioning/activate-domain.ts | 22 +- .../packages/api/src/provisioning/cleanup.ts | 10 +- platform/packages/api/src/routes/deploy.ts | 101 +- platform/packages/api/src/routes/domains.ts | 30 + .../api/src/routes/project-zero-trust.ts | 109 + platform/packages/api/src/routes/projects.ts | 62 +- platform/packages/api/src/routing-limits.ts | 50 + platform/packages/api/src/routing.ts | 105 +- platform/packages/api/src/schema.ts | 51 + platform/packages/api/src/types.ts | 2 + .../api/src/zero-trust/application.ts | 51 + .../packages/api/src/zero-trust/cloudflare.ts | 531 ++ .../api/src/zero-trust/configuration.ts | 384 + .../packages/api/src/zero-trust/service.ts | 2013 ++++++ .../test/integration/admin-dashboard.test.ts | 89 + .../api/test/integration/api-deploy.test.ts | 78 + .../api/test/integration/api-domains.test.ts | 268 +- .../api-project-access-routes.test.ts | 115 + .../api/test/integration/api-projects.test.ts | 31 + .../test/integration/operator-audit.test.ts | 53 +- .../platform-domain-activation.test.ts | 346 + .../zero-trust-deploy-compensation.test.ts | 298 + .../zero-trust-deployment-consistency.test.ts | 380 + .../zero-trust-disable-bypass.test.ts | 479 ++ .../zero-trust-operation-generation.test.ts | 460 ++ .../integration/zero-trust-recovery.test.ts | 971 +++ .../zero-trust-route-metadata.test.ts | 397 ++ .../zero-trust-routing-backfill.test.ts | 386 ++ .../zero-trust-routing-publication.test.ts | 293 + .../zero-trust-routing-retry.test.ts | 626 ++ .../integration/zero-trust-service.test.ts | 551 ++ .../unit/admin-upgrade-compatibility.test.ts | 36 + .../packages/api/test/unit/routing.test.ts | 64 +- .../api/test/unit/usage-counter.test.ts | 7 + .../test/unit/zero-trust-cloudflare.test.ts | 523 ++ .../unit/zero-trust-configuration.test.ts | 70 + platform/packages/dispatch/package.json | 3 +- platform/packages/dispatch/src/access.ts | 98 + platform/packages/dispatch/src/index.ts | 19 + .../dispatch/test/unit/access-cache.test.ts | 198 + .../dispatch/test/unit/access.test.ts | 152 + .../packages/dispatch/test/unit/index.test.ts | 27 + .../packages/dispatch/test/unit/isr.test.ts | 10 + pnpm-lock.yaml | 6 + 99 files changed, 18656 insertions(+), 236 deletions(-) create mode 100644 docs/guide/platform/administration/zero-trust.md create mode 100644 packages/edge/src/access.ts create mode 100644 packages/void/src/cli/project-zero-trust.ts create mode 100644 packages/void/test/unit/project-zero-trust-command.test.ts create mode 100644 platform/meta/design-docs/0083-project-zero-trust.md create mode 100644 platform/packages/api/drizzle/0053_project_zero_trust.sql create mode 100644 platform/packages/api/drizzle/meta/0053_snapshot.json create mode 100644 platform/packages/api/src/admin/routes/zero-trust.tsx create mode 100644 platform/packages/api/src/operator/zero-trust-routes.ts create mode 100644 platform/packages/api/src/platform-domain-activation.ts create mode 100644 platform/packages/api/src/routes/project-zero-trust.ts create mode 100644 platform/packages/api/src/routing-limits.ts create mode 100644 platform/packages/api/src/zero-trust/application.ts create mode 100644 platform/packages/api/src/zero-trust/cloudflare.ts create mode 100644 platform/packages/api/src/zero-trust/configuration.ts create mode 100644 platform/packages/api/src/zero-trust/service.ts create mode 100644 platform/packages/api/test/integration/platform-domain-activation.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-deploy-compensation.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-deployment-consistency.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-disable-bypass.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-operation-generation.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-recovery.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-route-metadata.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-routing-backfill.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-routing-publication.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-routing-retry.test.ts create mode 100644 platform/packages/api/test/integration/zero-trust-service.test.ts create mode 100644 platform/packages/api/test/unit/zero-trust-cloudflare.test.ts create mode 100644 platform/packages/api/test/unit/zero-trust-configuration.test.ts create mode 100644 platform/packages/dispatch/src/access.ts create mode 100644 platform/packages/dispatch/test/unit/access-cache.test.ts create mode 100644 platform/packages/dispatch/test/unit/access.test.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 2eac3675a..52071bfab 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -123,6 +123,7 @@ const platformSidebar = [ { text: 'Overview', link: '/guide/platform-administration' }, { text: 'Sign-in and Access', link: '/guide/platform/administration/access' }, { text: 'Users and Projects', link: '/guide/platform/administration/projects' }, + { text: 'Project Zero Trust', link: '/guide/platform/administration/zero-trust' }, { text: 'Email', link: '/guide/platform/administration/email' }, { text: 'Operations', link: '/guide/platform/administration/operations' }, ], diff --git a/docs/guide/edge/revalidation.md b/docs/guide/edge/revalidation.md index 51ddbb17f..7eeda3b61 100644 --- a/docs/guide/edge/revalidation.md +++ b/docs/guide/edge/revalidation.md @@ -105,7 +105,7 @@ revalidation, Void removes its HTML, Pages JSON, and KV cache entries. ## Cache bypass -Requests with `Cookie` or `Authorization` headers bypass the ISR cache entirely and always dispatch to the worker. This ensures personalized or authenticated pages are never served from a shared cache. +Requests with `Cookie`, `Authorization`, or Cloudflare Access's `Cf-Access-Jwt-Assertion` header bypass the ISR cache entirely and always dispatch to the worker. Authenticated responses are not shared between users, including when Access authentication supplies no cookie. ## Cache keys and rewrites diff --git a/docs/guide/edge/static-assets.md b/docs/guide/edge/static-assets.md index 1d6af26f1..f70367edd 100644 --- a/docs/guide/edge/static-assets.md +++ b/docs/guide/edge/static-assets.md @@ -51,6 +51,7 @@ Cached at the edge until the next deploy. Browsers always revalidate on the next - `/api/*` routes, which always hit the worker - SSR-rendered pages (paths without file extensions in SSR projects) - Non-GET requests +- Requests carrying `Cookie`, `Authorization`, or `Cf-Access-Jwt-Assertion` credentials - Responses other than a complete `200` ### Opting out diff --git a/docs/guide/platform-administration.md b/docs/guide/platform-administration.md index 1424fe981..10086b456 100644 --- a/docs/guide/platform-administration.md +++ b/docs/guide/platform-administration.md @@ -12,5 +12,6 @@ The operator commands for users, projects, deployments, and system status requir - [Sign-in and Access](/guide/platform/administration/access) - [Users and Projects](/guide/platform/administration/projects) +- [Project Zero Trust](/guide/platform/administration/zero-trust) - [Email](/guide/platform/administration/email) - [Operations](/guide/platform/administration/operations) diff --git a/docs/guide/platform/administration/zero-trust.md b/docs/guide/platform/administration/zero-trust.md new file mode 100644 index 000000000..c5533c444 --- /dev/null +++ b/docs/guide/platform/administration/zero-trust.md @@ -0,0 +1,52 @@ +--- +outline: deep +--- + +# Project Zero Trust + +## Protecting Projects with Zero Trust + +Project protection puts Cloudflare Access in front of deployed project hostnames. It is separate from the Access gate on the platform API. Void protects project hostnames with one shared wildcard Access application. Projects made public receive an exact-host bypass application; protected projects with custom domains receive one exact Access application containing those domains. + +Open **Zero Trust** in the administrator UI to configure, inspect, reconcile, or disable the feature. The project detail page shows desired and effective state and lets an installation administrator change the override. Void stores the API token encrypted and never shows it again. + +The same workflow is available from the operator CLI: + +```sh +void platform zero-trust status +printf '%s' "$ACCESS_API_TOKEN" | void platform zero-trust configure \ + --account \ + --identity-providers \ + --policies \ + --existing-projects public \ + --protect-new-projects \ + --token-stdin \ + --yes +``` + +Use `--plan` instead of `--yes` to inspect the change without applying it. + +Use an API token scoped to the platform account with **Access: Apps and Policies Write** and **Access: Organizations, Identity Providers, and Groups Read**. Void validates the selected identity providers and reusable policies before publishing protection. It does not add these permissions to the platform runtime token. + +## Project Overrides + +Owners and project administrators may opt a project out or back in: + +```sh +void project zero-trust status +void project zero-trust disable +void project zero-trust enable +void project zero-trust reconcile +``` + +## Limits + +Cloudflare's default Access application limit is 500 per account. Void uses one wildcard application, up to one bypass application per public project, and up to one additional application per protected project that has custom domains. Include existing Access applications when checking the account limit before enabling this on a large installation. A protected project can have up to 100 custom domains. + +## Recovery + +A project never becomes public by mistake while a change is in progress. Failed projects are retried automatically. Use `void platform zero-trust reconcile` after repairing permissions or account quota. Large operations may remain `configuring` or `disabling` after one request; the same reconcile command or scheduled maintenance resumes the saved operation. Disabling removes Void-managed applications and makes all projects public. + +If the saved Cloudflare API token expires or is revoked during an operation, repeat `void platform zero-trust configure --token-stdin` with a replacement token and the same account, identity providers, policies, and project selections. Void resumes the saved operation, including an interrupted disable. Finish that operation before changing these settings. + +If a selected identity provider or policy has been deleted, use `void platform zero-trust disable` to abandon the pending setup and remove its managed applications. This makes projects public. Once cleanup finishes, configure Zero Trust again with the replacement selections. diff --git a/docs/guide/platform/installation/domains.md b/docs/guide/platform/installation/domains.md index e6f0f8b17..3532921f9 100644 --- a/docs/guide/platform/installation/domains.md +++ b/docs/guide/platform/installation/domains.md @@ -15,7 +15,7 @@ void platform domain set example.app The command selects your installed platform (or offers a picker), finds or creates its zone, sets up DNS and routing, and verifies HTTPS before publishing the new application URLs. Set the management token as described in [installation setup](/guide/platform/installation/setup) when creating DNS or a zone. Grant the existing runtime token **Cache Purge: Purge** on the new zone; Void checks that permission through the running platform without asking you to paste its token again. -If nameservers or certificates are pending, follow the printed guidance and rerun the same command. Your workers.dev app URLs continue working during and after setup. Projects, deployments, secrets, and the platform API URL stay the same, so developers do not reconnect and configured login callbacks do not change. DNS and configuration changes may take time to propagate. +If nameservers, certificates, or project Zero Trust protection are pending, follow the printed guidance and rerun the same command. Void preserves each project's public or protected setting before making the new URLs available. Your workers.dev app URLs continue working during and after setup. Projects, deployments, secrets, and the platform API URL stay the same, so developers do not reconnect and configured login callbacks do not change. DNS and configuration changes may take time to propagate. Use `--installation ` to select an installation explicitly, `--zone example.com` for an app domain such as `apps.example.com`, or `--dedicated-zone` for catch-all routing on a dedicated zone. Nested domains still need the wildcard certificate described below. This command adds the first domain; replacing an existing application domain is not currently supported. It uses the installed runtime and does not require `--runtime` or an app redeploy. diff --git a/docs/guide/platform/installation/first-deployment.md b/docs/guide/platform/installation/first-deployment.md index 88e7689ac..fb25a4ea1 100644 --- a/docs/guide/platform/installation/first-deployment.md +++ b/docs/guide/platform/installation/first-deployment.md @@ -80,3 +80,7 @@ Void shows the proposed access change and asks you to confirm it. Once approved, To see who can join, run `void platform signup show`. You can also allow an email address or a domain such as `*@example.com`. For an OIDC user without verified email, use `void platform signup allow identity `; the subject match is exact and the provider's domain or group restrictions still apply. Remove that grant with `void platform signup disallow identity `. `void platform signup open` permits public signup; `void platform signup restrict` requires an allowlist match again. Your administrator session lasts for one hour. Use it to inspect users, projects, logs, and platform health. The [Platform Administration guide](/guide/platform-administration) walks through those workflows, previews, and automation. You can also open `/admin/login` to use the browser admin UI. + +### Protect Project Hostnames with Zero Trust + +After installation, an administrator can require Cloudflare Access on new project hostnames while allowing each project owner or project administrator to opt out. This is independent of Access login and protection for the platform API itself. It needs a separate Cloudflare API token, which Void stores encrypted apart from the platform runtime token. See [Project Zero Trust](/guide/platform/administration/zero-trust) for the token permissions, setup command, application limits, and recovery. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6130ffcca..7207cac04 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -38,6 +38,7 @@ Use this page as a command reference. If you are setting up a project for the fi | `void project requests` | Show request-level traffic (status, method, timing) | | `void project rollback` | Roll back to a previous deployment | | `void project cancel` | Cancel an active deployment | +| `void project zero-trust` | Inspect or override project Zero Trust protection | | `void project purge-cache` | Purge all cached pages | | `void build logs` | Stream, tail, or download build logs | | `void email status` | Show email readiness on your own Cloudflare account (`--platform cloudflare`) | @@ -282,6 +283,25 @@ platform, and accepting an invitation leaves directory links intact. To link the invited application, run `void project link` in an unlinked checkout of that application. +### `void project zero-trust` + +Inspect or override protection for a project on a Void platform: + +```sh +void project zero-trust status [--project ] +void project zero-trust enable [--project ] +void project zero-trust disable [--project ] +void project zero-trust reconcile [--project ] +``` + +Project selection follows `--project`, then `VOID_PROJECT`, then the linked +project. + +Readers can inspect the state. The owner or a project administrator can protect +the project, make it public, or repair drift. `disable` creates a Void-managed +Access bypass; it does not change the platform default for future projects. +These commands do not apply to direct Cloudflare deployments. + ### `void project token ` Manage revocable `aud: deploy` credentials for one Void platform project. The @@ -585,6 +605,44 @@ Search matches a project's slug, ID, or owner's login. `show` includes resources `owner` transfers a project to another registered user. Preview it with `--plan`; apply it interactively or with `--yes`. The preview reports active-work blockers and the account plan that will apply. The former owner becomes a project administrator, existing project-scoped CI deploy credentials are revoked, and usage already incurred remains with the former owner. +#### Project Zero Trust {#operator-zero-trust} + +Configure the installation-wide Cloudflare Access policy and project overrides: + +```sh +void platform zero-trust status +printf '%s' "$ACCESS_API_TOKEN" | void platform zero-trust configure \ + --account \ + --identity-providers \ + --policies \ + --existing-projects \ + --protect-new-projects \ + --token-stdin \ + --yes +void platform zero-trust reconcile +void platform zero-trust disable + +void platform zero-trust project-status +void platform zero-trust project-enable +void platform zero-trust project-disable +void platform zero-trust project-reconcile +``` + +Mutations support `--plan`, `--yes`, and `--timeout`; use `--plan` instead of +`--yes` to inspect this change without applying it. The Access management token +is accepted only on standard input and is encrypted at rest. Omit +`--protect-new-projects` to make new projects public by default. The +`--existing-projects` choice is applied only when Zero Trust is first enabled or +re-enabled; later configuration changes preserve project opt-outs. If status +remains `configuring` or `disabling`, run `void platform zero-trust reconcile` +to resume the saved operation. Protected projects support up to 100 custom +domains. + +The token needs **Access: Apps and Policies Write** plus **Access: +Organizations, Identity Providers, and Groups Read**, scoped to the platform's +Cloudflare account. Void rejects policies that allow Everyone, bypass identity, +or allow arbitrary service tokens. + #### Deployments {#operator-deployments} Find a deployment, inspect its manifest, or request cancellation: @@ -818,7 +876,7 @@ See [Self-host a Void platform](../guide/self-hosted-platform.md) for prerequisi void platform domain set [--installation ] [--zone ] [--dedicated-zone] [--plan] [--yes] ``` -Add an application domain to a workers.dev test platform. Domain-based installations remain the recommended default. The command detects the zone when possible, creates missing DNS and routes after confirmation, and checks HTTPS before making the domain canonical. If DNS or certificates are pending, rerun the same command to resume. `--plan` is read-only; non-interactive mutations require `--yes`. +Add an application domain to a workers.dev test platform. Domain-based installations remain the recommended default. The command detects the zone when possible, creates missing DNS and routes after confirmation, and checks HTTPS and project Zero Trust protection before making the domain canonical. If DNS, certificates, or protection are pending, rerun the same command to resume. `--plan` is read-only; non-interactive mutations require `--yes`. Existing workers.dev URLs remain available, and the platform API origin, OAuth callback, projects, and deployments stay unchanged. The command verifies the running runtime token's Cache Purge permission for the new zone. A disabled platform stays disabled. Use the database URL from the original installation when administering from another machine. Replacing an already configured application domain is not supported. See [Adding a Domain](../guide/platform/installation/domains.md#adding-a-domain). diff --git a/packages/edge/src/access.ts b/packages/edge/src/access.ts new file mode 100644 index 000000000..2188199a7 --- /dev/null +++ b/packages/edge/src/access.ts @@ -0,0 +1,4 @@ +export type ProjectZeroTrustRouting = Readonly<{ + teamDomain: string; + audiences: ReadonlyArray>; +}>; diff --git a/packages/edge/src/index.ts b/packages/edge/src/index.ts index 383e5f884..2f4c9581e 100644 --- a/packages/edge/src/index.ts +++ b/packages/edge/src/index.ts @@ -1,3 +1,4 @@ +export type { ProjectZeroTrustRouting } from './access.ts'; export { applyHeaderRules, parseClearSiteData, diff --git a/packages/isr/README.md b/packages/isr/README.md index 0cbcbf18b..69429daee 100644 --- a/packages/isr/README.md +++ b/packages/isr/README.md @@ -20,7 +20,7 @@ The package has three entry points: - `@void/isr/core` owns target-neutral codecs, validation, TTL selection, request bypass rules, response construction, header sanitization, Void Pages data extraction, and query-variant normalization. - `@void/isr/runtime` implements the fail-open, single-tenant Cloudflare KV and Cache API ladder used by direct Void deployments, including bounded variants from `routing.revalidateQueryAllowlist`. -The direct runtime applies ISR only to public page requests. API paths, credentialed requests, Range requests, and EventSource requests render live. A rendered response is shared only when it is a complete `200` response without `Cache-Control: private`, `no-store`, or `no-cache`, and without `Set-Cookie`. When a background revalidation returns one of those opt-outs, the runtime removes the old HTML, Pages JSON, and KV variants so previously public content cannot remain reachable. +The direct runtime applies ISR only to public page requests. API paths, requests carrying `Cookie`, `Authorization`, or `Cf-Access-Jwt-Assertion` credentials, Range requests, and EventSource requests render live. A rendered response is shared only when it is a complete `200` response without `Cache-Control: private`, `no-store`, or `no-cache`, and without `Set-Cookie`. When a background revalidation returns one of those opt-outs, the runtime removes the old HTML, Pages JSON, and KV variants so previously public content cannot remain reachable. | Symbol | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ | diff --git a/packages/isr/src/core.ts b/packages/isr/src/core.ts index 777dab449..293b38188 100644 --- a/packages/isr/src/core.ts +++ b/packages/isr/src/core.ts @@ -5,7 +5,7 @@ import { matchLiteralPattern, matchSegmentPattern } from '@void/edge'; * proven safe to share. Readers reject entries from earlier policies instead * of replaying opaque bodies whose private headers were stripped at write time. */ -export const SHARED_CACHE_POLICY_VERSION = 1 as const; +export const SHARED_CACHE_POLICY_VERSION = 2 as const; export const SHARED_CACHE_POLICY_HEADER = 'x-void-shared-cache-policy'; /** Stamp an internal edge-cache representation with the policy that admitted it. */ @@ -244,6 +244,7 @@ export function shouldBypassIsr(request: Request): boolean { return ( request.headers.has('Cookie') || request.headers.has('Authorization') || + request.headers.has('Cf-Access-Jwt-Assertion') || acceptsEventStream(request) ); } diff --git a/packages/platform/src/application.ts b/packages/platform/src/application.ts index 115302911..181550c7d 100644 --- a/packages/platform/src/application.ts +++ b/packages/platform/src/application.ts @@ -73,6 +73,12 @@ export async function resolvePlatformApplicationEnvironment< }; } +export function platformProjectIngressPrefix( + workersDev: NonNullable, +): string { + return `void-${workersDev.installationId.slice(-12)}-`; +} + export function platformProjectIngressName( project: PlatformApplicationProject, workersDev: NonNullable, @@ -80,7 +86,7 @@ export function platformProjectIngressName( if (!/^proj_[a-z0-9]{1,32}$/.test(project.id) || !/^[a-z0-9][a-z0-9-]*$/.test(project.slug)) { throw new Error('Invalid platform project identity.'); } - const prefix = `void-${workersDev.installationId.slice(-12)}-`; + const prefix = platformProjectIngressPrefix(workersDev); const suffix = `-${project.id.slice(5)}`; const slug = project.slug.slice(0, 63 - prefix.length - suffix.length).replace(/-+$/, ''); return `${prefix}${slug}${suffix}`; diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index d2cbd6c46..d0c7e4c78 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -86,6 +86,7 @@ export { platformProjectHostname, platformProjectHostnames, platformProjectIngressName, + platformProjectIngressPrefix, platformProjectWorkersDevHostname, platformWorkersDevAliasKey, readPlatformApplication, diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 78a540e0d..6081209e6 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -56,7 +56,7 @@ generated entries must preserve the module-boundary rules below. - **Platform resource identity**: New installations use `void--` names, without random suffixes. Conflicts fail closed; only matching ownership records/markers authorize reuse or resume. Externally managed Hyperdrive is an exception: users select it by ID, its name may differ from the generated name, and the selected name and origin are pinned for recovery. Keep recorded names and recovery behavior for older suffixed installations, and keep installation IDs/ownership nonces in metadata. Do not change application/user naming when changing platform infrastructure naming. -- **Platform test origins**: Domain installations remain the default recommendation. Explicit `platform install --workers-dev` uses schema-v2 application routing and a forwarding Worker per project; normal domain platforms keep schema v1. `platform domain set` checkpoints pending DNS separately, verifies the live runtime token and HTTPS, then publishes `PLATFORM_APPLICATION_KEY`. API and dispatch resolve that configuration into a request-local environment copy. Preserve the original workers.dev identity and API origin. Forwarders share the main slug routing entry, validate its project ID, and honor suspension/disablement. They remain for manual cleanup, and their unpurgeable workers.dev edge-cache layer is bypassed while shared ISR remains enabled. +- **Platform test origins**: Domain installations remain the default recommendation. Explicit `platform install --workers-dev` uses schema-v2 application routing and a forwarding Worker per project; normal domain platforms keep schema v1. `platform domain set` checkpoints pending DNS separately, verifies the live runtime token and HTTPS, then hands activation to the authenticated platform API. The service durably prepares Zero Trust destinations and project routing before publishing `PLATFORM_APPLICATION_KEY`; the CLI never publishes that key directly. API and dispatch resolve that configuration into a request-local environment copy. Preserve the original workers.dev identity and API origin. Forwarders share the main slug routing entry, validate its project ID, and honor suspension/disablement. They remain for manual cleanup, and their unpurgeable workers.dev edge-cache layer is bypassed while shared ISR remains enabled. - **Cloudflare session switching**: Explicit `void cloudflare login` forces a fresh browser sign-in, while automatic authentication reuses valid sessions. Adopted OAuth sessions persist the keyring preference so later compatible CLI logins update the same encrypted store. Environment API credentials block explicit browser login rather than masking it. Status reports identity/auth method and the pinned account separately; login never rewrites the project target. Keep credentials out of all diagnostics. diff --git a/packages/void/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md index b01a5da78..9bc3de003 100644 --- a/packages/void/skills/void/SKILL.md +++ b/packages/void/skills/void/SKILL.md @@ -77,6 +77,8 @@ Use `void connect` for deployment onboarding: no arguments offers Cloudflare or For Void platform project access, use `void project team`: invite only an email already registered on that platform and assign `reader`, `collaborator`, or `admin`. The invited account uses `void connect ` followed by pending/accept/decline on that active connection, regardless of the current directory's project link or deploy target. `VOID_API_URL` overrides the connection; an unscoped `VOID_TOKEN` selects Void Cloud. Acceptance preserves directory links; run `void project link` in an unlinked checkout of the invited application. Project-scoped team management does not apply to direct Cloudflare deployments. Installation administrators transfer ownership with `void platform project owner --plan` and apply the reviewed transfer with `--yes`. +For a self-hosted platform's project Zero Trust policy, installation administrators use `void platform zero-trust`; pass the Access management token only through `--token-stdin`. Project owners and project administrators use `void project zero-trust status|enable|disable|reconcile`; these commands do not apply to direct Cloudflare deployments. + Use Void commands for every user-facing workflow. Never ask the user to install, authenticate, or run Wrangler directly. Say Cloudflare or Void instead, except when naming literal `wrangler.jsonc` / `wrangler.json` files or `WRANGLER_*` environment variables the user must inspect. `void auth login|status|logout` follows the saved deploy destination or asks the user to choose; `void account` explicitly manages a Void platform account. diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 41c0cd816..c8f3574d7 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -284,7 +284,7 @@ if (helpPath) { }, { name: 'platform', - hint: 'auth | user | project | deployment | signup | email | system | install', + hint: 'auth | user | project | deployment | signup | email | zero-trust | system | install', description: 'Install platforms and administer users, projects, access, and operations', }, { @@ -294,7 +294,7 @@ if (helpPath) { }, { name: 'project', - hint: 'status | link | list | team | token | logs | requests | rollback | cancel | delete | purge-cache', + hint: 'status | link | list | team | token | zero-trust | logs | requests | rollback | cancel | delete | purge-cache', description: 'Manage projects, teams, deployments, and scoped CI credentials', }, { name: 'secret', hint: 'list | put | delete | sync', description: 'Manage project secrets' }, diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index d1d5a997e..20ca61078 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -172,6 +172,23 @@ export type ProjectDeployCredentialResponse = { revokedAt: string | null; }; +export type ProjectZeroTrustState = { + project_id: string; + desired_protected: boolean; + effective_protected: boolean; + state: 'public' | 'protected' | 'reconciling' | 'error'; + last_error: string | null; + updated_at: string; +}; + +export type ProjectZeroTrustResponse = { + available?: boolean; + platformStatus?: string; + protectNewProjects?: boolean; + ok?: boolean; + project: ProjectZeroTrustState | null; +}; + export type IssuedProjectDeployCredentialResponse = ProjectDeployCredentialResponse & { token: string; }; @@ -180,6 +197,8 @@ export type CreateProjectResponse = { url?: string; id: string; slug: string; + warnings?: Array; + zero_trust?: ProjectZeroTrustState; /** * Present exactly when the platform scheduled the project owner's own * address to be registered as an outbound email destination. The @@ -668,6 +687,42 @@ export class PlatformClient { return res.json(); } + async getProjectZeroTrust(projectId: string): Promise { + const res = await this.request(`${this.baseUrl}/projects/${projectId}/zero-trust`, { + headers: { ...this.authHeaders }, + }); + if (!res.ok) { + await throwPlatformApiError('Failed to inspect project Zero Trust', res); + } + return res.json(); + } + + async setProjectZeroTrust( + projectId: string, + protectedProject: boolean, + ): Promise { + const res = await this.request(`${this.baseUrl}/projects/${projectId}/zero-trust`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...this.authHeaders }, + body: JSON.stringify({ protected: protectedProject }), + }); + if (!res.ok) { + await throwPlatformApiError('Failed to update project Zero Trust', res); + } + return res.json(); + } + + async reconcileProjectZeroTrust(projectId: string): Promise { + const res = await this.request(`${this.baseUrl}/projects/${projectId}/zero-trust/reconcile`, { + method: 'POST', + headers: { ...this.authHeaders }, + }); + if (!res.ok) { + await throwPlatformApiError('Failed to reconcile project Zero Trust', res); + } + return res.json(); + } + async createProjectToken( projectId: string, input: { name?: string; expiresInDays?: number }, diff --git a/packages/void/src/cli/create-project.ts b/packages/void/src/cli/create-project.ts index 28436dd2f..462fbc25a 100644 --- a/packages/void/src/cli/create-project.ts +++ b/packages/void/src/cli/create-project.ts @@ -1,6 +1,6 @@ import { log } from '@clack/prompts'; import { PlatformApiError, type CreateProjectResponse, type PlatformClient } from './client.ts'; -import { pc } from './output.ts'; +import { pc, sanitizeTerminalText } from './output.ts'; import { attachSelectedPlatform, writeProjectConfig, type ProjectConfig } from './project.ts'; import { promptProjectSlug } from './prompt.ts'; import { inferDefaultSlug } from './resolve-project.ts'; @@ -29,6 +29,7 @@ export async function createProjectWithSlugRetry( log.step(`Creating project ${pc.blue(slug)}...`); const project = await client.createProject(slug); log.success(`Project created: ${pc.blue(project.slug)}`); + reportProjectCreationWarnings(project); return project; } catch (error) { if (error instanceof PlatformApiError && isRetryableCreateError(error)) { @@ -41,6 +42,12 @@ export async function createProjectWithSlugRetry( } } +export function reportProjectCreationWarnings(project: CreateProjectResponse): void { + for (const warning of project.warnings ?? []) { + log.warn(sanitizeTerminalText(warning)); + } +} + export async function promptAndCreateProject( root: string, client: PlatformClient, diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 96c85e029..4e6e657bf 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -182,7 +182,7 @@ import { readGeneratedWranglerConfigFile } from './generated-wrangler-config.ts' import { assertInstalledVoidMatchesCli } from './installed-void.ts'; import { migrateLegacyVoidConfig } from './migrate.ts'; export { parseRootWranglerConfig } from '@void/deploy-cloudflare'; -import { promptAndCreateProject } from './create-project.ts'; +import { promptAndCreateProject, reportProjectCreationWarnings } from './create-project.ts'; import { formatConnectionLostMessage, formatDeployFailureMessage, @@ -9053,6 +9053,7 @@ async function createProjectFromSlug( const config = { projectId: project.id, slug: project.slug }; writeProjectConfig(root, attachSelectedPlatform(root, config, client.apiUrl)); log.success(`Project created: ${pc.blue(config.slug)}`); + reportProjectCreationWarnings(project); cliLog?.info('project_create_success', { projectId: config.projectId, slug: config.slug }); return config; } diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index a2b00370e..e1b8d0f64 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -97,11 +97,15 @@ function operatorHelpTree(): HelpTree { ? 'Poll new logs until interrupted or the build ends' : flag === 'ended' ? 'Attest that the original provider execution has ended' - : flag === 'token-stdin' && group === 'email' - ? entry.action === 'domain-remove' - ? 'Read a replacement Cloudflare token for blocked project cleanup from stdin' - : 'Read a scoped Cloudflare API token from stdin' - : 'Read a full API session from stdin for explicit operator session exchange', + : flag === 'protect-new-projects' + ? 'Protect newly created projects by default' + : flag === 'token-stdin' && group === 'zero-trust' + ? 'Read the Access management API token from stdin' + : flag === 'token-stdin' && group === 'email' + ? entry.action === 'domain-remove' + ? 'Read a replacement Cloudflare token for blocked project cleanup from stdin' + : 'Read a scoped Cloudflare API token from stdin' + : 'Read a full API session from stdin for explicit operator session exchange', })), ], }, @@ -496,6 +500,10 @@ const HELP_TREE: HelpTree = { { command: 'list', description: 'List your projects' }, { command: 'token ', description: 'Manage project-scoped CI deploy credentials' }, { command: 'team ', description: 'Manage members and project invitations' }, + { + command: 'zero-trust ', + description: 'Inspect or override Cloudflare Zero Trust protection', + }, { command: 'logs', description: 'Show runtime logs from the deployed project' }, { command: 'requests', description: 'Show request-level traffic (status, method, timing)' }, { command: 'rollback [deployId]', description: 'Roll back to a previous deployment' }, @@ -591,6 +599,32 @@ const HELP_TREE: HelpTree = { ), }, }, + 'zero-trust': { + summary: 'Inspect and manage Zero Trust protection for a Void platform project.', + usage: ['void project zero-trust [options]'], + commands: [ + { command: 'status', description: 'Show desired and effective protection state' }, + { command: 'enable', description: 'Protect the project' }, + { command: 'disable', description: 'Make the project public' }, + { command: 'reconcile', description: 'Repair Cloudflare Access configuration drift' }, + ], + options: [{ command: '--project ', description: 'Project name override' }], + children: Object.fromEntries( + ['status', 'enable', 'disable', 'reconcile'].map((action) => [ + action, + withProjectOption( + action === 'status' + ? 'Show desired and effective Zero Trust protection.' + : action === 'enable' + ? 'Protect the project with the platform Access policy.' + : action === 'disable' + ? 'Make the project public with a managed Access bypass.' + : 'Repair Cloudflare Access configuration drift for the project.', + `void project zero-trust ${action} [options]`, + ), + ]), + ), + }, logs: { summary: 'Show runtime logs from the deployed project.', usage: ['void project logs [options]'], diff --git a/packages/void/src/cli/operator-args.ts b/packages/void/src/cli/operator-args.ts index eb95620a3..b2a872e58 100644 --- a/packages/void/src/cli/operator-args.ts +++ b/packages/void/src/cli/operator-args.ts @@ -117,6 +117,72 @@ export const operatorCommands: Array = [ description: 'Transfer project ownership to another registered user', path: '/projects/:id/owner', positional: ['id', 'userId'], + usage: ['void platform project owner [options]'], + method: 'POST', + }, + { + group: 'zero-trust', + action: 'status', + description: 'Inspect platform Zero Trust configuration, health, and project state counts', + path: '/zero-trust', + }, + { + group: 'zero-trust', + action: 'configure', + description: 'Configure and reconcile project Zero Trust protection', + path: '/zero-trust/configure', + values: { + account: 'Cloudflare account ID', + 'identity-providers': 'Comma-separated Access identity provider IDs', + policies: 'Comma-separated reusable Access policy IDs', + 'existing-projects': 'Initial state: protected or public', + }, + booleans: ['token-stdin', 'protect-new-projects'], + method: 'POST', + }, + { + group: 'zero-trust', + action: 'reconcile', + description: 'Repair platform and project Access configuration drift', + path: '/zero-trust/reconcile', + method: 'POST', + }, + { + group: 'zero-trust', + action: 'disable', + description: 'Remove Void-managed Access applications and make projects public', + path: '/zero-trust/disable', + method: 'POST', + }, + { + group: 'zero-trust', + action: 'project-status', + description: 'Inspect one project Zero Trust override and effective state', + path: '/zero-trust/projects/:id', + positional: ['id'], + }, + { + group: 'zero-trust', + action: 'project-enable', + description: 'Protect one project, overriding the platform default', + path: '/zero-trust/projects/:id', + positional: ['id'], + method: 'POST', + }, + { + group: 'zero-trust', + action: 'project-disable', + description: 'Make one project public, overriding the platform default', + path: '/zero-trust/projects/:id', + positional: ['id'], + method: 'POST', + }, + { + group: 'zero-trust', + action: 'project-reconcile', + description: 'Repair Access configuration drift for one project', + path: '/zero-trust/projects/:id/reconcile', + positional: ['id'], method: 'POST', }, { @@ -471,7 +537,16 @@ export function parseOperatorArgs(args: Array): OperatorArgs { const positional: Array = []; const values: Record = {}; const flags = new Set(); - const booleanFlags = new Set(['json', 'plan', 'yes', 'follow', 'token-stdin', 'force', 'ended']); + const booleanFlags = new Set([ + 'json', + 'plan', + 'yes', + 'follow', + 'token-stdin', + 'force', + 'ended', + ...operatorCommands.flatMap((command) => command.booleans ?? []), + ]); for (let index = 0; index < args.length; index++) { const arg = args[index]!; if (!arg.startsWith('-')) { @@ -648,5 +723,39 @@ export function parseOperatorArgs(args: Array): OperatorArgs { parsed.body[name] = true; } } + if (group === 'zero-trust' && action === 'configure') { + if (!parsed.tokenStdin) { + throw new Error('platform: zero-trust configure requires --token-stdin.'); + } + const accountId = values.account; + const identityProviderIds = values['identity-providers'] + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + const policyIds = values.policies + ?.split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + const existingProjects = values['existing-projects']; + if (!accountId || !identityProviderIds?.length || !policyIds?.length || !existingProjects) { + throw new Error( + 'platform: zero-trust configure requires --account, --identity-providers, --policies, and --existing-projects.', + ); + } + if (existingProjects !== 'protected' && existingProjects !== 'public') { + throw new Error('platform: --existing-projects must be protected or public.'); + } + parsed.body = { + accountId, + identityProviderIds, + policyIds, + existingProjects, + protectNewProjects: flags.has('protect-new-projects'), + }; + } else if (group === 'zero-trust' && action === 'project-enable') { + parsed.body.protected = true; + } else if (group === 'zero-trust' && action === 'project-disable') { + parsed.body.protected = false; + } return parsed; } diff --git a/packages/void/src/cli/operator-cmd.ts b/packages/void/src/cli/operator-cmd.ts index b86c111e2..2e25949d0 100644 --- a/packages/void/src/cli/operator-cmd.ts +++ b/packages/void/src/cli/operator-cmd.ts @@ -515,8 +515,9 @@ export async function runOperatorCommand(args: OperatorArgs): Promise { // Read once for preview and apply; the server excludes the token from audit output. if ( args.tokenStdin && - args.group === 'email' && - (args.action === 'domain-add' || args.action === 'domain-remove') + ((args.group === 'email' && + (args.action === 'domain-add' || args.action === 'domain-remove')) || + (args.group === 'zero-trust' && args.action === 'configure')) ) { args = { ...args, body: { ...args.body, apiToken: await readOperatorApiTokenStdin() } }; } diff --git a/packages/void/src/cli/output.ts b/packages/void/src/cli/output.ts index 0bf03c967..306e4bc26 100644 --- a/packages/void/src/cli/output.ts +++ b/packages/void/src/cli/output.ts @@ -79,6 +79,12 @@ export function tablePrefix(): string { return `${pc.gray('│')} `; } +/** Strip terminal control characters from text received from a platform. */ +export function sanitizeTerminalText(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' '); +} + export function helpLine(command: string, description: string): string { return `${pc.reset(command.padEnd(34))} ${pc.dim(description)}`; } diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index c93992fbc..6f0fed079 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -11,7 +11,11 @@ export const platformSubcommands: Array = [ ...operatorGroups.map((name) => ({ name, hint: - name === 'email' ? 'Domains, policy, limits, outcomes, and recovery' : 'Operator commands', + name === 'email' + ? 'Domains, policy, limits, outcomes, and recovery' + : name === 'zero-trust' + ? 'Configure Access protection and project overrides' + : 'Operator commands', description: `Administer platform ${name}`, })), { name: 'list', description: 'List installed and connected platforms' }, diff --git a/packages/void/src/cli/platform-domain.ts b/packages/void/src/cli/platform-domain.ts index 25b6e3923..ef25dcb31 100644 --- a/packages/void/src/cli/platform-domain.ts +++ b/packages/void/src/cli/platform-domain.ts @@ -4,7 +4,6 @@ import { createApplicationRoutes, createPlatformDeploymentManifest, normalizeApplicationDomain, - PLATFORM_APPLICATION_KEY, PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY, type PlatformApplication, type PlatformInstallationManifest, @@ -43,6 +42,7 @@ type DomainArgs = Extract; type DomainResult = Readonly<{ manifest: PlatformInstallationManifest; waitingForDns: boolean; + waitingForProtection: boolean; nameServers: ReadonlyArray; }>; @@ -98,6 +98,10 @@ export async function applyPlatformDomain( verifyOwnership: (manifest: PlatformInstallationManifest) => Promise; verifyRuntimeToken: (zoneId: string) => Promise; probe?: (hostname: string, manifest: PlatformInstallationManifest) => Promise; + activate?: ( + application: PlatformApplication, + manifest: PlatformInstallationManifest, + ) => Promise; }, ): Promise { let manifest = original; @@ -130,7 +134,12 @@ export async function applyPlatformDomain( ), ); if (zone.status !== 'active') { - return { manifest, waitingForDns: true, nameServers: zone.nameServers }; + return { + manifest, + waitingForDns: true, + waitingForProtection: false, + nameServers: zone.nameServers, + }; } await dependencies.verifyRuntimeToken(zone.id); const dnsAction = plan.actions.find((action) => action.key === 'application-dns'); @@ -190,15 +199,51 @@ export async function applyPlatformDomain( if (!routing) { throw new Error('platform: Routing storage is missing. Run platform repair first.'); } - await client.putKvValue( - manifest.account.id, - routing.id, - PLATFORM_APPLICATION_KEY, - JSON.stringify(deployment.application), + const activated = await (dependencies.activate ?? activatePlatformDomain)( + deployment.application, + manifest, ); + if (!activated) { + return { manifest, waitingForDns: false, waitingForProtection: true, nameServers: [] }; + } const { pendingDomain: _pending, ...ready } = manifest; await save({ ...ready, deployment, updatedAt: now() }); - return { manifest, waitingForDns: false, nameServers: [] }; + return { manifest, waitingForDns: false, waitingForProtection: false, nameServers: [] }; +} + +async function activatePlatformDomain( + application: PlatformApplication, + manifest: PlatformInstallationManifest, +): Promise { + const url = `${manifest.endpoints.apiUrl}/internal/platform/domain-activate`; + const response = await fetch(url, { + method: 'POST', + redirect: 'manual', + signal: AbortSignal.timeout(120_000), + headers: { + 'Content-Type': 'application/json', + 'X-Void-Platform-Probe': createPlatformHealthProbeToken(manifest.ownershipNonce), + ...cfAccessHeaders(process.env, url), + }, + body: JSON.stringify({ application }), + }); + const body = (await response.json()) as { + ok?: boolean; + installationId?: string; + ready?: boolean; + error?: string; + }; + if ( + !response.ok || + body.ok !== true || + body.installationId !== manifest.id || + typeof body.ready !== 'boolean' + ) { + throw new Error( + `platform: ${body.error ?? 'The running platform could not prepare domain protection. Update or repair the platform, then rerun this command.'}`, + ); + } + return body.ready; } async function probePlatformDomain( @@ -336,6 +381,13 @@ export async function runPlatformDomainCommand(args: DomainArgs): Promise ); return; } + if (result.waitingForProtection) { + note( + `Domain protection is still being prepared. Existing workers.dev URLs remain active. Rerun: void platform domain set ${domain}`, + 'Preparing application domain', + ); + return; + } savePlatformConnection({ accountId: manifest.account.id, apiUrl: manifest.endpoints.apiUrl!, diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index c187462dc..939749a7c 100644 --- a/packages/void/src/cli/project-args.ts +++ b/packages/void/src/cli/project-args.ts @@ -23,6 +23,11 @@ export const projectSubcommands: Array = [ hint: 'list | invite | invitations | role | remove | revoke | leave | pending | accept | decline', description: 'Manage project members and invitations', }, + { + name: 'zero-trust', + hint: 'status | enable | disable | reconcile', + description: 'Inspect or override Cloudflare Zero Trust protection', + }, { name: 'logs', description: 'Show runtime logs from the deployed project' }, { name: 'requests', description: 'Show request-level traffic (status, method, timing)' }, { name: 'rollback', arg: '[deployId]', description: 'Roll back to a previous deployment' }, @@ -39,6 +44,11 @@ export type ProjectArgs = | { subcommand: 'status'; name: string } | { subcommand: 'link'; name: string } | { subcommand: 'list' } + | { + subcommand: 'zero-trust'; + action: '' | 'status' | 'enable' | 'disable' | 'reconcile'; + projectSlug: string; + } | { subcommand: 'team'; action: '' } | { subcommand: 'team'; @@ -190,6 +200,36 @@ export function parseProjectArgs(args: Array): ProjectArgs { return parseTeamArgs(rest); } + if (subcommand === 'zero-trust') { + const [action = '', ...args] = rest; + if (action && !['status', 'enable', 'disable', 'reconcile'].includes(action)) { + throw new Error( + 'project: Usage: void project zero-trust [--project ].', + ); + } + let projectSlug = ''; + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (arg === '--project') { + const value = args[++index]; + if (!value || value.startsWith('-')) { + throw new Error('project: --project requires a value.'); + } + if (projectSlug) { + throw new Error("project: Duplicate flag '--project'."); + } + projectSlug = value; + } else { + throw new Error(`project: Unexpected argument '${arg}'.`); + } + } + return { + subcommand, + action: action as '' | 'status' | 'enable' | 'disable' | 'reconcile', + projectSlug, + }; + } + if (subcommand === 'logs') { let filter = ''; let range = '1h'; @@ -290,7 +330,7 @@ export function parseProjectArgs(args: Array): ProjectArgs { } throw new Error( - 'project: Usage: void project .', + 'project: Usage: void project .', ); } diff --git a/packages/void/src/cli/project-cmd.ts b/packages/void/src/cli/project-cmd.ts index 7f9322ef0..d1e8a04be 100644 --- a/packages/void/src/cli/project-cmd.ts +++ b/packages/void/src/cli/project-cmd.ts @@ -49,6 +49,21 @@ export async function runProjectCommand(root: string, args: ProjectArgs): Promis const { runProjectTeamCommand } = await import('./project-team.ts'); return runProjectTeamCommand(root, args); } + if (args.subcommand === 'zero-trust') { + if (cloudflare) { + throw new Error( + 'project: Zero Trust overrides are available only for projects hosted on a Void platform.', + ); + } + if (args.action === '') { + const { printHelpPage } = await import('./help.ts'); + printHelpPage(['project', 'zero-trust']); + return; + } + const action = args.action; + const { runProjectZeroTrustCommand } = await import('./project-zero-trust.ts'); + return runProjectZeroTrustCommand(root, { ...args, action }); + } if (args.subcommand === 'logs') { const { runLogsCommand } = await import('./logs.ts'); return runLogsCommand( diff --git a/packages/void/src/cli/project-zero-trust.ts b/packages/void/src/cli/project-zero-trust.ts new file mode 100644 index 000000000..33a88342e --- /dev/null +++ b/packages/void/src/cli/project-zero-trust.ts @@ -0,0 +1,94 @@ +import { intro, log, note, outro } from '@clack/prompts'; +import { getToken } from './auth.ts'; +import { PlatformClient, type ProjectZeroTrustResponse } from './client.ts'; +import { cliTitle, sanitizeTerminalText } from './output.ts'; +import type { ProjectArgs } from './project-args.ts'; +import { readProjectConfig } from './project.ts'; +import { getRequestedProjectSlug, resolveProjectBySlug } from './resolve-project.ts'; + +type ZeroTrustArgs = Extract & { + action: 'status' | 'enable' | 'disable' | 'reconcile'; +}; + +async function resolveTarget(root: string, args: ZeroTrustArgs) { + const token = getToken(root); + if (!token) { + throw new Error('project: Sign in before managing Zero Trust protection.'); + } + const client = new PlatformClient(token, { root }); + const requestedSlug = getRequestedProjectSlug({ projectSlug: args.projectSlug }); + if (requestedSlug) { + const project = resolveProjectBySlug(await client.listProjects(), requestedSlug); + if (!project) { + throw new Error(`project: No project found with slug '${requestedSlug}'.`); + } + return { client, projectId: project.projectId, slug: project.slug }; + } + const linked = readProjectConfig(root); + if (!linked) { + throw new Error('project: No linked project. Pass --project or set VOID_PROJECT.'); + } + return { client, projectId: linked.projectId, slug: linked.slug }; +} + +function statusLines(result: ProjectZeroTrustResponse): string { + const project = result.project; + const platformStatus = sanitizeTerminalText(result.platformStatus ?? 'unknown'); + const unavailableMessage = + result.platformStatus === 'invalid_configuration' + ? 'The platform Zero Trust configuration is invalid. Ask a platform administrator to review it.' + : result.platformStatus === 'configuring' + ? 'The platform is still applying Zero Trust configuration. A platform administrator can resume it with `void platform zero-trust reconcile`.' + : result.platformStatus === 'disabling' + ? 'The platform is disabling project Zero Trust. A platform administrator can resume it with `void platform zero-trust reconcile`.' + : result.platformStatus === 'error' + ? 'The platform Zero Trust configuration needs administrator attention. A platform administrator can inspect its status and reconcile it.' + : result.platformStatus === 'ready' + ? 'The platform administrator has disabled project Zero Trust.' + : 'The platform administrator has not configured project Zero Trust.'; + return [ + `Available: ${result.available ? 'yes' : 'no'}`, + `Platform: ${platformStatus}`, + ...(typeof result.protectNewProjects === 'boolean' + ? [`Default for new projects: ${result.protectNewProjects ? 'protected' : 'public'}`] + : []), + `Desired: ${project ? (project.desired_protected ? 'protected' : 'public') : 'not initialized'}`, + `Effective: ${project ? (project.effective_protected ? 'protected' : 'public') : 'not initialized'}`, + `State: ${project?.state ?? 'not initialized'}`, + ...(project?.last_error ? [`Last error: ${sanitizeTerminalText(project.last_error)}`] : []), + ...(!result.available ? [unavailableMessage] : []), + ].join('\n'); +} + +export async function runProjectZeroTrustCommand(root: string, args: ZeroTrustArgs): Promise { + console.log(); + intro(cliTitle(`project zero-trust ${args.action}`)); + const { client, projectId, slug } = await resolveTarget(root, args); + if (args.action === 'status') { + note(statusLines(await client.getProjectZeroTrust(projectId)), slug); + outro(`${slug} Zero Trust status`); + return; + } + let result: ProjectZeroTrustResponse; + if (args.action === 'reconcile') { + result = await client.reconcileProjectZeroTrust(projectId); + } else { + result = await client.setProjectZeroTrust(projectId, args.action === 'enable'); + } + if (result.project?.last_error) { + log.warn(sanitizeTerminalText(result.project.last_error)); + } + if (result.project?.state === 'reconciling') { + outro( + `${slug} Zero Trust update is still in progress. Run \`void project zero-trust reconcile\` to continue, or wait for scheduled maintenance.`, + ); + return; + } + outro( + args.action === 'enable' + ? `${slug} is protected by Zero Trust.` + : args.action === 'disable' + ? `${slug} is public.` + : `${slug} Zero Trust reconciled (${result.project?.state ?? 'unknown'}).`, + ); +} diff --git a/packages/void/test/integration/cli-help.test.ts b/packages/void/test/integration/cli-help.test.ts index 6bc08ae44..79a1a6199 100644 --- a/packages/void/test/integration/cli-help.test.ts +++ b/packages/void/test/integration/cli-help.test.ts @@ -118,8 +118,18 @@ describe('void help (subprocess)', () => { const owner = runVoid(root, ['platform', 'project', 'owner', '--help']); expect(owner.status).toBe(0); - expect(owner.output).toContain('void platform project owner '); + expect(owner.output).toContain('void platform project owner '); expect(owner.output).toContain('--plan'); + + const projectZeroTrust = runVoid(root, ['project', 'zero-trust', 'enable', '--help']); + expect(projectZeroTrust.status).toBe(0); + expect(projectZeroTrust.output).toContain('void project zero-trust enable'); + expect(projectZeroTrust.output).toContain('--project '); + + const platformZeroTrust = runVoid(root, ['platform', 'zero-trust', 'configure', '--help']); + expect(platformZeroTrust.status).toBe(0); + expect(platformZeroTrust.output).toContain('--token-stdin'); + expect(platformZeroTrust.output).toContain('--identity-providers '); }); it('fails unknown help topics', () => { diff --git a/packages/void/test/unit/create-project.test.ts b/packages/void/test/unit/create-project.test.ts index 8f77ae139..8ab239359 100644 --- a/packages/void/test/unit/create-project.test.ts +++ b/packages/void/test/unit/create-project.test.ts @@ -7,6 +7,7 @@ import type { PlatformClient } from '../../src/cli/client.ts'; const mockText = vi.fn(); const mockInfo = vi.fn(); const mockError = vi.fn(); +const mockWarn = vi.fn(); vi.mock('@clack/prompts', () => ({ cancel: vi.fn(), @@ -16,7 +17,7 @@ vi.mock('@clack/prompts', () => ({ info: (...args: Array) => mockInfo(...args), step: vi.fn(), success: vi.fn(), - warn: vi.fn(), + warn: (...args: Array) => mockWarn(...args), }, text: (...args: Array) => mockText(...args), })); @@ -41,6 +42,7 @@ describe('promptAndCreateProject', () => { mockText.mockReset(); mockInfo.mockReset(); mockError.mockReset(); + mockWarn.mockReset(); }); afterEach(() => { @@ -122,6 +124,29 @@ describe('promptAndCreateProject', () => { expect(client.createProject).toHaveBeenNthCalledWith(2, 'longer-app'); }); + it('surfaces every project creation warning without terminal control characters', async () => { + const { promptAndCreateProject } = await import('../../src/cli/create-project.ts'); + const client = createClient(); + client.createProject.mockResolvedValueOnce({ + id: 'proj_new', + slug: 'new-app', + warnings: [ + 'Project created, but its Zero Trust protection could not be initialized.', + 'Retry\u001b[31m before deployment.', + ], + }); + mockText.mockResolvedValueOnce('new-app'); + + await promptAndCreateProject(root, client); + + expect(mockWarn).toHaveBeenCalledTimes(2); + expect(mockWarn).toHaveBeenNthCalledWith( + 1, + 'Project created, but its Zero Trust protection could not be initialized.', + ); + expect(mockWarn).toHaveBeenNthCalledWith(2, 'Retry [31m before deployment.'); + }); + // The platform caps slugs at 56 characters (the `+noreply@` sender // has to fit RFC 5321's 64-octet local-part) and answers 400. Like every // other validation 400, that must re-prompt rather than abort the deploy. diff --git a/packages/void/test/unit/isr-cache.test.ts b/packages/void/test/unit/isr-cache.test.ts index a755e3491..453cd188f 100644 --- a/packages/void/test/unit/isr-cache.test.ts +++ b/packages/void/test/unit/isr-cache.test.ts @@ -1247,6 +1247,29 @@ describe('handleIsr', () => { expect(edge.store.size).toBe(0); }); + it('bypasses all shared ISR work for an assertion-only Access request', async () => { + const { kv, getWithMetadata, put } = createKvMock(); + const { render, spy } = createRender(() => new Response('private profile')); + const { ctx, tasks } = createCtx(); + + expect( + await handleIsr( + htmlRequest('/blog/post', { 'Cf-Access-Jwt-Assertion': 'signed-access-assertion' }), + makeEnv(kv), + ctx, + OPTS, + render, + ), + ).toBeNull(); + + expect(edge.match).not.toHaveBeenCalled(); + expect(getWithMetadata).not.toHaveBeenCalled(); + expect(edge.put).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + expect(tasks).toHaveLength(0); + }); + it('returns null (caller renders live) for non-GET, credentialed, and non-ISR paths', async () => { const { kv } = createKvMock(); const env = makeEnv(kv); diff --git a/packages/void/test/unit/operator-cli.test.ts b/packages/void/test/unit/operator-cli.test.ts index 82b1898bb..8de9cb8bf 100644 --- a/packages/void/test/unit/operator-cli.test.ts +++ b/packages/void/test/unit/operator-cli.test.ts @@ -61,7 +61,26 @@ describe('operator argument and help coverage', () => { const parameters = (command.positional ?? []).map((name) => name === 'type' ? 'email' : `${name}-value`, ); - const result = parsePlatformArgs([command.group, command.action, ...parameters]); + const configureFlags = + command.group === 'zero-trust' && command.action === 'configure' + ? [ + '--account', + 'account-1', + '--identity-providers', + 'idp-1', + '--policies', + 'policy-1', + '--existing-projects', + 'public', + '--token-stdin', + ] + : []; + const result = parsePlatformArgs([ + command.group, + command.action, + ...parameters, + ...configureFlags, + ]); expect(result).toMatchObject({ subcommand: 'operator', group: command.group, @@ -69,6 +88,71 @@ describe('operator argument and help coverage', () => { }); } }); + it('parses Zero Trust configuration and project overrides without accepting a token in argv', () => { + expect( + parseOperatorArgs([ + 'zero-trust', + 'configure', + '--account', + 'account-1', + '--identity-providers', + 'idp-1,idp-2', + '--policies', + 'policy-1, policy-2', + '--existing-projects', + 'public', + '--protect-new-projects', + '--token-stdin', + ]), + ).toMatchObject({ + method: 'POST', + path: '/zero-trust/configure', + tokenStdin: true, + body: { + accountId: 'account-1', + identityProviderIds: ['idp-1', 'idp-2'], + policyIds: ['policy-1', 'policy-2'], + existingProjects: 'public', + protectNewProjects: true, + }, + }); + expect(parseOperatorArgs(['zero-trust', 'project-enable', 'project/id'])).toMatchObject({ + path: '/zero-trust/projects/project%2Fid', + body: { protected: true }, + }); + expect(parseOperatorArgs(['zero-trust', 'project-disable', 'project/id'])).toMatchObject({ + body: { protected: false }, + }); + expect(() => + parseOperatorArgs([ + 'zero-trust', + 'configure', + '--account', + 'account-1', + '--identity-providers', + 'idp-1', + '--policies', + 'policy-1', + '--existing-projects', + 'public', + ]), + ).toThrow('requires --token-stdin'); + expect(() => + parseOperatorArgs([ + 'zero-trust', + 'configure', + '--account', + 'account-1', + '--identity-providers', + 'idp-1', + '--policies', + 'policy-1', + '--existing-projects', + 'invalid', + '--token-stdin', + ]), + ).toThrow('--existing-projects must be protected or public'); + }); it('accepts explicit flags before the operator group and encodes IDs', () => { expect( parsePlatformArgs(['--connection', 'chosen', '--json', 'user', 'show', 'user /?x']), diff --git a/packages/void/test/unit/platform-domain.test.ts b/packages/void/test/unit/platform-domain.test.ts index b94564241..218af1d01 100644 --- a/packages/void/test/unit/platform-domain.test.ts +++ b/packages/void/test/unit/platform-domain.test.ts @@ -103,6 +103,10 @@ function fixture() { verifyOwnership: vi.fn(async () => {}), verifyRuntimeToken: vi.fn(async () => {}), probe: vi.fn(async () => {}), + activate: vi.fn(async (application: unknown) => { + values.set(PLATFORM_APPLICATION_KEY, JSON.stringify(application)); + return true; + }), }; return { client, current: () => current, dependencies, manifest, plan, target, zone, values }; } @@ -171,6 +175,7 @@ describe('platform domain activation', () => { expect(pending.waitingForDns).toBe(true); expect(f.current().deployment).toEqual(f.manifest.deployment); expect(f.client.createDnsRecord).not.toHaveBeenCalled(); + expect(f.dependencies.activate).not.toHaveBeenCalled(); expect(f.values.has(PLATFORM_APPLICATION_KEY)).toBe(false); f.zone.status = 'active'; const resumedPlan = { @@ -186,6 +191,7 @@ describe('platform domain activation', () => { ).rejects.toThrow('TLS pending'); expect(f.values.has(PLATFORM_APPLICATION_KEY)).toBe(false); expect(f.current().deployment).toEqual(f.manifest.deployment); + expect(f.dependencies.activate).not.toHaveBeenCalled(); const finalPlan = { ...resumedPlan, actions: resumedPlan.actions.map((action) => @@ -223,6 +229,28 @@ describe('platform domain activation', () => { }); expect(await fetchApp(originalHostname)).toMatchObject({ deployment: 'original', count: 3 }); expect(f.values.get(project.slug)).toBe(route); + expect(f.client.putKvValue).not.toHaveBeenCalled(); + }); + it('retains the pending domain until the platform finishes protection', async () => { + const f = fixture(); + f.zone.status = 'active'; + f.dependencies.activate.mockResolvedValueOnce(false); + const result = await applyPlatformDomain( + f.client as unknown as PlatformCloudflareClient, + f.manifest, + f.target, + { ...f.plan, zone: f.zone }, + f.dependencies, + ); + expect(result).toMatchObject({ waitingForDns: false, waitingForProtection: true }); + expect(result.manifest.pendingDomain).toEqual(f.target); + expect(result.manifest.deployment.application).toEqual(f.manifest.deployment.application); + expect(f.values.has(PLATFORM_APPLICATION_KEY)).toBe(false); + expect(f.client.putKvValue).not.toHaveBeenCalled(); + expect(f.dependencies.activate).toHaveBeenCalledWith( + { ...f.target, zoneId: f.zone.id }, + expect.objectContaining({ pendingDomain: f.target }), + ); }); it('stops on ownership or credential errors before making the domain live', async () => { const f = fixture(); diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index 8f0d04670..76c81a463 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -120,6 +120,35 @@ describe('parseProjectArgs', () => { }); }); + describe('zero-trust', () => { + it('opens the group and parses every project operation', () => { + expect(parseProjectArgs(['zero-trust'])).toEqual({ + subcommand: 'zero-trust', + action: '', + projectSlug: '', + }); + for (const action of ['status', 'enable', 'disable', 'reconcile'] as const) { + expect(parseProjectArgs(['zero-trust', action, '--project', 'my-app'])).toEqual({ + subcommand: 'zero-trust', + action, + projectSlug: 'my-app', + }); + } + }); + + it('rejects unknown operations and malformed project selection', () => { + expect(() => parseProjectArgs(['zero-trust', 'remove'])).toThrow( + 'status|enable|disable|reconcile', + ); + expect(() => parseProjectArgs(['zero-trust', 'status', '--project'])).toThrow( + '--project requires a value', + ); + expect(() => + parseProjectArgs(['zero-trust', 'status', '--project', 'one', '--project', 'two']), + ).toThrow("Duplicate flag '--project'"); + }); + }); + describe('cancel', () => { it('parses cancel without a deployment id', () => { expect(parseProjectArgs(['cancel'])).toEqual({ diff --git a/packages/void/test/unit/project-zero-trust-command.test.ts b/packages/void/test/unit/project-zero-trust-command.test.ts new file mode 100644 index 000000000..f8a784f9e --- /dev/null +++ b/packages/void/test/unit/project-zero-trust-command.test.ts @@ -0,0 +1,253 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { runProjectCommand } from '../../src/cli/project-cmd.ts'; + +const roots: Array = []; + +function linkedProject(): string { + const root = mkdtempSync(join(tmpdir(), 'void-project-zero-trust-')); + roots.push(root); + mkdirSync(join(root, '.void')); + writeFileSync( + join(root, '.void', 'project.json'), + JSON.stringify({ + platform: 'void', + platformUrl: 'https://api.example.test', + projectId: 'project_1', + slug: 'linked-app', + }), + ); + return root; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('project Zero Trust command', () => { + it('reports a pending public transition without claiming completion', async () => { + const root = linkedProject(); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + ok: true, + project: { + project_id: 'project_1', + desired_protected: false, + effective_protected: true, + state: 'reconciling', + last_error: null, + updated_at: '2026-09-22T00:00:00Z', + }, + }), + ), + ); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const output: Array = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + await runProjectCommand(root, { subcommand: 'zero-trust', action: 'disable', projectSlug: '' }); + expect(output.join('')).toContain('still in progress'); + expect(output.join('')).toContain('void project zero-trust reconcile'); + expect(output.join('')).not.toContain('linked-app is public'); + }); + it('uses VOID_PROJECT without presenting an uninitialized state as public', async () => { + const root = linkedProject(); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + vi.stubEnv('VOID_PROJECT', 'selected-app'); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + Response.json([ + { + id: 'project_2', + user_id: 'owner_1', + slug: 'selected-app', + type: 'worker', + created_at: '2026-09-22T00:00:00Z', + }, + ]), + ) + .mockResolvedValueOnce( + Response.json({ + available: true, + platformStatus: 'ready', + protectNewProjects: true, + project: null, + }), + ); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const output: Array = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await runProjectCommand(root, { + subcommand: 'zero-trust', + action: 'status', + projectSlug: '', + }); + + expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual([ + 'https://api.example.test/projects', + 'https://api.example.test/projects/project_2/zero-trust', + ]); + expect(output.join('')).toContain('Desired: not initialized'); + expect(output.join('')).toContain('Effective: not initialized'); + expect(output.join('')).not.toContain('Desired: public'); + }); + + it.each([ + ['invalid_configuration', 'Zero Trust configuration is invalid'], + ['configuring', 'still applying Zero Trust configuration'], + ['ready', 'The platform administrator has disabled project Zero Trust.'], + ])('explains an unavailable %s platform state', async (platformStatus, message) => { + const root = linkedProject(); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + available: false, + platformStatus, + protectNewProjects: true, + project: { + project_id: 'project_1', + desired_protected: true, + effective_protected: true, + state: 'protected', + last_error: null, + updated_at: '2026-09-22T00:00:00Z', + }, + }), + ), + ); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const output: Array = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await runProjectCommand(root, { + subcommand: 'zero-trust', + action: 'status', + projectSlug: '', + }); + + const rendered = output.join(''); + expect(rendered).toContain(`Platform: ${platformStatus}`); + expect(rendered).toContain(message); + expect(rendered).toContain('Effective: protected'); + }); + + it('sends project protection mutations through the command runner', async () => { + const root = linkedProject(); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + const fetchMock = vi.fn(async () => + Response.json({ + ok: true, + project: { + project_id: 'project_1', + desired_protected: true, + effective_protected: true, + state: 'protected', + last_error: null, + updated_at: '2026-09-22T00:00:00Z', + }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await runProjectCommand(root, { + subcommand: 'zero-trust', + action: 'enable', + projectSlug: '', + }); + await runProjectCommand(root, { + subcommand: 'zero-trust', + action: 'disable', + projectSlug: '', + }); + await runProjectCommand(root, { + subcommand: 'zero-trust', + action: 'reconcile', + projectSlug: '', + }); + + expect(fetchMock.mock.calls).toEqual([ + [ + 'https://api.example.test/projects/project_1/zero-trust', + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ protected: true }) }), + ], + [ + 'https://api.example.test/projects/project_1/zero-trust', + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ protected: false }) }), + ], + [ + 'https://api.example.test/projects/project_1/zero-trust/reconcile', + expect.objectContaining({ method: 'POST' }), + ], + ]); + }); + + it('removes C1 terminal controls from platform errors', async () => { + const root = linkedProject(); + vi.stubEnv('VOID_TOKEN', 'test-token'); + vi.stubEnv('VOID_API_URL', 'https://api.example.test'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + Response.json({ + available: true, + platformStatus: 'ready', + protectNewProjects: true, + project: { + project_id: 'project_1', + desired_protected: true, + effective_protected: false, + state: 'error', + last_error: 'before\u009b31mafter\u009dtitle', + updated_at: '2026-09-22T00:00:00Z', + }, + }), + ), + ); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const output: Array = []; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + output.push(String(chunk)); + return true; + }); + + await runProjectCommand(root, { + subcommand: 'zero-trust', + action: 'status', + projectSlug: '', + }); + + const rendered = output.join(''); + expect(rendered).toContain('before 31mafter title'); + expect(rendered).not.toContain('\u009b'); + expect(rendered).not.toContain('\u009d'); + }); +}); diff --git a/platform/meta/design-docs/0083-project-zero-trust.md b/platform/meta/design-docs/0083-project-zero-trust.md new file mode 100644 index 000000000..88d205e0d --- /dev/null +++ b/platform/meta/design-docs/0083-project-zero-trust.md @@ -0,0 +1,194 @@ +--- +title: Project Cloudflare Zero Trust protection +status: implemented +created: 2026-09-22 +--- + +# Project Cloudflare Zero Trust protection + +## Decision + +A self-hosted Void installation can enforce Cloudflare Access on project +hostnames by default. Installation administrators configure the account, +identity providers, reusable policies, and default. A project owner or project +administrator can make an individual project public or protected. Readers may +inspect the state. Direct Cloudflare deployments are unchanged. + +This feature is independent from the Access applications that protect the Void +API or provide an OIDC login method. + +## Managed resources + +Void owns only applications identified by a durable ID or pre-creation name +checkpoint in its PostgreSQL records: + +- one self-hosted wildcard application for the installation's project domain + and retained workers.dev project origins; +- one exact-host bypass application for each public project; +- one exact self-hosted application per protected project with custom domains. + +The workers.dev wildcard includes the installation's project ingress prefix; +it must not match unrelated Workers or the platform's own API. Known resource +IDs are checked directly against Cloudflare. Recovery by name uses a fresh, +bounded inventory and never relies on a cached absence. + +The wildcard and protected applications reference administrator-selected +reusable policies. A bypass application has an explicit Everyone bypass policy. +Void validates that selected protection policies include at least one allow +policy tied to company identity, and rejects bypass, non-identity, Everyone, or +arbitrary-service-token protection. + +Cloudflare's default account limit is 500 Access applications. The installation +therefore consumes `1 + public projects + protected projects with custom domains`, +in addition to applications managed outside Void. + +## Credentials and configuration + +The Access management credential is separate from the platform runtime token. +It needs Apps and Policies Write plus Organizations, Identity Providers, and +Groups Read on the installation account. The token is accepted through the +operator API or standard input, encrypted with the platform credential keyring, +stored in PostgreSQL settings, redacted from reads and previews, and excluded +from audit targets. + +The saved configuration includes a compare-and-swap revision, desired default, +account/team identity, selected resource IDs, encrypted credential, wildcard +application identity, reconciliation status, last error, and resumable +operation state. The operation state records a stable operation ID, kind, phase, +owner, lease, and database start time. Each project records the operation it +completed; unrelated project writes cannot count as reconciliation progress. +Reconfiguration changes the default for future projects but +preserves existing project overrides. The explicit existing-project choice is +used only on initial enable or re-enable. + +## Project state and ordering + +`project_zero_trust` records desired/effective protection, reconciliation state, +managed application IDs and names, audience, and error. Project-row locks fence +owner changes, domain changes, deletion, Access mutations, and routing +publication so that an older reconciliation cannot publish over a newer owner +choice. Shared configuration locks also fence these mutations against a new +installation operation. Wildcard provider mutations and their checkpoints run +under the exclusive configuration lock, so an expired owner cannot overwrite +or delete a newer wildcard. Project initialization preserves an existing row, +including checkpoints created by a concurrent installation batch. +Newly generated application names include an unguessable nonce and are +committed before a Cloudflare create request; this lets a retry recover the one +matching application if the request succeeds but its returned ID is not +persisted, without adopting a pre-existing predictable-name application. + +Public transition: + +1. Create or update the exact-host bypass. +2. Remove JWT enforcement from every live project route. +3. Mark the project effectively public. +4. Delete its protected custom-domain application. + +Protected transition: + +1. Create or update an exact application for custom domains, if any. +2. Publish the wildcard and exact application audiences into every live route. +3. Mark the project effectively protected. +4. Delete its public bypass. + +This ordering is fail closed. During a public-to-protected transition, dispatch +requires a valid Access assertion before the bypass is removed. During a +protected-to-public transition, an old custom-domain application may temporarily +remain restrictive if cleanup fails, but the project is never accidentally +published before its bypass exists. + +New public projects create their bypass before they can deploy. New protected +projects need no per-project API call until they add a custom domain. Custom +domain activation reconciles Access before publishing routing. Deploy, rollback, +recovery, suspension-preserving cutover, and routing repair all copy the current +effective enforcement data. Failed-deploy routing compensation restores the +previous deployment while retaining the latest suspension and Zero Trust state. + +KV reads remain [eventually consistent](https://developers.cloudflare.com/kv/concepts/how-kv-works/#consistency) +even under a database lock. Every route rewrite derives its Zero Trust metadata +from PostgreSQL instead of copying that +field from a KV snapshot. This includes domain activation, ownership changes, +suspension, legacy backfills, and deployment compensation. Pending or invalid +protection uses an empty audience set to keep metadata maintenance possible +while dispatch denies access; unexpected database failures abort publication. +Public intent with a durable bypass remains public during partial routing +cleanup, so a metadata rewrite cannot reintroduce denial behind the cleanup +cursor. Deployment admission still requires completed public reconciliation. +During platform disablement, each project's first cleanup batch saves public +intent before rewriting any routes. Both deployment and metadata publication +honor that intent through partial pages and retries after an error, while +unvisited projects retain their protection. Transitioning a previously protected +row also resets any older cleanup cursor, so no restored protection is stranded +behind a completed page. Effective state and provider handles remain until +cleanup finishes. + +Protection reconciliation also checks project identity and the current cutover +target or committed deployment. Missing live routes, a previous project's route +under a reused slug, and stale deployment snapshots retain retryable state. +Undeployed projects and unpublished domains can have no route; stale +pending-domain residue for the same project is removed so activation can publish +a fresh route. Before rewriting a matching deployment, reconciliation derives +the current owner, suspension, and Worker limits from PostgreSQL, so a stale KV +snapshot cannot undo a completed operator change. + +Adding the first application domain is an authenticated installer handoff to +the platform API. The operation durably saves its target, prepares public +exceptions, updates the wildcard and all protected routes, and only then +publishes canonical application routing. The saved target takes precedence +over stale Worker variables and KV reads, including scheduled retries. + +Project deletion treats Access application deletion as critical because the +project row is the durable handle. The database cascade occurs only after those +deletes succeed. Platform disablement clears route enforcement and protected +custom-domain applications first, retaining public bypasses until the wildcard +is deleted. Wildcard deletion checkpoints enforcement as disabled and advances +to bounded bypass cleanup. The saved disable operation remains pending until +every retained bypass is removed, so failures can resume without interrupting +previously public applications or repeating completed routing writes. + +## Independent dispatch verification + +Cloudflare Access normally rejects a request before it reaches the dispatch +Worker. Dispatch also verifies `Cf-Access-Jwt-Assertion` against the configured +team's non-redirecting JWKS endpoint. It pins issuer, application audience, +RS256, expiry, issued-at, and `type=app`. Exact custom-domain audiences take +precedence over the one-label wildcard. Missing, malformed, oversized, expired, +wrong-audience, or unverifiable assertions return a non-cacheable 403 before the +user Worker or static assets run. + +This second check prevents a deleted or drifted Access application from silently +making a protected project public. + +Access assertions are credentials for shared-cache eligibility, even when no +cookie or Authorization header accompanies them. They bypass both reads and +writes for static-response and ISR caches, keeping authenticated users' origin +responses separate. The shared privacy-policy marker rejects entries admitted +before this credential was recognized. + +## Administration and recovery + +The browser admin UI and `/operator/v1/zero-trust` expose redacted status, +configuration, reconcile, disable, and per-project operations. The user API +exposes project status to readers and mutations to the `settings` capability +(project administrators and owners). + +Errored or interrupted project reconciliations retain their resource IDs and are +retried by scheduled maintenance while the platform configuration is ready. +Administrators can explicitly reconcile the installation or one project after +fixing token permissions, policies, or account quota. + +A replacement token can resume the same inactive operation without discarding +its application handles. Administrators can instead cancel a failed setup by +disabling and cleaning up. Teardown does not require deleted policy or identity +provider selections to validate, and a failed teardown remains authoritative +until cleanup finishes: project and domain updates cannot recreate applications. + +Installation-wide configure, reconcile, and disable operations are bounded and +resumable. A short advisory-lock transaction claims a durable owner/lease, and +each later configuration update uses the saved revision as a compare-and-swap +token. One invocation handles at most 100 projects and 300 routing keys. A +protected project may have at most 100 custom domains. Public projects have no +Access destination cap; route cleanup persists a domain cursor across calls. +These limits keep one invocation below the Workers subrequest limit and Cloudflare's +Access API rate limit; unfinished work releases its lease and continues through +an explicit or scheduled reconcile. diff --git a/platform/meta/design-docs/README.md b/platform/meta/design-docs/README.md index 8b29910a3..db4cc47a0 100644 --- a/platform/meta/design-docs/README.md +++ b/platform/meta/design-docs/README.md @@ -20,7 +20,7 @@ Public packages are under packages/ and platform implementations are under platf | Builds and GitHub | [Build service](./0010-builds.md), [implementation decisions](./0011-builds-implementation-plan.md), [identity linking](./0074-github-deploy-identity-linking.md), [build logs](./0076-build-logs.md) | | Serving and routing | [Asset fallback](./0066-wfp-asset-r2-fallback.md), [direct R2 uploads](./0068-direct-r2-asset-upload.md), [route-aware ISR](./0077-route-aware-isr-ttl.md) | | Custom domains | [HTTP certificate validation](./0073-custom-domain-http-dcv-migration.md) | -| Administration | [Operator CLI](./0078-platform-operator-cli.md), [project teams and ownership transfer](./0080-project-teams.md) | +| Administration | [Operator CLI](./0078-platform-operator-cli.md), [project teams and ownership transfer](./0080-project-teams.md), [project Zero Trust](./0083-project-zero-trust.md) | | Email | [Routing, recovery and delivery](./0078-email-cf-native.md) | | Sandboxes | [Managed Sandbox restoration](./0082-managed-sandbox-restoration.md) | diff --git a/platform/packages/api/README.md b/platform/packages/api/README.md index 0f8873483..bb1b158e3 100644 --- a/platform/packages/api/README.md +++ b/platform/packages/api/README.md @@ -99,6 +99,7 @@ drizzle/ `POST /projects/:id/tokens/:credentialId/renew`, and `DELETE /projects/:id/tokens/:credentialId`) - project members and invitations + - project Zero Trust status and owner/admin overrides - PostgreSQL and MySQL project database provisioning/update - `POST /projects/:id/prerender-token` — mint a five-minute, project-scoped token accepted only by the remote-binding proxy - `/projects/:id/github` — connect/inspect/update/disconnect the GitHub repo (POST/GET/PATCH/DELETE; readers may inspect, owners and project administrators may mutate) diff --git a/platform/packages/api/drizzle/0053_project_zero_trust.sql b/platform/packages/api/drizzle/0053_project_zero_trust.sql new file mode 100644 index 000000000..905c078a9 --- /dev/null +++ b/platform/packages/api/drizzle/0053_project_zero_trust.sql @@ -0,0 +1,22 @@ +CREATE TABLE "project_zero_trust" ( + "project_id" text PRIMARY KEY NOT NULL, + "desired_protected" boolean DEFAULT false NOT NULL, + "effective_protected" boolean DEFAULT false NOT NULL, + "state" text DEFAULT 'public' NOT NULL, + "bypass_application_id" text, + "bypass_application_name" text, + "protected_application_id" text, + "protected_application_name" text, + "protected_application_audience" text, + "routing_cursor" text, + "reconciled_operation" text, + "last_error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "project_zero_trust_state_check" CHECK ("project_zero_trust"."state" IN ('public', 'protected', 'reconciling', 'error')), + CONSTRAINT "project_zero_trust_public_check" CHECK ("project_zero_trust"."state" <> 'public' OR ("project_zero_trust"."desired_protected" = false AND "project_zero_trust"."effective_protected" = false)), + CONSTRAINT "project_zero_trust_protected_check" CHECK ("project_zero_trust"."state" <> 'protected' OR ("project_zero_trust"."desired_protected" = true AND "project_zero_trust"."effective_protected" = true)) +); +--> statement-breakpoint +ALTER TABLE "project_zero_trust" ADD CONSTRAINT "project_zero_trust_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_project_zero_trust_state" ON "project_zero_trust" USING btree ("state","updated_at"); \ No newline at end of file diff --git a/platform/packages/api/drizzle/compatibility.json b/platform/packages/api/drizzle/compatibility.json index b04738add..00ea96546 100644 --- a/platform/packages/api/drizzle/compatibility.json +++ b/platform/packages/api/drizzle/compatibility.json @@ -42,6 +42,30 @@ "platformVersion": "0.21.2", "schemaDigest": "9a9ae6677b795debd4ffaa20a05f533727063c7a21366310a2fe4b17bd918edd" }, + { + "platformVersion": "0.21.3", + "schemaDigest": "ac70deb5847c71ece9938edd27e5dbc50ce41e16771926c78e757850baf15bfa" + }, + { + "platformVersion": "0.21.4", + "schemaDigest": "ac70deb5847c71ece9938edd27e5dbc50ce41e16771926c78e757850baf15bfa" + }, + { + "platformVersion": "0.21.5", + "schemaDigest": "ac70deb5847c71ece9938edd27e5dbc50ce41e16771926c78e757850baf15bfa" + }, + { + "platformVersion": "0.21.6", + "schemaDigest": "ac70deb5847c71ece9938edd27e5dbc50ce41e16771926c78e757850baf15bfa" + }, + { + "platformVersion": "0.21.7", + "schemaDigest": "ac70deb5847c71ece9938edd27e5dbc50ce41e16771926c78e757850baf15bfa" + }, + { + "platformVersion": "0.21.8", + "schemaDigest": "ac70deb5847c71ece9938edd27e5dbc50ce41e16771926c78e757850baf15bfa" + }, { "platformVersion": "0.21.9", "schemaDigest": "ac70deb5847c71ece9938edd27e5dbc50ce41e16771926c78e757850baf15bfa" diff --git a/platform/packages/api/drizzle/meta/0053_snapshot.json b/platform/packages/api/drizzle/meta/0053_snapshot.json new file mode 100644 index 000000000..9d7d73312 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0053_snapshot.json @@ -0,0 +1,6157 @@ +{ + "id": "9b9ab510-2762-45ee-84ae-046e05b40558", + "prevId": "ec460f71-8fd5-46e0-885b-e60c769eff80", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_codes": { + "name": "auth_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "jwt": { + "name": "jwt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cli_state": { + "name": "cli_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.builds": { + "name": "builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "executor": { + "name": "executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_id": { + "name": "github_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_run_attempt": { + "name": "github_run_attempt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logs_url": { + "name": "logs_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_run_id": { + "name": "check_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_builds_project_created": { + "name": "idx_builds_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_status": { + "name": "idx_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_user_status": { + "name": "idx_builds_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_builds_project_github_run_id": { + "name": "idx_builds_project_github_run_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_run_attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"builds\".\"github_run_id\" IS NOT NULL AND \"builds\".\"github_run_attempt\" IS NOT NULL AND \"builds\".\"status\" IN ('starting', 'building', 'deploying')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "builds_project_id_projects_id_fk": { + "name": "builds_project_id_projects_id_fk", + "tableFrom": "builds", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_user_id_users_id_fk": { + "name": "builds_user_id_users_id_fk", + "tableFrom": "builds", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "builds_deployment_id_deployments_id_fk": { + "name": "builds_deployment_id_deployments_id_fk", + "tableFrom": "builds", + "tableTo": "deployments", + "columnsFrom": ["deployment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "manifest": { + "name": "manifest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit": { + "name": "commit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_url": { + "name": "commit_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_ref": { + "name": "git_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_version": { + "name": "cli_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asset_hashes": { + "name": "asset_hashes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "retained": { + "name": "retained", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "applied_migrations": { + "name": "applied_migrations", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bundle_stored": { + "name": "bundle_stored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_user_id": { + "name": "billing_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiated_by_user_id": { + "name": "initiated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_project_id": { + "name": "idx_deployments_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_project_status": { + "name": "idx_deployments_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_created_id": { + "name": "idx_deployments_created_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_project_id_projects_id_fk": { + "name": "deployments_project_id_projects_id_fk", + "tableFrom": "deployments", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domains": { + "name": "domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_hostname_id": { + "name": "cf_hostname_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_data": { + "name": "verification_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_domains_project_id": { + "name": "idx_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "domains_project_id_projects_id_fk": { + "name": "domains_project_id_projects_id_fk", + "tableFrom": "domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "domains_hostname_unique": { + "name": "domains_hostname_unique", + "nullsNotDistinct": false, + "columns": ["hostname"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_destination_cleanups": { + "name": "email_destination_cleanups", + "schema": "", + "columns": { + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_started_at": { + "name": "lease_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destination_cleanups_due": { + "name": "idx_email_destination_cleanups_due", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_destination_cleanups_status_check": { + "name": "email_destination_cleanups_status_check", + "value": "\"email_destination_cleanups\".\"status\" IN ('pending', 'deleting', 'completed')" + }, + "email_destination_cleanups_attempts_check": { + "name": "email_destination_cleanups_attempts_check", + "value": "\"email_destination_cleanups\".\"attempts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_destinations": { + "name": "email_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cf_destination_id": { + "name": "cf_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verification_source": { + "name": "verification_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cf'" + }, + "verified_by_user_id": { + "name": "verified_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_by_email_snapshot": { + "name": "verified_by_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_destinations_project_id": { + "name": "idx_email_destinations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_cf_destination_id": { + "name": "idx_email_destinations_cf_destination_id", + "columns": [ + { + "expression": "cf_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_destinations_verified_by_user_id": { + "name": "idx_email_destinations_verified_by_user_id", + "columns": [ + { + "expression": "verified_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "verified_by_email_snapshot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_destinations_project_id_projects_id_fk": { + "name": "email_destinations_project_id_projects_id_fk", + "tableFrom": "email_destinations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "email_destinations_verified_by_user_id_users_id_fk": { + "name": "email_destinations_verified_by_user_id_users_id_fk", + "tableFrom": "email_destinations", + "tableTo": "users", + "columnsFrom": ["verified_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_destinations_project_address_uq": { + "name": "email_destinations_project_address_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "address"] + } + }, + "policies": {}, + "checkConstraints": { + "email_destinations_status_check": { + "name": "email_destinations_status_check", + "value": "\"email_destinations\".\"status\" IN ('pending', 'verified')" + }, + "email_destinations_verification_source_check": { + "name": "email_destinations_verification_source_check", + "value": "\"email_destinations\".\"verification_source\" IN ('cf', 'platform')" + } + }, + "isRLSEnabled": false + }, + "public.email_domains": { + "name": "email_domains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "credential_kind": { + "name": "credential_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_ciphertext": { + "name": "token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_iv": { + "name": "token_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_key_version": { + "name": "token_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'owner'" + }, + "relay_ciphertext": { + "name": "relay_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_iv": { + "name": "relay_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_key_version": { + "name": "relay_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "relay_worker_name": { + "name": "relay_worker_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "relay_version": { + "name": "relay_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_on": { + "name": "token_expires_on", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "setup_state": { + "name": "setup_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_domains_project_id": { + "name": "idx_email_domains_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_zone_uq": { + "name": "email_domains_zone_uq", + "columns": [ + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"zone_id\" IS NOT NULL AND \"email_domains\".\"status\" IN ('active', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domains_relay_uq": { + "name": "email_domains_relay_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relay_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"email_domains\".\"relay_worker_name\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domains_project_id_projects_id_fk": { + "name": "email_domains_project_id_projects_id_fk", + "tableFrom": "email_domains", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_domains_domain_uq": { + "name": "email_domains_domain_uq", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": { + "email_domains_mode_check": { + "name": "email_domains_mode_check", + "value": "\"email_domains\".\"mode\" IN ('apex', 'subdomain')" + }, + "email_domains_status_check": { + "name": "email_domains_status_check", + "value": "\"email_domains\".\"status\" IN ('pending', 'active', 'token_revoked', 'token_expired', 'failed')" + }, + "email_domains_credential_kind_check": { + "name": "email_domains_credential_kind_check", + "value": "\"email_domains\".\"credential_kind\" IN ('oauth_grant', 'api_token', 'platform')" + }, + "email_domains_platform_token_check": { + "name": "email_domains_platform_token_check", + "value": "(\"email_domains\".\"credential_kind\" = 'platform' AND \"email_domains\".\"token_ciphertext\" IS NULL AND \"email_domains\".\"token_iv\" IS NULL AND \"email_domains\".\"token_key_version\" IS NULL) OR (\"email_domains\".\"credential_kind\" <> 'platform' AND \"email_domains\".\"token_ciphertext\" IS NOT NULL AND \"email_domains\".\"token_iv\" IS NOT NULL AND \"email_domains\".\"token_key_version\" IS NOT NULL)" + }, + "email_domains_managed_by_check": { + "name": "email_domains_managed_by_check", + "value": "\"email_domains\".\"managed_by\" IN ('owner', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.github_connections": { + "name": "github_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "build_executor": { + "name": "build_executor", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'container'" + }, + "workflow_path": { + "name": "workflow_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'.github/workflows/void-deploy.yml'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_connections_installation_id": { + "name": "idx_github_connections_installation_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_repo_full_name": { + "name": "idx_github_connections_repo_full_name", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_connections_installation_repo_id": { + "name": "idx_github_connections_installation_repo_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_connections_project_id_projects_id_fk": { + "name": "github_connections_project_id_projects_id_fk", + "tableFrom": "github_connections", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_connections_authorized_by_user_id_users_id_fk": { + "name": "github_connections_authorized_by_user_id_users_id_fk", + "tableFrom": "github_connections", + "tableTo": "users", + "columnsFrom": ["authorized_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_connections_project_id_unique": { + "name": "github_connections_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_members": { + "name": "github_installation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_members_install_user": { + "name": "idx_github_installation_members_install_user", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installation_members_user_id": { + "name": "idx_github_installation_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installation_members_user_id_users_id_fk": { + "name": "github_installation_members_user_id_users_id_fk", + "tableFrom": "github_installation_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installation_revocations": { + "name": "github_installation_revocations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_account_id": { + "name": "github_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installation_revocations_install_account": { + "name": "idx_github_installation_revocations_install_account", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "repository_selection": { + "name": "repository_selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'selected'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_installations_user_id": { + "name": "idx_github_installations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_installations_account_id": { + "name": "idx_github_installations_account_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_user_id_users_id_fk": { + "name": "github_installations_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": ["installation_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_webhook_redeliveries": { + "name": "github_webhook_redeliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "redelivered_at": { + "name": "redelivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_github_webhook_redeliveries_guid": { + "name": "idx_github_webhook_redeliveries_guid", + "columns": [ + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email_delivery_status": { + "name": "email_delivery_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by": { + "name": "accepted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invitations_email": { + "name": "idx_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitations_status": { + "name": "idx_invitations_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "invitations_email_delivery_status_check": { + "name": "invitations_email_delivery_status_check", + "value": "\"invitations\".\"email_delivery_status\" IS NULL OR \"invitations\".\"email_delivery_status\" IN ('sent', 'failed', 'unavailable')" + } + }, + "isRLSEnabled": false + }, + "public.migration_renames": { + "name": "migration_renames", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_migration_renames_project_id": { + "name": "idx_migration_renames_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "migration_renames_project_id_projects_id_fk": { + "name": "migration_renames_project_id_projects_id_fk", + "tableFrom": "migration_renames", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "migration_renames_project_id_from_name_to_name_pk": { + "name": "migration_renames_project_id_from_name_to_name_pk", + "columns": ["project_id", "from_name", "to_name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_accounts": { + "name": "oauth_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_oauth_accounts_provider_id": { + "name": "idx_oauth_accounts_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_oauth_accounts_user_id": { + "name": "idx_oauth_accounts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_accounts_user_id_users_id_fk": { + "name": "oauth_accounts_user_id_users_id_fk", + "tableFrom": "oauth_accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_nonces": { + "name": "oauth_nonces", + "schema": "", + "columns": { + "nonce": { + "name": "nonce", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_events": { + "name": "operator_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_events_created": { + "name": "idx_operator_events_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.operator_sessions": { + "name": "operator_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_session_id": { + "name": "human_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_operator_sessions_expiry": { + "name": "idx_operator_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "operator_sessions_user_id_users_id_fk": { + "name": "operator_sessions_user_id_users_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "operator_sessions_human_session_id_platform_human_sessions_id_fk": { + "name": "operator_sessions_human_session_id_platform_human_sessions_id_fk", + "tableFrom": "operator_sessions", + "tableTo": "platform_human_sessions", + "columnsFrom": ["human_session_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_attempts": { + "name": "platform_auth_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_auth_attempts_expiry": { + "name": "platform_auth_attempts_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_auth_attempts_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_attempts_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_attempts", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_auth_bootstrap": { + "name": "platform_auth_bootstrap", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "claim_hash": { + "name": "claim_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pending_identity": { + "name": "pending_identity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk": { + "name": "platform_auth_bootstrap_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_auth_bootstrap", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_bootstrap_singleton": { + "name": "platform_auth_bootstrap_singleton", + "value": "\"platform_auth_bootstrap\".\"singleton\" = true" + } + }, + "isRLSEnabled": false + }, + "public.platform_auth_settings": { + "name": "platform_auth_settings", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "admission": { + "name": "admission", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "protection": { + "name": "protection", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_seconds": { + "name": "session_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2592000 + }, + "sessions_required": { + "name": "sessions_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_auth_settings_singleton": { + "name": "platform_auth_settings_singleton", + "value": "\"platform_auth_settings\".\"singleton\" = true" + }, + "platform_auth_settings_revision": { + "name": "platform_auth_settings_revision", + "value": "\"platform_auth_settings\".\"revision\" > 0" + }, + "platform_auth_settings_session_seconds": { + "name": "platform_auth_settings_session_seconds", + "value": "\"platform_auth_settings\".\"session_seconds\" BETWEEN 300 AND 2592000" + } + }, + "isRLSEnabled": false + }, + "public.platform_company_identities": { + "name": "platform_company_identities", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_company_identity_user": { + "name": "platform_company_identity_user", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_company_identities_user_id_users_id_fk": { + "name": "platform_company_identities_user_id_users_id_fk", + "tableFrom": "platform_company_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_company_identities_issuer_subject_pk": { + "name": "platform_company_identities_issuer_subject_pk", + "columns": ["issuer", "subject"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_events": { + "name": "platform_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "batch_id": { + "name": "batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_name": { + "name": "worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "script_name": { + "name": "script_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_login": { + "name": "admin_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_platform_events_batch_id": { + "name": "idx_platform_events_batch_id", + "columns": [ + { + "expression": "batch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_platform_events_env_created": { + "name": "idx_platform_events_env_created", + "columns": [ + { + "expression": "environment", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_human_sessions": { + "name": "platform_human_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "audience": { + "name": "audience", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_issuer": { + "name": "access_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_subject": { + "name": "access_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_human_sessions_connection": { + "name": "platform_human_sessions_connection", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_user": { + "name": "platform_human_sessions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_human_sessions_expiry": { + "name": "platform_human_sessions_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_human_sessions_user_id_users_id_fk": { + "name": "platform_human_sessions_user_id_users_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_human_sessions_connection_id_platform_login_connections_id_fk": { + "name": "platform_human_sessions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_human_sessions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_human_sessions_audience": { + "name": "platform_human_sessions_audience", + "value": "\"platform_human_sessions\".\"audience\" IN ('api', 'admin')" + }, + "platform_human_sessions_access_identity": { + "name": "platform_human_sessions_access_identity", + "value": "(\"platform_human_sessions\".\"access_issuer\" IS NULL) = (\"platform_human_sessions\".\"access_subject\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_connections": { + "name": "platform_login_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active_revision": { + "name": "active_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_login_connections_kind": { + "name": "platform_login_connections_kind", + "value": "\"platform_login_connections\".\"kind\" IN ('github', 'google', 'oidc', 'cloudflare-access')" + }, + "platform_login_connections_revision": { + "name": "platform_login_connections_revision", + "value": "\"platform_login_connections\".\"revision\" > 0 AND \"platform_login_connections\".\"generation\" > 0" + }, + "platform_login_connections_active_revision": { + "name": "platform_login_connections_active_revision", + "value": "\"platform_login_connections\".\"active_revision\" IS NULL OR \"platform_login_connections\".\"active_revision\" BETWEEN 1 AND \"platform_login_connections\".\"revision\"" + }, + "platform_login_connections_enabled": { + "name": "platform_login_connections_enabled", + "value": "NOT \"platform_login_connections\".\"enabled\" OR \"platform_login_connections\".\"active_revision\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.platform_login_identities": { + "name": "platform_login_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_authenticated_at": { + "name": "last_authenticated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "platform_login_identity_subject": { + "name": "platform_login_identity_subject", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "platform_login_identity_user": { + "name": "platform_login_identity_user", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_identities_user_id_users_id_fk": { + "name": "platform_login_identities_user_id_users_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_identities_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_identities_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_identities", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_revisions": { + "name": "platform_login_revisions", + "schema": "", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "platform_login_revisions_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_revisions_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_revisions", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "platform_login_revisions_connection_id_revision_pk": { + "name": "platform_login_revisions_connection_id_revision_pk", + "columns": ["connection_id", "revision"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_login_tests": { + "name": "platform_login_tests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "platform_login_tests_expiry": { + "name": "platform_login_tests_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_login_tests_user_id_users_id_fk": { + "name": "platform_login_tests_user_id_users_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_login_tests_connection_id_platform_login_connections_id_fk": { + "name": "platform_login_tests_connection_id_platform_login_connections_id_fk", + "tableFrom": "platform_login_tests", + "tableTo": "platform_login_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.platform_settings": { + "name": "platform_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_access_events": { + "name": "project_access_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invitation_email_snapshot": { + "name": "invitation_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "new_role": { + "name": "new_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_access_events_project_created": { + "name": "idx_project_access_events_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_access_events_subject_created": { + "name": "idx_project_access_events_subject_created", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_access_events_project_id_projects_id_fk": { + "name": "project_access_events_project_id_projects_id_fk", + "tableFrom": "project_access_events", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_access_events_previous_role_check": { + "name": "project_access_events_previous_role_check", + "value": "\"project_access_events\".\"previous_role\" IS NULL OR \"project_access_events\".\"previous_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_new_role_check": { + "name": "project_access_events_new_role_check", + "value": "\"project_access_events\".\"new_role\" IS NULL OR \"project_access_events\".\"new_role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_access_events_action_check": { + "name": "project_access_events_action_check", + "value": "\"project_access_events\".\"action\" IN ('invited', 'invitation_resent', 'member_added', 'accepted', 'declined', 'invitation_revoked', 'role_changed', 'member_removed', 'left')" + } + }, + "isRLSEnabled": false + }, + "public.project_cleanup_jobs": { + "name": "project_cleanup_jobs", + "schema": "", + "columns": { + "target": { + "name": "target", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_cleanup_jobs_due": { + "name": "project_cleanup_jobs_due", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_database_credentials": { + "name": "project_database_credentials", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_database_credentials_project_id_projects_id_fk": { + "name": "project_database_credentials_project_id_projects_id_fk", + "tableFrom": "project_database_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_deploy_credentials": { + "name": "project_deploy_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issued_by": { + "name": "issued_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_deploy_credentials_project": { + "name": "project_deploy_credentials_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_deploy_credentials_expiry": { + "name": "project_deploy_credentials_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_deploy_credentials_project_id_projects_id_fk": { + "name": "project_deploy_credentials_project_id_projects_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_deploy_credentials_issued_by_users_id_fk": { + "name": "project_deploy_credentials_issued_by_users_id_fk", + "tableFrom": "project_deploy_credentials", + "tableTo": "users", + "columnsFrom": ["issued_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_deploy_credentials_name": { + "name": "project_deploy_credentials_name", + "value": "length(\"project_deploy_credentials\".\"name\") BETWEEN 1 AND 80" + } + }, + "isRLSEnabled": false + }, + "public.project_invitations": { + "name": "project_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_email_snapshot": { + "name": "target_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accepted_by_user_id": { + "name": "accepted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_invitations_project_status": { + "name": "idx_project_invitations_project_status", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_target_status": { + "name": "idx_project_invitations_target_status", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_invitations_pending_target": { + "name": "idx_project_invitations_pending_target", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_invitations\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_invitations_project_id_projects_id_fk": { + "name": "project_invitations_project_id_projects_id_fk", + "tableFrom": "project_invitations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_invitations_target_user_id_users_id_fk": { + "name": "project_invitations_target_user_id_users_id_fk", + "tableFrom": "project_invitations", + "tableTo": "users", + "columnsFrom": ["target_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_invitations_role_check": { + "name": "project_invitations_role_check", + "value": "\"project_invitations\".\"role\" IN ('reader', 'collaborator', 'admin')" + }, + "project_invitations_status_check": { + "name": "project_invitations_status_check", + "value": "\"project_invitations\".\"status\" IN ('pending', 'accepted', 'declined', 'revoked', 'expired')" + } + }, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_members_user_project": { + "name": "idx_project_members_user_project", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_members_project_id_user_id_pk": { + "name": "project_members_project_id_user_id_pk", + "columns": ["project_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_members_role_check": { + "name": "project_members_role_check", + "value": "\"project_members\".\"role\" IN ('reader', 'collaborator', 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.project_migrations": { + "name": "project_migrations", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source_deployment_id": { + "name": "source_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_migrations_project_id": { + "name": "idx_project_migrations_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "applied_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_migrations_project_id_projects_id_fk": { + "name": "project_migrations_project_id_projects_id_fk", + "tableFrom": "project_migrations", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_migrations_source_deployment_id_deployments_id_fk": { + "name": "project_migrations_source_deployment_id_deployments_id_fk", + "tableFrom": "project_migrations", + "tableTo": "deployments", + "columnsFrom": ["source_deployment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "project_migrations_project_id_name_pk": { + "name": "project_migrations_project_id_name_pk", + "columns": ["project_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_ownership_periods": { + "name": "project_ownership_periods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "changed_by_user_id": { + "name": "changed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_project_ownership_periods_owner_time": { + "name": "idx_project_ownership_periods_owner_time", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_project_time": { + "name": "idx_project_ownership_periods_project_time", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_ownership_periods_open": { + "name": "idx_project_ownership_periods_open", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"project_ownership_periods\".\"ends_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_ownership_periods_project_id_projects_id_fk": { + "name": "project_ownership_periods_project_id_projects_id_fk", + "tableFrom": "project_ownership_periods", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_ownership_periods_time_check": { + "name": "project_ownership_periods_time_check", + "value": "\"project_ownership_periods\".\"ends_at\" IS NULL OR \"project_ownership_periods\".\"ends_at\" >= \"project_ownership_periods\".\"starts_at\"" + } + }, + "isRLSEnabled": false + }, + "public.project_secrets": { + "name": "project_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_reason": { + "name": "managed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_secrets_project_id": { + "name": "idx_project_secrets_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_secrets_project_id_projects_id_fk": { + "name": "project_secrets_project_id_projects_id_fk", + "tableFrom": "project_secrets", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_secrets_project_id_name_unique": { + "name": "project_secrets_project_id_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_zero_trust": { + "name": "project_zero_trust", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "desired_protected": { + "name": "desired_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "effective_protected": { + "name": "effective_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "bypass_application_id": { + "name": "bypass_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bypass_application_name": { + "name": "bypass_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_id": { + "name": "protected_application_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_name": { + "name": "protected_application_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "protected_application_audience": { + "name": "protected_application_audience", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_cursor": { + "name": "routing_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconciled_operation": { + "name": "reconciled_operation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_zero_trust_state": { + "name": "idx_project_zero_trust_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_zero_trust_project_id_projects_id_fk": { + "name": "project_zero_trust_project_id_projects_id_fk", + "tableFrom": "project_zero_trust", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "project_zero_trust_state_check": { + "name": "project_zero_trust_state_check", + "value": "\"project_zero_trust\".\"state\" IN ('public', 'protected', 'reconciling', 'error')" + }, + "project_zero_trust_public_check": { + "name": "project_zero_trust_public_check", + "value": "\"project_zero_trust\".\"state\" <> 'public' OR (\"project_zero_trust\".\"desired_protected\" = false AND \"project_zero_trust\".\"effective_protected\" = false)" + }, + "project_zero_trust_protected_check": { + "name": "project_zero_trust_protected_check", + "value": "\"project_zero_trust\".\"state\" <> 'protected' OR (\"project_zero_trust\".\"desired_protected\" = true AND \"project_zero_trust\".\"effective_protected\" = true)" + } + }, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'worker'" + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "websocket_version": { + "name": "websocket_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_projects_slug": { + "name": "idx_projects_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_projects_user_id": { + "name": "idx_projects_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_user_id_users_id_fk": { + "name": "projects_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_user_id_slug_unique": { + "name": "projects_user_id_slug_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cf_id": { + "name": "cf_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding_name": { + "name": "binding_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "resources_project_id_projects_id_fk": { + "name": "resources_project_id_projects_id_fk", + "tableFrom": "resources", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "resources_project_id_type_binding_name_unique": { + "name": "resources_project_id_type_binding_name_unique", + "nullsNotDistinct": false, + "columns": ["project_id", "type", "binding_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_drain_checkpoints": { + "name": "sandbox_drain_checkpoints", + "schema": "", + "columns": { + "singleton": { + "name": "singleton", + "type": "boolean", + "primaryKey": true, + "notNull": true + }, + "admission_protocol": { + "name": "admission_protocol", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "generation": { + "name": "generation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "login": { + "name": "login", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "billing_cycle_start": { + "name": "billing_cycle_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "suspended_reason": { + "name": "suspended_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_provider_id": { + "name": "idx_users_provider_id", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_cache_outbox": { + "name": "email_cache_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_kind": { + "name": "resource_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_cache_outbox_version_uq": { + "name": "email_cache_outbox_version_uq", + "columns": [ + { + "expression": "resource_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_cache_outbox_pending_idx": { + "name": "email_cache_outbox_pending_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_credentials": { + "name": "email_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key_version": { + "name": "key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_id": { + "name": "token_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_on": { + "name": "expires_on", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_credentials_scope_idx": { + "name": "email_credentials_scope_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_credentials_material_check": { + "name": "email_credentials_material_check", + "value": "(\"email_credentials\".\"kind\" = 'platform' AND \"email_credentials\".\"ciphertext\" IS NULL AND \"email_credentials\".\"iv\" IS NULL AND \"email_credentials\".\"key_version\" IS NULL) OR (\"email_credentials\".\"kind\" = 'api_token' AND \"email_credentials\".\"ciphertext\" IS NOT NULL AND \"email_credentials\".\"iv\" IS NOT NULL AND \"email_credentials\".\"key_version\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_domain_routes": { + "name": "email_domain_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "addressing": { + "name": "addressing", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_user_id": { + "name": "assigned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "desired_state": { + "name": "desired_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "outbound_status": { + "name": "outbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outbound_reason": { + "name": "outbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outbound_observed_at": { + "name": "outbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_domain_routes_domain_uq": { + "name": "email_domain_routes_domain_uq", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_connection_idx": { + "name": "email_domain_routes_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_domain_routes_project_idx": { + "name": "email_domain_routes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_domain_routes_connection_id_email_zone_connections_id_fk": { + "name": "email_domain_routes_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_domain_routes", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_domain_routes_assignment_check": { + "name": "email_domain_routes_assignment_check", + "value": "(\"email_domain_routes\".\"addressing\" = 'custom' AND \"email_domain_routes\".\"project_id\" IS NOT NULL) OR (\"email_domain_routes\".\"addressing\" = 'shared' AND \"email_domain_routes\".\"project_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.email_operation_steps": { + "name": "email_operation_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_identity": { + "name": "resource_identity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "outcome": { + "name": "outcome", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "email_operation_steps_order_uq": { + "name": "email_operation_steps_order_uq", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ordinal", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operation_steps_operation_id_email_operations_id_fk": { + "name": "email_operation_steps_operation_id_email_operations_id_fk", + "tableFrom": "email_operation_steps", + "tableTo": "email_operations", + "columnsFrom": ["operation_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_operations": { + "name": "email_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_generation": { + "name": "route_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "actor_kind": { + "name": "actor_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_scope": { + "name": "actor_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_digest": { + "name": "request_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_step": { + "name": "current_step", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_detail": { + "name": "failure_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_operations_key_uq": { + "name": "email_operations_key_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_work_idx": { + "name": "email_operations_work_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_operations_connection_idx": { + "name": "email_operations_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_operations_connection_id_email_zone_connections_id_fk": { + "name": "email_operations_connection_id_email_zone_connections_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_zone_connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "email_operations_route_id_email_domain_routes_id_fk": { + "name": "email_operations_route_id_email_domain_routes_id_fk", + "tableFrom": "email_operations", + "tableTo": "email_domain_routes", + "columnsFrom": ["route_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_zone_connections": { + "name": "email_zone_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_id": { + "name": "zone_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "zone_name": { + "name": "zone_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "managed_by": { + "name": "managed_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_user_id": { + "name": "manager_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ingress_worker_name": { + "name": "ingress_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingress_version": { + "name": "ingress_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "desired_generation": { + "name": "desired_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "active_secret_generation": { + "name": "active_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "staged_secret_generation": { + "name": "staged_secret_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "secret_ciphertext": { + "name": "secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_iv": { + "name": "secret_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_key_version": { + "name": "secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "staged_secret_ciphertext": { + "name": "staged_secret_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_iv": { + "name": "staged_secret_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "staged_secret_key_version": { + "name": "staged_secret_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_hash": { + "name": "previous_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_secret_until": { + "name": "previous_secret_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "inbound_status": { + "name": "inbound_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "inbound_reason": { + "name": "inbound_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_observed_at": { + "name": "inbound_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "management_status": { + "name": "management_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'probe_failed'" + }, + "management_reason": { + "name": "management_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "management_observed_at": { + "name": "management_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_zone_connections_zone_uq": { + "name": "email_zone_connections_zone_uq", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "zone_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_ingress_uq": { + "name": "email_zone_connections_ingress_uq", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ingress_worker_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_zone_connections_manager_idx": { + "name": "email_zone_connections_manager_idx", + "columns": [ + { + "expression": "manager_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "email_zone_connections_manager_check": { + "name": "email_zone_connections_manager_check", + "value": "(\"email_zone_connections\".\"managed_by\" = 'owner' AND \"email_zone_connections\".\"manager_user_id\" IS NOT NULL) OR (\"email_zone_connections\".\"managed_by\" = 'admin')" + } + }, + "isRLSEnabled": false + }, + "public.email_action_recipients": { + "name": "email_action_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "action_id": { + "name": "action_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_reference": { + "name": "provider_reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_email_action_recipients_project_reserved": { + "name": "idx_email_action_recipients_project_reserved", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_action_recipients_expiring": { + "name": "idx_email_action_recipients_expiring", + "columns": [ + { + "expression": "reserved_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"email_action_recipients\".\"state\" = 'reserved'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_action_recipients_action_id_email_actions_id_fk": { + "name": "email_action_recipients_action_id_email_actions_id_fk", + "tableFrom": "email_action_recipients", + "tableTo": "email_actions", + "columnsFrom": ["action_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_action_recipients_action_recipient_uq": { + "name": "email_action_recipients_action_recipient_uq", + "nullsNotDistinct": false, + "columns": ["action_id", "recipient"] + } + }, + "policies": {}, + "checkConstraints": { + "email_action_recipients_state_check": { + "name": "email_action_recipients_state_check", + "value": "\"email_action_recipients\".\"state\" IN ('rejected', 'reserved', 'attempt_started', 'provider_accepted', 'failed', 'outcome_unknown', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.email_actions": { + "name": "email_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "receipt_id": { + "name": "receipt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_actions_project_created": { + "name": "idx_email_actions_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_actions_receipt_id_email_receipts_id_fk": { + "name": "email_actions_receipt_id_email_receipts_id_fk", + "tableFrom": "email_actions", + "tableTo": "email_receipts", + "columnsFrom": ["receipt_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_actions_project_key_uq": { + "name": "email_actions_project_key_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "idempotency_key"] + } + }, + "policies": {}, + "checkConstraints": { + "email_actions_kind_check": { + "name": "email_actions_kind_check", + "value": "\"email_actions\".\"kind\" IN ('direct', 'forward', 'reply')" + } + }, + "isRLSEnabled": false + }, + "public.email_monthly_usage": { + "name": "email_monthly_usage", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_attempts": { + "name": "recipient_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "inbound_receipts": { + "name": "inbound_receipts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_monthly_usage_project_month_uq": { + "name": "email_monthly_usage_project_month_uq", + "nullsNotDistinct": false, + "columns": ["project_id", "month"] + } + }, + "policies": {}, + "checkConstraints": { + "email_monthly_usage_nonnegative_check": { + "name": "email_monthly_usage_nonnegative_check", + "value": "\"email_monthly_usage\".\"recipient_attempts\" >= 0" + }, + "email_monthly_usage_inbound_nonnegative_check": { + "name": "email_monthly_usage_inbound_nonnegative_check", + "value": "\"email_monthly_usage\".\"inbound_receipts\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.email_project_controls": { + "name": "email_project_controls", + "schema": "", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "monthly_limit": { + "name": "monthly_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 200 + }, + "burst_limit": { + "name": "burst_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "email_project_controls_project_id_projects_id_fk": { + "name": "email_project_controls_project_id_projects_id_fk", + "tableFrom": "email_project_controls", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_receipts": { + "name": "email_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_reject": { + "name": "final_reject", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_email_receipts_project_created": { + "name": "idx_email_receipts_project_created", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_unresolved_attempts": { + "name": "email_unresolved_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_domain": { + "name": "sender_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_email_unresolved_attempts_project": { + "name": "idx_email_unresolved_attempts_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_email_unresolved_attempts_domain": { + "name": "idx_email_unresolved_attempts_domain", + "columns": [ + { + "expression": "sender_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/platform/packages/api/drizzle/meta/_journal.json b/platform/packages/api/drizzle/meta/_journal.json index 43f2a1319..b362aeb23 100644 --- a/platform/packages/api/drizzle/meta/_journal.json +++ b/platform/packages/api/drizzle/meta/_journal.json @@ -323,6 +323,13 @@ "when": 1790827715142, "tag": "0052_github_connection_authority", "breakpoints": true + }, + { + "idx": 53, + "version": "7", + "when": 1790838220205, + "tag": "0053_project_zero_trust", + "breakpoints": true } ] } diff --git a/platform/packages/api/package.json b/platform/packages/api/package.json index fed00fc61..6f5e5edd7 100644 --- a/platform/packages/api/package.json +++ b/platform/packages/api/package.json @@ -29,6 +29,7 @@ "@nkzw/fate": "1.7.3", "@stylexjs/stylex": "catalog:", "@void/deploy-core": "workspace:*", + "@void/edge": "workspace:*", "@void/isr": "workspace:*", "@void/react": "workspace:*", "@xterm/headless": "^6.0.0", diff --git a/platform/packages/api/src/admin/routes/projects.tsx b/platform/packages/api/src/admin/routes/projects.tsx index efa46c933..2b18c0014 100644 --- a/platform/packages/api/src/admin/routes/projects.tsx +++ b/platform/packages/api/src/admin/routes/projects.tsx @@ -20,6 +20,11 @@ import { import { getPlatformDisplayName } from '../../platform-identity.ts'; import { deliverInvitation } from '../../routes/project-team.ts'; import type { ProjectMemberRole } from '../../types.ts'; +import { + reconcileProjectZeroTrust, + setProjectZeroTrustProtection, + ZeroTrustUnavailableError, +} from '../../zero-trust/service.ts'; import { renderFateDetailPage, renderFateListPage } from '../fate/render.ts'; import { renderOperationError } from '../operation-error.tsx'; import { renderAdminPage } from '../react/server.ts'; @@ -62,6 +67,18 @@ function teamMutationError(error: unknown): never { throw error; } +function projectZeroTrustError(error: unknown): never { + if (error instanceof ZeroTrustUnavailableError) { + throw new OperatorError(error.status, error.message, 'zero_trust_unavailable'); + } + console.error('[zero-trust] admin project operation failed', error); + throw new OperatorError( + 502, + 'Cloudflare Zero Trust reconciliation failed. Review the project state and retry.', + 'zero_trust_reconciliation_failed', + ); +} + // GET /projects app.get('/', (context) => renderFateListPage(context, 'projects')); @@ -206,6 +223,54 @@ app.post('/:id/team/invitations/:invitationId/revoke', async (context) => { ); }); +app.post('/:id/zero-trust', async (context) => { + const projectId = context.req.param('id'); + const body = await context.req.parseBody(); + if (body.protected !== 'true' && body.protected !== 'false') { + throw new OperatorError(400, 'Project protection must be true or false.', 'invalid_argument'); + } + const protectedProject = body.protected === 'true'; + return executeAuditedMutation( + context, + operatorAuditIntent(`zero-trust/projects/${projectId}`, { protected: protectedProject }), + async () => { + try { + await setProjectZeroTrustProtection( + context.env, + context.get('db'), + projectId, + protectedProject, + ); + } catch (error) { + projectZeroTrustError(error); + } + return { + response: context.redirect(`/admin/projects/${encodeURIComponent(projectId)}`), + succeeded: true, + }; + }, + ); +}); + +app.post('/:id/zero-trust/reconcile', async (context) => { + const projectId = context.req.param('id'); + return executeAuditedMutation( + context, + operatorAuditIntent(`zero-trust/projects/${projectId}/reconcile`), + async () => { + try { + await reconcileProjectZeroTrust(context.env, context.get('db'), projectId); + } catch (error) { + projectZeroTrustError(error); + } + return { + response: context.redirect(`/admin/projects/${encodeURIComponent(projectId)}`), + succeeded: true, + }; + }, + ); +}); + // POST /projects/:id/owner/preview app.post('/:id/owner/preview', async (context) => { const projectId = context.req.param('id'); diff --git a/platform/packages/api/src/admin/routes/zero-trust.tsx b/platform/packages/api/src/admin/routes/zero-trust.tsx new file mode 100644 index 000000000..60a3b8b31 --- /dev/null +++ b/platform/packages/api/src/admin/routes/zero-trust.tsx @@ -0,0 +1,326 @@ +import { Hono } from 'hono'; +import type { ApiEnv } from '../../env.ts'; +import { executeAuditedMutation, operatorAuditIntent } from '../../operator/audit.ts'; +import { OperatorError } from '../../operator/common.ts'; +import { + ZeroTrustConfigurationError, + normalizeZeroTrustConfigureInput, +} from '../../zero-trust/configuration.ts'; +import { + configurePlatformZeroTrust, + disablePlatformZeroTrust, + reconcilePlatformZeroTrust, + ZeroTrustUnavailableError, + zeroTrustStatus, +} from '../../zero-trust/service.ts'; +import { renderOperationError } from '../operation-error.tsx'; +// TODO(zt-admin-port): port the GET page to the React admin; views/layout.tsx was removed. +import { Layout } from '../views/layout.tsx'; + +const app = new Hono(); +app.use('*', async (context, next) => { + context.header('Cache-Control', 'no-store'); + context.header('Referrer-Policy', 'no-referrer'); + await next(); +}); +app.onError(renderOperationError); + +function values(value: FormDataEntryValue | undefined): ReadonlyArray { + return typeof value === 'string' + ? value + .split(/[\s,]+/) + .map((entry) => entry.trim()) + .filter(Boolean) + : []; +} + +function adminError(error: unknown): never { + if (error instanceof ZeroTrustConfigurationError) { + throw new OperatorError(400, error.message, 'invalid_zero_trust_configuration'); + } + if (error instanceof ZeroTrustUnavailableError) { + throw new OperatorError(error.status, error.message, 'zero_trust_unavailable'); + } + console.error('[zero-trust] admin operation failed', error); + throw new OperatorError( + 502, + 'Cloudflare Zero Trust reconciliation failed. Review the saved status and retry.', + 'zero_trust_reconciliation_failed', + ); +} + +app.get('/', async (context) => { + const status = await zeroTrustStatus(context.env, context.get('db')); + return context.html( + +

Project Zero Trust

+

+ Void manages a wildcard Cloudflare Access application for protected projects and exact-host + bypass applications for public exceptions. Credentials are encrypted and never returned. +

+ +

Status

+ + + + + + + + + + + {'status' in status && ( + + + + + )} + {'operation' in status && status.operation && ( + + + + + )} + {'accountId' in status && ( + + + + + )} + {'teamDomain' in status && ( + + + + + )} + {'protectNewProjects' in status && ( + + + + + )} + + + + + {'lastError' in status && status.lastError && ( + + + + + )} + +
Configured{status.configured ? 'Yes' : 'No'}
Enabled{status.enabled ? 'Yes' : 'No'}
Reconciliation{String(status.status)}
Current operation + {status.operation.kind} · {status.operation.phase} +
Cloudflare account{String(status.accountId)}
Access team domain{String(status.teamDomain)}
Protect new projects{status.protectNewProjects ? 'Yes' : 'No'}
Live application + {status.live + ? status.live.present && !status.live.drifted + ? 'Healthy' + : 'Missing or drifted' + : 'Not active'} +
Last error{String(status.lastError)}
+ +

Project states

+ {status.projects.length > 0 ? ( + + + + + + + + + + + {status.projects.map((entry) => ( + + + + + + + ))} + +
DesiredEffectiveStateCount
{entry.desiredProtected ? 'protected' : 'public'}{entry.effectiveProtected ? 'protected' : 'public'}{entry.state}{entry.count}
+ ) : ( +

No project protection state has been recorded.

+ )} + +

Configure

+

+ The token needs Access Apps and Policies Write plus Organizations, Identity Providers, and + Groups Read for this account. +

+ {'operation' in status && status.operation && ( +

+ To replace an expired or revoked token, keep the saved settings and submit a new token. + The pending operation will resume. +

+ )} +
+

+ +

+

+ +

+

+ +

+

+ +

+

+ +

+

+ +
+ This selection is used only when Zero Trust is first enabled or re-enabled. +

+ +
+ + {status.configured && ( + <> +

Maintenance

+
+ +
{' '} + {(status.enabled || ('operation' in status && status.operation)) && ( +
+ +
+ )} + + )} +
, + ); +}); + +app.post('/configure', async (context) => { + const form = await context.req.parseBody(); + let input; + try { + input = normalizeZeroTrustConfigureInput({ + accountId: form.accountId, + apiToken: form.apiToken, + identityProviderIds: values(form.identityProviderIds), + policyIds: values(form.policyIds), + protectNewProjects: form.protectNewProjects === 'yes', + existingProjects: form.existingProjects, + }); + } catch (error) { + adminError(error); + } + return executeAuditedMutation( + context, + operatorAuditIntent('zero-trust/configure', input), + async () => { + try { + await configurePlatformZeroTrust(context.env, context.get('db'), input); + } catch (error) { + adminError(error); + } + return { response: context.redirect('/admin/zero-trust'), succeeded: true }; + }, + ); +}); + +app.post('/reconcile', async (context) => + executeAuditedMutation(context, operatorAuditIntent('zero-trust/reconcile'), async () => { + try { + await reconcilePlatformZeroTrust(context.env, context.get('db')); + } catch (error) { + adminError(error); + } + return { response: context.redirect('/admin/zero-trust'), succeeded: true }; + }), +); + +app.post('/disable', async (context) => + executeAuditedMutation(context, operatorAuditIntent('zero-trust/disable'), async () => { + try { + await disablePlatformZeroTrust(context.env, context.get('db')); + } catch (error) { + adminError(error); + } + return { response: context.redirect('/admin/zero-trust'), succeeded: true }; + }), +); + +export default app; diff --git a/platform/packages/api/src/api.ts b/platform/packages/api/src/api.ts index 3f5042bc8..1087975f5 100644 --- a/platform/packages/api/src/api.ts +++ b/platform/packages/api/src/api.ts @@ -6,6 +6,7 @@ import { Hono } from 'hono'; import type { ApiEnv } from './env.ts'; import domains from './routes/domains.ts'; import me from './routes/me.ts'; +import projectZeroTrustRoutes from './routes/project-zero-trust.ts'; import projects from './routes/projects.ts'; import secrets from './routes/secrets.ts'; import { projectUsage, accountUsage } from './routes/usage.ts'; @@ -15,6 +16,7 @@ const api = new Hono() .route('/projects', projects) .route('/projects', secrets) .route('/projects', domains) + .route('/projects', projectZeroTrustRoutes) .route('/projects', projectUsage) .route('/usage', accountUsage); diff --git a/platform/packages/api/src/db.ts b/platform/packages/api/src/db.ts index b624798cf..c41808121 100644 --- a/platform/packages/api/src/db.ts +++ b/platform/packages/api/src/db.ts @@ -2416,19 +2416,6 @@ export async function deleteDomain(db: Db, id: string): Promise { await db.delete(domains).where(eq(domains.id, id)); } -// Atomically delete a domain row ONLY if it is still `pending`. Used by the -// cleanup janitor to "claim" a stale row: if a concurrent status poll has -// since activated the domain (status → 'active'), the delete is a no-op and -// returns false, so the janitor leaves the now-live domain (and its CF -// hostname) untouched. Returns true iff a pending row was deleted. -export async function deleteDomainIfPending(db: Db, id: string): Promise { - const deleted = await db - .delete(domains) - .where(and(eq(domains.id, id), eq(domains.status, 'pending'))) - .returning({ id: domains.id }); - return deleted.length > 0; -} - // --- Email destinations --- /** diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index 89f9939f6..e5a6dd00f 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -20,6 +20,7 @@ import adminProjects from './admin/routes/projects.tsx'; import adminSignupControl from './admin/routes/signup-control.tsx'; import { dashboardHandler, adminSystem } from './admin/routes/system.tsx'; import adminUsers from './admin/routes/users.tsx'; +import adminZeroTrust from './admin/routes/zero-trust.tsx'; import { pendingAuthenticationSetup } from './auth/bootstrap.ts'; import { readAuthSettings, readEnabledLoginConnections } from './auth/configuration.ts'; import { BuildManager, BuildManagerLarge } from './build-manager.ts'; @@ -34,7 +35,7 @@ import { getPendingDomainsOlderThan, getPendingDomains, getPendingDomainsNewerThan, - deleteDomainIfPending, + deleteDomain, purgeExpiredOAuthNonces, purgeExpiredAuthCodes, failStaleDeployments, @@ -42,6 +43,7 @@ import { failStaleActiveGithubActionsBuilds, hasQueuedContainerBuilds, getProject, + getDomain, getOpenProjectOwnershipPeriodsByProjectIds, getProjectsByIds, getUsersByIds, @@ -65,6 +67,8 @@ import { authMiddleware } from './middleware/auth.ts'; import { platformAccessProtection } from './middleware/platform-access.ts'; import { operatorApi } from './operator/index.ts'; import { sandboxDrainStatus } from './operator/sandbox-operations.ts'; +import { platformDomainActivation } from './platform-domain-activation.ts'; +import { platformDomainPreflight } from './platform-domain-preflight.ts'; import { getPlatformApiUrl, getPlatformDisplayName, @@ -105,6 +109,7 @@ import { projectTeamRoutes, } from './routes/project-team.ts'; import projectTokens from './routes/project-tokens.ts'; +import projectZeroTrustRoutes from './routes/project-zero-trust.ts'; import projects from './routes/projects.ts'; import revalidate from './routes/revalidate.ts'; import secrets from './routes/secrets.ts'; @@ -125,6 +130,13 @@ import type { Domain, Project } from './types.ts'; import { ensureUsageSigningKey, USAGE_PROTOCOL_VERSION } from './usage-auth.ts'; import { UsageCounter } from './usage-counter.ts'; import { replayFailedWebhookDeliveries } from './webhook-replay.ts'; +import { + reconcilePendingProjectZeroTrust, + reconcileProjectZeroTrustDomains, + resolveProjectZeroTrustRoutingForPublication, + resumePlatformZeroTrustOperation, + withProjectZeroTrustRouting, +} from './zero-trust/service.ts'; function isSandboxDrainMaintenanceRequest(request: Request): boolean { const path = new URL(request.url).pathname; @@ -173,6 +185,8 @@ const app = new Hono() (context.req.path === '/internal/platform/email-bootstrap' && context.req.method === 'POST') || (context.req.path === '/internal/platform/domain-preflight' && + context.req.method === 'POST') || + (context.req.path === '/internal/platform/domain-activate' && context.req.method === 'POST')); const maintenanceSandboxDrain = context.env.PLATFORM_TRAFFIC_STATE === 'sandbox-drain' && @@ -292,6 +306,7 @@ const app = new Hono() ); }) .post('/internal/platform/domain-preflight', platformDomainPreflight) + .post('/internal/platform/domain-activate', platformDomainActivation) .post('/internal/platform/email-bootstrap', async (context) => { const healthProbeToken = context.env.PLATFORM_HEALTH_PROBE_TOKEN; if ( @@ -471,6 +486,7 @@ ${ .route('/projects', projects) .route('/projects', projectTokens) .route('/projects', projectTeamRoutes) + .route('/projects', projectZeroTrustRoutes) .route('/projects', deploy) .route('/projects', deleteRoute) .route('/projects', revalidate) @@ -495,6 +511,7 @@ app.get('/admin/', adminAuthMiddleware, dashboardHandler); app.route('/admin/users', adminUsers); app.route('/admin/projects', adminProjects); app.route('/admin/email', adminEmail); +app.route('/admin/zero-trust', adminZeroTrust); app.route('/admin/builds', adminBuilds); app.route('/admin/deployments', adminDeployments); app.route('/admin/signup-control', adminSignupControl); @@ -535,6 +552,7 @@ async function scheduled( env: ApiEnv['Bindings'], _ctx: ExecutionContext, ): Promise { + env = await resolvePlatformApplicationEnvironment(env); if ( env.PLATFORM_TRAFFIC_STATE === 'staging' || env.PLATFORM_TRAFFIC_STATE === 'sandbox-drain' || @@ -550,6 +568,19 @@ async function scheduled( console.error('[scheduled] project cleanup retry failed:', error), ); + // Fast tier: a resumable Zero Trust batch owns the rest of this invocation so + // it stays within the per-invocation subrequest budget. A failed batch does + // not, so the pending-domain and email work below still runs. + if (controller.cron === FAST_CRON) { + const zeroTrustProgressed = await resumePlatformZeroTrustOperation(env, db).catch((error) => { + console.error('[scheduled] Zero Trust operation resume failed:', error); + return false; + }); + if (zeroTrustProgressed) { + return; + } + } + if (capabilities.email) { await reconcileEmailDestinationCleanups(db, { accountId: env.CF_ACCOUNT_ID, @@ -575,10 +606,9 @@ async function scheduled( } } - // Fast tier (every 2 min): only reconcile recently-created pending domains - // (within the last 30 min) so a freshly-configured custom domain comes online - // quickly without the user polling. The heavier hourly maintenance does NOT - // run on this tick. + // Fast tier (every 2 min): if no Zero Trust batch consumed this invocation, + // reconcile recently-created pending domains (within the last 30 min). The + // heavier hourly maintenance does not run on this tick. if (controller.cron === FAST_CRON) { if (!capabilities.customDomains) { return; @@ -606,6 +636,10 @@ async function scheduled( ); } + await reconcilePendingProjectZeroTrust(env, db).catch((error) => + console.error('[scheduled] Zero Trust reconciliation failed:', error), + ); + // Auto-fail deployments stuck in active states for more than 30 minutes await failStaleDeployments(db) .then((n) => { @@ -852,6 +886,26 @@ async function reconcilePendingDomains( } } +async function cleanupPendingDomainUnderProjectFence( + env: ApiEnv['Bindings'], + db: Db, + domain: Domain, + beforeDelete?: () => Promise, +): Promise { + return db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${domain.project_id} FOR UPDATE`); + const txDb = tx as unknown as Db; + const current = await getDomain(txDb, domain.id); + if (current?.status !== 'pending') { + return false; + } + await beforeDelete?.(); + await env.ROUTING.delete(`domain:${domain.hostname}`).catch(() => {}); + await deleteDomain(txDb, domain.id); + return true; + }); +} + // Exported for direct integration testing of the janitor branch in isolation // from the reconcile pass (the hourly `scheduled()` runs reconcile first, which // would otherwise activate any gate-passing pending domain before the janitor @@ -886,10 +940,16 @@ export async function cleanupStalePendingDomains( // cleanup). Nothing to revoke — fall through to drop the orphaned DB row // (and any stray KV) so the customer can re-add the hostname. if (err instanceof CloudflareApiError && err.status === 404) { - await env.ROUTING.delete(`domain:${domain.hostname}`).catch(() => {}); - if (await deleteDomainIfPending(db, domain.id)) { - cleaned += 1; + if (!(await cleanupPendingDomainUnderProjectFence(env, db, domain))) { + continue; } + cleaned += 1; + await reconcileProjectZeroTrustDomains(env, db, domain.project_id).catch((error) => + console.error( + `[scheduled] failed to reconcile Zero Trust after deleting ${domain.hostname}:`, + error, + ), + ); continue; } console.error(`[scheduled] failed to read stale CF hostname for ${domain.hostname}:`, err); @@ -927,36 +987,35 @@ export async function cleanupStalePendingDomains( continue; } - // Still genuinely pending. Claim the row FIRST via a conditional delete: - // if a concurrent poll has activated it, this no-ops and we skip teardown, - // so we never revoke the cert of a live domain. - if (!(await deleteDomainIfPending(db, domain.id))) { - continue; - } - - // Row claimed — revoke the CF cert order (treat an already-gone 404 as - // success) and drop any routing entry a racing activation may have written. - // The DB row is already deleted, so a non-404 CF failure orphans the CF - // hostname (cert order stays open). Retry the DELETE ONCE to shrink that - // orphan window — no backoff/sleep (cron context). If the retry also fails - // non-404, log an error explicitly noting the orphan so ops can detect it. - try { - await deleteCustomHostname(env.CF_ZONE_ID, env.CF_API_TOKEN, domain.cf_hostname_id); - } catch (err) { - if (!(err instanceof CloudflareApiError && err.status === 404)) { - try { - await deleteCustomHostname(env.CF_ZONE_ID, env.CF_API_TOKEN, domain.cf_hostname_id); - } catch (retryErr) { - if (!(retryErr instanceof CloudflareApiError && retryErr.status === 404)) { - console.error( - `[scheduled] failed to delete stale CF hostname for ${domain.hostname} after retry — CF hostname ${domain.cf_hostname_id} is ORPHANED (DB row already removed):`, - retryErr, - ); + // Keep the row visible until provider and routing cleanup finish so a + // concurrent add cannot publish a replacement that this run then removes. + const claimed = await cleanupPendingDomainUnderProjectFence(env, db, domain, async () => { + try { + await deleteCustomHostname(env.CF_ZONE_ID, env.CF_API_TOKEN, domain.cf_hostname_id); + } catch (err) { + if (!(err instanceof CloudflareApiError && err.status === 404)) { + try { + await deleteCustomHostname(env.CF_ZONE_ID, env.CF_API_TOKEN, domain.cf_hostname_id); + } catch (retryErr) { + if (!(retryErr instanceof CloudflareApiError && retryErr.status === 404)) { + console.error( + `[scheduled] failed to delete stale CF hostname for ${domain.hostname} after retry — CF hostname ${domain.cf_hostname_id} is ORPHANED:`, + retryErr, + ); + } } } } + }); + if (!claimed) { + continue; } - await env.ROUTING.delete(`domain:${domain.hostname}`).catch(() => {}); + await reconcileProjectZeroTrustDomains(env, db, domain.project_id).catch((error) => + console.error( + `[scheduled] failed to reconcile Zero Trust after deleting ${domain.hostname}:`, + error, + ), + ); cleaned += 1; } @@ -976,42 +1035,39 @@ async function processUserRollover( // Wrapped in try/catch so a malformed KV entry doesn't block rollover/unsuspension. try { for (const project of userProjects) { - const raw = await env.ROUTING.get(project.slug); - if (!raw) { - continue; - } - const routing = JSON.parse(raw); - if (!routing.userId) { - routing.userId = user.id; - await env.ROUTING.put(project.slug, JSON.stringify(routing)); - } - } - - // Also backfill domain:* entries — check all projects, not just those that needed - // slug backfill (slug may have been patched in a prior run before domain support). - const projectIds = userProjects.map((p) => p.id); - if (projectIds.length > 0) { - const domainMap = await getDomainsByProjectIds(db, projectIds); - for (const projectId of projectIds) { - const projectDomains = domainMap.get(projectId); - if (!projectDomains) { - continue; + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${project.id} FOR UPDATE`); + const txDb = tx as unknown as Db; + const current = await getProject(txDb, project.id); + if (!current || current.user_id !== user.id || current.status === 'deleting') { + return; } - for (const domain of projectDomains) { - if (domain.status !== 'active') { - continue; - } - const raw = await env.ROUTING.get(`domain:${domain.hostname}`); + + // Resolve both route keys and values behind the same fence as deploy, + // protection changes, domain activation, transfer, and deletion. + const domainMap = await getDomainsByProjectIds(txDb, [current.id]); + const protection = await resolveProjectZeroTrustRoutingForPublication(tx, current.id); + const keys = [ + current.slug, + ...(domainMap.get(current.id) ?? []) + .filter((domain) => domain.status === 'active') + .map((domain) => `domain:${domain.hostname}`), + ]; + for (const key of keys) { + const raw = await env.ROUTING.get(key); if (!raw) { continue; } const routing = JSON.parse(raw); if (!routing.userId) { - routing.userId = user.id; - await env.ROUTING.put(`domain:${domain.hostname}`, JSON.stringify(routing)); + routing.userId = current.user_id; + await env.ROUTING.put( + key, + withProjectZeroTrustRouting(JSON.stringify(routing), protection), + ); } } - } + }); } } catch (err) { console.error(`[backfill] userId backfill failed for user ${user.id}:`, err); @@ -1125,4 +1181,3 @@ export default { await processPlatformQueue(batch, env); }, } satisfies ExportedHandler; -import { platformDomainPreflight } from './platform-domain-preflight.ts'; diff --git a/platform/packages/api/src/middleware/platform-access.ts b/platform/packages/api/src/middleware/platform-access.ts index 79172f0b5..162ef191b 100644 --- a/platform/packages/api/src/middleware/platform-access.ts +++ b/platform/packages/api/src/middleware/platform-access.ts @@ -66,7 +66,8 @@ export const platformAccessProtection: MiddlewareHandler = async (contex ((path === '/health' && method === 'GET') || (path === '/internal/platform/sandbox-drain' && method === 'GET') || (path === '/internal/platform/email-bootstrap' && method === 'POST') || - (path === '/internal/platform/domain-preflight' && method === 'POST'))) + (path === '/internal/platform/domain-preflight' && method === 'POST') || + (path === '/internal/platform/domain-activate' && method === 'POST'))) ) { return next(); } diff --git a/platform/packages/api/src/operator/audit.ts b/platform/packages/api/src/operator/audit.ts index ad0b6aa8b..0563aba80 100644 --- a/platform/packages/api/src/operator/audit.ts +++ b/platform/packages/api/src/operator/audit.ts @@ -45,6 +45,16 @@ function accessPatterns(value: unknown) { : undefined; } +function boundedIds(value: unknown) { + if (!Array.isArray(value) || value.length === 0 || value.length > 16) { + return undefined; + } + const values = value.map((entry) => boundedString(entry, 128)); + return values.every((entry): entry is string => entry !== undefined) + ? [...new Set(values)] + : undefined; +} + function rollbackVersions(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; @@ -215,6 +225,28 @@ function auditTarget( } } } + if (segments[0] === 'zero-trust') { + if (segments[1] === 'configure') { + return JSON.stringify({ + accountId: boundedString(body.accountId, 128), + identityProviderIds: boundedIds(body.identityProviderIds), + policyIds: boundedIds(body.policyIds), + protectNewProjects: body.protectNewProjects === true, + existingProjects: boundedString(body.existingProjects, 16), + }); + } + if (segments[1] === 'reconcile' || segments[1] === 'disable') { + return JSON.stringify({ installation: 'platform' }); + } + if (segments[1] === 'projects' && segments[2]) { + return JSON.stringify({ + projectId: pathIdentifier(segments[2]), + ...(segments[3] === 'reconcile' + ? { reconcile: true } + : { protected: body.protected === true }), + }); + } + } if (segments[0] === 'system' && segments[1] === 'backfill-queue-tokens') { return JSON.stringify({ entries: 'queue:*' }); } diff --git a/platform/packages/api/src/operator/index.ts b/platform/packages/api/src/operator/index.ts index af043735e..e666291df 100644 --- a/platform/packages/api/src/operator/index.ts +++ b/platform/packages/api/src/operator/index.ts @@ -10,6 +10,7 @@ import { OperatorError } from './common.ts'; import { emailOperatorRoutes } from './email-routes.ts'; import { operationsRoutes } from './operations-routes.ts'; import { tenantRoutes } from './tenant-routes.ts'; +import { zeroTrustOperatorRoutes } from './zero-trust-routes.ts'; export const operatorApi = new Hono(); operatorApi.onError((error, context) => { @@ -63,6 +64,7 @@ operatorApi.route('/', tenantRoutes); operatorApi.route('/', operationsRoutes); operatorApi.route('/', authenticationConfigRoutes); operatorApi.route('/', emailOperatorRoutes); +operatorApi.route('/', zeroTrustOperatorRoutes); operatorApi.notFound((context) => context.json({ error: 'not_found', message: 'Unknown operator API endpoint.' }, 404), ); diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 21f9b467c..99de92b68 100644 --- a/platform/packages/api/src/operator/tenant-service.ts +++ b/platform/packages/api/src/operator/tenant-service.ts @@ -70,6 +70,10 @@ import { } from '../suspension.ts'; import { getCurrentBillingPeriodStart, PLAN_LIMITS, type PlanId } from '../tiers.ts'; import type { DeployManifest, Project, User } from '../types.ts'; +import { + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from '../zero-trust/service.ts'; import { OperatorError, preview } from './common.ts'; type Page = { page: number; limit: number; offset: number }; @@ -671,6 +675,7 @@ async function republishProjectOwnerState( manifest = JSON.parse(activeDeployment.manifest) as DeployManifest; } const dispatchLimits = computeDispatchLimits(PLAN_LIMITS[owner.plan], manifest?.limits); + const protection = await resolveProjectZeroTrustRoutingForPublication(tx, project.id); for (const key of [project.slug, ...domains.map((domain) => `domain:${domain.hostname}`)]) { const raw = await context.env.ROUTING.get(key); if (!raw) { @@ -691,7 +696,10 @@ async function republishProjectOwnerState( } else { delete entry.suspended; } - await context.env.ROUTING.put(key, JSON.stringify(entry)); + await context.env.ROUTING.put( + key, + withProjectZeroTrustRouting(JSON.stringify(entry), protection), + ); } await context.env.ROUTING.put( projectAccountKey(project.id), diff --git a/platform/packages/api/src/operator/zero-trust-routes.ts b/platform/packages/api/src/operator/zero-trust-routes.ts new file mode 100644 index 000000000..9683391a3 --- /dev/null +++ b/platform/packages/api/src/operator/zero-trust-routes.ts @@ -0,0 +1,183 @@ +import { Hono } from 'hono'; +import { getProject, type Db } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { + ZeroTrustConfigurationError, + normalizeZeroTrustConfigureInput, + readZeroTrustConfigurationStrict, + redactedZeroTrustConfiguration, +} from '../zero-trust/configuration.ts'; +import { + configurePlatformZeroTrust, + disablePlatformZeroTrust, + getProjectZeroTrustState, + reconcilePlatformZeroTrust, + reconcileProjectZeroTrust, + setProjectZeroTrustProtection, + zeroTrustStatus, + ZeroTrustUnavailableError, +} from '../zero-trust/service.ts'; +import { dryRun, OperatorError, preview, readBody } from './common.ts'; + +export const zeroTrustOperatorRoutes = new Hono(); + +async function requireOperatorProject(db: Db, projectId: string, active: boolean): Promise { + const project = await getProject(db, projectId); + if (!project) { + throw new OperatorError(404, 'Project not found.', 'not_found'); + } + if (active && project.status === 'deleting') { + throw new OperatorError(409, 'Project is being deleted.', 'project_deleting'); + } +} + +function operationError(error: unknown): never { + if (error instanceof ZeroTrustConfigurationError) { + throw new OperatorError(400, error.message, 'invalid_zero_trust_configuration'); + } + if (error instanceof ZeroTrustUnavailableError) { + throw new OperatorError(error.status, error.message, 'zero_trust_unavailable'); + } + console.error('[zero-trust] operator operation failed', error); + throw new OperatorError( + 502, + 'Cloudflare Zero Trust reconciliation failed. Inspect the saved status before retrying.', + 'zero_trust_reconciliation_failed', + ); +} + +zeroTrustOperatorRoutes.get('/zero-trust', async (context) => + context.json(await zeroTrustStatus(context.env, context.get('db'))), +); + +zeroTrustOperatorRoutes.post('/zero-trust/configure', async (context) => { + const body = await readBody(context); + let input; + try { + input = normalizeZeroTrustConfigureInput(body); + } catch (error) { + operationError(error); + } + if (dryRun(body)) { + return context.json( + preview('zero-trust.configure', 'platform', { + accountId: input.accountId, + identityProviderIds: input.identityProviderIds, + policyIds: input.policyIds, + protectNewProjects: input.protectNewProjects, + existingProjects: input.existingProjects, + credential: 'replace-encrypted', + }), + ); + } + try { + return context.json({ + ok: true, + configuration: await configurePlatformZeroTrust(context.env, context.get('db'), input), + }); + } catch (error) { + operationError(error); + } +}); + +zeroTrustOperatorRoutes.post('/zero-trust/reconcile', async (context) => { + const body = await readBody(context); + if (dryRun(body)) { + return context.json( + preview('zero-trust.reconcile', 'platform', { + configuration: redactedZeroTrustConfiguration( + await readZeroTrustConfigurationStrict(context.get('db')), + ), + }), + ); + } + try { + return context.json({ + ok: true, + configuration: await reconcilePlatformZeroTrust(context.env, context.get('db')), + }); + } catch (error) { + operationError(error); + } +}); + +zeroTrustOperatorRoutes.post('/zero-trust/disable', async (context) => { + const body = await readBody(context); + if (dryRun(body)) { + return context.json( + preview('zero-trust.disable', 'platform', { + configuration: redactedZeroTrustConfiguration( + await readZeroTrustConfigurationStrict(context.get('db')), + ), + result: 'All project hostnames become public.', + }), + ); + } + try { + return context.json({ + ok: true, + configuration: await disablePlatformZeroTrust(context.env, context.get('db')), + }); + } catch (error) { + operationError(error); + } +}); + +zeroTrustOperatorRoutes.get('/zero-trust/projects/:id', async (context) => { + const projectId = context.req.param('id'); + await requireOperatorProject(context.get('db'), projectId, false); + return context.json({ + project: await getProjectZeroTrustState(context.get('db'), projectId), + }); +}); + +zeroTrustOperatorRoutes.post('/zero-trust/projects/:id', async (context) => { + const body = await readBody(context); + if (typeof body.protected !== 'boolean') { + throw new OperatorError(400, 'protected must be a boolean.', 'invalid_argument'); + } + const projectId = context.req.param('id'); + await requireOperatorProject(context.get('db'), projectId, true); + if (dryRun(body)) { + return context.json( + preview('zero-trust.project', projectId, { + previous: await getProjectZeroTrustState(context.get('db'), projectId), + protected: body.protected, + }), + ); + } + try { + return context.json({ + ok: true, + project: await setProjectZeroTrustProtection( + context.env, + context.get('db'), + projectId, + body.protected, + ), + }); + } catch (error) { + operationError(error); + } +}); + +zeroTrustOperatorRoutes.post('/zero-trust/projects/:id/reconcile', async (context) => { + const body = await readBody(context); + const projectId = context.req.param('id'); + await requireOperatorProject(context.get('db'), projectId, true); + if (dryRun(body)) { + return context.json( + preview('zero-trust.project-reconcile', projectId, { + project: await getProjectZeroTrustState(context.get('db'), projectId), + }), + ); + } + try { + return context.json({ + ok: true, + project: await reconcileProjectZeroTrust(context.env, context.get('db'), projectId), + }); + } catch (error) { + operationError(error); + } +}); diff --git a/platform/packages/api/src/platform-domain-activation.ts b/platform/packages/api/src/platform-domain-activation.ts new file mode 100644 index 000000000..7fa0b9cf6 --- /dev/null +++ b/platform/packages/api/src/platform-domain-activation.ts @@ -0,0 +1,46 @@ +import type { Context } from 'hono'; +import type { ApiEnv } from './env.ts'; +import { + parseZeroTrustApplicationTarget, + ZeroTrustConfigurationError, +} from './zero-trust/configuration.ts'; +import { activatePlatformApplication, ZeroTrustUnavailableError } from './zero-trust/service.ts'; + +/** Final installer handoff; only the service may publish application routing. */ +export async function platformDomainActivation(context: Context): Promise { + const token = context.env.PLATFORM_HEALTH_PROBE_TOKEN; + if ( + !token || + context.req.header('X-Void-Platform-Probe') !== token || + !context.env.PLATFORM_APPLICATION || + !context.env.PLATFORM_INSTALLATION_ID + ) { + return context.json({ error: 'not found' }, 404); + } + let target; + try { + const body = await context.req.json<{ application: unknown }>(); + target = parseZeroTrustApplicationTarget(body.application); + } catch { + return context.json({ error: 'Invalid application domain target.' }, 400); + } + try { + const result = await activatePlatformApplication(context.env, context.get('db'), target); + return context.json( + { ok: true, installationId: context.env.PLATFORM_INSTALLATION_ID, ...result }, + result.ready ? 200 : 202, + ); + } catch (error) { + if ( + error instanceof ZeroTrustConfigurationError || + error instanceof ZeroTrustUnavailableError + ) { + return context.json({ error: error.message }, 409); + } + console.error('[platform] application domain activation failed', error); + return context.json( + { error: 'Application domain protection is not ready. Retry domain setup.' }, + 503, + ); + } +} diff --git a/platform/packages/api/src/provisioning/activate-domain.ts b/platform/packages/api/src/provisioning/activate-domain.ts index 40434600a..1ae63ed51 100644 --- a/platform/packages/api/src/provisioning/activate-domain.ts +++ b/platform/packages/api/src/provisioning/activate-domain.ts @@ -9,6 +9,11 @@ import { import type { ApiEnv } from '../env.ts'; import type { DomainVerificationData } from '../schema.ts'; import type { Domain, Project } from '../types.ts'; +import { + reconcileProjectZeroTrustDomains, + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from '../zero-trust/service.ts'; import { type CustomHostnameInfo, getCustomHostname, @@ -93,18 +98,17 @@ export async function tryActivateDomain( // the next poll/cron retries and re-writes the same KV entry — the system // heals itself. The converse ordering (DB flip first) would strand an // `active` row with no route, producing 404s until a manual deploy or fix. - // Accepted limitation: a concurrent `void domain delete` can interleave so - // that this call writes `domain:` KV after the delete removed the DB - // row, leaving a rare orphan routing entry (the entry serves the domain's - // own project's content). This is a low-probability, bounded race accepted - // as a documented limitation — see design-doc 0073 — rather than something - // guarded here; a lock-free reconciler and creation-time quota are deferred. // Read slug routing whenever the CF gate is satisfied so we can surface // `awaitingDeployment` to the caller regardless of whether activation // actually happened (activation also requires `domain.status === 'pending'`). let slugRouting: string | null = null; let activated = false; if (gateOk && wasPending) { + // A protected project needs an exact-host Access application and its + // audience in dispatch routing before this custom hostname can become + // reachable. Reconciliation is idempotent and fails closed: a Cloudflare + // API failure leaves the domain pending with no routing entry. + await reconcileProjectZeroTrustDomains(env, db, project.id); await db.transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${project.id} FOR UPDATE`); const txDb = tx as unknown as Db; @@ -123,7 +127,11 @@ export async function tryActivateDomain( if (!slugRouting) { return; } - await env.ROUTING.put(`domain:${lockedDomain.hostname}`, slugRouting); + const protection = await resolveProjectZeroTrustRoutingForPublication(txDb, project.id); + await env.ROUTING.put( + `domain:${lockedDomain.hostname}`, + withProjectZeroTrustRouting(slugRouting, protection), + ); await updateDomainStatus(txDb, lockedDomain.id, 'active'); activated = true; domain.status = 'active'; diff --git a/platform/packages/api/src/provisioning/cleanup.ts b/platform/packages/api/src/provisioning/cleanup.ts index 4e82a4019..ad59efc79 100644 --- a/platform/packages/api/src/provisioning/cleanup.ts +++ b/platform/packages/api/src/provisioning/cleanup.ts @@ -51,6 +51,7 @@ import { } from '../schema.ts'; import { getCurrentBillingPeriodStart } from '../tiers.ts'; import type { Build, DeployManifest, Project, ProjectOwnershipPeriod, Resource } from '../types.ts'; +import { cleanupProjectZeroTrust } from '../zero-trust/service.ts'; import { CloudflareApiError, deleteCustomHostname, @@ -801,9 +802,9 @@ export async function reconcileProjectCleanupJobs(db: Db, env: ApiEnv['Bindings' * in-flight builds (revoking their deploy tokens). Then stop the container * DOs of those builds — BEFORE any CF cleanup runs, so a still-running build * can't keep acting on its now-revoked token during the cleanup window. - * 2. Run critical CF cleanup (container apps + per-deploy container - * orphan recovery). Throws on non-404 failure; the caller should surface - * a 5xx and the DB row remains, allowing retry. + * 2. Run critical CF cleanup (container apps, per-deploy container orphan + * recovery, and Zero Trust applications). Throws on non-404 failure; the + * caller should surface a 5xx and the DB row remains, allowing retry. * 3. Delete CF resources (D1, KV, R2, queues, Hyperdrive, custom hostnames, * routing entries, deployment Workers). Persist failed operations for * scheduled retry; keep reusable names reserved if their deletion fails. @@ -856,6 +857,9 @@ export async function teardownProject( } await cleanupCriticalResources(context, projectId, project); + // Access application IDs live only on the project row. Deletion must succeed + // before the cascade or a retry would no longer be able to address them. + await cleanupProjectZeroTrust(context.env, context.get('db'), projectId); // Customer-side cleanup is durable and may continue after project deletion. await cleanupProjectEmailAssignments(context, projectId); const { warnings: bestEffortWarnings, cleanedCfIds } = await cleanupBestEffortResources( diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index d3ea3993e..cd458d69f 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -147,6 +147,11 @@ import { type SandboxMigrationMeta, type WebSocketMigrationMeta, } from '../types.ts'; +import { + resolveProjectZeroTrustRouting, + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from '../zero-trust/service.ts'; import { requireProjectRouteCapability } from './project-access.ts'; export { MAX_REVALIDATE_QUERY_ALLOWLIST_ENTRIES, MAX_ROUTING_RULES, validateManifestShape }; @@ -324,6 +329,7 @@ async function replayDeploymentCutover( const existingEntry = existingRouting ? (JSON.parse(existingRouting) as { suspended?: string }) : {}; + const zeroTrust = await resolveProjectZeroTrustRouting(db, project.id); let hasAssets = false; if (deployment.asset_hashes) { const assetHashes = JSON.parse(deployment.asset_hashes) as Record; @@ -341,6 +347,7 @@ async function replayDeploymentCutover( } as const), manifest, suspended: existingEntry.suspended, + zeroTrust, }) : buildRoutingJson({ workerName, @@ -353,6 +360,7 @@ async function replayDeploymentCutover( hasCrons: !!manifest.schedules?.length, assetManifestId: hasAssets ? deployment.id : undefined, assetConfig: hasAssets ? manifest.assetConfig : undefined, + zeroTrust, }); // Complete every PostgreSQL read and validation before the first external @@ -2953,7 +2961,9 @@ async function handleManifestOnlyDeploy(context: Context): Promise Promise): Promise => { + const withProjectCompensationFence = async ( + restore: (txDb: Db) => Promise, + ): Promise => { await context.get('db').transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); const txDb = tx as unknown as Db; @@ -2984,7 +2994,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise( @@ -3102,7 +3112,8 @@ async function handleManifestOnlyDeploy(context: Context): Promise => { - await withProjectCompensationFence(async () => { + await withProjectCompensationFence(async (txDb) => { + const zeroTrust = await resolveProjectZeroTrustRoutingForPublication(txDb, projectId); const errors: Array = []; for (const [key, previous] of [...routingValuesBeforeWrite].reverse()) { try { @@ -3120,7 +3131,12 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): Promise { + const existingRouting = await context.env.ROUTING.get(project.slug); + const existingEntry = existingRouting ? JSON.parse(existingRouting) : null; + const zeroTrust = await resolveProjectZeroTrustRouting(txDb, projectId); + const routingJson = buildStaticRoutingJson({ + projectId, + userId: project.user_id, + deploymentId: deployment.id, + assetConfig, + manifest, + suspended: + existingEntry?.suspended ?? + (owner.suspended_reason === REASON_REQUESTS_EXCEEDED + ? REASON_REQUESTS_EXCEEDED + : undefined), + zeroTrust, + }); const cutoverDomains = await getDomainsByProjectId(txDb, projectId); await putTrackedRouting(project.slug, routingJson); await putTrackedRouting( @@ -4862,30 +4880,32 @@ async function handleManifestOnlyDeploy(context: Context): Promise 0 ? deployment.id : undefined, - assetConfig: assetCount > 0 ? assetConfig : undefined, - }); await snapshotCronState(); await assertDeploymentNotCanceled(context.get('db'), deployment.id); await assertProjectNotDeleting(context.get('db'), projectId); markPhase('routing_switch'); await withProjectCutoverFence(deployment.id, async (txDb) => { + const existingRouting = await context.env.ROUTING.get(project.slug); + const existingEntry = existingRouting ? JSON.parse(existingRouting) : null; + const zeroTrust = await resolveProjectZeroTrustRouting(txDb, projectId); + const routingJson = buildRoutingJson({ + workerName, + projectId, + userId: project.user_id, + manifest, + deploymentId: deployment.id, + dispatchLimits, + suspended: + existingEntry?.suspended ?? + (owner.suspended_reason === REASON_REQUESTS_EXCEEDED + ? REASON_REQUESTS_EXCEEDED + : undefined), + hasCrons, + assetManifestId: assetCount > 0 ? deployment.id : undefined, + assetConfig: assetCount > 0 ? assetConfig : undefined, + zeroTrust, + }); const cutoverDomains = await getDomainsByProjectId(txDb, projectId); await putTrackedRouting(project.slug, routingJson); await putTrackedRouting( @@ -5443,7 +5463,14 @@ async function handleManifestOnlyDeploy(context: Context): Promise= ORPHAN_ADOPTION_MIN_AGE_MS; } +async function protectedProjectReachedDomainLimit(db: Db, projectId: string): Promise { + const zeroTrust = await getProjectZeroTrustState(db, projectId); + if (!zeroTrust?.desired_protected) { + return false; + } + return ( + (await getDomainsByProjectId(db, projectId)).length >= MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT + ); +} + const app = new Hono() .use('*', capabilityMiddleware('customDomains')) @@ -81,6 +97,9 @@ const app = new Hono() if (existing) { return context.json({ error: 'hostname already in use' }, 409); } + if (await protectedProjectReachedDomainLimit(context.get('db'), projectId)) { + return context.json({ error: DOMAIN_LIMIT_ERROR }, 409); + } // Create custom hostname via CF API. CF's 4xx responses are not all // semantically equivalent and only a subset can be safely surfaced to @@ -201,6 +220,9 @@ const app = new Hono() if (lockedExisting) { return context.json({ error: 'hostname already in use' }, 409); } + if (await protectedProjectReachedDomainLimit(txDb, projectId)) { + return context.json({ error: DOMAIN_LIMIT_ERROR }, 409); + } // Under HTTP-DCV the certificate is validated at CF's edge once the // hostname proxies through it, so CF never emits @@ -341,6 +363,14 @@ const app = new Hono() return lockedResponse; } + await reconcileProjectZeroTrustDomains(context.env, context.get('db'), projectId).catch( + (err) => { + warnings.push( + `Failed to reconcile Zero Trust custom-domain protection: ${err instanceof Error ? err.message : String(err)}`, + ); + }, + ); + return context.json({ ok: true, ...(warnings.length > 0 && { warnings }) }); }); diff --git a/platform/packages/api/src/routes/project-zero-trust.ts b/platform/packages/api/src/routes/project-zero-trust.ts new file mode 100644 index 000000000..dc5d3ec54 --- /dev/null +++ b/platform/packages/api/src/routes/project-zero-trust.ts @@ -0,0 +1,109 @@ +import { Hono } from 'hono'; +import type { ApiEnv } from '../env.ts'; +import { + ZeroTrustConfigurationError, + readZeroTrustConfigurationStrict, +} from '../zero-trust/configuration.ts'; +import { + getProjectZeroTrustState, + publicProjectZeroTrustState, + reconcileProjectZeroTrust, + setProjectZeroTrustProtection, + ZeroTrustUnavailableError, +} from '../zero-trust/service.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; + +const app = new Hono() + .get('/:id/zero-trust', async (context) => { + const projectId = context.req.param('id'); + const access = await requireProjectRouteCapability(context, projectId, 'read'); + if (access instanceof Response) { + return access; + } + let configuration; + try { + configuration = await readZeroTrustConfigurationStrict(context.get('db')); + } catch (error) { + if (!(error instanceof ZeroTrustConfigurationError)) { + throw error; + } + return context.json({ + available: false, + platformStatus: 'invalid_configuration', + protectNewProjects: false, + project: publicProjectZeroTrustState( + await getProjectZeroTrustState(context.get('db'), projectId), + ), + }); + } + const state = await getProjectZeroTrustState(context.get('db'), projectId); + return context.json({ + available: !!configuration?.enabled, + platformStatus: configuration?.status ?? 'not_configured', + protectNewProjects: configuration?.protectNewProjects ?? false, + project: publicProjectZeroTrustState(state), + }); + }) + .put('/:id/zero-trust', async (context) => { + const projectId = context.req.param('id'); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; + } + let body: unknown; + try { + body = await context.req.json(); + } catch { + return context.json({ error: 'a JSON body is required' }, 400); + } + const protectedProject = + body && typeof body === 'object' && !Array.isArray(body) + ? (body as Record).protected + : undefined; + if (typeof protectedProject !== 'boolean') { + return context.json({ error: 'protected must be a boolean' }, 400); + } + try { + const project = await setProjectZeroTrustProtection( + context.env, + context.get('db'), + projectId, + protectedProject, + ); + return context.json({ ok: true, project: publicProjectZeroTrustState(project) }); + } catch (error) { + if (error instanceof ZeroTrustUnavailableError) { + return context.json({ error: error.message }, error.status); + } + console.error('[zero-trust] project update failed', { projectId, error }); + return context.json( + { + error: 'Zero Trust reconciliation failed. Protection remains fail closed; retry later.', + }, + 502, + ); + } + }) + .post('/:id/zero-trust/reconcile', async (context) => { + const projectId = context.req.param('id'); + const access = await requireProjectRouteCapability(context, projectId, 'settings'); + if (access instanceof Response) { + return access; + } + try { + return context.json({ + ok: true, + project: publicProjectZeroTrustState( + await reconcileProjectZeroTrust(context.env, context.get('db'), projectId), + ), + }); + } catch (error) { + if (error instanceof ZeroTrustUnavailableError) { + return context.json({ error: error.message }, error.status); + } + console.error('[zero-trust] project reconcile failed', { projectId, error }); + return context.json({ error: 'Zero Trust reconciliation failed.' }, 502); + } + }); + +export default app; diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 6899fed09..d9b040f5e 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -1,3 +1,4 @@ +import type { ProjectZeroTrustRouting } from '@void/edge'; import { sql as drizzleSql } from 'drizzle-orm'; import { Hono, type Context } from 'hono'; import { deployTokenTtlSecondsForBuildMinutes, signPrerenderToken } from '../auth/deploy-token.ts'; @@ -84,6 +85,13 @@ import { import { tokenizeSql } from '../sql/tokenize.ts'; import { getPlanLimits } from '../tiers.ts'; import type { Build, DeployManifest, Project, ProjectRole, User } from '../types.ts'; +import { + initializeNewProjectZeroTrust, + publicProjectZeroTrustState, + resolveProjectZeroTrustRouting, + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from '../zero-trust/service.ts'; import { rollbackCfDestination } from './email.ts'; import { requireProjectRouteCapability } from './project-access.ts'; import { getRequestsDataset, queryProjectRequestOverview } from './usage.ts'; @@ -729,6 +737,18 @@ const app = new Hono() const userId = context.get('user').id; try { const project = await createProject(context.get('db'), userId, body.slug); + let zeroTrust: Awaited> = null; + let zeroTrustWarning: string | undefined; + try { + zeroTrust = await initializeNewProjectZeroTrust(context.env, context.get('db'), project.id); + } catch (error) { + zeroTrustWarning = + 'Project created, but its Zero Trust protection could not be initialized. Reconcile the project Zero Trust status before deployment.'; + console.error('[zero-trust] new project initialization failed', { + projectId: project.id, + error, + }); + } // Pre-seed the owner's own address as a destination so mail to // themselves works from the first deploy. CF sends them a verification // link; the row stays `pending` until they click it. @@ -768,9 +788,24 @@ const app = new Hono() // which has not run yet. The CLI prints this so the verification // email that may follow is explained; `void email destinations` // shows the outcome. - return context.json({ ...project, owner_email_destination: { address } }, 201); + return context.json( + { + ...project, + owner_email_destination: { address }, + ...(zeroTrust ? { zero_trust: publicProjectZeroTrustState(zeroTrust) } : {}), + ...(zeroTrustWarning ? { warnings: [zeroTrustWarning] } : {}), + }, + 201, + ); } - return context.json(projectWithUrl(project, context.env), 201); + return context.json( + { + ...projectWithUrl(project, context.env), + ...(zeroTrust ? { zero_trust: publicProjectZeroTrustState(zeroTrust) } : {}), + ...(zeroTrustWarning ? { warnings: [zeroTrustWarning] } : {}), + }, + 201, + ); } catch (err) { // Unique constraint violation — slug was taken between check and insert if ( @@ -1010,7 +1045,7 @@ const app = new Hono() // we restore KV, cron, and queue state from the callback's undo log. let rollbackLeaseId: string | undefined; const rollbackOwnerId = crypto.randomUUID(); - const externalUndo: Array<() => Promise> = []; + const externalUndo: Array<(zeroTrust: ProjectZeroTrustRouting | null) => Promise> = []; const routingValuesWritten = new Map(); let externalCompensated = false; let rollbackCommitted = false; @@ -1313,6 +1348,7 @@ const app = new Hono() const workerName = getProjectDeploymentWorkerName(lockedProject.slug, target.id); const existingRouting = await context.env.ROUTING.get(lockedProject.slug); const existingEntry = existingRouting ? JSON.parse(existingRouting) : null; + const zeroTrust = await resolveProjectZeroTrustRouting(txDb, projectId); const targetType = manifest?.type ?? (manifest?.framework @@ -1354,6 +1390,7 @@ const app = new Hono() assetConfig, manifest, suspended: existingEntry?.suspended, + zeroTrust, }); } else { routingJson = buildRoutingJson({ @@ -1367,6 +1404,7 @@ const app = new Hono() hasCrons: !!manifest?.schedules?.length, assetManifestId: targetHasAssets ? target.id : undefined, assetConfig: targetHasAssets ? manifest?.assetConfig : undefined, + zeroTrust, }); } @@ -1382,7 +1420,7 @@ const app = new Hono() } const previous = await context.env.ROUTING.get(key); snapshottedRoutingKeys.add(key); - externalUndo.push(async () => { + externalUndo.push(async (currentZeroTrust) => { if (!routingValuesWritten.has(key)) { return; } @@ -1397,7 +1435,12 @@ const app = new Hono() if (undo.value === null) { await context.env.ROUTING.delete(key); } else { - await context.env.ROUTING.put(key, undo.value); + await context.env.ROUTING.put( + key, + key === lockedProject.slug || key.startsWith('domain:') + ? withProjectZeroTrustRouting(undo.value, currentZeroTrust) + : undo.value, + ); } }); }; @@ -1598,7 +1641,7 @@ const app = new Hono() const compensationErrors: Array = []; for (const undo of externalUndo.reverse()) { try { - await undo(); + await undo(zeroTrust); } catch (compensationError) { compensationErrors.push(compensationError); } @@ -1698,9 +1741,10 @@ const app = new Hono() if (!stillOwnsLease) { return; } + const zeroTrust = await resolveProjectZeroTrustRoutingForPublication(txDb, projectId); for (const undo of [...externalUndo].reverse()) { try { - await undo(); + await undo(zeroTrust); } catch (compensationError) { compensationErrors.push(compensationError); } @@ -1717,7 +1761,9 @@ const app = new Hono() // database status update was interrupted. let restoredProjectRouting: string | null = null; try { - restoredProjectRouting = await context.env.ROUTING.get(project.slug); + const rawRouting = await context.env.ROUTING.get(project.slug); + restoredProjectRouting = + rawRouting === null ? null : withProjectZeroTrustRouting(rawRouting, zeroTrust); } catch (compensationError) { compensationErrors.push(compensationError); } diff --git a/platform/packages/api/src/routing-limits.ts b/platform/packages/api/src/routing-limits.ts new file mode 100644 index 000000000..44963b2e7 --- /dev/null +++ b/platform/packages/api/src/routing-limits.ts @@ -0,0 +1,50 @@ +import type { PlanLimits } from './tiers.ts'; +import type { DeployManifest } from './types.ts'; + +/** + * Compute the `dispatchLimits` blob `buildRoutingJson` embeds in a routing + * entry, honoring an optional per-project `manifest.limits.cpuMs` override. + * + * - Override present: clamp to the plan's `maxCpuMsPerRequest` when that + * ceiling is non-zero (rollback under a downgraded plan clamps, never + * rejects — ingest is where over-ceiling requests get a 400), otherwise + * pass it through verbatim. + * - No override: fall back to the plan default `cpuMsPerRequest` (0 = no + * cpuMs key), byte-identical in shape to the historical inline expression. + * - An override that is not a safe integer >= 1 is treated as absent and + * falls back to the plan default. A rollback replays a stored manifest, + * and a manifest stored before ingest validated `limits` can carry an + * out-of-domain value that must not erase or corrupt the plan cap. + * - `subRequests` always comes from the plan (0 = no key). + * + * Returns `null` when neither limit is set so the routing entry omits the + * `limits` key entirely. + */ +export function computeDispatchLimits( + planLimits: PlanLimits, + manifestLimits: DeployManifest['limits'] | null | undefined, +): { cpuMs?: number; subRequests?: number } | null { + const rawRequested = manifestLimits?.cpuMs; + const requested = + typeof rawRequested === 'number' && Number.isSafeInteger(rawRequested) && rawRequested >= 1 + ? rawRequested + : undefined; + const max = planLimits.maxCpuMsPerRequest; + const cpuMs = + requested !== undefined + ? max > 0 + ? Math.min(requested, max) + : requested + : planLimits.cpuMsPerRequest > 0 + ? planLimits.cpuMsPerRequest + : undefined; + const subRequests = + planLimits.subRequestsPerInvocation > 0 ? planLimits.subRequestsPerInvocation : undefined; + if (!cpuMs && !subRequests) { + return null; + } + return { + ...(cpuMs ? { cpuMs } : {}), + ...(subRequests ? { subRequests } : {}), + }; +} diff --git a/platform/packages/api/src/routing.ts b/platform/packages/api/src/routing.ts index e5883aca4..a3aa8bbed 100644 --- a/platform/packages/api/src/routing.ts +++ b/platform/packages/api/src/routing.ts @@ -1,7 +1,11 @@ +import type { ProjectZeroTrustRouting } from '@void/edge'; import { sql as drizzleSql } from 'drizzle-orm'; import { getDomainsByProjectIds, getProject, type Db } from './db.ts'; -import type { PlanLimits } from './tiers.ts'; import type { AssetConfig, DeployManifest } from './types.ts'; +import { + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from './zero-trust/service.ts'; /** * KV key + payload shape for the per-project routing entry consumed by the @@ -21,6 +25,8 @@ export function projectAccountKey(projectId: string): string { return `project:${projectId}`; } +export { computeDispatchLimits } from './routing-limits.ts'; + export function sandboxBlockKey(userId: string): string { return `sandbox-block:${userId}`; } @@ -92,10 +98,10 @@ export type RoutingSuspensionUpdate = /** * Resolve a routing undo without overwriting a later authoritative mutation. - * Exact matches restore directly. A suspension-only rewrite still belongs to - * the attempted deployment, so restore its prior route while carrying the - * newest `suspended` marker. Any other change (including deletion) wins and - * suppresses the stale undo. + * Exact matches restore directly. Suspension and Zero Trust rewrites still + * belong to the attempted deployment, so restore its prior route while carrying + * the newest protection metadata. Any other change (including deletion) wins + * and suppresses the stale undo. */ export function resolveRoutingUndo( current: string | null, @@ -111,10 +117,15 @@ export function resolveRoutingUndo( try { const currentEntry = JSON.parse(current) as Record; const writtenEntry = JSON.parse(written) as Record; - const hasCurrentSuspension = Object.hasOwn(currentEntry, 'suspended'); - const currentSuspension = currentEntry.suspended; - delete currentEntry.suspended; - delete writtenEntry.suspended; + const protectionFields = ['suspended', 'zeroTrust'] as const; + const currentProtection: Record = {}; + for (const field of protectionFields) { + if (Object.hasOwn(currentEntry, field)) { + currentProtection[field] = currentEntry[field]; + } + delete currentEntry[field]; + delete writtenEntry[field]; + } if (JSON.stringify(currentEntry) !== JSON.stringify(writtenEntry)) { return { restore: false, value: null }; } @@ -122,10 +133,12 @@ export function resolveRoutingUndo( return { restore: true, value: null }; } const restoredEntry = JSON.parse(previous) as Record; - if (hasCurrentSuspension) { - restoredEntry.suspended = currentSuspension; - } else { - delete restoredEntry.suspended; + for (const field of protectionFields) { + if (Object.hasOwn(currentProtection, field)) { + restoredEntry[field] = currentProtection[field]; + } else { + delete restoredEntry[field]; + } } return { restore: true, value: JSON.stringify(restoredEntry) }; } catch { @@ -143,11 +156,14 @@ export function resolveRoutingUndo( * mid-deploy), and skips the write whenever the entry already satisfies the * update (set → same reason / weaker reason preserved, clear → no matching * marker) so repeated suspend/unsuspend is a cheap no-op. + * Production callers supply protection read under the project lock, because a + * stale KV value must never decide protection for a newly published route. */ export async function applyRoutingSuspension( routing: KVNamespace, keys: ReadonlyArray, update: RoutingSuspensionUpdate, + protection?: ProjectZeroTrustRouting | null, ): Promise { for (const key of keys) { const raw = await routing.get(key); @@ -172,7 +188,11 @@ export async function applyRoutingSuspension( } delete entry.suspended; } - await routing.put(key, JSON.stringify(entry)); + const next = JSON.stringify(entry); + await routing.put( + key, + protection === undefined ? next : withProjectZeroTrustRouting(next, protection), + ); } } @@ -213,7 +233,7 @@ export async function setProjectsRoutingSuspension( const current = await getProject(tx as unknown as Db, project.id); // If ownership changed before this lock, transfer convergence owns the // route and this stale fan-out must stand down. - if (current?.user_id !== project.user_id) { + if (!current || current.user_id !== project.user_id || current.status === 'deleting') { return; } // Resolve domains only after the project lock. Domain activation takes @@ -221,62 +241,15 @@ export async function setProjectsRoutingSuspension( // behind it or a domain activated between the eager read and this lock // could escape the suspension entirely. const domainMap = await getDomainsByProjectIds(tx as unknown as Db, [project.id]); + const protection = await resolveProjectZeroTrustRoutingForPublication(tx, project.id); const domainKeys = (domainMap.get(project.id) ?? []).map( (domain) => `domain:${domain.hostname}`, ); - await applyRoutingSuspension(routing, [project.slug, ...domainKeys], update); + await applyRoutingSuspension(routing, [project.slug, ...domainKeys], update, protection); }); } } -/** - * Compute the `dispatchLimits` blob `buildRoutingJson` embeds in a routing - * entry, honoring an optional per-project `manifest.limits.cpuMs` override. - * - * - Override present: clamp to the plan's `maxCpuMsPerRequest` when that - * ceiling is non-zero (rollback under a downgraded plan clamps, never - * rejects — ingest is where over-ceiling requests get a 400), otherwise - * pass it through verbatim. - * - No override: fall back to the plan default `cpuMsPerRequest` (0 = no - * cpuMs key), byte-identical in shape to the historical inline expression. - * - An override that is not a safe integer >= 1 is treated as absent and - * falls back to the plan default. A rollback replays a stored manifest, - * and a manifest stored before ingest validated `limits` can carry an - * out-of-domain value that must not erase or corrupt the plan cap. - * - `subRequests` always comes from the plan (0 = no key). - * - * Returns `null` when neither limit is set so the routing entry omits the - * `limits` key entirely. - */ -export function computeDispatchLimits( - planLimits: PlanLimits, - manifestLimits: DeployManifest['limits'] | null | undefined, -): { cpuMs?: number; subRequests?: number } | null { - const rawRequested = manifestLimits?.cpuMs; - const requested = - typeof rawRequested === 'number' && Number.isSafeInteger(rawRequested) && rawRequested >= 1 - ? rawRequested - : undefined; - const max = planLimits.maxCpuMsPerRequest; - const cpuMs = - requested !== undefined - ? max > 0 - ? Math.min(requested, max) - : requested - : planLimits.cpuMsPerRequest > 0 - ? planLimits.cpuMsPerRequest - : undefined; - const subRequests = - planLimits.subRequestsPerInvocation > 0 ? planLimits.subRequestsPerInvocation : undefined; - if (!cpuMs && !subRequests) { - return null; - } - return { - ...(cpuMs ? { cpuMs } : {}), - ...(subRequests ? { subRequests } : {}), - }; -} - export function buildRoutingJson(opts: { workerName: string; projectId: string; @@ -288,6 +261,7 @@ export function buildRoutingJson(opts: { hasCrons?: boolean; assetManifestId?: string; assetConfig?: AssetConfig; + zeroTrust?: ProjectZeroTrustRouting | null; }): string { const { manifest } = opts; return JSON.stringify({ @@ -314,6 +288,7 @@ export function buildRoutingJson(opts: { ...(opts.hasCrons ? { hasCrons: true } : {}), ...(opts.assetManifestId ? { assetManifestId: opts.assetManifestId } : {}), ...(opts.assetConfig ? { assetConfig: opts.assetConfig } : {}), + ...(opts.zeroTrust ? { zeroTrust: opts.zeroTrust } : {}), }); } @@ -324,6 +299,7 @@ export function buildStaticRoutingJson(opts: { assetConfig: AssetConfig; manifest: DeployManifest | null; suspended?: string; + zeroTrust?: ProjectZeroTrustRouting | null; }): string { const { manifest } = opts; return JSON.stringify({ @@ -341,5 +317,6 @@ export function buildStaticRoutingJson(opts: { ? { queryAllowlist: manifest.revalidateQueryAllowlist } : {}), ...(opts.suspended ? { suspended: opts.suspended } : {}), + ...(opts.zeroTrust ? { zeroTrust: opts.zeroTrust } : {}), }); } diff --git a/platform/packages/api/src/schema.ts b/platform/packages/api/src/schema.ts index 040b92dbf..312cf56fd 100644 --- a/platform/packages/api/src/schema.ts +++ b/platform/packages/api/src/schema.ts @@ -472,6 +472,57 @@ export const projectAccessEvents = pgTable( ], ); +// --------------------------------------------------------------------------- +// Project Cloudflare Access state. The desired/effective split is deliberate: +// publication is a multi-system operation and dispatch must keep enforcing the +// last completed state until the corresponding Access application mutation has +// been verified. Application ids are durable recovery handles for retries and +// project teardown. +// --------------------------------------------------------------------------- +export const projectZeroTrust = pgTable( + 'project_zero_trust', + { + project_id: text('project_id') + .primaryKey() + .references(() => projects.id, { onDelete: 'cascade' }), + desired_protected: boolean('desired_protected').notNull().default(false), + effective_protected: boolean('effective_protected').notNull().default(false), + state: text('state') + .notNull() + .default('public') + .$type<'public' | 'protected' | 'reconciling' | 'error'>(), + bypass_application_id: text('bypass_application_id'), + bypass_application_name: text('bypass_application_name'), + protected_application_id: text('protected_application_id'), + protected_application_name: text('protected_application_name'), + protected_application_audience: text('protected_application_audience'), + // Last custom hostname whose routing protection was cleared. Public + // projects can have more domains than fit in one reconciliation run. + routing_cursor: text('routing_cursor'), + // Set only after every route and provider application reflects the + // current platform operation, after its wildcard preparation phase. + reconciled_operation: text('reconciled_operation'), + last_error: text('last_error'), + created_at: timestamp('created_at', { mode: 'string' }).notNull().defaultNow(), + updated_at: timestamp('updated_at', { mode: 'string' }).notNull().defaultNow(), + }, + (table) => [ + index('idx_project_zero_trust_state').on(table.state, table.updated_at), + check( + 'project_zero_trust_state_check', + sql`${table.state} IN ('public', 'protected', 'reconciling', 'error')`, + ), + check( + 'project_zero_trust_public_check', + sql`${table.state} <> 'public' OR (${table.desired_protected} = false AND ${table.effective_protected} = false)`, + ), + check( + 'project_zero_trust_protected_check', + sql`${table.state} <> 'protected' OR (${table.desired_protected} = true AND ${table.effective_protected} = true)`, + ), + ], +); + // --------------------------------------------------------------------------- // deployments // --------------------------------------------------------------------------- diff --git a/platform/packages/api/src/types.ts b/platform/packages/api/src/types.ts index e5555c0a7..8bdeadd39 100644 --- a/platform/packages/api/src/types.ts +++ b/platform/packages/api/src/types.ts @@ -14,6 +14,7 @@ import type { projectInvitations, projectMembers, projectOwnershipPeriods, + projectZeroTrust, projects, projectSecrets, resources, @@ -74,6 +75,7 @@ export type Project = InferSelectModel; export type ProjectMember = InferSelectModel; export type ProjectInvitation = InferSelectModel; export type ProjectOwnershipPeriod = InferSelectModel; +export type ProjectZeroTrust = InferSelectModel; export type Deployment = InferSelectModel; export type DeploymentStatus = Deployment['status']; export type ProjectSecret = InferSelectModel; diff --git a/platform/packages/api/src/zero-trust/application.ts b/platform/packages/api/src/zero-trust/application.ts new file mode 100644 index 000000000..6ff71b234 --- /dev/null +++ b/platform/packages/api/src/zero-trust/application.ts @@ -0,0 +1,51 @@ +import { readPlatformApplication, type PlatformApplication } from '@void/platform'; +import type { DbOrTx } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { getPlatformSetting } from '../platform-settings.ts'; +import { + parseZeroTrustApplicationTarget, + readZeroTrustConfigurationStrict, + ZeroTrustConfigurationError, + type ZeroTrustConfiguration, +} from './configuration.ts'; + +export const PLATFORM_APPLICATION_SETTING = 'platform.application'; + +export function applicationTargetEnvironment( + env: ApiEnv['Bindings'], + target: PlatformApplication, +): ApiEnv['Bindings'] { + const current = readPlatformApplication(env); + if ( + !current.workersDev || + !target.workersDev || + current.workersDev.installationId !== target.workersDev.installationId || + current.workersDev.subdomain !== target.workersDev.subdomain || + target.workersDev.installationId !== env.PLATFORM_INSTALLATION_ID || + (current.zoneName && current.baseDomain !== target.baseDomain) + ) { + throw new ZeroTrustConfigurationError('Application target does not match this installation.'); + } + return { + ...env, + PLATFORM_APPLICATION: JSON.stringify(target), + SITE_DOMAIN: target.baseDomain, + CF_ZONE_ID: target.zoneId!, + }; +} + +/** Durable targets take precedence over Worker vars and eventually consistent KV reads. */ +export async function resolveZeroTrustApplicationEnvironment( + env: ApiEnv['Bindings'], + db: DbOrTx, + configuration?: ZeroTrustConfiguration | null, +): Promise { + const saved = + configuration === undefined ? await readZeroTrustConfigurationStrict(db) : configuration; + const value = + saved?.operation?.applicationTarget ?? + (await getPlatformSetting(db, PLATFORM_APPLICATION_SETTING)); + return value === null + ? env + : applicationTargetEnvironment(env, parseZeroTrustApplicationTarget(value)); +} diff --git a/platform/packages/api/src/zero-trust/cloudflare.ts b/platform/packages/api/src/zero-trust/cloudflare.ts new file mode 100644 index 000000000..05b1ffd01 --- /dev/null +++ b/platform/packages/api/src/zero-trust/cloudflare.ts @@ -0,0 +1,531 @@ +import type { ManagedAccessApplication } from './configuration.ts'; + +type ObjectValue = Record; + +type CloudflareEnvelope = Readonly<{ + success?: boolean; + result?: T; + errors?: ReadonlyArray<{ code?: number; message?: string }>; +}>; + +class ZeroTrustCloudflareError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = 'ZeroTrustCloudflareError'; + } +} + +function object(value: unknown, label: string): ObjectValue { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Cloudflare returned an invalid ${label}.`); + } + return value as ObjectValue; +} + +function text(value: unknown, label: string): string { + if (typeof value !== 'string' || !value || value.length > 8192) { + throw new Error(`Cloudflare did not return ${label}.`); + } + return value; +} + +function destinationList(application: ObjectValue): ReadonlyArray { + const destinations = Array.isArray(application.destinations) ? application.destinations : []; + return [ + ...(typeof application.domain === 'string' ? [application.domain] : []), + ...destinations.flatMap((value) => { + const destination = object(value, 'Access application destination'); + return destination.type === 'public' && typeof destination.uri === 'string' + ? [destination.uri] + : []; + }), + ].filter((value, index, all) => all.indexOf(value) === index); +} + +function identityRule(rule: ObjectValue): boolean { + if (rule.email_domain) { + const domain = object(rule.email_domain, 'email domain').domain; + return typeof domain === 'string' && /^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/i.test(domain); + } + if (rule.email) { + const email = object(rule.email, 'email rule').email; + return typeof email === 'string' && /^[^\s*@]+@[^\s*@]+\.[^\s*@]+$/.test(email); + } + return [ + 'email_list', + 'gsuite', + 'github-organization', + 'saml', + 'okta', + 'azureAD', + 'login_method', + 'auth_context', + 'group', + ].some( + (key) => + Object.hasOwn(rule, key) && + !!rule[key] && + typeof rule[key] === 'object' && + !Array.isArray(rule[key]) && + Object.keys(rule[key] as object).length > 0, + ); +} + +type AccessApplicationSpec = Readonly<{ + name: string; + destinations: ReadonlyArray; + allowedIdentityProviderIds?: ReadonlyArray; + policies: ReadonlyArray; +}>; + +function sameDestinations( + application: ObjectValue, + expectedDestinations: ReadonlyArray, +): boolean { + const actual = [...destinationList(application)].sort(); + const expected = [...new Set(expectedDestinations)].sort(); + return ( + actual.length === expected.length && + expected.every((destination, index) => destination === actual[index]) + ); +} + +function samePolicies(actualValue: unknown, expected: ReadonlyArray): boolean { + if (!Array.isArray(actualValue) || actualValue.length !== expected.length) { + return false; + } + return expected.every((expectedPolicy, index) => { + const actualPolicy = object(actualValue[index], 'Access application policy'); + if (actualPolicy.precedence !== expectedPolicy.precedence) { + return false; + } + if (typeof expectedPolicy.id === 'string') { + return actualPolicy.id === expectedPolicy.id; + } + return ( + actualPolicy.decision === expectedPolicy.decision && + JSON.stringify(actualPolicy.include) === JSON.stringify(expectedPolicy.include) + ); + }); +} + +function applicationConfigurationDrift( + application: ObjectValue, + spec: AccessApplicationSpec, +): boolean { + const expectedIdps = [...(spec.allowedIdentityProviderIds ?? [])].sort(); + const actualIdps = Array.isArray(application.allowed_idps) + ? application.allowed_idps.filter((value): value is string => typeof value === 'string').sort() + : []; + const idpsDrifted = spec.allowedIdentityProviderIds + ? actualIdps.length !== expectedIdps.length || + !expectedIdps.every((id, index) => id === actualIdps[index]) || + application.auto_redirect_to_identity !== (expectedIdps.length === 1) + : actualIdps.length > 0 || application.auto_redirect_to_identity === true; + return ( + application.name !== spec.name || + application.type !== 'self_hosted' || + application.session_duration !== '12h' || + !sameDestinations(application, spec.destinations) || + idpsDrifted || + !samePolicies(application.policies, spec.policies) + ); +} + +export class ZeroTrustCloudflareClient { + readonly #base: string; + + constructor( + accountId: string, + private readonly token: string, + private readonly fetcher: typeof fetch = fetch, + apiBase = 'https://api.cloudflare.com/client/v4', + ) { + this.#base = `${apiBase.replace(/\/+$/, '')}/accounts/${encodeURIComponent(accountId)}/access`; + } + + private async request(path: string, init?: RequestInit): Promise { + const response = await this.fetcher(`${this.#base}/${path.replace(/^\/+/, '')}`, { + ...init, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${this.token}`, + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + ...init?.headers, + }, + redirect: 'manual', + signal: init?.signal ?? AbortSignal.timeout(30_000), + }); + let body: CloudflareEnvelope; + try { + body = (await response.json()) as CloudflareEnvelope; + } catch { + throw new ZeroTrustCloudflareError( + `Cloudflare returned a non-JSON response (HTTP ${response.status}).`, + response.status, + ); + } + if (!response.ok || body.success !== true || body.result === undefined) { + const detail = + body.errors + ?.map((error) => error.message) + .filter(Boolean) + .join(', ') || `HTTP ${response.status}`; + throw new ZeroTrustCloudflareError( + `Cloudflare Zero Trust API error: ${detail} (HTTP ${response.status}).`, + response.status, + ); + } + return body.result; + } + + async getOrganization(): Promise<{ teamDomain: string }> { + const organization = object(await this.request('organizations'), 'Access organization'); + const authDomain = text(organization.auth_domain, 'Access team domain'); + const teamDomain = ( + authDomain.startsWith('https://') ? authDomain : `https://${authDomain}` + ).replace(/\/+$/, ''); + const url = new URL(teamDomain); + if ( + url.protocol !== 'https:' || + !url.hostname.endsWith('.cloudflareaccess.com') || + url.origin !== teamDomain + ) { + throw new Error('Cloudflare returned an invalid Access team domain.'); + } + return { teamDomain }; + } + + async validateSelection(input: { + identityProviderIds: ReadonlyArray; + policyIds: ReadonlyArray; + }): Promise<{ teamDomain: string }> { + const [{ teamDomain }, ...providers] = await Promise.all([ + this.getOrganization(), + ...input.identityProviderIds.map((id) => + this.request(`identity_providers/${encodeURIComponent(id)}`), + ), + ]); + for (const [index, provider] of providers.entries()) { + if ( + text(object(provider, 'identity provider').id, 'identity provider id') !== + input.identityProviderIds[index] + ) { + throw new Error( + `Cloudflare Access identity provider ${input.identityProviderIds[index]} is unavailable.`, + ); + } + } + const policies = await Promise.all( + input.policyIds.map((id) => + this.request(`policies/${encodeURIComponent(id)}`).then((value) => + object(value, 'Access policy'), + ), + ), + ); + await this.verifyCompanyPolicies(policies); + return { teamDomain }; + } + + private async verifyCompanyPolicies(policies: ReadonlyArray): Promise { + let allowPolicies = 0; + const inspectedGroups = new Set(); + const activeGroups = new Set(); + const checkRules = async (value: unknown, depth = 0): Promise => { + if (value === undefined) { + return; + } + if (!Array.isArray(value) || value.length > 100 || depth > 8) { + throw new Error('Access policies must contain bounded identity rules.'); + } + for (const raw of value) { + const rule = object(raw, 'Access policy rule'); + if (Object.hasOwn(rule, 'everyone') || Object.hasOwn(rule, 'any_valid_service_token')) { + throw new Error('Access project policies must not allow Everyone or any service token.'); + } + if (rule.group) { + const id = text(object(rule.group, 'Access group rule').id, 'Access group id'); + if (activeGroups.has(id)) { + throw new Error('Access groups contain a recursive membership rule.'); + } + if (!inspectedGroups.has(id)) { + if (inspectedGroups.size >= 100) { + throw new Error('Access group policy exceeds the inspection limit.'); + } + activeGroups.add(id); + const group = object( + await this.request(`groups/${encodeURIComponent(id)}`), + 'Access group', + ); + await checkRules(group.include, depth + 1); + await checkRules(group.require, depth + 1); + if (!Array.isArray(group.include) || group.include.length < 1) { + throw new Error('Access groups must contain explicit identity rules.'); + } + const groupHasRequiredIdentity = + Array.isArray(group.require) && + group.require.some((entry) => identityRule(object(entry, 'Access group rule'))); + if ( + !groupHasRequiredIdentity && + !group.include.every((entry) => identityRule(object(entry, 'Access group rule'))) + ) { + throw new Error('Access groups must restrict company identities.'); + } + activeGroups.delete(id); + inspectedGroups.add(id); + } + } + } + }; + for (const policy of policies) { + if (policy.decision === 'bypass' || policy.decision === 'non_identity') { + throw new Error('Bypass and non-identity policies cannot protect Void projects.'); + } + if (policy.decision !== 'allow' && policy.decision !== 'deny') { + throw new Error('Unsupported Cloudflare Access policy decision.'); + } + if (policy.decision === 'allow') { + allowPolicies += 1; + await checkRules(policy.include); + await checkRules(policy.require); + const hasRequiredIdentity = + Array.isArray(policy.require) && + policy.require.some((entry) => identityRule(object(entry, 'Access policy rule'))); + if ( + !Array.isArray(policy.include) || + policy.include.length < 1 || + (!hasRequiredIdentity && + !policy.include.every((entry) => identityRule(object(entry, 'Access policy rule')))) + ) { + throw new Error('Access allow policies must restrict company identities.'); + } + } + } + if (allowPolicies === 0) { + throw new Error('Select at least one Cloudflare Access allow policy.'); + } + } + + private applicationBody(spec: AccessApplicationSpec): ObjectValue { + const destinations = [...new Set(spec.destinations)]; + if (destinations.length < 1 || destinations.length > 100) { + throw new Error('An Access application needs between 1 and 100 destinations.'); + } + return { + name: spec.name, + type: 'self_hosted', + domain: destinations[0], + destinations: destinations.map((uri) => ({ type: 'public', uri })), + session_duration: '12h', + ...(spec.allowedIdentityProviderIds + ? { + allowed_idps: [...spec.allowedIdentityProviderIds], + auto_redirect_to_identity: spec.allowedIdentityProviderIds.length === 1, + } + : {}), + policies: [...spec.policies], + }; + } + + private async applications(): Promise> { + // Name checkpoints can come from another request after any earlier inventory. + // Always refresh before using a name to recover ownership or establish absence. + const entries: Array = []; + for (let page = 1; page <= 6; page++) { + const result = await this.request>(`apps?page=${page}&per_page=100`); + const parsed = result.map((entry) => object(entry, 'Access application')); + if (page === 6 && parsed.length > 0) { + throw new Error( + 'Cloudflare Access has more than 500 applications; Void cannot safely reconcile this installation within provider limits.', + ); + } + entries.push(...parsed); + if (parsed.length < 100) { + break; + } + } + return new Map( + entries.map((application) => [text(application.id, 'application id'), application]), + ); + } + + private async application(id: string): Promise { + try { + const application = object( + await this.request(`apps/${encodeURIComponent(id)}`), + 'Access application', + ); + if (text(application.id, 'application id') !== id) { + throw new Error('Cloudflare returned a different Access application id.'); + } + return application; + } catch (error) { + if (error instanceof ZeroTrustCloudflareError && error.status === 404) { + return null; + } + throw error; + } + } + + private verifyApplication( + application: ObjectValue, + spec: AccessApplicationSpec, + ): ManagedAccessApplication { + const id = text(application.id, 'application id'); + const expectedDestinations = [...new Set(spec.destinations)].sort(); + if (applicationConfigurationDrift(application, spec)) { + throw new Error('Cloudflare Access did not retain the requested application configuration.'); + } + return { + id, + name: spec.name, + audience: text(application.aud, 'application audience'), + destinations: expectedDestinations, + }; + } + + async ensureApplication( + spec: AccessApplicationSpec, + current?: Readonly<{ id?: string | null; name?: string | null }>, + ): Promise { + let application = current?.id ? await this.application(current.id) : null; + if (application && application.name !== spec.name) { + throw new Error('The recorded Access application no longer has its Void-owned name.'); + } + let inventory: Map | undefined; + if (!application) { + inventory = await this.applications(); + const sameName = [...inventory.values()].filter((entry) => entry.name === spec.name); + if (current?.id && sameName.length > 0) { + throw new Error( + `The recorded Access application ${current.id} is missing and its name is now owned by another application.`, + ); + } + if (current?.name) { + if (current.name !== spec.name) { + throw new Error('The recorded Access application no longer has its Void-owned name.'); + } + if (sameName.length > 1) { + throw new Error(`Multiple Cloudflare Access applications are named ${spec.name}.`); + } + application = sameName[0] ?? null; + } else { + if (sameName.length > 0) { + throw new Error( + `A Cloudflare Access application named ${spec.name} exists without a recorded Void creation intent.`, + ); + } + } + } + const body = this.applicationBody(spec); + if (application) { + const id = text(application.id, 'application id'); + application = object( + await this.request(`apps/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify(body), + }), + 'updated Access application', + ); + if (text(application.id, 'application id') !== id) { + throw new Error('Cloudflare returned a different Access application id.'); + } + } else { + application = object( + await this.request('apps', { method: 'POST', body: JSON.stringify(body) }), + 'created Access application', + ); + const id = text(application.id, 'application id'); + if (inventory?.has(id)) { + throw new Error('Cloudflare reused an existing Access application id.'); + } + } + return this.verifyApplication(application, spec); + } + + async inspectApplication( + application: ManagedAccessApplication, + spec: AccessApplicationSpec, + ): Promise<{ + present: boolean; + drifted: boolean; + }> { + try { + const current = object( + await this.request(`apps/${encodeURIComponent(application.id)}`), + 'Access application', + ); + return { + present: true, + drifted: + current.name !== application.name || + current.aud !== application.audience || + applicationConfigurationDrift(current, spec), + }; + } catch (error) { + if (error instanceof ZeroTrustCloudflareError && error.status === 404) { + return { present: false, drifted: true }; + } + throw error; + } + } + + async deleteApplication( + id: string | null | undefined, + expectedName?: string | null, + ): Promise { + if (!id && !expectedName) { + return; + } + let resolvedId = id ?? null; + let application = resolvedId ? await this.application(resolvedId) : null; + if (!application && expectedName) { + const inventory = await this.applications(); + const sameName = [...inventory.values()].filter((entry) => entry.name === expectedName); + if (resolvedId) { + if (sameName.length > 0) { + throw new Error( + `The recorded Access application ${resolvedId} is missing and its name is now owned by another application.`, + ); + } + } else { + if (sameName.length > 1) { + throw new Error(`Multiple Cloudflare Access applications are named ${expectedName}.`); + } + application = sameName[0] ?? null; + resolvedId = application ? text(application.id, 'application id') : null; + } + } + if (!application) { + return; + } + if (expectedName && application.name !== expectedName) { + throw new Error('The recorded Access application no longer has its Void-owned name.'); + } + try { + await this.request(`apps/${encodeURIComponent(resolvedId!)}`, { method: 'DELETE' }); + } catch (error) { + if (!(error instanceof ZeroTrustCloudflareError && error.status === 404)) { + throw error; + } + } + } +} + +export function reusablePolicyReferences(policyIds: ReadonlyArray) { + return policyIds.map((id, index) => ({ id, precedence: index + 1 })); +} + +export function bypassPolicy(name: string): ReadonlyArray { + return [ + { + name, + decision: 'bypass', + precedence: 1, + include: [{ everyone: {} }], + }, + ]; +} diff --git a/platform/packages/api/src/zero-trust/configuration.ts b/platform/packages/api/src/zero-trust/configuration.ts new file mode 100644 index 000000000..04e1b4b8d --- /dev/null +++ b/platform/packages/api/src/zero-trust/configuration.ts @@ -0,0 +1,384 @@ +import type { PlatformApplication } from '@void/platform'; +import { platformProjectIngressPrefix, readPlatformApplication } from '@void/platform'; +import { and, eq, sql } from 'drizzle-orm'; +import type { DbOrTx } from '../db.ts'; +import { getPlatformSetting, setPlatformSetting } from '../platform-settings.ts'; +import type { EncryptedPlatformCredential } from '../project-secrets.ts'; +import { platformSettings } from '../schema.ts'; + +const ZERO_TRUST_SETTING = 'zeroTrust.configuration'; +export const ZERO_TRUST_CREDENTIAL_ID = 'zero-trust-cloudflare-api-token'; + +export type ManagedAccessApplication = Readonly<{ + id: string; + name: string; + audience: string; + destinations: ReadonlyArray; +}>; + +export type ZeroTrustOperation = Readonly<{ + /** Stable across retries; separates completion even when database clocks coincide. */ + id?: string; + kind: 'configure' | 'reconcile' | 'disable'; + phase: 'seed' | 'public-bypasses' | 'wildcard' | 'projects' | 'wildcard-delete' | 'bypass-delete'; + owner: string | null; + leaseExpiresAt: string | null; + startedAt: string; + existingProjects: 'protected' | 'public' | null; + /** Installer-verified domain addition, retained across bounded reconciliation runs. */ + applicationTarget?: PlatformApplication; +}>; + +export type ZeroTrustConfiguration = Readonly<{ + version: 1; + revision: number; + status: 'configuring' | 'ready' | 'disabling' | 'error'; + enabled: boolean; + protectNewProjects: boolean; + accountId: string; + teamDomain: string; + identityProviderIds: ReadonlyArray; + policyIds: ReadonlyArray; + credential: EncryptedPlatformCredential; + wildcardApplicationName: string | null; + wildcardApplication: ManagedAccessApplication | null; + operation: ZeroTrustOperation | null; + lastError: string | null; + updatedAt: string; +}>; + +export type ZeroTrustConfigureInput = Readonly<{ + accountId: string; + apiToken: string; + identityProviderIds: ReadonlyArray; + policyIds: ReadonlyArray; + protectNewProjects: boolean; + existingProjects: 'protected' | 'public'; +}>; + +export class ZeroTrustConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ZeroTrustConfigurationError'; + } +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ZeroTrustConfigurationError(`${label} must be an object`); + } + return value as Record; +} + +function requiredText(value: unknown, label: string, maximum = 8192): string { + if (typeof value !== 'string' || !value.trim() || value.length > maximum) { + throw new ZeroTrustConfigurationError(`${label} must be a non-empty string`); + } + return value.trim(); +} + +function timestamp(value: unknown, label: string): string { + const result = requiredText(value, label, 128); + if (!Number.isFinite(Date.parse(result))) { + throw new ZeroTrustConfigurationError(`${label} is invalid`); + } + return result; +} + +function ids(value: unknown, label: string): ReadonlyArray { + if (!Array.isArray(value) || value.length < 1 || value.length > 16) { + throw new ZeroTrustConfigurationError(`${label} must contain between 1 and 16 ids`); + } + const result = value.map((entry) => requiredText(entry, `${label} id`, 128)); + if (new Set(result).size !== result.length) { + throw new ZeroTrustConfigurationError(`${label} must not contain duplicate ids`); + } + return result; +} + +function parseCredential(value: unknown): EncryptedPlatformCredential { + const input = record(value, 'encrypted Zero Trust credential'); + return { + ciphertext: requiredText(input.ciphertext, 'credential ciphertext', 32 * 1024), + iv: requiredText(input.iv, 'credential IV', 256), + keyVersion: requiredText(input.keyVersion, 'credential key version', 128), + }; +} + +function parseApplication(value: unknown): ManagedAccessApplication | null { + if (value === null) { + return null; + } + const input = record(value, 'managed Access application'); + if (!Array.isArray(input.destinations) || input.destinations.length < 1) { + throw new ZeroTrustConfigurationError('managed Access application needs destinations'); + } + return { + id: requiredText(input.id, 'managed application id', 128), + name: requiredText(input.name, 'managed application name', 128), + audience: requiredText(input.audience, 'managed application audience', 512), + destinations: input.destinations.map((destination) => + requiredText(destination, 'managed application destination', 512), + ), + }; +} + +function parseOperation(value: unknown): ZeroTrustOperation | null { + if (value === null || value === undefined) { + return null; + } + const input = record(value, 'Zero Trust operation'); + if (!['configure', 'reconcile', 'disable'].includes(String(input.kind))) { + throw new ZeroTrustConfigurationError('Zero Trust operation kind is invalid'); + } + if ( + ![ + 'seed', + 'public-bypasses', + 'wildcard', + 'projects', + 'wildcard-delete', + 'bypass-delete', + ].includes(String(input.phase)) + ) { + throw new ZeroTrustConfigurationError('Zero Trust operation phase is invalid'); + } + if (input.owner !== null && typeof input.owner !== 'string') { + throw new ZeroTrustConfigurationError('Zero Trust operation owner is invalid'); + } + if (input.leaseExpiresAt !== null && typeof input.leaseExpiresAt !== 'string') { + throw new ZeroTrustConfigurationError('Zero Trust operation lease is invalid'); + } + if ((input.owner === null) !== (input.leaseExpiresAt === null)) { + throw new ZeroTrustConfigurationError('Zero Trust operation ownership is invalid'); + } + if ( + input.existingProjects !== null && + input.existingProjects !== 'protected' && + input.existingProjects !== 'public' + ) { + throw new ZeroTrustConfigurationError('Zero Trust existing-project selection is invalid'); + } + const kind = input.kind as ZeroTrustOperation['kind']; + const phase = input.phase as ZeroTrustOperation['phase']; + if ( + (kind === 'disable' && + !['seed', 'projects', 'wildcard-delete', 'bypass-delete'].includes(phase)) || + (kind !== 'disable' && ['wildcard-delete', 'bypass-delete'].includes(phase)) + ) { + throw new ZeroTrustConfigurationError('Zero Trust operation phase is inconsistent'); + } + if (kind !== 'configure' && input.existingProjects !== null) { + throw new ZeroTrustConfigurationError('Zero Trust existing-project selection is inconsistent'); + } + if (input.applicationTarget !== undefined && kind !== 'reconcile') { + throw new ZeroTrustConfigurationError('Zero Trust application target is inconsistent'); + } + return { + kind, + phase, + ...(input.id === undefined ? {} : { id: requiredText(input.id, 'operation id', 128) }), + owner: input.owner === null ? null : requiredText(input.owner, 'operation owner', 128), + leaseExpiresAt: + input.leaseExpiresAt === null ? null : timestamp(input.leaseExpiresAt, 'operation lease'), + startedAt: timestamp(input.startedAt, 'operation start timestamp'), + existingProjects: input.existingProjects as ZeroTrustOperation['existingProjects'], + ...(input.applicationTarget === undefined + ? {} + : { applicationTarget: parseZeroTrustApplicationTarget(input.applicationTarget) }), + }; +} + +export function parseZeroTrustApplicationTarget(value: unknown): PlatformApplication { + const input = record(value, 'application target'); + if ( + typeof input.dedicatedZone !== 'boolean' || + typeof input.zoneId !== 'string' || + !/^[a-f0-9]{32}$/.test(input.zoneId) || + typeof input.zoneName !== 'string' || + !input.zoneName || + !input.workersDev + ) { + throw new ZeroTrustConfigurationError('Application target is invalid'); + } + try { + const application = readPlatformApplication({ PLATFORM_APPLICATION: JSON.stringify(input) }); + if ( + application.baseDomain !== application.zoneName && + !application.baseDomain.endsWith(`.${application.zoneName}`) + ) { + throw new Error('Application target is outside its zone'); + } + return { + ...application, + workersDev: { + subdomain: application.workersDev!.subdomain, + installationId: application.workersDev!.installationId, + }, + }; + } catch { + throw new ZeroTrustConfigurationError('Application target is invalid'); + } +} + +function parseZeroTrustConfiguration(value: unknown): ZeroTrustConfiguration | null { + if (value === null || value === undefined) { + return null; + } + const input = record(value, 'Zero Trust configuration'); + if (input.version !== 1) { + throw new ZeroTrustConfigurationError('unsupported Zero Trust configuration version'); + } + const revision = input.revision; + if (typeof revision !== 'number' || !Number.isSafeInteger(revision) || revision < 1) { + throw new ZeroTrustConfigurationError('Zero Trust configuration revision is invalid'); + } + if (!['configuring', 'ready', 'disabling', 'error'].includes(String(input.status))) { + throw new ZeroTrustConfigurationError('Zero Trust configuration status is invalid'); + } + if (typeof input.enabled !== 'boolean' || typeof input.protectNewProjects !== 'boolean') { + throw new ZeroTrustConfigurationError('Zero Trust configuration flags are invalid'); + } + const teamDomain = requiredText(input.teamDomain, 'Access team domain', 512).replace(/\/+$/, ''); + let parsedTeamDomain: URL; + try { + parsedTeamDomain = new URL(teamDomain); + } catch { + throw new ZeroTrustConfigurationError( + 'Access team domain must be an HTTPS cloudflareaccess.com origin', + ); + } + if ( + parsedTeamDomain.protocol !== 'https:' || + !parsedTeamDomain.hostname.endsWith('.cloudflareaccess.com') || + parsedTeamDomain.origin !== teamDomain + ) { + throw new ZeroTrustConfigurationError( + 'Access team domain must be an HTTPS cloudflareaccess.com origin', + ); + } + const wildcardApplication = parseApplication(input.wildcardApplication); + const operation = parseOperation(input.operation); + const status = input.status as ZeroTrustConfiguration['status']; + if (operation?.phase === 'bypass-delete' && (input.enabled || wildcardApplication)) { + throw new ZeroTrustConfigurationError('Zero Trust bypass cleanup requires wildcard removal'); + } + if ( + (status === 'ready' && operation !== null) || + (status === 'configuring' && (!operation || operation.kind === 'disable')) || + (status === 'disabling' && operation?.kind !== 'disable') || + (operation?.kind === 'disable' && status !== 'disabling' && status !== 'error') || + (operation && operation.kind !== 'disable' && status !== 'configuring' && status !== 'error') + ) { + throw new ZeroTrustConfigurationError('Zero Trust operation status is inconsistent'); + } + if (input.enabled && status === 'ready' && !wildcardApplication) { + throw new ZeroTrustConfigurationError( + 'enabled Zero Trust configuration needs a managed Access application', + ); + } + return { + version: 1, + revision, + status, + enabled: input.enabled, + protectNewProjects: input.protectNewProjects, + accountId: requiredText(input.accountId, 'Cloudflare account id', 128), + teamDomain, + identityProviderIds: ids(input.identityProviderIds, 'identity providers'), + policyIds: ids(input.policyIds, 'policies'), + credential: parseCredential(input.credential), + wildcardApplicationName: + input.wildcardApplicationName === null || input.wildcardApplicationName === undefined + ? (wildcardApplication?.name ?? null) + : requiredText(input.wildcardApplicationName, 'managed application name', 128), + wildcardApplication, + operation, + lastError: input.lastError === null ? null : requiredText(input.lastError, 'last error', 4096), + updatedAt: timestamp(input.updatedAt, 'updated timestamp'), + }; +} + +export function normalizeZeroTrustConfigureInput(input: unknown): ZeroTrustConfigureInput { + const value = record(input, 'Zero Trust configuration'); + if (typeof value.protectNewProjects !== 'boolean') { + throw new ZeroTrustConfigurationError('protectNewProjects must be a boolean'); + } + if (value.existingProjects !== 'protected' && value.existingProjects !== 'public') { + throw new ZeroTrustConfigurationError('existingProjects must be protected or public'); + } + const apiToken = requiredText(value.apiToken, 'Cloudflare API token', 8192); + return { + accountId: requiredText(value.accountId, 'Cloudflare account id', 128), + apiToken, + identityProviderIds: ids(value.identityProviderIds, 'identity providers'), + policyIds: ids(value.policyIds, 'policies'), + protectNewProjects: value.protectNewProjects, + existingProjects: value.existingProjects, + }; +} + +export async function readZeroTrustConfigurationStrict( + db: DbOrTx, +): Promise { + return parseZeroTrustConfiguration(await getPlatformSetting(db, ZERO_TRUST_SETTING)); +} + +export async function writeZeroTrustConfiguration( + db: DbOrTx, + configuration: ZeroTrustConfiguration, +): Promise { + await setPlatformSetting(db, ZERO_TRUST_SETTING, configuration); +} + +export async function replaceZeroTrustConfiguration( + db: DbOrTx, + expectedRevision: number, + configuration: ZeroTrustConfiguration, +): Promise { + const rows = await db + .update(platformSettings) + .set({ value: configuration, updated_at: sql`now()` }) + .where( + and( + eq(platformSettings.key, ZERO_TRUST_SETTING), + sql`(${platformSettings.value} ->> 'revision')::integer = ${expectedRevision}`, + ), + ) + .returning({ key: platformSettings.key }); + return rows.length === 1; +} + +export function zeroTrustWildcardDestinations( + application: PlatformApplication, +): ReadonlyArray { + return [ + ...new Set([ + ...(application.zoneName ? [`*.${application.baseDomain}`] : []), + ...(application.workersDev + ? [ + `${platformProjectIngressPrefix(application.workersDev)}*.${application.workersDev.subdomain}.workers.dev`, + ] + : []), + ]), + ]; +} + +export function redactedZeroTrustConfiguration(configuration: ZeroTrustConfiguration | null) { + if (!configuration) { + return { configured: false, enabled: false } as const; + } + const { credential: _credential, operation, ...visible } = configuration; + return { + configured: true, + credentialConfigured: true, + ...visible, + operation: operation + ? { + kind: operation.kind, + phase: operation.phase, + existingProjects: operation.existingProjects, + } + : null, + } as const; +} diff --git a/platform/packages/api/src/zero-trust/service.ts b/platform/packages/api/src/zero-trust/service.ts new file mode 100644 index 000000000..61fb57f11 --- /dev/null +++ b/platform/packages/api/src/zero-trust/service.ts @@ -0,0 +1,2013 @@ +import type { ProjectZeroTrustRouting } from '@void/edge'; +import { + PLATFORM_APPLICATION_KEY, + platformProjectHostnames, + readPlatformApplication, + type PlatformApplication, +} from '@void/platform'; +import { and, eq, gt, isNull, lt, lte, or, sql } from 'drizzle-orm'; +import type { Db, DbOrTx } from '../db.ts'; +import { + getActiveDeployment, + getDeployment, + getDomainsByProjectId, + getLatestLiveDeployment, + getProject, + getUser, +} from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { getPlatformSetting, setPlatformSetting } from '../platform-settings.ts'; +import { decryptPlatformCredential, encryptPlatformCredential } from '../project-secrets.ts'; +import { computeDispatchLimits } from '../routing-limits.ts'; +import { domains, projects, projectZeroTrust } from '../schema.ts'; +import { routingSuspensionReason } from '../suspension.ts'; +import { PLAN_LIMITS } from '../tiers.ts'; +import type { DeployManifest, Project, ProjectZeroTrust } from '../types.ts'; +import { + applicationTargetEnvironment, + PLATFORM_APPLICATION_SETTING, + resolveZeroTrustApplicationEnvironment, +} from './application.ts'; +import { ZeroTrustCloudflareClient, bypassPolicy, reusablePolicyReferences } from './cloudflare.ts'; +import { + ZERO_TRUST_CREDENTIAL_ID, + type ManagedAccessApplication, + type ZeroTrustConfiguration, + ZeroTrustConfigurationError, + type ZeroTrustConfigureInput, + type ZeroTrustOperation, + parseZeroTrustApplicationTarget, + readZeroTrustConfigurationStrict, + redactedZeroTrustConfiguration, + replaceZeroTrustConfiguration, + writeZeroTrustConfiguration, + zeroTrustWildcardDestinations, +} from './configuration.ts'; + +export class ZeroTrustUnavailableError extends Error { + readonly status = 409 as const; + constructor(message: string) { + super(message); + this.name = 'ZeroTrustUnavailableError'; + } +} + +const OPERATION_LEASE_MS = 15 * 60 * 1000; +const MAX_PROJECTS_PER_RUN = 100; +const MAX_ROUTING_KEYS_PER_RUN = 300; +export const MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT = 100; + +async function lockZeroTrustConfiguration(db: DbOrTx): Promise { + await db.execute(sql`SELECT pg_advisory_xact_lock_shared(hashtext('zero-trust:configuration'))`); +} + +function installationId(env: ApiEnv['Bindings']): string { + return env.PLATFORM_INSTALLATION_ID ?? 'installation'; +} + +async function applicationName(env: ApiEnv['Bindings'], suffix: string): Promise { + const installation = installationId(env); + const identity = installation + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .slice(0, 32); + const digest = new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(installation)), + ); + const fingerprint = Array.from(digest.subarray(0, 8), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + const nonce = crypto.randomUUID().replaceAll('-', ''); + return `void-${identity}-${fingerprint}-${nonce}-${suffix}`.slice(0, 120); +} + +function errorMessage(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 4096); +} + +async function clientForConfiguration( + env: ApiEnv['Bindings'], + configuration: ZeroTrustConfiguration, +): Promise { + const token = await decryptPlatformCredential( + env, + installationId(env), + ZERO_TRUST_CREDENTIAL_ID, + configuration.credential, + ); + return new ZeroTrustCloudflareClient(configuration.accountId, token); +} + +function projectApplicationReference(row: ProjectZeroTrust, kind: 'bypass' | 'protected') { + return kind === 'bypass' + ? { id: row.bypass_application_id, name: row.bypass_application_name } + : { id: row.protected_application_id, name: row.protected_application_name }; +} + +async function putProjectState( + db: DbOrTx, + projectId: string, + values: Partial, +): Promise { + const [row] = await db + .insert(projectZeroTrust) + .values({ + project_id: projectId, + desired_protected: values.desired_protected ?? values.effective_protected ?? false, + effective_protected: false, + state: 'reconciling', + ...values, + }) + .onConflictDoUpdate({ + target: projectZeroTrust.project_id, + set: { ...values, updated_at: sql`now()` }, + }) + .returning(); + return row!; +} + +export async function getProjectZeroTrustState( + db: DbOrTx, + projectId: string, +): Promise { + const [row] = await db + .select() + .from(projectZeroTrust) + .where(eq(projectZeroTrust.project_id, projectId)) + .limit(1); + return row ?? null; +} + +type PublicProjectZeroTrustState = Readonly< + Pick< + ProjectZeroTrust, + | 'project_id' + | 'desired_protected' + | 'effective_protected' + | 'state' + | 'last_error' + | 'updated_at' + > +>; + +export function publicProjectZeroTrustState( + row: ProjectZeroTrust | null, +): PublicProjectZeroTrustState | null { + if (!row) { + return null; + } + return { + project_id: row.project_id, + desired_protected: row.desired_protected, + effective_protected: row.effective_protected, + state: row.state, + last_error: row.last_error, + updated_at: row.updated_at, + }; +} + +async function updateProjectState( + db: DbOrTx, + projectId: string, + values: Partial, +): Promise { + const [row] = await db + .update(projectZeroTrust) + .set({ ...values, updated_at: sql`now()` }) + .where(eq(projectZeroTrust.project_id, projectId)) + .returning(); + if (!row) { + throw new ZeroTrustUnavailableError('Project is unavailable.'); + } + return row; +} + +async function projectDestinations( + env: ApiEnv['Bindings'], + db: Db, + project: Pick, + protectedProject: boolean, +): Promise<{ platform: ReadonlyArray; custom: ReadonlyArray }> { + const application = readPlatformApplication( + await resolveZeroTrustApplicationEnvironment(env, db), + ); + const platform = [...new Set(platformProjectHostnames(project, application))]; + if (!protectedProject) { + return { platform, custom: [] }; + } + const custom = [ + ...new Set((await getDomainsByProjectId(db, project.id)).map((domain) => domain.hostname)), + ]; + if (custom.length > MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT) { + throw new ZeroTrustUnavailableError( + `Project Zero Trust supports at most ${MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT} custom domains per project.`, + ); + } + return { platform, custom }; +} + +function routingProtection( + configuration: ZeroTrustConfiguration, + protectedApplication: ManagedAccessApplication | null, +): ProjectZeroTrustRouting { + if (!configuration.wildcardApplication) { + throw new ZeroTrustUnavailableError('The platform Access application is unavailable.'); + } + return { + teamDomain: configuration.teamDomain, + audiences: [ + ...configuration.wildcardApplication.destinations.map((hostname) => ({ + hostname, + audience: configuration.wildcardApplication!.audience, + })), + ...(protectedApplication + ? protectedApplication.destinations.map((hostname) => ({ + hostname, + audience: protectedApplication.audience, + })) + : []), + ], + }; +} + +function managedProtectedApplication( + row: ProjectZeroTrust, + destinations: ReadonlyArray, +): ManagedAccessApplication | null { + return row.protected_application_id && + row.protected_application_name && + row.protected_application_audience + ? { + id: row.protected_application_id, + name: row.protected_application_name, + audience: row.protected_application_audience, + destinations, + } + : null; +} + +export async function resolveProjectZeroTrustRouting( + db: DbOrTx, + projectId: string, +): Promise { + return readProjectZeroTrustRouting(db, projectId, false); +} + +async function readProjectZeroTrustRouting( + db: DbOrTx, + projectId: string, + allowReconcilingPublic: boolean, +): Promise { + await lockZeroTrustConfiguration(db); + const [configuration, row] = await Promise.all([ + readZeroTrustConfigurationStrict(db), + getProjectZeroTrustState(db, projectId), + ]); + if (!configuration) { + return null; + } + if (!row) { + if ( + configuration.enabled || + (configuration.operation !== null && configuration.operation.kind !== 'disable') + ) { + throw new ZeroTrustUnavailableError('Project Zero Trust is still being initialized.'); + } + return null; + } + if (!row.desired_protected) { + if ( + configuration.enabled && + configuration.operation?.kind !== 'disable' && + (!row.bypass_application_id || (!allowReconcilingPublic && row.state !== 'public')) + ) { + throw new ZeroTrustUnavailableError('The project public exception is still reconciling.'); + } + return null; + } + if (!configuration.enabled || !configuration.wildcardApplication) { + throw new ZeroTrustUnavailableError('Project Zero Trust is still being initialized.'); + } + if (!row.effective_protected) { + throw new ZeroTrustUnavailableError('Project Zero Trust is still reconciling.'); + } + const protectedApplication = managedProtectedApplication( + row, + (await getDomainsByProjectId(db as Db, projectId)).map((domain) => domain.hostname), + ); + return routingProtection(configuration, protectedApplication); +} + +/** Read current protection inside the caller's project-row transaction before publishing KV. */ +export async function resolveProjectZeroTrustRoutingForPublication( + db: DbOrTx, + projectId: string, +): Promise { + try { + // A durable bypass makes PUBLIC intent safe even while paginated cleanup + // is unfinished. Reintroducing a deny marker behind its routing cursor + // would strand already-completed keys. Deploys retain the stricter gate. + return await readProjectZeroTrustRouting(db, projectId, true); + } catch (error) { + if ( + error instanceof ZeroTrustUnavailableError || + error instanceof ZeroTrustConfigurationError + ) { + // Metadata rewrites must remain possible during recovery. An empty + // audience set makes dispatch deny access until reconciliation finishes. + return { teamDomain: '', audiences: [] }; + } + throw error; + } +} + +export function withProjectZeroTrustRouting( + raw: string, + protection: ProjectZeroTrustRouting | null, +): string { + const entry = JSON.parse(raw) as Record; + if (protection) { + entry.zeroTrust = protection; + } else { + delete entry.zeroTrust; + } + return JSON.stringify(entry); +} + +function routingMatchesDeployment( + entry: Record, + projectSlug: string, + deploymentId: string, +): boolean { + const identifiers = [entry.version, entry.staticDeploymentId, entry.deploymentId]; + if (typeof entry.workerName === 'string' && entry.workerName.length > 0) { + if (entry.workerName !== `user-${projectSlug}-${deploymentId}`) { + return false; + } + identifiers.push(deploymentId); + } + // Canonical builders identify the target. Unknown legacy snapshots cannot + // prove freshness after a committed deployment, and conflicting fields must + // not be hidden by another field that happens to match. + const knownIdentifiers = identifiers.filter( + (identifier) => typeof identifier === 'string' && identifier.length > 0, + ); + return ( + knownIdentifiers.length > 0 && + knownIdentifiers.every((identifier) => identifier === deploymentId) + ); +} + +async function publishProjectZeroTrustRoutingLocked( + env: Pick, + db: Db, + project: Pick, + protection: ProjectZeroTrustRouting | null, + budget = { remaining: MAX_ROUTING_KEYS_PER_RUN }, +): Promise { + const row = await getProjectZeroTrustState(db, project.id); + // Cutover publishes before committing the project pointer. Read its target + // once under the project lock so metadata changes can accompany that route, + // but cannot republish a stale previous deployment from KV. + const [activeDeployment, owner] = await Promise.all([ + getActiveDeployment(db, project.id), + getUser(db, project.user_id), + ]); + if (!owner) { + throw new ZeroTrustUnavailableError('Project owner is unavailable.'); + } + const routeDeployment = + activeDeployment?.status === 'cutover' + ? activeDeployment + : project.active_deployment_id + ? await getDeployment(db, project.active_deployment_id) + : await getLatestLiveDeployment(db, project.id); + const expectedDeploymentId = + activeDeployment?.status === 'cutover' + ? activeDeployment.id + : (project.active_deployment_id ?? routeDeployment?.id); + const manifest = routeDeployment?.manifest + ? (JSON.parse(routeDeployment.manifest) as DeployManifest) + : null; + const dispatchLimits = computeDispatchLimits(PLAN_LIMITS[owner.plan], manifest?.limits); + const suspensionReason = routingSuspensionReason(owner.suspended_reason); + // Protected destinations are capped separately. Clearing public routing has + // no domain cap and checkpoints progress while holding the project lock. + const cursor = protection ? null : (row?.routing_cursor ?? null); + const domainBudget = Math.max(0, budget.remaining - (cursor === null ? 1 : 0)); + const projectDomains = await db + .select({ hostname: domains.hostname, status: domains.status }) + .from(domains) + .where( + and( + eq(domains.project_id, project.id), + cursor === null ? undefined : gt(domains.hostname, cursor), + ), + ) + .orderBy(domains.hostname) + .limit(domainBudget + 1); + const page = projectDomains.slice(0, domainBudget); + const keys = [ + ...(cursor === null + ? [{ key: project.slug, expected: !!expectedDeploymentId, pending: false }] + : []), + ...page.map((domain) => ({ + key: `domain:${domain.hostname}`, + expected: domain.status === 'active', + pending: domain.status === 'pending', + })), + ]; + for (const { key, expected, pending } of keys) { + budget.remaining -= 1; + const raw = await env.ROUTING.get(key); + if (!raw) { + // KV can return a cached miss even after a route was published. Keep its + // cleanup checkpoint retryable instead of permanently skipping live keys. + if (expected) { + throw new ZeroTrustUnavailableError( + `Live routing for ${key} is temporarily unavailable. Retry Zero Trust reconciliation.`, + ); + } + continue; + } + const entry = JSON.parse(raw) as Record; + if (entry.projectId !== undefined && entry.projectId !== project.id) { + // A reused slug or hostname may still return the previous project's KV + // value, even when this project has never deployed. Never republish or + // delete another project's route based on that stale read. + throw new ZeroTrustUnavailableError( + `Live routing for ${key} is temporarily unavailable. Retry Zero Trust reconciliation.`, + ); + } + if ( + expectedDeploymentId && + !routingMatchesDeployment(entry, project.slug, expectedDeploymentId) + ) { + if (pending) { + // Activation may have crashed before marking this domain active. Drop + // obsolete residue so its next activation can publish the current slug + // without getting stuck on reconciliation of the old deployment. + await env.ROUTING.delete(key); + continue; + } + throw new ZeroTrustUnavailableError( + `Live routing for ${key} is temporarily unavailable. Retry Zero Trust reconciliation.`, + ); + } + // The deployment payload is immutable, but these fields can change after + // deployment. A fresh project lock does not make a cached KV snapshot fresh. + entry.userId = owner.id; + if ('workerName' in entry) { + if (dispatchLimits) { + entry.limits = dispatchLimits; + } else { + delete entry.limits; + } + } + if (suspensionReason) { + entry.suspended = suspensionReason; + } else { + delete entry.suspended; + } + if (protection) { + entry.zeroTrust = protection; + } else { + delete entry.zeroTrust; + } + await env.ROUTING.put(key, JSON.stringify(entry)); + } + const complete = projectDomains.length <= domainBudget; + await putProjectState(db, project.id, { + routing_cursor: complete ? null : (page.at(-1)?.hostname ?? cursor ?? ''), + ...(!complete ? { state: 'reconciling' as const } : {}), + }); + return complete; +} + +async function markProjectError(db: DbOrTx, projectId: string, error: unknown): Promise { + await db + .update(projectZeroTrust) + .set({ state: 'error', last_error: errorMessage(error), updated_at: sql`now()` }) + .where(eq(projectZeroTrust.project_id, projectId)); +} + +function sameConfiguration( + expected: ZeroTrustConfiguration, + current: ZeroTrustConfiguration | null, +): current is ZeroTrustConfiguration { + return ( + current !== null && + current.revision === expected.revision && + current.status === expected.status && + current.operation?.owner === expected.operation?.owner && + current.wildcardApplication?.id === expected.wildcardApplication?.id + ); +} + +function operationCompletionKey(operation: ZeroTrustOperation): string { + return operation.id ?? `${operation.kind}:${operation.startedAt}`; +} + +function completedProjectOperation(configuration: ZeroTrustConfiguration): string | null { + const operation = configuration.operation; + return operation?.phase === 'projects' && operation.kind !== 'disable' + ? operationCompletionKey(operation) + : null; +} + +async function checkpointProjectReconciliation( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + options?: { + configuration?: ZeroTrustConfiguration; + desiredProtection?: boolean; + }, +): Promise { + return db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + await lockZeroTrustConfiguration(tx); + const lockedDb = tx as unknown as Db; + const project = await getProject(lockedDb, projectId); + if (!project || project.status === 'deleting') { + throw new ZeroTrustUnavailableError('Project is unavailable.'); + } + const saved = await readZeroTrustConfigurationStrict(lockedDb); + const configuration = options?.configuration ?? saved; + if ( + !configuration || + configuration.operation?.kind === 'disable' || + (options?.configuration && !sameConfiguration(configuration, saved)) || + (!options?.configuration && + (!saved?.enabled || saved.status !== 'ready' || !saved.wildcardApplication)) + ) { + throw new ZeroTrustUnavailableError('Project Zero Trust is not ready on this platform.'); + } + const existing = await getProjectZeroTrustState(lockedDb, projectId); + const desired = + options?.desiredProtection ?? existing?.desired_protected ?? configuration.protectNewProjects; + const destinations = await projectDestinations(env, lockedDb, project, desired); + const values: Partial = { + desired_protected: desired, + state: 'reconciling', + last_error: null, + ...(existing?.desired_protected !== desired ? { routing_cursor: null } : {}), + }; + if (!desired && !existing?.bypass_application_name) { + values.bypass_application_name = await applicationName(env, `public-${project.id}`); + } + if (desired && destinations.custom.length > 0 && !existing?.protected_application_name) { + values.protected_application_name = await applicationName(env, `protected-${project.id}`); + } + return putProjectState(lockedDb, projectId, values); + }); +} + +async function reconcileProjectZeroTrustLocked( + env: ApiEnv['Bindings'], + lockedDb: Db, + projectId: string, + options?: { + configuration?: ZeroTrustConfiguration; + client?: ZeroTrustCloudflareClient; + routingBudget?: { remaining: number }; + }, +): Promise { + const saved = await readZeroTrustConfigurationStrict(lockedDb); + const configuration = options?.configuration ?? saved; + if ( + !saved || + !configuration || + !saved.enabled || + !saved.wildcardApplication || + saved.operation?.kind === 'disable' || + (!options?.configuration && saved.status !== 'ready') || + (options?.configuration && !sameConfiguration(configuration, saved)) + ) { + throw new ZeroTrustUnavailableError('Project Zero Trust is not enabled on this platform.'); + } + const project = await getProject(lockedDb, projectId); + if (!project || project.status === 'deleting') { + throw new ZeroTrustUnavailableError('Project is unavailable.'); + } + let row = await getProjectZeroTrustState(lockedDb, projectId); + if (!row) { + throw new ZeroTrustUnavailableError('Project Zero Trust is still being initialized.'); + } + const client = options?.client ?? (await clientForConfiguration(env, configuration)); + const destinations = await projectDestinations(env, lockedDb, project, row.desired_protected); + + try { + if (!row.desired_protected) { + if (!row.bypass_application_name) { + throw new Error('The project public application checkpoint is missing.'); + } + const bypass = await client.ensureApplication( + { + name: row.bypass_application_name, + destinations: destinations.platform, + policies: bypassPolicy('Void project is public'), + }, + projectApplicationReference(row, 'bypass'), + ); + row = await updateProjectState(lockedDb, projectId, { + bypass_application_id: bypass.id, + bypass_application_name: bypass.name, + }); + if ( + !(await publishProjectZeroTrustRoutingLocked( + env, + lockedDb, + project, + null, + options?.routingBudget, + )) + ) { + return (await getProjectZeroTrustState(lockedDb, projectId))!; + } + row = await updateProjectState(lockedDb, projectId, { effective_protected: false }); + await client.deleteApplication(row.protected_application_id, row.protected_application_name); + return updateProjectState(lockedDb, projectId, { + effective_protected: false, + state: 'public', + reconciled_operation: completedProjectOperation(configuration), + protected_application_id: null, + protected_application_name: null, + protected_application_audience: null, + last_error: null, + }); + } + + let protectedApplication: ManagedAccessApplication | null = null; + if (destinations.custom.length > 0) { + if (!row.protected_application_name) { + throw new Error('The project protected application checkpoint is missing.'); + } + protectedApplication = await client.ensureApplication( + { + name: row.protected_application_name, + destinations: destinations.custom, + allowedIdentityProviderIds: configuration.identityProviderIds, + policies: reusablePolicyReferences(configuration.policyIds), + }, + projectApplicationReference(row, 'protected'), + ); + row = await updateProjectState(lockedDb, projectId, { + protected_application_id: protectedApplication.id, + protected_application_name: protectedApplication.name, + protected_application_audience: protectedApplication.audience, + }); + } else if (row.protected_application_id || row.protected_application_name) { + await client.deleteApplication(row.protected_application_id, row.protected_application_name); + row = await updateProjectState(lockedDb, projectId, { + protected_application_id: null, + protected_application_name: null, + protected_application_audience: null, + }); + } + await publishProjectZeroTrustRoutingLocked( + env, + lockedDb, + project, + routingProtection(configuration, protectedApplication), + options?.routingBudget, + ); + row = await updateProjectState(lockedDb, projectId, { effective_protected: true }); + await client.deleteApplication(row.bypass_application_id, row.bypass_application_name); + return updateProjectState(lockedDb, projectId, { + effective_protected: true, + state: 'protected', + reconciled_operation: completedProjectOperation(configuration), + bypass_application_id: null, + bypass_application_name: null, + last_error: null, + }); + } catch (error) { + await markProjectError(lockedDb, projectId, error); + throw error; + } +} + +export async function reconcileProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + options?: { + configuration?: ZeroTrustConfiguration; + client?: ZeroTrustCloudflareClient; + desiredProtection?: boolean; + routingBudget?: { remaining: number }; + }, +): Promise { + await checkpointProjectReconciliation(env, db, projectId, options); + let reconciliationError: unknown; + const result = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + await lockZeroTrustConfiguration(tx); + try { + return await reconcileProjectZeroTrustLocked(env, tx as unknown as Db, projectId, options); + } catch (error) { + reconciliationError = error; + return null; + } + }); + if (reconciliationError !== undefined) { + throw reconciliationError; + } + return result!; +} + +export async function setProjectZeroTrustProtection( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + protectedProject: boolean, +): Promise { + return reconcileProjectZeroTrust(env, db, projectId, { + desiredProtection: protectedProject, + }); +} + +async function ensurePublicProjectBeforeWildcard( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + configuration: ZeroTrustConfiguration, + client: ZeroTrustCloudflareClient, + initializing = false, +): Promise { + await checkpointProjectReconciliation(env, db, projectId, { configuration }); + let operationError: unknown; + const result = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + await lockZeroTrustConfiguration(tx); + const lockedDb = tx as unknown as Db; + if (!sameConfiguration(configuration, await readZeroTrustConfigurationStrict(lockedDb))) { + throw new ZeroTrustUnavailableError('The Zero Trust operation was replaced.'); + } + try { + const project = await getProject(lockedDb, projectId); + const row = await getProjectZeroTrustState(lockedDb, projectId); + if (!project || project.status === 'deleting' || !row || row.desired_protected) { + return null; + } + if (!row.bypass_application_name) { + throw new Error('The project public application checkpoint is missing.'); + } + const destinations = await projectDestinations(env, lockedDb, project, false); + const bypass = await client.ensureApplication( + { + name: row.bypass_application_name, + destinations: destinations.platform, + policies: bypassPolicy('Void project is public'), + }, + projectApplicationReference(row, 'bypass'), + ); + return putProjectState(lockedDb, project.id, { + bypass_application_id: bypass.id, + bypass_application_name: bypass.name, + desired_protected: false, + effective_protected: false, + state: initializing && configuration.operation ? 'reconciling' : 'public', + last_error: null, + }); + } catch (error) { + await markProjectError(lockedDb, projectId, error); + operationError = error; + return null; + } + }); + if (operationError !== undefined) { + throw operationError; + } + return result; +} + +export async function initializeNewProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, +): Promise { + const configuration = await readZeroTrustConfigurationStrict(db); + if ( + !configuration || + configuration.operation?.kind === 'disable' || + (!configuration.enabled && configuration.operation?.kind !== 'configure') + ) { + return null; + } + const initialized = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + await lockZeroTrustConfiguration(tx); + const lockedDb = tx as unknown as Db; + const project = await getProject(lockedDb, projectId); + const saved = await readZeroTrustConfigurationStrict(lockedDb); + if ( + !project || + project.status === 'deleting' || + !saved || + saved.revision !== configuration.revision + ) { + throw new ZeroTrustUnavailableError('Project Zero Trust configuration changed.'); + } + // Project creation commits before this initializer runs. A platform batch + // may already have seeded or reconciled it, including its ownership handles. + const existing = await getProjectZeroTrustState(lockedDb, projectId); + if (existing) { + return { row: existing, created: false }; + } + const operation = saved.operation; + const existingSelection = + operation?.kind === 'configure' && + operation.existingProjects !== null && + Date.parse(project.created_at) <= Date.parse(operation.startedAt) + ? operation.existingProjects + : null; + const desired = existingSelection + ? existingSelection === 'protected' + : saved.protectNewProjects; + return { + row: await putProjectState(lockedDb, projectId, { + desired_protected: desired, + effective_protected: desired && !!saved.wildcardApplication && !operation, + state: desired && saved.wildcardApplication && !operation ? 'protected' : 'reconciling', + ...(!desired + ? { bypass_application_name: await applicationName(env, `public-${project.id}`) } + : {}), + }), + created: true, + }; + }); + const { row } = initialized; + if (!initialized.created) { + return row; + } + if (!row.desired_protected) { + const client = await clientForConfiguration(env, configuration); + return ensurePublicProjectBeforeWildcard(env, db, projectId, configuration, client, true); + } + return row; +} + +type ProjectCandidate = Readonly<{ project: Project }>; + +function candidateQuery(db: Db) { + return db + .select({ project: projects }) + .from(projects) + .leftJoin(projectZeroTrust, eq(projectZeroTrust.project_id, projects.id)); +} + +async function seedCandidates( + db: Db, + operation: ZeroTrustOperation, + initial: boolean, + limit: number, +): Promise> { + const missing = isNull(projectZeroTrust.project_id); + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + initial + ? and( + lte(projects.created_at, operation.startedAt), + or(missing, lt(projectZeroTrust.updated_at, operation.startedAt)), + ) + : missing, + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); +} + +async function publicBypassCandidates( + db: Db, + limit: number, + operation?: ZeroTrustOperation, +): Promise> { + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + eq(projectZeroTrust.desired_protected, false), + or( + isNull(projectZeroTrust.bypass_application_id), + sql`${projectZeroTrust.state} <> 'public'`, + ...(operation?.applicationTarget + ? [lt(projectZeroTrust.updated_at, operation.startedAt)] + : []), + ), + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); +} + +async function reconciliationCandidates( + db: Db, + operation: ZeroTrustOperation, + limit: number, +): Promise> { + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + or( + isNull(projectZeroTrust.project_id), + sql`${projectZeroTrust.reconciled_operation} IS DISTINCT FROM ${operationCompletionKey(operation)}`, + sql`${projectZeroTrust.state} IN ('reconciling', 'error')`, + sql`${projectZeroTrust.desired_protected} <> ${projectZeroTrust.effective_protected}`, + ), + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); +} + +async function disableCandidates( + db: Db, + operation: ZeroTrustOperation, + limit: number, +): Promise> { + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + or( + isNull(projectZeroTrust.project_id), + sql`${projectZeroTrust.reconciled_operation} IS DISTINCT FROM ${operationCompletionKey(operation)}`, + eq(projectZeroTrust.effective_protected, true), + sql`${projectZeroTrust.state} <> 'public'`, + sql`${projectZeroTrust.protected_application_id} IS NOT NULL`, + sql`${projectZeroTrust.protected_application_name} IS NOT NULL`, + ), + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); +} + +async function bypassCleanupCandidates( + db: Db, + limit: number, +): Promise> { + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + or( + sql`${projectZeroTrust.bypass_application_id} IS NOT NULL`, + sql`${projectZeroTrust.bypass_application_name} IS NOT NULL`, + ), + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); +} + +async function setInitialProjectProtection( + db: Db, + projectId: string, + protectedProject: boolean, + configuration: ZeroTrustConfiguration, +): Promise { + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + await lockZeroTrustConfiguration(tx); + const lockedDb = tx as unknown as Db; + if (!sameConfiguration(configuration, await readZeroTrustConfigurationStrict(lockedDb))) { + throw new ZeroTrustUnavailableError('The Zero Trust operation was replaced.'); + } + const project = await getProject(lockedDb, projectId); + if (!project || project.status === 'deleting') { + return; + } + await putProjectState(lockedDb, projectId, { + desired_protected: protectedProject, + state: 'reconciling', + last_error: null, + }); + }); +} + +function leaseExpiresAt(): string { + return new Date(Date.now() + OPERATION_LEASE_MS).toISOString(); +} + +function leaseActive(operation: ZeroTrustOperation): boolean { + const expiry = operation.leaseExpiresAt ? Date.parse(operation.leaseExpiresAt) : Number.NaN; + return operation.owner !== null && Number.isFinite(expiry) && expiry > Date.now(); +} + +async function operationStartedAt(db: DbOrTx): Promise { + // Project timestamps are stored without a time zone. Use their database clock, + // rather than a Worker clock/UTC literal that may compare in a different zone. + const result = await db.execute<{ startedAt: string }>( + sql`SELECT now()::timestamp::text AS "startedAt"`, + ); + return result.rows[0]!.startedAt; +} + +async function claimConfiguration( + db: Db, + claim: ( + current: ZeroTrustConfiguration | null, + owner: string, + startedAt: string, + ) => ZeroTrustConfiguration, +): Promise { + return db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('zero-trust:configuration'))`); + const current = await readZeroTrustConfigurationStrict(tx); + if (current?.operation && leaseActive(current.operation)) { + throw new ZeroTrustUnavailableError('Another Zero Trust operation is already running.'); + } + const claimed = claim(current, crypto.randomUUID(), await operationStartedAt(tx)); + const configuration: ZeroTrustConfiguration = claimed.operation + ? { + ...claimed, + operation: { + ...claimed.operation, + id: + current?.operation?.kind === claimed.operation.kind && + current.operation.startedAt === claimed.operation.startedAt + ? (current.operation.id ?? crypto.randomUUID()) + : crypto.randomUUID(), + }, + } + : claimed; + await writeZeroTrustConfiguration(tx, configuration); + return configuration; + }); +} + +async function patchConfiguration( + db: Db, + current: ZeroTrustConfiguration, + values: Partial, +): Promise { + const next: ZeroTrustConfiguration = { + ...current, + ...values, + revision: current.revision + 1, + updatedAt: new Date().toISOString(), + }; + if (!(await replaceZeroTrustConfiguration(db, current.revision, next))) { + throw new ZeroTrustUnavailableError('A newer Zero Trust operation replaced this request.'); + } + return next; +} + +async function heartbeatConfiguration( + db: Db, + current: ZeroTrustConfiguration, +): Promise { + if (!current.operation?.owner) { + throw new ZeroTrustUnavailableError('The Zero Trust operation lease was lost.'); + } + return patchConfiguration(db, current, { + operation: { ...current.operation, leaseExpiresAt: leaseExpiresAt() }, + }); +} + +async function advanceOperation( + db: Db, + current: ZeroTrustConfiguration, + phase: ZeroTrustOperation['phase'], + values: Partial = {}, +): Promise { + if (!current.operation?.owner) { + throw new ZeroTrustUnavailableError('The Zero Trust operation lease was lost.'); + } + return patchConfiguration(db, current, { + ...values, + operation: { ...current.operation, phase, leaseExpiresAt: leaseExpiresAt() }, + }); +} + +async function releaseOperation( + db: Db, + current: ZeroTrustConfiguration, +): Promise { + if (!current.operation) { + return current; + } + return patchConfiguration(db, current, { + operation: { ...current.operation, owner: null, leaseExpiresAt: null }, + }); +} + +async function failOperation( + db: Db, + current: ZeroTrustConfiguration, + error: unknown, +): Promise { + await patchConfiguration(db, current, { + status: 'error', + lastError: errorMessage(error), + operation: current.operation + ? { ...current.operation, owner: null, leaseExpiresAt: null } + : null, + }).catch(() => undefined); +} + +function isDeletedProject(error: unknown): boolean { + return error instanceof ZeroTrustUnavailableError && error.message === 'Project is unavailable.'; +} + +async function withZeroTrustOperationLock( + db: Db, + configuration: ZeroTrustConfiguration, + mutate: (lockedDb: Db) => Promise, +): Promise { + return db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('zero-trust:configuration'))`); + const saved = await readZeroTrustConfigurationStrict(tx); + if (!sameConfiguration(configuration, saved) || !saved.operation?.owner) { + throw new ZeroTrustUnavailableError('The Zero Trust operation was replaced.'); + } + // The provider write and its checkpoint share the claim lock. A CAS after + // the provider call alone cannot stop a stale request restoring old policy + // or deleting the application used by a newer operation. + return mutate(tx as unknown as Db); + }); +} + +async function completeConfigureOperation( + env: ApiEnv['Bindings'], + db: Db, + configuration: ZeroTrustConfiguration, +): Promise { + return db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('zero-trust:configuration'))`); + const saved = await readZeroTrustConfigurationStrict(tx); + if (!sameConfiguration(configuration, saved) || !saved.operation) { + throw new ZeroTrustUnavailableError('A newer Zero Trust operation replaced this request.'); + } + // Project mutation/deployment holds the shared lock. Recheck under its exclusive + // counterpart so the final publication cannot overtake an in-flight reconciliation. + if ((await reconciliationCandidates(tx as unknown as Db, saved.operation, 1)).length) { + return null; + } + if (saved.operation.applicationTarget) { + const application = saved.operation.applicationTarget; + await setPlatformSetting(tx, PLATFORM_APPLICATION_SETTING, application); + await env.ROUTING.put(PLATFORM_APPLICATION_KEY, JSON.stringify(application)); + } + return patchConfiguration(tx as unknown as Db, saved, { + status: 'ready', + operation: null, + lastError: null, + }); + }); +} + +/** The installer calls this only after DNS, token permissions and HTTPS are verified. */ +export async function activatePlatformApplication( + env: ApiEnv['Bindings'], + db: Db, + value: PlatformApplication, +): Promise<{ ready: boolean }> { + const target = parseZeroTrustApplicationTarget(value); + const claim = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('zero-trust:configuration'))`); + const current = await readZeroTrustConfigurationStrict(tx); + const currentEnv = await resolveZeroTrustApplicationEnvironment(env, tx, current); + applicationTargetEnvironment(currentEnv, target); + if (current?.operation) { + if ( + !current.operation.applicationTarget || + JSON.stringify(current.operation.applicationTarget) !== JSON.stringify(target) + ) { + throw new ZeroTrustUnavailableError( + 'Finish the saved Zero Trust operation before adding this domain.', + ); + } + if (leaseActive(current.operation)) { + return { ready: false, configuration: null }; + } + } else { + const completed = await getPlatformSetting(tx, PLATFORM_APPLICATION_SETTING); + if ( + !current?.enabled || + (completed && + JSON.stringify(parseZeroTrustApplicationTarget(completed)) === JSON.stringify(target)) + ) { + await setPlatformSetting(tx, PLATFORM_APPLICATION_SETTING, target); + await env.ROUTING.put(PLATFORM_APPLICATION_KEY, JSON.stringify(target)); + return { ready: true, configuration: null }; + } + } + if (!current) { + throw new ZeroTrustUnavailableError('Project Zero Trust configuration is unavailable.'); + } + const now = new Date().toISOString(); + const configuration: ZeroTrustConfiguration = { + ...current, + revision: current.revision + 1, + status: 'configuring', + operation: { + ...(current.operation ?? { + id: crypto.randomUUID(), + kind: 'reconcile' as const, + phase: 'seed' as const, + startedAt: await operationStartedAt(tx), + existingProjects: null, + applicationTarget: target, + }), + owner: crypto.randomUUID(), + leaseExpiresAt: leaseExpiresAt(), + }, + lastError: null, + updatedAt: now, + }; + await writeZeroTrustConfiguration(tx, configuration); + return { ready: false, configuration }; + }); + if (!claim.configuration) { + return { ready: claim.ready }; + } + const completed = await runConfigureOperation(env, db, claim.configuration); + return { ready: completed.operation === null }; +} + +async function runConfigureOperation( + env: ApiEnv['Bindings'], + db: Db, + initial: ZeroTrustConfiguration, + suppliedClient?: ZeroTrustCloudflareClient, + suppliedSelection?: { teamDomain: string }, +): Promise { + let configuration = initial; + let projectsProcessed = 0; + const routingBudget = { remaining: MAX_ROUTING_KEYS_PER_RUN }; + try { + env = await resolveZeroTrustApplicationEnvironment(env, db, configuration); + const client = suppliedClient ?? (await clientForConfiguration(env, configuration)); + const selection = + suppliedSelection ?? + (await client.validateSelection({ + identityProviderIds: configuration.identityProviderIds, + policyIds: configuration.policyIds, + })); + if (selection.teamDomain !== configuration.teamDomain) { + configuration = await patchConfiguration(db, configuration, { + teamDomain: selection.teamDomain, + }); + } + + while (configuration.operation && configuration.operation.kind !== 'disable') { + const operation = configuration.operation; + if (operation.phase === 'seed') { + if (operation.existingProjects) { + const existing = await seedCandidates( + db, + operation, + true, + MAX_PROJECTS_PER_RUN - projectsProcessed, + ); + for (const candidate of existing) { + await setInitialProjectProtection( + db, + candidate.project.id, + operation.existingProjects === 'protected', + configuration, + ); + projectsProcessed += 1; + configuration = await heartbeatConfiguration(db, configuration); + } + if (projectsProcessed >= MAX_PROJECTS_PER_RUN) { + return releaseOperation(db, configuration); + } + } + const missing = await seedCandidates( + db, + operation, + false, + MAX_PROJECTS_PER_RUN - projectsProcessed, + ); + for (const candidate of missing) { + const isInitial = + operation.existingProjects !== null && + Date.parse(candidate.project.created_at) <= Date.parse(operation.startedAt); + await setInitialProjectProtection( + db, + candidate.project.id, + isInitial + ? operation.existingProjects === 'protected' + : configuration.protectNewProjects, + configuration, + ); + projectsProcessed += 1; + configuration = await heartbeatConfiguration(db, configuration); + } + if (projectsProcessed >= MAX_PROJECTS_PER_RUN) { + return releaseOperation(db, configuration); + } + configuration = await advanceOperation( + db, + configuration, + configuration.wildcardApplication && !operation.applicationTarget + ? 'wildcard' + : 'public-bypasses', + ); + continue; + } + + if (operation.phase === 'public-bypasses') { + const candidates = await publicBypassCandidates( + db, + MAX_PROJECTS_PER_RUN - projectsProcessed, + operation, + ); + for (const candidate of candidates) { + try { + if (configuration.wildcardApplication) { + await reconcileProjectZeroTrust(env, db, candidate.project.id, { + configuration, + client, + routingBudget, + }); + } else { + await ensurePublicProjectBeforeWildcard( + env, + db, + candidate.project.id, + configuration, + client, + ); + } + } catch (error) { + if (!isDeletedProject(error)) { + throw error; + } + } + projectsProcessed += 1; + configuration = await heartbeatConfiguration(db, configuration); + if (routingBudget.remaining === 0) { + return releaseOperation(db, configuration); + } + } + if (projectsProcessed >= MAX_PROJECTS_PER_RUN) { + return releaseOperation(db, configuration); + } + configuration = await advanceOperation(db, configuration, 'wildcard'); + continue; + } + + if (operation.phase === 'wildcard') { + if (!configuration.wildcardApplicationName) { + throw new Error('The platform Access application checkpoint is missing.'); + } + const name = configuration.wildcardApplicationName; + configuration = await withZeroTrustOperationLock(db, configuration, async (lockedDb) => { + const wildcard = await client.ensureApplication( + { + name, + destinations: zeroTrustWildcardDestinations(readPlatformApplication(env)), + allowedIdentityProviderIds: configuration.identityProviderIds, + policies: reusablePolicyReferences(configuration.policyIds), + }, + { id: configuration.wildcardApplication?.id, name }, + ); + return advanceOperation(lockedDb, configuration, 'projects', { + enabled: true, + wildcardApplication: wildcard, + lastError: null, + }); + }); + continue; + } + + if (operation.phase === 'projects') { + const candidates = await reconciliationCandidates( + db, + operation, + MAX_PROJECTS_PER_RUN - projectsProcessed, + ); + if (candidates.length === 0) { + const completed = await completeConfigureOperation(env, db, configuration); + if (completed) { + return completed; + } + continue; + } + let stoppedForRoutingBudget = false; + for (const candidate of candidates) { + const row = await getProjectZeroTrustState(db, candidate.project.id); + if ( + routingBudget.remaining < + (row?.desired_protected ? MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT + 1 : 1) + ) { + stoppedForRoutingBudget = true; + break; + } + try { + await reconcileProjectZeroTrust(env, db, candidate.project.id, { + configuration, + client, + routingBudget, + }); + } catch (error) { + if (!isDeletedProject(error)) { + throw error; + } + } + projectsProcessed += 1; + configuration = await heartbeatConfiguration(db, configuration); + if (projectsProcessed >= MAX_PROJECTS_PER_RUN) { + break; + } + } + if (stoppedForRoutingBudget || projectsProcessed >= MAX_PROJECTS_PER_RUN) { + return releaseOperation(db, configuration); + } + continue; + } + + throw new Error(`Invalid configure operation phase: ${operation.phase}`); + } + throw new Error('The Zero Trust configure operation was replaced.'); + } catch (error) { + await failOperation(db, configuration, error); + throw error; + } +} + +async function disableProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + client: ZeroTrustCloudflareClient, + routingBudget: { remaining: number }, + configuration: ZeroTrustConfiguration, +): Promise { + let operationError: unknown; + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + await lockZeroTrustConfiguration(tx); + const lockedDb = tx as unknown as Db; + const saved = await readZeroTrustConfigurationStrict(lockedDb); + if (!sameConfiguration(configuration, saved) || saved.operation?.kind !== 'disable') { + throw new ZeroTrustUnavailableError('The Zero Trust disable operation was replaced.'); + } + try { + const project = await getProject(lockedDb, projectId); + if (!project || project.status === 'deleting') { + return; + } + const row = await getProjectZeroTrustState(lockedDb, projectId); + // Record public intent before any routing write. Partial pages and caught + // provider failures commit this checkpoint, so later metadata writes cannot + // restore protection behind the cleanup cursor. Unvisited projects retain + // their existing protection until their own disable batch starts. + await putProjectState(lockedDb, projectId, { + desired_protected: false, + state: 'reconciling', + // Replay older partial checkpoints that still recorded protected intent; + // readers may have restored protection to keys before their saved cursor. + ...(row?.desired_protected ? { routing_cursor: null } : {}), + }); + if ( + !(await publishProjectZeroTrustRoutingLocked(env, lockedDb, project, null, routingBudget)) + ) { + return; + } + if (row) { + await client.deleteApplication( + row.protected_application_id, + row.protected_application_name, + ); + } + await putProjectState(lockedDb, projectId, { + desired_protected: false, + effective_protected: false, + state: 'public', + reconciled_operation: operationCompletionKey(saved.operation), + // Public exceptions must outlive the wildcard, including when a later + // project or wildcard deletion fails. The final phase clears these handles. + protected_application_id: null, + protected_application_name: null, + protected_application_audience: null, + last_error: null, + }); + } catch (error) { + await markProjectError(lockedDb, projectId, error); + operationError = error; + } + }); + if (operationError !== undefined) { + throw operationError; + } +} + +async function deleteProjectZeroTrustBypass( + db: Db, + projectId: string, + client: ZeroTrustCloudflareClient, + configuration: ZeroTrustConfiguration, +): Promise { + let operationError: unknown; + await db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + await lockZeroTrustConfiguration(tx); + const saved = await readZeroTrustConfigurationStrict(tx); + if (!sameConfiguration(configuration, saved) || saved.operation?.phase !== 'bypass-delete') { + throw new ZeroTrustUnavailableError('The Zero Trust disable operation was replaced.'); + } + const row = await getProjectZeroTrustState(tx, projectId); + if (!row) { + return; + } + try { + await client.deleteApplication(row.bypass_application_id, row.bypass_application_name); + await updateProjectState(tx, projectId, { + bypass_application_id: null, + bypass_application_name: null, + state: 'public', + last_error: null, + }); + } catch (error) { + await markProjectError(tx, projectId, error); + operationError = error; + } + }); + if (operationError !== undefined) { + throw operationError; + } +} + +async function runDisableOperation( + env: ApiEnv['Bindings'], + db: Db, + initial: ZeroTrustConfiguration, +): Promise { + let configuration = initial; + let projectsProcessed = 0; + const routingBudget = { remaining: MAX_ROUTING_KEYS_PER_RUN }; + try { + const client = await clientForConfiguration(env, configuration); + if (configuration.operation?.phase === 'seed') { + configuration = await advanceOperation(db, configuration, 'projects'); + } + while (configuration.operation?.kind === 'disable') { + const operation = configuration.operation; + if (operation.phase === 'projects') { + const candidates = await disableCandidates( + db, + operation, + MAX_PROJECTS_PER_RUN - projectsProcessed, + ); + if (candidates.length === 0) { + configuration = await advanceOperation(db, configuration, 'wildcard-delete'); + continue; + } + let stoppedForRoutingBudget = false; + for (const candidate of candidates) { + if (routingBudget.remaining < 1) { + stoppedForRoutingBudget = true; + break; + } + await disableProjectZeroTrust( + env, + db, + candidate.project.id, + client, + routingBudget, + configuration, + ); + projectsProcessed += 1; + configuration = await heartbeatConfiguration(db, configuration); + if (projectsProcessed >= MAX_PROJECTS_PER_RUN) { + break; + } + } + if (stoppedForRoutingBudget || projectsProcessed >= MAX_PROJECTS_PER_RUN) { + return releaseOperation(db, configuration); + } + continue; + } + if (operation.phase === 'wildcard-delete') { + configuration = await withZeroTrustOperationLock(db, configuration, async (lockedDb) => { + // A resumed cleanup must drain late/legacy project work before losing + // the wildcard checkpoint, including work left by an errored request. + if ((await disableCandidates(lockedDb, operation, 1)).length > 0) { + return advanceOperation(lockedDb, configuration, 'projects'); + } + await client.deleteApplication( + configuration.wildcardApplication?.id, + configuration.wildcardApplicationName ?? configuration.wildcardApplication?.name, + ); + return advanceOperation(lockedDb, configuration, 'bypass-delete', { + enabled: false, + wildcardApplication: null, + lastError: null, + }); + }); + continue; + } + if (operation.phase === 'bypass-delete') { + const candidates = await bypassCleanupCandidates( + db, + MAX_PROJECTS_PER_RUN - projectsProcessed, + ); + for (const candidate of candidates) { + await deleteProjectZeroTrustBypass(db, candidate.project.id, client, configuration); + projectsProcessed += 1; + configuration = await heartbeatConfiguration(db, configuration); + } + if (projectsProcessed >= MAX_PROJECTS_PER_RUN) { + return releaseOperation(db, configuration); + } + configuration = await withZeroTrustOperationLock(db, configuration, async (lockedDb) => { + // Recheck late project work under the lock used by project mutations + // before releasing the disable operation and allowing configuration again. + if ((await disableCandidates(lockedDb, operation, 1)).length > 0) { + return advanceOperation(lockedDb, configuration, 'projects'); + } + if ((await bypassCleanupCandidates(lockedDb, 1)).length > 0) { + return configuration; + } + return patchConfiguration(lockedDb, configuration, { + status: 'ready', + operation: null, + lastError: null, + }); + }); + if (configuration.operation) { + continue; + } + return configuration; + } + throw new Error(`Invalid disable operation phase: ${operation.phase}`); + } + throw new Error('The Zero Trust disable operation was replaced.'); + } catch (error) { + await failOperation(db, configuration, error); + throw error; + } +} + +export async function configurePlatformZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + input: ZeroTrustConfigureInput, +): Promise> { + if (input.accountId !== env.CF_ACCOUNT_ID) { + throw new ZeroTrustUnavailableError( + 'Zero Trust must use the Cloudflare account that owns this Void installation.', + ); + } + env = await resolveZeroTrustApplicationEnvironment(env, db); + const destinations = zeroTrustWildcardDestinations(readPlatformApplication(env)); + if (destinations.length === 0) { + throw new ZeroTrustUnavailableError( + 'Project Zero Trust needs a platform domain or workers.dev application hostname.', + ); + } + const client = new ZeroTrustCloudflareClient(input.accountId, input.apiToken); + const previous = await readZeroTrustConfigurationStrict(db); + const replacingDisableCredential = previous?.operation?.kind === 'disable'; + // Teardown needs the account credential, not the IdPs/policies being removed. + // Those selections may themselves have disappeared while cleanup was pending. + const selection = replacingDisableCredential + ? await client.getOrganization() + : await client.validateSelection(input); + const credential = await encryptPlatformCredential( + env, + installationId(env), + ZERO_TRUST_CREDENTIAL_ID, + input.apiToken, + ); + const generatedApplicationName = await applicationName(env, 'project-zero-trust'); + const configuration = await claimConfiguration(db, (current, owner, startedAt) => { + if ( + replacingDisableCredential && + (current?.revision !== previous.revision || current.operation?.kind !== 'disable') + ) { + throw new ZeroTrustUnavailableError( + 'The saved Zero Trust operation changed. Retry configuration.', + ); + } + if (current?.operation) { + const sameIds = (left: ReadonlyArray, right: ReadonlyArray) => + left.length === right.length && left.every((id) => right.includes(id)); + if ( + current.accountId !== input.accountId || + current.protectNewProjects !== input.protectNewProjects || + !sameIds(current.identityProviderIds, input.identityProviderIds) || + !sameIds(current.policyIds, input.policyIds) || + (current.operation.existingProjects !== null && + current.operation.existingProjects !== input.existingProjects) + ) { + throw new ZeroTrustUnavailableError( + 'A saved Zero Trust operation is pending. To replace its API token, configure again with the same account, identity providers, policies, and project selections.', + ); + } + return { + ...current, + revision: current.revision + 1, + status: current.operation.kind === 'disable' ? 'disabling' : 'configuring', + credential, + teamDomain: selection.teamDomain, + operation: { ...current.operation, owner, leaseExpiresAt: leaseExpiresAt() }, + lastError: null, + updatedAt: new Date().toISOString(), + }; + } + const enabling = !current?.enabled; + const now = new Date().toISOString(); + return { + version: 1, + revision: (current?.revision ?? 0) + 1, + status: 'configuring', + enabled: current?.enabled ?? false, + protectNewProjects: input.protectNewProjects, + accountId: input.accountId, + teamDomain: selection.teamDomain, + identityProviderIds: [...input.identityProviderIds], + policyIds: [...input.policyIds], + credential, + wildcardApplicationName: + current?.wildcardApplicationName ?? + current?.wildcardApplication?.name ?? + generatedApplicationName, + wildcardApplication: current?.wildcardApplication ?? null, + operation: { + kind: 'configure', + phase: 'seed', + owner, + leaseExpiresAt: leaseExpiresAt(), + startedAt, + existingProjects: enabling ? input.existingProjects : null, + }, + lastError: null, + updatedAt: now, + }; + }); + return redactedZeroTrustConfiguration( + configuration.operation?.kind === 'disable' + ? await runDisableOperation(env, db, configuration) + : await runConfigureOperation(env, db, configuration, client, selection), + ); +} + +async function claimReconcileOperation(db: Db): Promise { + return claimConfiguration(db, (current, owner, startedAt) => { + if (!current) { + throw new ZeroTrustUnavailableError('Project Zero Trust is not configured.'); + } + const now = new Date().toISOString(); + if (current.operation) { + return { + ...current, + revision: current.revision + 1, + status: current.operation.kind === 'disable' ? 'disabling' : 'configuring', + operation: { + ...current.operation, + owner, + leaseExpiresAt: leaseExpiresAt(), + }, + lastError: null, + updatedAt: now, + }; + } + if (!current.enabled) { + throw new ZeroTrustUnavailableError('Project Zero Trust is not enabled.'); + } + return { + ...current, + revision: current.revision + 1, + status: 'configuring', + operation: { + kind: 'reconcile', + phase: 'seed', + owner, + leaseExpiresAt: leaseExpiresAt(), + startedAt, + existingProjects: null, + }, + lastError: null, + updatedAt: now, + }; + }); +} + +export async function reconcilePlatformZeroTrust( + env: ApiEnv['Bindings'], + db: Db, +): Promise> { + const configuration = await claimReconcileOperation(db); + const result = + configuration.operation?.kind === 'disable' + ? await runDisableOperation(env, db, configuration) + : await runConfigureOperation(env, db, configuration); + return redactedZeroTrustConfiguration(result); +} + +export async function disablePlatformZeroTrust( + env: ApiEnv['Bindings'], + db: Db, +): Promise> { + const saved = await readZeroTrustConfigurationStrict(db); + if (!saved) { + return redactedZeroTrustConfiguration(null); + } + if (!saved.enabled && !saved.operation) { + return redactedZeroTrustConfiguration(saved); + } + const configuration = await claimConfiguration(db, (current, owner, startedAt) => { + if (!current) { + throw new ZeroTrustUnavailableError('Project Zero Trust is not configured.'); + } + const now = new Date().toISOString(); + return { + ...current, + revision: current.revision + 1, + status: 'disabling', + operation: + current.operation?.kind === 'disable' + ? { ...current.operation, owner, leaseExpiresAt: leaseExpiresAt() } + : { + kind: 'disable', + phase: 'seed', + owner, + leaseExpiresAt: leaseExpiresAt(), + startedAt, + existingProjects: null, + }, + lastError: null, + updatedAt: now, + }; + }); + return redactedZeroTrustConfiguration(await runDisableOperation(env, db, configuration)); +} + +export async function zeroTrustStatus(env: ApiEnv['Bindings'], db: Db) { + const projectCounts = await db + .select({ + desiredProtected: projectZeroTrust.desired_protected, + effectiveProtected: projectZeroTrust.effective_protected, + state: projectZeroTrust.state, + count: sql`count(*)::int`, + }) + .from(projectZeroTrust) + .groupBy( + projectZeroTrust.desired_protected, + projectZeroTrust.effective_protected, + projectZeroTrust.state, + ); + let configuration: ZeroTrustConfiguration | null; + try { + configuration = await readZeroTrustConfigurationStrict(db); + } catch (error) { + if (!(error instanceof ZeroTrustConfigurationError)) { + throw error; + } + return { + configured: true, + enabled: false, + status: 'invalid_configuration' as const, + lastError: error.message, + live: null, + projects: projectCounts, + }; + } + let live: { present: boolean; drifted: boolean } | null = null; + if (configuration?.enabled && configuration.wildcardApplication) { + try { + env = await resolveZeroTrustApplicationEnvironment(env, db, configuration); + const client = await clientForConfiguration(env, configuration); + const selection = await client.validateSelection({ + identityProviderIds: configuration.identityProviderIds, + policyIds: configuration.policyIds, + }); + live = await client.inspectApplication(configuration.wildcardApplication, { + name: configuration.wildcardApplicationName ?? configuration.wildcardApplication.name, + destinations: zeroTrustWildcardDestinations(readPlatformApplication(env)), + allowedIdentityProviderIds: configuration.identityProviderIds, + policies: reusablePolicyReferences(configuration.policyIds), + }); + if (selection.teamDomain !== configuration.teamDomain) { + live = { ...live, drifted: true }; + } + } catch (error) { + live = { present: false, drifted: true }; + console.error('[zero-trust] live status check failed', error); + } + } + return { + ...redactedZeroTrustConfiguration(configuration), + live, + projects: projectCounts, + }; +} + +export async function cleanupProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, +): Promise { + const [configuration, row] = await Promise.all([ + readZeroTrustConfigurationStrict(db), + getProjectZeroTrustState(db, projectId), + ]); + if ( + !row || + (!row.bypass_application_id && + !row.bypass_application_name && + !row.protected_application_id && + !row.protected_application_name) + ) { + return; + } + if (!configuration) { + throw new ZeroTrustUnavailableError( + 'Zero Trust configuration is unavailable; managed Access applications cannot be safely deleted.', + ); + } + const client = await clientForConfiguration(env, configuration); + await client.deleteApplication(row.bypass_application_id, row.bypass_application_name); + await client.deleteApplication(row.protected_application_id, row.protected_application_name); +} + +export async function reconcileProjectZeroTrustDomains( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, +): Promise { + const [configuration, row] = await Promise.all([ + readZeroTrustConfigurationStrict(db), + getProjectZeroTrustState(db, projectId), + ]); + if (!configuration?.enabled || configuration.operation?.kind === 'disable' || !row) { + return row; + } + return reconcileProjectZeroTrust(env, db, projectId, { configuration }); +} + +export async function reconcilePendingProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + limit = 20, +): Promise { + const configuration = await readZeroTrustConfigurationStrict(db); + if (!configuration) { + return; + } + if (configuration.operation) { + await resumePlatformZeroTrustOperation(env, db, configuration); + return; + } + if (!configuration.enabled || configuration.status !== 'ready') { + return; + } + const rows = await candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + or( + isNull(projectZeroTrust.project_id), + sql`${projectZeroTrust.state} IN ('reconciling', 'error')`, + ), + ), + ) + .orderBy(projectZeroTrust.updated_at, projects.created_at) + .limit(limit); + if (rows.length === 0) { + return; + } + const client = await clientForConfiguration(env, configuration); + const routingBudget = { remaining: MAX_ROUTING_KEYS_PER_RUN }; + for (const row of rows) { + if (routingBudget.remaining < MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT + 1) { + break; + } + await reconcileProjectZeroTrust(env, db, row.project.id, { + configuration, + client, + routingBudget, + }).catch((error) => + console.error('[zero-trust] scheduled project reconciliation failed', { + projectId: row.project.id, + error, + }), + ); + } +} + +export async function resumePlatformZeroTrustOperation( + env: ApiEnv['Bindings'], + db: Db, + saved?: ZeroTrustConfiguration, +): Promise { + const configuration = saved ?? (await readZeroTrustConfigurationStrict(db)); + if (configuration?.operation && !leaseActive(configuration.operation)) { + const result = await reconcilePlatformZeroTrust(env, db); + // Only a batch that made progress owns the invocation; a failed one must + // not starve the rest of the scheduled work. + return !('status' in result && result.status === 'error'); + } + return false; +} diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 4ca78c1d3..502b35968 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -16,6 +16,7 @@ import { domains, githubInstallations, invitations, + platformSettings, platformLoginConnections, platformLoginRevisions, operatorEvents, @@ -23,6 +24,7 @@ import { projectInvitations, projectMembers, projectOwnershipPeriods, + projectZeroTrust, projects, users, } from '../../src/schema.ts'; @@ -449,6 +451,93 @@ describe('auth middleware', () => { }); describe('GET /admin/ (dashboard)', () => { + test('renders Zero Trust configuration without exposing a credential', async () => { + const admin = await createTestUser( + env.db, + 'zero-trust-admin', + 'github', + undefined, + 'free', + 'admin', + ); + const encryptedCredential = 'stored-access-management-credential'; + await env.db.insert(platformSettings).values({ + key: 'zeroTrust.configuration', + value: { + version: 1, + revision: 1, + status: 'configuring', + enabled: false, + protectNewProjects: true, + accountId: 'zero-trust-account', + teamDomain: 'https://company.cloudflareaccess.com', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + credential: { + ciphertext: encryptedCredential, + iv: 'credential-iv', + keyVersion: 'v1', + }, + wildcardApplication: null, + operation: { + kind: 'configure', + phase: 'public-bypasses', + owner: null, + leaseExpiresAt: null, + startedAt: '2026-09-22T00:00:00.000Z', + existingProjects: 'public', + }, + lastError: null, + updatedAt: '2026-09-22T00:00:00.000Z', + }, + }); + const project = await createTestProject(env.db, admin.id, 'zero-trust-admin-status'); + await env.db.insert(projectZeroTrust).values({ + project_id: project.id, + desired_protected: false, + effective_protected: false, + state: 'public', + }); + + const res = await req('/admin/zero-trust', admin.adminJwt); + expect(res.status).toBe(200); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect(res.headers.get('Referrer-Policy')).toBe('no-referrer'); + const html = await res.text(); + expect(html).toContain('Project Zero Trust'); + expect(html).toContain('Protect new projects by default'); + expect(html).toContain('zero-trust-account'); + expect(html).toContain('configure · public-bypasses'); + expect(html).toContain('value="public" selected'); + expect(html).toContain('pending operation will resume'); + expect(html).toContain('action="/admin/zero-trust/disable"'); + expect(html).toContain('Cancel setup and clean up'); + expect(html).toContain('Project states'); + expect(html).toContain('name="apiToken"'); + expect(html).not.toContain(encryptedCredential); + expect(html).not.toContain(env.CF_API_TOKEN); + }); + + test('rejects an invalid project protection form value', async () => { + const admin = await createTestUser( + env.db, + 'zero-trust-project-admin', + 'github', + undefined, + 'free', + 'admin', + ); + const project = await createTestProject(env.db, admin.id, 'zero-trust-project-form'); + + const res = await req(`/admin/projects/${project.id}/zero-trust`, admin.adminJwt, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ protected: 'invalid' }).toString(), + }); + + expect(res.status).toBe(400); + }); + test('renders dashboard with stats and projects', async () => { const admin = await createTestUser(env.db, 'admin1', 'github', undefined, 'free', 'admin'); await createTestUser(env.db, 'user1'); diff --git a/platform/packages/api/test/integration/api-deploy.test.ts b/platform/packages/api/test/integration/api-deploy.test.ts index 91bdccc6d..4b5f8b9d7 100644 --- a/platform/packages/api/test/integration/api-deploy.test.ts +++ b/platform/packages/api/test/integration/api-deploy.test.ts @@ -32,7 +32,9 @@ import { projects, projectMigrations, projectMembers, + projectZeroTrust, projectSecrets, + platformSettings, users, } from '../../src/schema.ts'; import { @@ -563,6 +565,82 @@ describe('POST /projects/:id/deploy', () => { }); }); + it.each([ + { backend: 'worker' as const, slug: 'zero-trust-cutover-worker' }, + { backend: 'static' as const, slug: 'zero-trust-cutover-static' }, + ])( + 'publishes the Zero Trust state committed before the $backend cutover lock', + async ({ backend, slug }) => { + const user = await createTestUser(env.db, `owner-${backend}`); + const project = await createTestProject(env.db, user.id, slug); + mockCfApi(); + + await env.db.insert(platformSettings).values({ + key: 'zeroTrust.configuration', + value: { + version: 1, + revision: 1, + status: 'ready', + enabled: true, + protectNewProjects: true, + accountId: env.CF_ACCOUNT_ID, + teamDomain: 'https://company.cloudflareaccess.com', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + credential: { ciphertext: 'unused', iv: 'unused', keyVersion: 'v1' }, + wildcardApplication: { + id: 'access-wildcard', + name: 'void-test-projects', + audience: 'access-audience', + destinations: ['*.void.app'], + }, + lastError: null, + updatedAt: new Date().toISOString(), + }, + }); + await env.db.insert(projectZeroTrust).values({ + project_id: project.id, + desired_protected: true, + effective_protected: false, + state: 'reconciling', + }); + + let protectionCommitted = false; + const cronScheduler = { + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ + fetch: async (request: Request) => { + if (new URL(request.url).pathname === '/snapshot' && !protectionCommitted) { + await env.db + .update(projectZeroTrust) + .set({ effective_protected: true, state: 'protected' }) + .where(eq(projectZeroTrust.project_id, project.id)); + protectionCommitted = true; + return Response.json({ state: null }); + } + return Response.json({ ok: true }); + }, + }), + } as unknown as DurableObjectNamespace; + const testEnv = { ...env, CRON_SCHEDULER: cronScheduler }; + const request = + backend === 'static' + ? await buildStaticDeployRequest(env, project.id, user.jwt, { type: 'static' }) + : await buildDeployRequest(env, project.id, user.jwt); + + const response = await app.fetch(request, testEnv); + expect(response.status).toBe(200); + expect(parseDeployStream(await response.text()).at(-1)).toMatchObject({ event: 'done' }); + expect(protectionCommitted).toBe(true); + expect(await env.ROUTING.get(project.slug, 'json')).toMatchObject({ + zeroTrust: { + teamDomain: 'https://company.cloudflareaccess.com', + audiences: [{ hostname: '*.void.app', audience: 'access-audience' }], + }, + }); + }, + ); + it.each([ { notFoundHandling: 'single-page-application', framework: undefined, type: 'void' }, { notFoundHandling: '404-page', framework: undefined, type: 'void' }, diff --git a/platform/packages/api/test/integration/api-domains.test.ts b/platform/packages/api/test/integration/api-domains.test.ts index d8cefc546..c590af0db 100644 --- a/platform/packages/api/test/integration/api-domains.test.ts +++ b/platform/packages/api/test/integration/api-domains.test.ts @@ -17,8 +17,15 @@ import { import type { ApiEnv } from '../../src/env.ts'; import app, { scheduled, cleanupStalePendingDomains, FAST_CRON } from '../../src/index.ts'; import { tryActivateDomain } from '../../src/provisioning/activate-domain.ts'; -import { domains, projectMembers, users } from '../../src/schema.ts'; +import { + domains, + platformSettings, + projectMembers, + projectZeroTrust, + users, +} from '../../src/schema.ts'; import type { Domain, Project } from '../../src/types.ts'; +import { configurePlatformZeroTrust } from '../../src/zero-trust/service.ts'; import { setupEnv, teardownEnv, @@ -28,6 +35,7 @@ import { mockCfApi, cfSuccess, cfError, + captureQueries, type TestEnv, } from './helpers.ts'; @@ -214,6 +222,70 @@ describe('POST /projects/:id/domains', () => { ).toHaveLength(1); }); + it('compensates a protected-project create that loses the final domain-limit race', async () => { + const user = await createTestUser(env.db, 'domain-limit-owner'); + const project = await createTestProject(env.db, user.id, 'domain-limit-race'); + await env.db.insert(projectZeroTrust).values({ + project_id: project.id, + desired_protected: true, + effective_protected: true, + state: 'protected', + }); + await env.db.insert(domains).values( + Array.from({ length: 99 }, (_, index) => ({ + id: `dom_limit_${index}`, + project_id: project.id, + hostname: `limit-${index}.example.com`, + cf_hostname_id: `ch-limit-${index}`, + status: 'active' as const, + })), + ); + const fetchMock = mockCfApi({ + createCustomHostname: async () => { + await env.db.insert(domains).values({ + id: 'dom_limit_winner', + project_id: project.id, + hostname: 'limit-winner.example.com', + cf_hostname_id: 'ch-limit-winner', + status: 'active', + }); + return cfSuccess({ + id: 'ch-limit-loser', + hostname: 'limit-loser.example.com', + status: 'pending', + ssl: { status: 'pending_validation', method: 'http', type: 'dv' }, + }); + }, + }); + + const response = await app.fetch( + new Request(`http://localhost/projects/${project.id}/domains`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ hostname: 'limit-loser.example.com' }), + }), + env, + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + error: 'Protected projects support at most 100 custom domains', + }); + expect( + await env.db.select().from(domains).where(eq(domains.project_id, project.id)), + ).toHaveLength(100); + expect( + fetchMock.mock.calls.filter( + ([url, init]) => + String(url).includes('/custom_hostnames/ch-limit-loser') && + (init as RequestInit | undefined)?.method === 'DELETE', + ), + ).toHaveLength(1); + }); + it('returns 404 for non-existent project', async () => { const user = await createTestUser(env.db); mockCfApi(); @@ -1650,8 +1722,10 @@ describe('scheduled() — stale pending domain janitor', () => { status: 'pending', }); - const controller = { scheduledTime: nowMs } as ScheduledController; - await scheduled(controller, makeBindings(env), mockCtx); + const { queries } = await captureQueries(env.db, () => + cleanupStalePendingDomains(makeBindings(env), env.db, nowMs), + ); + expect(queries.some((query) => /from\s+"?projects"?.*for update/i.test(query))).toBe(true); // Stale row is gone. const [staleRow] = await env.db.select().from(domains).where(eq(domains.id, 'dom_stale')); @@ -1707,6 +1781,131 @@ describe('scheduled() — stale pending domain janitor', () => { expect(customHostnameDeletes).toHaveLength(0); }); + it('removes a deleted stale domain from protected Zero Trust destinations', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'janitor-zero-trust'); + const applications = new Map>(); + let nextApplication = 1; + let customHostnameStatus: 'active' | 'pending' = 'active'; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + if (url.pathname.includes('/custom_hostnames/')) { + if (init?.method === 'DELETE') { + return cfSuccess(null); + } + return cfSuccess({ + id: 'ch-janitor-zero-trust', + hostname: 'stale-protected.example.com', + status: customHostnameStatus, + ssl: { + status: customHostnameStatus === 'active' ? 'active' : 'pending_validation', + method: 'http', + type: 'dv', + }, + }); + } + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return cfSuccess({ auth_domain: 'company.cloudflareaccess.com' }); + } + if (path === 'identity_providers/idp-1') { + return cfSuccess({ id: 'idp-1', name: 'Company SSO' }); + } + if (path === 'policies/policy-1') { + return cfSuccess({ + id: 'policy-1', + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return cfSuccess(application); + } + if (path === 'apps') { + return cfSuccess([...applications.values()]); + } + const match = /^apps\/([^/]+)$/.exec(path); + const applicationId = match?.[1]; + if (applicationId && init?.method === 'PUT') { + const previous = applications.get(applicationId); + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id: applicationId, aud: previous?.aud }; + applications.set(applicationId, application); + return cfSuccess(application); + } + if (applicationId && init?.method === 'DELETE') { + applications.delete(applicationId); + return cfSuccess({ id: applicationId }); + } + if (applicationId) { + return cfSuccess(applications.get(applicationId)); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }), + ); + const bindings = { + ...makeBindings(env), + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as ApiEnv['Bindings']; + + await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected', + }); + + const nowMs = Date.now(); + await env.db.insert(domains).values({ + id: 'dom_janitor_zero_trust', + project_id: project.id, + hostname: 'stale-protected.example.com', + cf_hostname_id: 'ch-janitor-zero-trust', + status: 'pending', + created_at: new Date(nowMs - 8 * 24 * 60 * 60 * 1000).toISOString(), + }); + const [pendingDomain] = await env.db + .select() + .from(domains) + .where(eq(domains.id, 'dom_janitor_zero_trust')); + + const activation = await tryActivateDomain( + bindings, + env.db, + pendingDomain as unknown as Domain, + project as unknown as Project, + ); + expect(activation).toMatchObject({ activated: false, awaitingDeployment: true }); + expect( + [...applications.values()].some((application) => + JSON.stringify(application.destinations).includes('stale-protected.example.com'), + ), + ).toBe(true); + + customHostnameStatus = 'pending'; + await cleanupStalePendingDomains(bindings, env.db, nowMs); + + const [deletedDomain] = await env.db + .select() + .from(domains) + .where(eq(domains.id, 'dom_janitor_zero_trust')); + expect(deletedDomain).toBeUndefined(); + expect( + [...applications.values()].some((application) => + JSON.stringify(application.destinations).includes('stale-protected.example.com'), + ), + ).toBe(false); + }); + it('does NOT delete a stale row that CF reports active (verified after day 7)', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'janitor-late-verify'); @@ -1772,11 +1971,31 @@ describe('scheduled() — stale pending domain janitor', () => { created_at: eightDaysAgo, }); + let transactionDepth = 0; + const originalTransaction = env.db.transaction.bind(env.db); + vi.spyOn(env.db, 'transaction').mockImplementation(async (...args) => { + transactionDepth += 1; + try { + return await originalTransaction(...args); + } finally { + transactionDepth -= 1; + } + }); + let routeDeletedUnderFence = false; + const originalRoutingDelete = env.ROUTING.delete.bind(env.ROUTING); + vi.spyOn(env.ROUTING, 'delete').mockImplementation(async (key) => { + if (key === 'domain:cf-gone.example.com') { + routeDeletedUnderFence = transactionDepth > 0; + } + return originalRoutingDelete(key); + }); + const controller = { scheduledTime: Date.now() } as ScheduledController; await scheduled(controller, makeBindings(env), mockCtx); const [row] = await env.db.select().from(domains).where(eq(domains.id, 'dom_cf_gone')); expect(row).toBeUndefined(); + expect(routeDeletedUnderFence).toBe(true); // No DELETE attempted — CF already reported it gone on read. const deletes = fetchMock.mock.calls.filter((call: Array) => { @@ -2041,6 +2260,49 @@ describe('scheduled() — cron-driven auto-activation reconciliation', () => { }); }); + it('does not let malformed Zero Trust state starve fast domain reconciliation', async () => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'fast-invalid-zero-trust'); + const fetchMock = mockCfApi({ + getCustomHostname: () => + gatePassingCfHostname('ch-fast-invalid-zero-trust', 'invalid-zero-trust.example.com'), + }); + await env.db.insert(platformSettings).values({ + key: 'zeroTrust.configuration', + value: { version: 999 }, + }); + await env.db.insert(domains).values({ + id: 'dom_fast_invalid_zero_trust', + project_id: project.id, + hostname: 'invalid-zero-trust.example.com', + cf_hostname_id: 'ch-fast-invalid-zero-trust', + status: 'pending', + created_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + }); + await env.ROUTING.put( + project.slug, + JSON.stringify({ workerName: 'user-fast-invalid-zero-trust', projectId: project.id }), + ); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await scheduled( + { scheduledTime: Date.now(), cron: FAST_CRON } as ScheduledController, + makeBindings(env), + mockCtx, + ); + + const [row] = await env.db + .select({ status: domains.status }) + .from(domains) + .where(eq(domains.id, 'dom_fast_invalid_zero_trust')); + expect(row?.status).toBe('pending'); + expect( + fetchMock.mock.calls.some(([input]) => + String(input).includes('/custom_hostnames/ch-fast-invalid-zero-trust'), + ), + ).toBe(true); + }); + it('fast tier (*/2) ignores an old pending domain (created >30 min ago)', async () => { const user = await createTestUser(env.db); const project = await createTestProject(env.db, user.id, 'fast-old-skip'); diff --git a/platform/packages/api/test/integration/api-project-access-routes.test.ts b/platform/packages/api/test/integration/api-project-access-routes.test.ts index aa20e41b9..bab692caf 100644 --- a/platform/packages/api/test/integration/api-project-access-routes.test.ts +++ b/platform/packages/api/test/integration/api-project-access-routes.test.ts @@ -5,6 +5,7 @@ import app from '../../src/index.ts'; import { deployments, domains, + platformSettings, projectAccessEvents, projectDeployCredentials, projectInvitations, @@ -12,6 +13,7 @@ import { projectOwnershipPeriods, projects, projectSecrets, + projectZeroTrust, resources, users, } from '../../src/schema.ts'; @@ -250,6 +252,119 @@ describe('project route membership capabilities', () => { expect(await projectState()).toEqual(before); }); + it('lets readers inspect Zero Trust while reserving overrides for project admins and owners', async () => { + const owner = await createTestUser(env.db, 'zero-trust-owner'); + const reader = await createTestUser(env.db, 'zero-trust-reader'); + const collaborator = await createTestUser(env.db, 'zero-trust-collaborator'); + const admin = await createTestUser(env.db, 'zero-trust-admin'); + const project = await createTestProject(env.db, owner.id, 'zero-trust-roles'); + await env.db.insert(projectMembers).values([ + { + project_id: project.id, + user_id: reader.id, + role: 'reader', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: collaborator.id, + role: 'collaborator', + added_by_user_id: owner.id, + }, + { + project_id: project.id, + user_id: admin.id, + role: 'admin', + added_by_user_id: owner.id, + }, + ]); + await env.db.insert(projectZeroTrust).values({ + project_id: project.id, + desired_protected: true, + effective_protected: true, + state: 'protected', + protected_application_id: 'access-app-internal', + protected_application_name: 'void-internal-name', + protected_application_audience: 'internal-audience', + }); + + const status = await app.fetch(authed(`/projects/${project.id}/zero-trust`, reader.jwt), env); + expect(status.status).toBe(200); + expect(await status.json()).toEqual({ + available: false, + platformStatus: 'not_configured', + protectNewProjects: false, + project: { + project_id: project.id, + desired_protected: true, + effective_protected: true, + state: 'protected', + last_error: null, + updated_at: expect.any(String), + }, + }); + + const collaboratorUpdate = await app.fetch( + new Request(`http://localhost/projects/${project.id}/zero-trust`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${collaborator.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ protected: true }), + }), + env, + ); + expect(collaboratorUpdate.status).toBe(403); + + const adminUpdate = await app.fetch( + new Request(`http://localhost/projects/${project.id}/zero-trust`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${admin.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ protected: true }), + }), + env, + ); + expect(adminUpdate.status).toBe(409); + expect(await adminUpdate.json()).toEqual({ + error: 'Project Zero Trust is not ready on this platform.', + }); + }); + + it('reports malformed saved Zero Trust configuration without exposing a server error', async () => { + const owner = await createTestUser(env.db, 'zero-trust-invalid-owner'); + const project = await createTestProject(env.db, owner.id, 'zero-trust-invalid'); + await env.db.insert(platformSettings).values({ + key: 'zeroTrust.configuration', + value: { + version: 1, + revision: 1, + status: 'ready', + enabled: false, + protectNewProjects: true, + accountId: env.CF_ACCOUNT_ID, + teamDomain: 'not a URL', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + credential: { ciphertext: 'saved', iv: 'saved', keyVersion: 'v1' }, + wildcardApplication: null, + operation: null, + lastError: null, + updatedAt: new Date().toISOString(), + }, + }); + + const response = await app.fetch(authed(`/projects/${project.id}/zero-trust`, owner.jwt), env); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + available: false, + platformStatus: 'invalid_configuration', + }); + }); + it('lists shared projects with role and owner and grants readers view routes', async () => { const owner = await createTestUser(env.db, 'owner'); const reader = await createTestUser(env.db, 'reader'); diff --git a/platform/packages/api/test/integration/api-projects.test.ts b/platform/packages/api/test/integration/api-projects.test.ts index e1d113c3e..2e6489d3e 100644 --- a/platform/packages/api/test/integration/api-projects.test.ts +++ b/platform/packages/api/test/integration/api-projects.test.ts @@ -30,6 +30,7 @@ import { projectDatabaseCredentials, projectDeployCredentials, platformLoginConnections, + platformSettings, } from '../../src/schema.ts'; import { setupEnv, @@ -1858,6 +1859,36 @@ describe('POST /projects', () => { expect(body.user_id).toBe(user.id); }); + it('returns initialization failures in the standard warnings collection', async () => { + const user = await createTestUser(env.db); + await env.db.insert(platformSettings).values({ + key: 'zeroTrust.configuration', + value: { version: 999 }, + }); + + const res = await app.fetch( + new Request('http://localhost/projects', { + method: 'POST', + headers: { + Authorization: `Bearer ${user.jwt}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ slug: 'warning-app' }), + }), + env, + ); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body).toMatchObject({ + slug: 'warning-app', + warnings: [ + 'Project created, but its Zero Trust protection could not be initialized. Reconcile the project Zero Trust status before deployment.', + ], + }); + expect(body).not.toHaveProperty('warning'); + }); + it('rejects missing slug', async () => { const user = await createTestUser(env.db); diff --git a/platform/packages/api/test/integration/operator-audit.test.ts b/platform/packages/api/test/integration/operator-audit.test.ts index d366576dd..390c8dbef 100644 --- a/platform/packages/api/test/integration/operator-audit.test.ts +++ b/platform/packages/api/test/integration/operator-audit.test.ts @@ -1,6 +1,7 @@ +import { eq } from 'drizzle-orm'; import { afterAll, afterEach, beforeAll, beforeEach, expect, test, vi } from 'vite-plus/test'; import { app } from '../../src/index.ts'; -import { operatorEvents } from '../../src/schema.ts'; +import { operatorEvents, projects } from '../../src/schema.ts'; import { clearTables, createOperatorToken, @@ -104,6 +105,56 @@ test('previews and reads produce no audit or configuration writes', async () => expect(await env.db.select().from(operatorEvents)).toHaveLength(0); }); +test('previews Zero Trust configuration without returning or auditing the API token', async () => { + const secret = 'access-management-secret'; + const res = await request('/zero-trust/configure', { + dryRun: true, + accountId: env.CF_ACCOUNT_ID, + apiToken: secret, + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'public', + }); + + expect(res.status).toBe(200); + const body = await res.text(); + expect(JSON.parse(body)).toMatchObject({ + dryRun: true, + action: 'zero-trust.configure', + target: 'platform', + accountId: env.CF_ACCOUNT_ID, + credential: 'replace-encrypted', + protectNewProjects: true, + existingProjects: 'public', + }); + expect(body).not.toContain(secret); + expect(await env.db.select().from(operatorEvents)).toHaveLength(0); +}); + +test('validates operator project Zero Trust targets', async () => { + expect((await request('/zero-trust/projects/missing')).status).toBe(404); + expect( + (await request('/zero-trust/projects/missing', { protected: true, dryRun: true })).status, + ).toBe(404); + + const owner = await createTestUser(env.db, 'zero-trust-target-owner'); + const project = await createTestProject(env.db, owner.id, 'zero-trust-target'); + await env.db.update(projects).set({ status: 'deleting' }).where(eq(projects.id, project.id)); + + const status = await request(`/zero-trust/projects/${project.id}`); + expect(status.status).toBe(200); + expect(await status.json()).toEqual({ project: null }); + expect( + ( + await request(`/zero-trust/projects/${project.id}`, { + protected: true, + dryRun: true, + }) + ).status, + ).toBe(409); +}); + test('records actor, target, and success without recording credentials', async () => { const res = await request('/signup/restrict', { credential: 'must-not-be-audited' }); expect(res.status).toBe(200); diff --git a/platform/packages/api/test/integration/platform-domain-activation.test.ts b/platform/packages/api/test/integration/platform-domain-activation.test.ts new file mode 100644 index 000000000..568473caa --- /dev/null +++ b/platform/packages/api/test/integration/platform-domain-activation.test.ts @@ -0,0 +1,346 @@ +import { PLATFORM_APPLICATION_KEY, PLATFORM_DISABLED_KEY } from '@void/platform'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import type { ApiEnv } from '../../src/env.ts'; +import app, { FAST_CRON, scheduled } from '../../src/index.ts'; +import { projects as projectTable } from '../../src/schema.ts'; +import { readZeroTrustConfigurationStrict } from '../../src/zero-trust/configuration.ts'; +import { + activatePlatformApplication, + configurePlatformZeroTrust, + getProjectZeroTrustState, + initializeNewProjectZeroTrust, + reconcilePlatformZeroTrust, + setProjectZeroTrustProtection, + zeroTrustStatus, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); + await env.ROUTING.delete(PLATFORM_APPLICATION_KEY); + await env.ROUTING.delete(PLATFORM_DISABLED_KEY); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const installationId = 'team-123456abcdef'; +const original = { + baseDomain: 'test.workers.dev', + zoneName: '', + dedicatedZone: false, + workersDev: { subdomain: 'test', installationId }, +}; +const target = { + ...original, + baseDomain: 'apps.example.com', + zoneName: 'example.com', + zoneId: 'a'.repeat(32), +}; +function bindings() { + return { + ...env, + PLATFORM_APPLICATION: JSON.stringify(original), + PLATFORM_INSTALLATION_ID: installationId, + PLATFORM_HEALTH_PROBE_TOKEN: 'test-probe', + } as unknown as ApiEnv['Bindings']; +} +function request(token?: string, application: unknown = target) { + return new Request('https://api.test.workers.dev/internal/platform/domain-activate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { 'X-Void-Platform-Probe': token } : {}), + }, + body: JSON.stringify({ application }), + }); +} + +function accessApi() { + const applications = new Map>(); + let nextId = 1; + const requests: Array> = []; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1') { + return Response.json({ success: true, result: { id: 'idp-1', name: 'SSO' } }); + } + if (path === 'policies/policy-1') { + return Response.json({ + success: true, + result: { + id: 'policy-1', + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)); + const id = `app-${nextId++}`; + const result = { ...body, id, aud: `aud-${id}` }; + requests.push(result); + applications.set(id, result); + return Response.json({ success: true, result }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? 1); + const perPage = Number(url.searchParams.get('per_page') ?? 100); + return Response.json({ + success: true, + result: [...applications.values()].slice((page - 1) * perPage, page * perPage), + }); + } + const id = path.match(/^apps\/([^/]+)$/)?.[1]; + if (id && init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + const result = { ...body, id, aud: applications.get(id)?.aud ?? `aud-${id}` }; + requests.push(result); + applications.set(id, result); + return Response.json({ success: true, result }); + } + if (id && init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + if (id) { + return Response.json({ success: true, result: applications.get(id) }); + } + throw new Error(`Unexpected Cloudflare request: ${url.pathname}`); + }); + return { applications, requests, fetcher }; +} + +describe('installer domain activation', () => { + it('requires the installer credential and permits a disabled installation', async () => { + const cloudflare = vi.fn(); + vi.stubGlobal('fetch', cloudflare); + expect((await app.fetch(request(), bindings())).status).toBe(404); + expect((await app.fetch(request('wrong'), bindings())).status).toBe(404); + expect(await env.ROUTING.get(PLATFORM_APPLICATION_KEY)).toBeNull(); + await env.ROUTING.put(PLATFORM_DISABLED_KEY, 'disabled'); + const response = await app.fetch(request('test-probe'), bindings()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, ready: true, installationId }); + expect(JSON.parse((await env.ROUTING.get(PLATFORM_APPLICATION_KEY))!)).toEqual(target); + expect(cloudflare).not.toHaveBeenCalled(); + }); + + it('rejects malformed targets, changed ingress identity and replacement domains', async () => { + expect( + (await app.fetch(request('test-probe', { ...target, zoneId: 'bad' }), bindings())).status, + ).toBe(400); + expect( + ( + await app.fetch( + request('test-probe', { + ...target, + workersDev: { ...target.workersDev, subdomain: 'other' }, + }), + bindings(), + ) + ).status, + ).toBe(409); + expect(await activatePlatformApplication(bindings(), env.db, target)).toEqual({ ready: true }); + expect( + ( + await app.fetch( + request('test-probe', { ...target, baseDomain: 'other.example.com' }), + bindings(), + ) + ).status, + ).toBe(409); + expect(JSON.parse((await env.ROUTING.get(PLATFORM_APPLICATION_KEY))!)).toEqual(target); + }); + + // Exercises several persisted reconciliation batches across more than 100 projects. + it('resumes a bounded cutover with its saved target and preserves public and protected projects', async () => { + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const owner = await createTestUser(env.db, 'domain-owner'); + const projects = Array.from({ length: 103 }, (_, index) => ({ + id: `proj_domain${index}`, + slug: `domain-public-${index}`, + })); + await env.db + .insert(projectTable) + .values(projects.map((project) => ({ ...project, user_id: owner.id }))); + for (const project of projects) { + await env.ROUTING.put( + project.slug, + JSON.stringify({ workerName: 'original-worker', projectId: project.id }), + ); + } + await configurePlatformZeroTrust(bindings(), env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: false, + existingProjects: 'public', + }); + for ( + let index = 0; + index < 8 && (await readZeroTrustConfigurationStrict(env.db))?.operation; + index++ + ) { + await reconcilePlatformZeroTrust(bindings(), env.db); + } + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + status: 'ready', + enabled: true, + }); + const protectedProject = projects[0]!; + await setProjectZeroTrustProtection(bindings(), env.db, protectedProject.id, true); + const ready = await activatePlatformApplication(bindings(), env.db, target); + expect(ready).toEqual({ ready: false }); + expect(await env.ROUTING.get(PLATFORM_APPLICATION_KEY)).toBeNull(); + expect((await readZeroTrustConfigurationStrict(env.db))?.operation?.applicationTarget).toEqual( + target, + ); + await expect( + setProjectZeroTrustProtection(bindings(), env.db, protectedProject.id, false), + ).rejects.toThrow('not ready'); + const added = await createTestProject(env.db, owner.id, 'domain-new-public'); + await initializeNewProjectZeroTrust(bindings(), env.db, added.id); + const newBypass = [...cloudflare.applications.values()].find((application) => + JSON.stringify(application).includes('domain-new-public.apps.example.com'), + ); + expect(newBypass).toBeDefined(); + for ( + let index = 0; + index < 8 && (await readZeroTrustConfigurationStrict(env.db))?.operation; + index++ + ) { + await scheduled( + { cron: FAST_CRON, scheduledTime: Date.now() } as ScheduledController, + bindings(), + { waitUntil() {}, passThroughOnException() {} } as unknown as ExecutionContext, + ); + } + expect((await readZeroTrustConfigurationStrict(env.db))?.status).toBe('ready'); + expect(JSON.parse((await env.ROUTING.get(PLATFORM_APPLICATION_KEY))!)).toEqual(target); + const route = JSON.parse((await env.ROUTING.get(protectedProject.slug))!); + expect(route.workerName).toBe('original-worker'); + expect(route.zeroTrust.audiences).toEqual( + expect.arrayContaining([expect.objectContaining({ hostname: '*.apps.example.com' })]), + ); + for (const project of projects.slice(1)) { + expect( + [...cloudflare.applications.values()].some((application) => + JSON.stringify(application).includes(`${project.slug}.apps.example.com`), + ), + ).toBe(true); + expect(JSON.parse((await env.ROUTING.get(project.slug))!).zeroTrust).toBeUndefined(); + } + // Even a request holding old Worker vars must keep the completed target. + await reconcilePlatformZeroTrust(bindings(), env.db); + for ( + let index = 0; + index < 8 && (await readZeroTrustConfigurationStrict(env.db))?.operation; + index++ + ) { + await reconcilePlatformZeroTrust(bindings(), env.db); + } + const wildcard = (await readZeroTrustConfigurationStrict(env.db))?.wildcardApplication; + expect(wildcard?.destinations).toContain('*.apps.example.com'); + expect(wildcard?.destinations).toContain( + 'void-123456abcdef-*.' + original.workersDev.subdomain + '.workers.dev', + ); + expect(await zeroTrustStatus(bindings(), env.db)).toMatchObject({ + live: { present: true, drifted: false }, + }); + const requestsBeforeRetry = cloudflare.fetcher.mock.calls.length; + expect( + await activatePlatformApplication(bindings(), env.db, { + ...target, + workersDev: { installationId, subdomain: original.workersDev.subdomain }, + }), + ).toEqual({ ready: true }); + expect(cloudflare.fetcher.mock.calls.length).toBe(requestsBeforeRetry); + }, 10_000); + + it('keeps the domain unpublished on provider failure and reconciles protected projects created before retry', async () => { + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const owner = await createTestUser(env.db, 'domain-protected-owner'); + const project = await createTestProject(env.db, owner.id, 'domain-protected'); + await env.ROUTING.put( + project.slug, + JSON.stringify({ workerName: 'preserved-worker', projectId: project.id }), + ); + const input = { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected' as const, + }; + await configurePlatformZeroTrust(bindings(), env.db, input); + const previousRoute = await env.ROUTING.get(project.slug); + vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'PUT' && String(init.body).includes('apps.example.com')) { + return Response.json({ success: false, errors: [{ message: 'denied' }] }, { status: 403 }); + } + return cloudflare.fetcher(url, init); + }); + await expect(activatePlatformApplication(bindings(), env.db, target)).rejects.toThrow(); + expect(await env.ROUTING.get(PLATFORM_APPLICATION_KEY)).toBeNull(); + expect(await env.ROUTING.get(project.slug)).toBe(previousRoute); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + status: 'error', + operation: { applicationTarget: target, owner: null }, + }); + const added = await createTestProject(env.db, owner.id, 'domain-new-protected'); + expect(await initializeNewProjectZeroTrust(bindings(), env.db, added.id)).toMatchObject({ + desired_protected: true, + effective_protected: false, + state: 'reconciling', + }); + vi.stubGlobal('fetch', cloudflare.fetcher); + await expect( + configurePlatformZeroTrust(bindings(), env.db, { ...input, protectNewProjects: false }), + ).rejects.toThrow('saved Zero Trust operation'); + expect(await activatePlatformApplication(bindings(), env.db, target)).toEqual({ ready: true }); + expect(await getProjectZeroTrustState(env.db, added.id)).toMatchObject({ + desired_protected: true, + effective_protected: true, + state: 'protected', + }); + expect(JSON.parse((await env.ROUTING.get(PLATFORM_APPLICATION_KEY))!)).toEqual(target); + expect(JSON.parse((await env.ROUTING.get(project.slug))!).zeroTrust.audiences).toEqual( + expect.arrayContaining([expect.objectContaining({ hostname: '*.apps.example.com' })]), + ); + }); +}); diff --git a/platform/packages/api/test/integration/zero-trust-deploy-compensation.test.ts b/platform/packages/api/test/integration/zero-trust-deploy-compensation.test.ts new file mode 100644 index 000000000..6ca61be08 --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-deploy-compensation.test.ts @@ -0,0 +1,298 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import { computeAssetHash } from '../../src/assets.ts'; +import { getProject } from '../../src/db.ts'; +import type { ApiEnv } from '../../src/env.ts'; +import app from '../../src/index.ts'; +import { deployments, domains, projects } from '../../src/schema.ts'; +import { + configurePlatformZeroTrust, + getProjectZeroTrustState, + setProjectZeroTrustProtection, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestDeployment, + createTestProject, + createTestUser, + mockCfApi, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function accessApi() { + const applications = new Map>(); + let nextApplication = 1; + return async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1') { + return Response.json({ success: true, result: { id: 'idp-1' } }); + } + if (path === 'policies/policy-1') { + return Response.json({ + success: true, + result: { + id: 'policy-1', + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + return Response.json({ success: true, result: [...applications.values()] }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return Response.json( + { success: false, errors: [{ message: 'application not found' }] }, + { status: 404 }, + ); + } + if (init?.method === 'PUT') { + const application = { ...JSON.parse(String(init.body)), id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Access request: ${url}`); + }; +} + +async function deployRequest(projectId: string, jwt: string, type: 'static' | 'worker') { + const bytes = new TextEncoder().encode('doomed replacement'); + const hash = computeAssetHash(bytes.buffer, '/index.html'); + await env.STATIC_ASSETS_R2.put(`assets/${projectId}/${hash}`, bytes.buffer); + return new Request(`http://localhost/projects/${projectId}/deploy`, { + method: 'POST', + headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + manifest: { + version: 3, + assetManifestVersion: 2, + bindings: {}, + ...(type === 'static' + ? { type: 'static', assetConfig: { not_found_handling: '404-page' } } + : {}), + }, + assetManifest: { '/index.html': { blake3: hash, md5: hash, size: bytes.length } }, + ...(type === 'worker' + ? { + workerFiles: { + 'index.js': btoa("export default { fetch() { return new Response('ok'); } }"), + }, + } + : {}), + }), + }); +} + +describe('deployment compensation during Zero Trust changes', () => { + it.each( + [ + { type: 'static' as const, protectedAfter: true }, + { type: 'static' as const, protectedAfter: false }, + { type: 'worker' as const, protectedAfter: true }, + { type: 'worker' as const, protectedAfter: false }, + ].flatMap((testCase) => [ + { ...testCase, staleRead: false }, + { ...testCase, staleRead: true }, + ]), + )( + 'restores a $type deployment with protection=$protectedAfter and stale KV=$staleRead', + async ({ type, protectedAfter, staleRead }) => { + const user = await createTestUser(env.db); + const project = await createTestProject(env.db, user.id, 'compensation-protection'); + const source = await createTestDeployment(env.db, project.id, 'live'); + const sourceWorkerName = `user-${project.slug}-${source.id}`; + await env.db + .update(deployments) + .set({ + retained: true, + manifest: JSON.stringify({ + version: 3, + type: type === 'static' ? 'static' : 'void', + bindings: {}, + }), + }) + .where(eq(deployments.id, source.id)); + await env.db + .update(projects) + .set({ type: type === 'static' ? 'static' : 'void' }) + .where(eq(projects.id, project.id)); + const sourceRoute = JSON.stringify({ + ...(type === 'static' + ? { staticDeploymentId: source.id } + : { workerName: sourceWorkerName }), + projectId: project.id, + userId: user.id, + }); + const domainKey = 'domain:compensation.example.com'; + await env.db.insert(domains).values({ + id: 'dom_compensation', + project_id: project.id, + hostname: 'compensation.example.com', + cf_hostname_id: 'cf-compensation', + status: 'active', + }); + await env.ROUTING.put(project.slug, sourceRoute); + await env.ROUTING.put(domainKey, sourceRoute); + + const cloudflare = mockCfApi(); + if (type === 'worker') { + cloudflare.seedHostScript({ scriptName: sourceWorkerName }); + } + const workerFetch = globalThis.fetch; + const access = accessApi(); + vi.stubGlobal('fetch', (input: string | URL | Request, init?: RequestInit) => + new URL(input.toString()).pathname.includes('/access/') + ? access(input, init) + : workerFetch(input, init), + ); + const platform = { + ...env, + SITE_DOMAIN: 'void.app', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; + await configurePlatformZeroTrust(platform, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'test-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: !protectedAfter, + existingProjects: protectedAfter ? 'public' : 'protected', + }); + + const originalPut = env.ROUTING.put.bind(env.ROUTING); + let changed = false; + let failedWorkerName: string | undefined; + let latestProtection: unknown; + let staleProtection: unknown; + let serveStaleReads = false; + let staleReads = 0; + const deploymentPlatform = { + ...platform, + ROUTING: new Proxy(env.ROUTING, { + get(target, property, receiver) { + if (property !== 'get') { + return Reflect.get(target, property, receiver); + } + return async (...args: Array) => { + const raw: unknown = await Reflect.apply(target.get, target, args); + if ( + serveStaleReads && + typeof raw === 'string' && + (args[0] === project.slug || args[0] === domainKey) + ) { + staleReads += 1; + const route = JSON.parse(raw); + route.zeroTrust = staleProtection; + return JSON.stringify(route); + } + return raw; + }; + }, + }), + }; + vi.spyOn(env.ROUTING, 'put').mockImplementation(async (key, value, options) => { + // The cutover releases its project fence before publishing this checkpoint. + if (key.startsWith('cutover-ready:') && !changed) { + changed = true; + const route = JSON.parse((await env.ROUTING.get(project.slug))!); + failedWorkerName = route.workerName; + staleProtection = route.zeroTrust; + await setProjectZeroTrustProtection(platform, env.db, project.id, protectedAfter); + latestProtection = JSON.parse((await env.ROUTING.get(project.slug))!).zeroTrust; + serveStaleReads = staleRead; + throw new Error('injected post-cutover failure'); + } + return originalPut(key, value, options); + }); + + const response = await app.fetch( + await deployRequest(project.id, user.jwt, type), + deploymentPlatform, + ); + const events = (await response.text()) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(events.at(-1)).toMatchObject({ + event: 'error', + message: 'injected post-cutover failure', + }); + expect(changed).toBe(true); + expect(staleReads > 0).toBe(staleRead); + expect((await getProject(env.db, project.id))?.active_deployment_id).toBe(source.id); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: protectedAfter, + effective_protected: protectedAfter, + }); + for (const key of [project.slug, domainKey]) { + const route = JSON.parse((await env.ROUTING.get(key))!); + expect(route.zeroTrust).toEqual(latestProtection); + if (type === 'static') { + expect(route.staticDeploymentId).toBe(source.id); + } else { + expect(route.workerName).toBe(sourceWorkerName); + } + } + if (type === 'worker') { + expect(failedWorkerName).toBeDefined(); + expect(failedWorkerName).not.toBe(sourceWorkerName); + expect(cloudflare.state.hostScripts.has(failedWorkerName!)).toBe(false); + expect(cloudflare.state.hostScripts.has(sourceWorkerName)).toBe(true); + } + }, + ); +}); diff --git a/platform/packages/api/test/integration/zero-trust-deployment-consistency.test.ts b/platform/packages/api/test/integration/zero-trust-deployment-consistency.test.ts new file mode 100644 index 000000000..f4a914c58 --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-deployment-consistency.test.ts @@ -0,0 +1,380 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import { beginDeploymentCutover, getDomain, getProject } from '../../src/db.ts'; +import type { ApiEnv } from '../../src/env.ts'; +import { tryActivateDomain } from '../../src/provisioning/activate-domain.ts'; +import { domains } from '../../src/schema.ts'; +import { + configurePlatformZeroTrust, + getProjectZeroTrustState, + reconcileProjectZeroTrust, + setProjectZeroTrustProtection, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestDeployment, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; +} + +function providerError(message: string, status: number): Response { + return Response.json({ success: false, errors: [{ message }] }, { status }); +} + +function accessApi() { + const applications = new Map>(); + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1' || path === 'identity_providers/idp-2') { + return Response.json({ success: true, result: { id: path.split('/').at(-1) } }); + } + if (path === 'policies/policy-1' || path === 'policies/policy-2') { + return Response.json({ + success: true, + result: { + id: path.split('/').at(-1), + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return providerError('application not found', 404); + } + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, fetcher }; +} + +const domainKey = 'domain:deployment-consistency.example.com'; +type RouteType = 'static' | 'worker'; + +async function fixture(type: RouteType, initiallyProtected: boolean) { + const owner = await createTestUser(env.db, 'route-consistency-owner'); + const project = await createTestProject(env.db, owner.id, 'route-consistency'); + const source = await createTestDeployment(env.db, project.id, 'live'); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + await env.db.insert(domains).values({ + id: 'dom_consistency', + project_id: project.id, + hostname: 'deployment-consistency.example.com', + cf_hostname_id: 'cf-consistency', + status: 'active', + }); + function route(deploymentId: string, protection?: unknown) { + return JSON.stringify({ + projectId: project.id, + userId: owner.id, + version: deploymentId, + ...(type === 'static' + ? { + type: 'static', + staticDeploymentId: deploymentId, + assetConfig: { not_found_handling: '404-page' }, + } + : { type: 'void', workerName: 'user-' + project.slug + '-' + deploymentId }), + customMetadata: { preserve: true }, + ...(protection ? { zeroTrust: protection } : {}), + }); + } + await env.ROUTING.put(project.slug, route(source.id)); + await env.ROUTING.put(domainKey, route(source.id)); + await configurePlatformZeroTrust(bindings(), env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'test-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: initiallyProtected, + existingProjects: initiallyProtected ? 'protected' : 'public', + }); + const sourceRaw = (await env.ROUTING.get(project.slug))!; + return { + project, + source, + cloudflare, + route, + sourceRaw, + protection: JSON.parse(sourceRaw).zeroTrust, + }; +} + +function staleRead(key: string, raw: string) { + const originalGet = env.ROUTING.get.bind(env.ROUTING); + return vi + .spyOn(env.ROUTING, 'get') + .mockImplementation((async (candidate: string | Array, ...args: Array) => + candidate === key + ? raw + : Reflect.apply(originalGet, env.ROUTING, [candidate, ...args])) as typeof env.ROUTING.get); +} + +async function assertRoutes(projectSlug: string, expectedId: string, protectedProject: boolean) { + for (const key of [projectSlug, domainKey]) { + const route = JSON.parse((await env.ROUTING.get(key))!); + expect(route.version).toBe(expectedId); + expect(route.customMetadata).toEqual({ preserve: true }); + if (route.staticDeploymentId) { + expect(route.staticDeploymentId).toBe(expectedId); + } + if (route.workerName) { + expect(route.workerName).toBe('user-' + projectSlug + '-' + expectedId); + } + if (protectedProject) { + expect(route.zeroTrust.audiences.length).toBeGreaterThan(0); + } else { + expect(route.zeroTrust).toBeUndefined(); + } + } +} + +describe('Zero Trust deployment route consistency', () => { + it.each(['static', 'worker'] as const)( + 'accepts a verifiable $type backend when a legacy route omits version', + async (type) => { + const { project, route } = await fixture(type, false); + const committed = await createTestDeployment(env.db, project.id, 'live'); + const entry = JSON.parse(route(committed.id)); + delete entry.version; + const raw = JSON.stringify(entry); + await env.ROUTING.put(project.slug, raw); + await env.ROUTING.put(domainKey, raw); + await setProjectZeroTrustProtection(bindings(), env.db, project.id, true); + for (const key of [project.slug, domainKey]) { + const published = JSON.parse((await env.ROUTING.get(key))!); + expect(published).toMatchObject(entry); + expect(published.version).toBeUndefined(); + expect(published.zeroTrust.audiences.length).toBeGreaterThan(0); + } + }, + ); + + it.each([ + { identity: 'unknown worker', type: 'worker' as const }, + { identity: 'conflicting worker', type: 'worker' as const }, + { identity: 'conflicting static deployment', type: 'static' as const }, + ])( + 'rejects a stale route with $identity identity despite committed B', + async ({ identity, type }) => { + const { project, sourceRaw, route } = await fixture(type, false); + const committed = await createTestDeployment(env.db, project.id, 'live'); + const committedRaw = route(committed.id); + await env.ROUTING.put(project.slug, committedRaw); + await env.ROUTING.put(domainKey, committedRaw); + const staleEntry = JSON.parse(sourceRaw); + if (identity === 'unknown worker') { + delete staleEntry.version; + staleEntry.workerName = 'legacy-worker-without-deployment-id'; + } else { + // A matching version alone cannot authorize publication of another backend. + staleEntry.version = committed.id; + } + const stale = staleRead(project.slug, JSON.stringify(staleEntry)); + try { + await expect( + setProjectZeroTrustProtection(bindings(), env.db, project.id, true), + ).rejects.toThrow(/routing|deployment/i); + } finally { + stale.mockRestore(); + } + expect(await env.ROUTING.get(project.slug)).toBe(committedRaw); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ state: 'error' }); + await reconcileProjectZeroTrust(bindings(), env.db, project.id); + await assertRoutes(project.slug, committed.id, true); + }, + ); + + it.each( + (['static', 'worker'] as const).flatMap((type) => + [true, false].flatMap((protectedAfter) => + (['slug', 'domain'] as const).map((surface) => ({ type, protectedAfter, surface })), + ), + ), + )( + 'retains committed $type deployment B when $surface reads A while setting protection=$protectedAfter', + async ({ type, protectedAfter, surface }) => { + const { project, sourceRaw, route, protection } = await fixture(type, !protectedAfter); + const committed = await createTestDeployment(env.db, project.id, 'live'); + const committedRaw = route(committed.id, protection); + await env.ROUTING.put(project.slug, committedRaw); + await env.ROUTING.put(domainKey, committedRaw); + const key = surface === 'slug' ? project.slug : domainKey; + const stale = staleRead(key, sourceRaw); + try { + await expect( + setProjectZeroTrustProtection(bindings(), env.db, project.id, protectedAfter), + ).rejects.toThrow(/routing|deployment/i); + } finally { + stale.mockRestore(); + } + expect((await getProject(env.db, project.id))!.active_deployment_id).toBe(committed.id); + expect(await env.ROUTING.get(key)).toBe(committedRaw); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: protectedAfter, + state: 'error', + }); + await reconcileProjectZeroTrust(bindings(), env.db, project.id); + await assertRoutes(project.slug, committed.id, protectedAfter); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + effective_protected: protectedAfter, + state: protectedAfter ? 'protected' : 'public', + }); + }, + ); + + it.each( + (['static', 'worker'] as const).flatMap((type) => + [true, false].map((staleSource) => ({ type, staleSource })), + ), + )( + 'uses in-flight $type cutover B with stale source read=$staleSource', + async ({ type, staleSource }) => { + const { project, source, sourceRaw, route } = await fixture(type, false); + const candidate = await createTestDeployment(env.db, project.id, 'pending'); + expect(await beginDeploymentCutover(env.db, candidate.id)).toBe(true); + const candidateRaw = route(candidate.id); + await env.ROUTING.put(project.slug, candidateRaw); + await env.ROUTING.put(domainKey, candidateRaw); + const stale = staleSource ? staleRead(project.slug, sourceRaw) : null; + try { + if (staleSource) { + await expect( + setProjectZeroTrustProtection(bindings(), env.db, project.id, true), + ).rejects.toThrow(/routing|deployment/i); + } else { + await setProjectZeroTrustProtection(bindings(), env.db, project.id, true); + } + } finally { + stale?.mockRestore(); + } + expect((await getProject(env.db, project.id))!.active_deployment_id).toBe(source.id); + if (staleSource) { + expect(await env.ROUTING.get(project.slug)).toBe(candidateRaw); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + state: 'error', + desired_protected: true, + }); + await reconcileProjectZeroTrust(bindings(), env.db, project.id); + } + await assertRoutes(project.slug, candidate.id, true); + }, + ); + + it.each(['static', 'worker'] as const)( + 'heals a pending domain with $type source A residue from the verified B slug', + async (type) => { + const { project, sourceRaw, route, protection, cloudflare } = await fixture(type, true); + const committed = await createTestDeployment(env.db, project.id, 'live'); + await env.ROUTING.put(project.slug, route(committed.id, protection)); + await env.ROUTING.put(domainKey, sourceRaw); + await env.db + .update(domains) + .set({ status: 'pending' }) + .where(eq(domains.id, 'dom_consistency')); + vi.stubGlobal('fetch', (input: string | URL | Request, init?: RequestInit) => { + if (new URL(input.toString()).pathname.endsWith('/custom_hostnames/cf-consistency')) { + return Response.json({ + success: true, + result: { + id: 'cf-consistency', + hostname: 'deployment-consistency.example.com', + status: 'active', + ssl: { status: 'active', method: 'http', type: 'dv' }, + }, + }); + } + return cloudflare.fetcher(input, init); + }); + const put = vi.spyOn(env.ROUTING, 'put'); + const result = await tryActivateDomain( + bindings(), + env.db, + (await getDomain(env.db, 'dom_consistency'))!, + (await getProject(env.db, project.id))!, + ); + expect(result.activated).toBe(true); + expect((await getDomain(env.db, 'dom_consistency'))!.status).toBe('active'); + const domainWrites = put.mock.calls.filter(([key]) => key === domainKey); + expect(domainWrites.length).toBeGreaterThan(0); + for (const [, value] of domainWrites) { + expect(JSON.parse(String(value)).version).toBe(committed.id); + } + await assertRoutes(project.slug, committed.id, true); + }, + ); +}); diff --git a/platform/packages/api/test/integration/zero-trust-disable-bypass.test.ts b/platform/packages/api/test/integration/zero-trust-disable-bypass.test.ts new file mode 100644 index 000000000..f44a3ea06 --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-disable-bypass.test.ts @@ -0,0 +1,479 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import type { ApiEnv } from '../../src/env.ts'; +import { projects, projectZeroTrust } from '../../src/schema.ts'; +import { readZeroTrustConfigurationStrict } from '../../src/zero-trust/configuration.ts'; +import { + configurePlatformZeroTrust, + disablePlatformZeroTrust, + getProjectZeroTrustState, + reconcilePlatformZeroTrust, + resolveProjectZeroTrustRouting, + setProjectZeroTrustProtection, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestDeployment, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; +} + +function providerError(message: string, status: number): Response { + return Response.json({ success: false, errors: [{ message }] }, { status }); +} + +function accessApi() { + const applications = new Map>(); + const deletedIds: Array = []; + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1') { + return Response.json({ success: true, result: { id: 'idp-1' } }); + } + if (path === 'policies/policy-1') { + return Response.json({ + success: true, + result: { + id: 'policy-1', + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return providerError('application not found', 404); + } + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + deletedIds.push(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, deletedIds, fetcher }; +} + +async function configurePublicProjects(platform: ApiEnv['Bindings']): Promise { + await configurePlatformZeroTrust(platform, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'public', + }); + for (let attempt = 0; attempt < 5; attempt++) { + if (!(await readZeroTrustConfigurationStrict(env.db))!.operation) { + return; + } + await reconcilePlatformZeroTrust(platform, env.db); + } + throw new Error('Public project setup did not finish within its expected batches'); +} + +function expectPublicBypass( + cloudflare: ReturnType, + bypassId: string, + slug: string, +): void { + expect(cloudflare.applications.get(bypassId)).toMatchObject({ + domain: `${slug}.apps.example.com`, + policies: [{ decision: 'bypass', include: [{ everyone: {} }] }], + }); +} + +describe('Zero Trust disable public bypass lifecycle', () => { + it('preserves every public bypass across project batches until the wildcard is removed', async () => { + const owner = await createTestUser(env.db, 'batched-public-owner'); + const publicProjects = Array.from({ length: 101 }, (_, index) => ({ + id: `public-batch-${index.toString().padStart(3, '0')}`, + user_id: owner.id, + slug: `public-batch-${index}`, + })); + await env.db.insert(projects).values(publicProjects); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + await configurePublicProjects(platform); + const wildcard = (await readZeroTrustConfigurationStrict(env.db))!.wildcardApplication!; + const originalRows = await env.db.select().from(projectZeroTrust); + expect(originalRows).toHaveLength(101); + expect(cloudflare.applications.size).toBe(102); + + await disablePlatformZeroTrust(platform, env.db); + + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: true, + operation: { kind: 'disable', phase: 'projects', owner: null }, + }); + expect(cloudflare.applications.has(wildcard.id)).toBe(true); + expect(cloudflare.deletedIds).toEqual([]); + for (const project of publicProjects) { + const row = originalRows.find((entry) => entry.project_id === project.id)!; + expectPublicBypass(cloudflare, row.bypass_application_id!, project.slug); + } + + await reconcilePlatformZeroTrust(platform, env.db); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + wildcardApplication: null, + operation: { kind: 'disable', phase: 'bypass-delete', owner: null }, + }); + expect(cloudflare.applications.has(wildcard.id)).toBe(false); + expect(cloudflare.deletedIds[0]).toBe(wildcard.id); + const remainingBypasses = (await env.db.select().from(projectZeroTrust)).filter( + (row) => row.bypass_application_id !== null, + ); + expect(remainingBypasses.length).toBeGreaterThan(0); + expect(101 - remainingBypasses.length).toBeLessThanOrEqual(100); + expect(cloudflare.applications.size).toBe(remainingBypasses.length); + + for (let attempt = 0; attempt < 5; attempt++) { + if (!(await readZeroTrustConfigurationStrict(env.db))!.operation) { + break; + } + await reconcilePlatformZeroTrust(platform, env.db); + } + + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + operation: null, + }); + expect(cloudflare.deletedIds[0]).toBe(wildcard.id); + expect(cloudflare.applications.size).toBe(0); + for (const row of await env.db.select().from(projectZeroTrust)) { + expect(row).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + bypass_application_id: null, + bypass_application_name: null, + }); + } + }, 10_000); + + it('keeps the public exception usable when wildcard deletion fails', async () => { + const owner = await createTestUser(env.db, 'wildcard-failure-owner'); + const project = await createTestProject(env.db, owner.id, 'wildcard-failure-public'); + const cloudflare = accessApi(); + let failedWildcardId: string | null = null; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if ( + init?.method === 'DELETE' && + new URL(input.toString()).pathname.endsWith(`/apps/${failedWildcardId}`) + ) { + return providerError('Wildcard deletion unavailable', 503); + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + await configurePublicProjects(platform); + const wildcard = (await readZeroTrustConfigurationStrict(env.db))!.wildcardApplication!; + const bypass = (await getProjectZeroTrustState(env.db, project.id))!; + failedWildcardId = wildcard.id; + + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Wildcard deletion unavailable', + ); + + expect(cloudflare.applications.has(wildcard.id)).toBe(true); + expectPublicBypass(cloudflare, bypass.bypass_application_id!, project.slug); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + bypass_application_id: bypass.bypass_application_id, + bypass_application_name: bypass.bypass_application_name, + }); + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).resolves.toBeNull(); + expect(cloudflare.deletedIds).toEqual([]); + + failedWildcardId = null; + await reconcilePlatformZeroTrust(platform, env.db); + expect(cloudflare.deletedIds).toEqual([wildcard.id, bypass.bypass_application_id]); + expect(cloudflare.applications.size).toBe(0); + }); + + it('preserves public access and unvisited project protection when a later project fails', async () => { + const owner = await createTestUser(env.db, 'mixed-disable-owner'); + const [publicProject, failingProject, unvisitedProject] = [ + { id: 'mixed-public', slug: 'mixed-public', created_at: '2020-01-01 00:00:00' }, + { id: 'mixed-failing', slug: 'mixed-failing', created_at: '2020-01-02 00:00:00' }, + { id: 'mixed-unvisited', slug: 'mixed-unvisited', created_at: '2020-01-03 00:00:00' }, + ] as const; + await env.db.insert(projects).values( + [publicProject, failingProject, unvisitedProject].map((project) => ({ + ...project, + user_id: owner.id, + })), + ); + for (const project of [failingProject, unvisitedProject]) { + const deployment = await createTestDeployment(env.db, project.id, 'live'); + await env.ROUTING.put( + project.slug, + JSON.stringify({ + projectId: project.id, + workerName: `user-${project.slug}-${deployment.id}`, + version: deployment.id, + }), + ); + } + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + await configurePublicProjects(platform); + for (const project of [failingProject, unvisitedProject]) { + await setProjectZeroTrustProtection(platform, env.db, project.id, true); + } + const wildcard = (await readZeroTrustConfigurationStrict(env.db))!.wildcardApplication!; + const bypass = (await getProjectZeroTrustState(env.db, publicProject.id))!; + const originalUnvisitedRoute = await env.ROUTING.get(unvisitedProject.slug); + expect(JSON.parse(originalUnvisitedRoute!).zeroTrust).toBeDefined(); + cloudflare.deletedIds.length = 0; + const originalPut = env.ROUTING.put.bind(env.ROUTING); + const putSpy = vi.spyOn(env.ROUTING, 'put').mockImplementation(async (...args) => { + if (args[0] === failingProject.slug) { + throw new Error('Later protected project routing failed'); + } + return originalPut(...args); + }); + try { + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Later protected project routing failed', + ); + } finally { + putSpy.mockRestore(); + } + + expectPublicBypass(cloudflare, bypass.bypass_application_id!, publicProject.slug); + expect(cloudflare.applications.has(wildcard.id)).toBe(true); + expect(cloudflare.deletedIds).toEqual([]); + await expect(resolveProjectZeroTrustRouting(env.db, publicProject.id)).resolves.toBeNull(); + expect(await getProjectZeroTrustState(env.db, unvisitedProject.id)).toMatchObject({ + desired_protected: true, + effective_protected: true, + state: 'protected', + }); + expect(await env.ROUTING.get(unvisitedProject.slug)).toBe(originalUnvisitedRoute); + expect(await resolveProjectZeroTrustRouting(env.db, unvisitedProject.id)).toMatchObject({ + audiences: [{ audience: wildcard.audience, hostname: '*.apps.example.com' }], + }); + + await reconcilePlatformZeroTrust(platform, env.db); + expect(cloudflare.deletedIds).toEqual([wildcard.id, bypass.bypass_application_id]); + expect(cloudflare.applications.size).toBe(0); + }); + + it.each(['reconcile', 'replacement credential'] as const)( + 'retains a failed bypass cleanup handle and resumes with %s without deleting the wildcard again', + async (recovery) => { + const owner = await createTestUser(env.db, 'bypass-failure-owner'); + const project = await createTestProject(env.db, owner.id, 'bypass-failure-public'); + const cloudflare = accessApi(); + let failedBypassId: string | null = null; + let oldTokenRevoked = false; + const deleteAttempts: Array = []; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if ( + oldTokenRevoked && + new Headers(init?.headers).get('Authorization') === 'Bearer access-token' + ) { + return providerError('Old token revoked', 403); + } + if (init?.method === 'DELETE') { + const id = new URL(input.toString()).pathname.split('/').at(-1)!; + deleteAttempts.push(id); + if (id === failedBypassId) { + return providerError('Bypass deletion unavailable', 503); + } + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + await configurePublicProjects(platform); + const wildcard = (await readZeroTrustConfigurationStrict(env.db))!.wildcardApplication!; + const bypass = (await getProjectZeroTrustState(env.db, project.id))!; + failedBypassId = bypass.bypass_application_id; + + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Bypass deletion unavailable', + ); + + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'error', + wildcardApplication: null, + operation: { kind: 'disable', phase: 'bypass-delete', owner: null }, + }); + expect(cloudflare.applications.has(wildcard.id)).toBe(false); + expectPublicBypass(cloudflare, bypass.bypass_application_id!, project.slug); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + bypass_application_id: bypass.bypass_application_id, + bypass_application_name: bypass.bypass_application_name, + }); + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).resolves.toBeNull(); + + failedBypassId = null; + if (recovery === 'replacement credential') { + oldTokenRevoked = true; + await configurePlatformZeroTrust(platform, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'replacement-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'public', + }); + } else { + await reconcilePlatformZeroTrust(platform, env.db); + } + expect(deleteAttempts).toEqual([ + wildcard.id, + bypass.bypass_application_id, + bypass.bypass_application_id, + ]); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + operation: null, + }); + expect(cloudflare.applications.size).toBe(0); + }, + ); + + it('recovers a name-only bypass after provider deletion succeeds but its database checkpoint rolls back', async () => { + const owner = await createTestUser(env.db, 'bypass-checkpoint-owner'); + const project = await createTestProject(env.db, owner.id, 'bypass-checkpoint-public'); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + await configurePublicProjects(platform); + const wildcard = (await readZeroTrustConfigurationStrict(env.db))!.wildcardApplication!; + const bypass = (await getProjectZeroTrustState(env.db, project.id))!; + await env.db + .update(projectZeroTrust) + .set({ bypass_application_id: null }) + .where(eq(projectZeroTrust.project_id, project.id)); + + const originalTransaction = env.db.transaction.bind(env.db); + let failCheckpoint = true; + const transactionSpy = vi + .spyOn(env.db, 'transaction') + .mockImplementation(async (callback, options) => + originalTransaction(async (tx) => { + const result = await callback(tx); + if (failCheckpoint && cloudflare.deletedIds.includes(bypass.bypass_application_id!)) { + failCheckpoint = false; + throw new Error('Bypass checkpoint transaction failed'); + } + return result; + }, options), + ); + try { + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Bypass checkpoint transaction failed', + ); + } finally { + transactionSpy.mockRestore(); + } + + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'error', + wildcardApplication: null, + operation: { kind: 'disable', phase: 'bypass-delete', owner: null }, + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + bypass_application_id: null, + bypass_application_name: bypass.bypass_application_name, + }); + expect(cloudflare.applications.size).toBe(0); + + await reconcilePlatformZeroTrust(platform, env.db); + + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + operation: null, + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + bypass_application_id: null, + bypass_application_name: null, + state: 'public', + }); + expect(cloudflare.deletedIds).toEqual([wildcard.id, bypass.bypass_application_id]); + }); +}); diff --git a/platform/packages/api/test/integration/zero-trust-operation-generation.test.ts b/platform/packages/api/test/integration/zero-trust-operation-generation.test.ts new file mode 100644 index 000000000..6d10db01c --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-operation-generation.test.ts @@ -0,0 +1,460 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import type { ApiEnv } from '../../src/env.ts'; +import { domains, platformSettings } from '../../src/schema.ts'; +import { + readZeroTrustConfigurationStrict, + type ZeroTrustConfigureInput, +} from '../../src/zero-trust/configuration.ts'; +import { + configurePlatformZeroTrust, + reconcileProjectZeroTrustDomains, + disablePlatformZeroTrust, + getProjectZeroTrustState, + initializeNewProjectZeroTrust, + reconcilePlatformZeroTrust, + resolveProjectZeroTrustRouting, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; +} + +function configurationInput( + overrides: Partial = {}, +): ZeroTrustConfigureInput { + return { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'old-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected', + ...overrides, + }; +} + +function providerError(message: string, status: number): Response { + return Response.json({ success: false, errors: [{ message }] }, { status }); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function accessApi() { + const applications = new Map>(); + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1' || path === 'identity_providers/idp-2') { + return Response.json({ success: true, result: { id: path.split('/').at(-1) } }); + } + if (path === 'policies/policy-1' || path === 'policies/policy-2') { + return Response.json({ + success: true, + result: { + id: path.split('/').at(-1), + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return providerError('application not found', 404); + } + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, fetcher }; +} + +describe('Zero Trust operation completion', () => { + it('does not let an expired configure seed mutate a project after disable completes', async () => { + const owner = await createTestUser(env.db, 'generation-seed-owner'); + const project = await createTestProject(env.db, owner.id, 'generation-seed-project'); + const cloudflare = accessApi(); + const platform = bindings(); + vi.stubGlobal('fetch', cloudflare.fetcher); + await env.ROUTING.put( + project.slug, + JSON.stringify({ worker: 'worker', projectId: project.id }), + ); + const originalTransaction = env.db.transaction.bind(env.db); + let transactions = 0; + const transactionSpy = vi.spyOn(env.db, 'transaction').mockImplementation(async (...args) => { + transactions++; + if (transactions === 2) { + const saved = (await readZeroTrustConfigurationStrict(env.db))!; + await env.db + .update(platformSettings) + .set({ + value: { + ...saved, + operation: { ...saved.operation, leaseExpiresAt: new Date(0).toISOString() }, + }, + }) + .where(eq(platformSettings.key, 'zeroTrust.configuration')); + await disablePlatformZeroTrust(platform, env.db); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: false, + state: 'public', + }); + } + return originalTransaction(...args); + }); + try { + await expect( + configurePlatformZeroTrust(platform, env.db, configurationInput()), + ).rejects.toThrow('replaced'); + } finally { + transactionSpy.mockRestore(); + } + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: false, + state: 'public', + }); + expect(await resolveProjectZeroTrustRouting(env.db, project.id)).toBeNull(); + }); + + it('reconciles routes touched during wildcard audience replacement', async () => { + const owner = await createTestUser(env.db, 'review-owner'); + const project = await createTestProject(env.db, owner.id, 'review-project'); + const cloudflare = accessApi(); + const platform = bindings(); + let interleave = false; + let operationId: string | undefined; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if (interleave && new URL(input.toString()).pathname.endsWith('/access/organizations')) { + interleave = false; + operationId = (await readZeroTrustConfigurationStrict(env.db))!.operation!.id; + await reconcileProjectZeroTrustDomains(platform, env.db, project.id); + expect( + (await getProjectZeroTrustState(env.db, project.id))!.reconciled_operation, + ).toBeNull(); + } + return cloudflare.fetcher(input, init); + }); + await env.ROUTING.put( + project.slug, + JSON.stringify({ worker: 'worker', projectId: project.id }), + ); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + const oldConfiguration = (await readZeroTrustConfigurationStrict(env.db))!; + cloudflare.applications.delete(oldConfiguration.wildcardApplication!.id); + await env.db.insert(domains).values({ + id: 'review-domain', + project_id: project.id, + hostname: 'review.example.net', + cf_hostname_id: 'cf-review', + status: 'pending', + }); + interleave = true; + await reconcilePlatformZeroTrust(platform, env.db); + const updated = (await readZeroTrustConfigurationStrict(env.db))!; + const routing = JSON.parse((await env.ROUTING.get(project.slug))!); + expect(updated.status).toBe('ready'); + expect(updated.wildcardApplication!.audience).not.toBe( + oldConfiguration.wildcardApplication!.audience, + ); + expect(operationId).toBeTruthy(); + expect((await getProjectZeroTrustState(env.db, project.id))!.reconciled_operation).toBe( + operationId, + ); + expect( + routing.zeroTrust.audiences.find( + (entry: { hostname: string }) => entry.hostname === '*.apps.example.com', + ).audience, + ).toBe(updated.wildcardApplication!.audience); + }); + + it('keeps a protected project created during wildcard replacement pending until the project pass', async () => { + const owner = await createTestUser(env.db, 'generation-new-owner'); + const cloudflare = accessApi(); + const platform = bindings(); + vi.stubGlobal('fetch', cloudflare.fetcher); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + const old = (await readZeroTrustConfigurationStrict(env.db))!; + cloudflare.applications.delete(old.wildcardApplication!.id); + let newProjectId = ''; + let operationId: string | undefined; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if (!newProjectId && new URL(input.toString()).pathname.endsWith('/access/organizations')) { + const project = await createTestProject(env.db, owner.id, 'generation-new-project'); + newProjectId = project.id; + operationId = (await readZeroTrustConfigurationStrict(env.db))!.operation!.id; + expect(await initializeNewProjectZeroTrust(platform, env.db, project.id)).toMatchObject({ + state: 'reconciling', + effective_protected: false, + reconciled_operation: null, + }); + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).rejects.toThrow( + 'still reconciling', + ); + } + return cloudflare.fetcher(input, init); + }); + await reconcilePlatformZeroTrust(platform, env.db); + const updated = (await readZeroTrustConfigurationStrict(env.db))!; + expect(await getProjectZeroTrustState(env.db, newProjectId)).toMatchObject({ + state: 'protected', + effective_protected: true, + reconciled_operation: operationId, + }); + expect((await resolveProjectZeroTrustRouting(env.db, newProjectId))?.audiences).toContainEqual({ + hostname: '*.apps.example.com', + audience: updated.wildcardApplication!.audience, + }); + }); + + it('does not let an expired disable mutate the state of a subsequently protected project', async () => { + const owner = await createTestUser(env.db, 'generation-lease-owner'); + const project = await createTestProject(env.db, owner.id, 'generation-lease-project'); + const cloudflare = accessApi(); + const platform = bindings(); + vi.stubGlobal('fetch', cloudflare.fetcher); + await env.ROUTING.put( + project.slug, + JSON.stringify({ worker: 'worker', projectId: project.id }), + ); + await env.db.insert(domains).values({ + id: 'generation-lease-domain', + project_id: project.id, + hostname: 'lease.example.net', + cf_hostname_id: 'cf-lease', + status: 'active', + }); + await env.ROUTING.put( + 'domain:lease.example.net', + JSON.stringify({ worker: 'worker', projectId: project.id }), + ); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + const originalTransaction = env.db.transaction.bind(env.db); + let transactions = 0; + let freshApplicationId: string | null = null; + const transactionSpy = vi.spyOn(env.db, 'transaction').mockImplementation(async (...args) => { + transactions++; + if (transactions === 2) { + const old = (await readZeroTrustConfigurationStrict(env.db))!; + await env.db + .update(platformSettings) + .set({ + value: { + ...old, + operation: { ...old.operation, leaseExpiresAt: new Date(0).toISOString() }, + }, + }) + .where(eq(platformSettings.key, 'zeroTrust.configuration')); + await disablePlatformZeroTrust(platform, env.db); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + freshApplicationId = (await getProjectZeroTrustState(env.db, project.id))! + .protected_application_id; + expect(freshApplicationId).toBeTruthy(); + } + return originalTransaction(...args); + }); + try { + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'operation was replaced', + ); + } finally { + transactionSpy.mockRestore(); + } + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: true, + status: 'ready', + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: true, + effective_protected: true, + state: 'protected', + protected_application_id: freshApplicationId, + }); + expect(cloudflare.applications.has(freshApplicationId!)).toBe(true); + expect( + JSON.parse((await env.ROUTING.get('domain:lease.example.net'))!).zeroTrust, + ).toBeDefined(); + }); + + it.each(['PUT', 'DELETE'] as const)( + 'holds the configuration claim through wildcard %s and its checkpoint', + async (method) => { + const cloudflare = accessApi(); + const platform = bindings(); + vi.stubGlobal('fetch', cloudflare.fetcher); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + const applicationId = (await readZeroTrustConfigurationStrict(env.db))!.wildcardApplication! + .id; + const entered = deferred(); + const release = deferred(); + let transactionDepth = 0; + const originalTransaction = env.db.transaction.bind(env.db); + const transactionSpy = vi + .spyOn(env.db, 'transaction') + .mockImplementation(async (callback, options) => { + return originalTransaction(async (tx) => { + transactionDepth++; + try { + return await callback(tx); + } finally { + transactionDepth--; + } + }, options); + }); + let paused = false; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if ( + !paused && + init?.method === method && + new URL(input.toString()).pathname.endsWith(`/access/apps/${applicationId}`) + ) { + paused = true; + entered.resolve(); + await release.promise; + } + return cloudflare.fetcher(input, init); + }); + const oldOperation = + method === 'PUT' + ? reconcilePlatformZeroTrust(platform, env.db) + : disablePlatformZeroTrust(platform, env.db); + await entered.promise; + const nextInput = configurationInput({ + identityProviderIds: ['idp-2'], + policyIds: ['policy-2'], + }); + // Simulate a slow provider call outliving the lease without writing through + // the transaction whose claim lock this test expects to remain held. + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 16 * 60 * 1000); + let contenderSettled = false; + const contender = configurePlatformZeroTrust(platform, env.db, nextInput) + .then( + () => ({ error: null }), + (error: unknown) => ({ error }), + ) + .then((result) => { + contenderSettled = true; + return result; + }); + try { + // This assertion also fails deterministically when provider I/O happens + // outside the configuration transaction, before the scheduling check. + expect(transactionDepth).toBe(1); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(contenderSettled).toBe(false); + } finally { + release.resolve(); + } + await oldOperation; + const result = await contender; + transactionSpy.mockRestore(); + if (result.error) { + expect(String(result.error)).toMatch( + /already running|operation.*changed|operation.*pending/, + ); + await configurePlatformZeroTrust(platform, env.db, nextInput); + } + const current = (await readZeroTrustConfigurationStrict(env.db))!; + expect(current).toMatchObject({ + enabled: true, + status: 'ready', + identityProviderIds: ['idp-2'], + policyIds: ['policy-2'], + }); + expect(cloudflare.applications.get(current.wildcardApplication!.id)).toMatchObject({ + allowed_idps: ['idp-2'], + policies: [{ id: 'policy-2' }], + }); + if (method === 'DELETE') { + expect(current.wildcardApplication!.id).not.toBe(applicationId); + expect(cloudflare.applications.has(applicationId)).toBe(false); + } + }, + ); +}); diff --git a/platform/packages/api/test/integration/zero-trust-recovery.test.ts b/platform/packages/api/test/integration/zero-trust-recovery.test.ts new file mode 100644 index 000000000..c06978e4e --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-recovery.test.ts @@ -0,0 +1,971 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import type { ApiEnv } from '../../src/env.ts'; +import app from '../../src/index.ts'; +import { decryptPlatformCredential } from '../../src/project-secrets.ts'; +import { domains, projectZeroTrust } from '../../src/schema.ts'; +import { + ZERO_TRUST_CREDENTIAL_ID, + readZeroTrustConfigurationStrict, + type ZeroTrustConfigureInput, + type ZeroTrustOperation, +} from '../../src/zero-trust/configuration.ts'; +import { + configurePlatformZeroTrust, + disablePlatformZeroTrust, + getProjectZeroTrustState, + initializeNewProjectZeroTrust, + reconcilePendingProjectZeroTrust, + reconcilePlatformZeroTrust, + reconcileProjectZeroTrustDomains, + resolveProjectZeroTrustRouting, + setProjectZeroTrustProtection, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; +} + +function configurationInput( + overrides: Partial = {}, +): ZeroTrustConfigureInput { + return { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'old-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected', + ...overrides, + }; +} + +function providerError(message: string, status: number): Response { + return Response.json({ success: false, errors: [{ message }] }, { status }); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +function accessApi() { + const applications = new Map>(); + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1' || path === 'identity_providers/idp-2') { + return Response.json({ success: true, result: { id: path.split('/').at(-1) } }); + } + if (path === 'policies/policy-1' || path === 'policies/policy-2') { + return Response.json({ + success: true, + result: { + id: path.split('/').at(-1), + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return providerError('application not found', 404); + } + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, fetcher }; +} + +describe('Zero Trust recovery', () => { + it.each([false, true])( + 'preserves public batch state when POST /projects resumes with protectNewProjects=%s', + async (protectNewProjects) => { + const owner = await createTestUser(env.db, 'project-create-race-owner'); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + const slug = 'project-create-race'; + const originalTransaction = env.db.transaction.bind(env.db); + let interleaved = false; + let seeded: Awaited> = null; + const transactionSpy = vi.spyOn(env.db, 'transaction').mockImplementation(async (...args) => { + const result = await originalTransaction(...args); + if ( + !interleaved && + result && + typeof result === 'object' && + 'slug' in result && + result.slug === slug && + 'id' in result + ) { + // The route's project insert is committed, but its initialization + // step has not begun. A complete administrator batch runs here. + interleaved = true; + await configurePlatformZeroTrust( + platform, + env.db, + configurationInput({ + existingProjects: 'public', + protectNewProjects, + }), + ); + seeded = await getProjectZeroTrustState(env.db, String(result.id)); + expect(seeded).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + }); + expect(seeded?.bypass_application_id).toBeTruthy(); + expect(seeded?.bypass_application_name).toBeTruthy(); + cloudflare.fetcher.mockClear(); + } + return result; + }); + let response: Response; + try { + response = await app.fetch( + new Request('http://localhost/projects', { + method: 'POST', + headers: { Authorization: `Bearer ${owner.jwt}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug }), + }), + platform, + ); + } finally { + transactionSpy.mockRestore(); + } + expect(response.status).toBe(201); + expect(interleaved).toBe(true); + const body = (await response.json()) as { id: string; warnings?: Array }; + expect(body.warnings).toBeUndefined(); + expect(await getProjectZeroTrustState(env.db, body.id)).toEqual(seeded); + expect(cloudflare.fetcher).not.toHaveBeenCalled(); + await expect(resolveProjectZeroTrustRouting(env.db, body.id)).resolves.toBeNull(); + expect(cloudflare.applications.size).toBe(2); + + await expect(disablePlatformZeroTrust(platform, env.db)).resolves.toMatchObject({ + enabled: false, + operation: null, + }); + expect(cloudflare.applications.size).toBe(0); + expect(await getProjectZeroTrustState(env.db, body.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + bypass_application_id: null, + bypass_application_name: null, + }); + }, + ); + + it.each(['error', 'reconciling'] as const)( + 'leaves an existing %s creation checkpoint for explicit reconciliation', + async (state) => { + const owner = await createTestUser(env.db, 'interrupted-initializer-owner'); + const project = await createTestProject(env.db, owner.id, 'interrupted-initializer'); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + await configurePlatformZeroTrust( + platform, + env.db, + configurationInput({ + existingProjects: 'public', + protectNewProjects: true, + }), + ); + const completed = (await getProjectZeroTrustState(env.db, project.id))!; + // Preserve the name intent for an app created before its ID checkpoint + // was durable. Initializing again must not replace that recovery handle. + await env.db + .update(projectZeroTrust) + .set({ + state, + bypass_application_id: null, + last_error: state === 'error' ? 'Interrupted after provider creation' : null, + }) + .where(eq(projectZeroTrust.project_id, project.id)); + const interrupted = await getProjectZeroTrustState(env.db, project.id); + cloudflare.fetcher.mockClear(); + + await expect(initializeNewProjectZeroTrust(platform, env.db, project.id)).resolves.toEqual( + interrupted, + ); + expect(await getProjectZeroTrustState(env.db, project.id)).toEqual(interrupted); + expect(cloudflare.fetcher).not.toHaveBeenCalled(); + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).rejects.toThrow( + 'public exception is still reconciling', + ); + + await reconcilePendingProjectZeroTrust(platform, env.db); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + state: 'public', + desired_protected: false, + effective_protected: false, + bypass_application_id: completed.bypass_application_id, + bypass_application_name: completed.bypass_application_name, + last_error: null, + }); + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).resolves.toBeNull(); + expect(cloudflare.applications.size).toBe(2); + await disablePlatformZeroTrust(platform, env.db); + expect(cloudflare.applications.size).toBe(0); + }, + ); + + it('cleans up a bypass created by another request between projects in the pending batch', async () => { + const owner = await createTestUser(env.db, 'batch-owner'); + const first = await createTestProject(env.db, owner.id, 'batch-first'); + const second = await createTestProject(env.db, owner.id, 'batch-second'); + const cloudflare = accessApi(); + let failDeletes = false; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if (failDeletes && init?.method === 'DELETE') { + return providerError('temporary deletion failure', 500); + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + await env.db.insert(domains).values({ + id: 'domain-batch', + project_id: first.id, + hostname: 'batch.example.net', + cf_hostname_id: 'cf-batch', + status: 'active', + }); + await env.ROUTING.put( + 'domain:batch.example.net', + JSON.stringify({ worker: 'live-worker', projectId: first.id }), + ); + await env.db + .update(projectZeroTrust) + .set({ state: 'error' }) + .where(eq(projectZeroTrust.project_id, first.id)); + await env.db + .update(projectZeroTrust) + .set({ state: 'error' }) + .where(eq(projectZeroTrust.project_id, second.id)); + const originalTransaction = env.db.transaction.bind(env.db); + let transactions = 0; + let bypassId: string | null = null; + // Interleave after the first project's checkpoint and provider reconciliation + // have committed, before the same batch client begins the second project. + const transactionSpy = vi.spyOn(env.db, 'transaction').mockImplementation(async (...args) => { + const result = await originalTransaction(...args); + transactions += 1; + if (transactions === 2) { + await setProjectZeroTrustProtection(platform, env.db, second.id, false); + bypassId = (await getProjectZeroTrustState(env.db, second.id))!.bypass_application_id; + failDeletes = true; + await expect( + setProjectZeroTrustProtection(platform, env.db, second.id, true), + ).rejects.toThrow('temporary deletion failure'); + failDeletes = false; + expect(await getProjectZeroTrustState(env.db, second.id)).toMatchObject({ + state: 'error', + bypass_application_id: bypassId, + }); + } + return result; + }); + try { + await reconcilePendingProjectZeroTrust(platform, env.db); + } finally { + transactionSpy.mockRestore(); + } + expect(bypassId).toBeTruthy(); + expect(await getProjectZeroTrustState(env.db, second.id)).toMatchObject({ + state: 'protected', + bypass_application_id: null, + bypass_application_name: null, + }); + expect(cloudflare.applications.has(bypassId!)).toBe(false); + }); + + it('replaces a revoked token and recovers a provider application from the saved creation checkpoint', async () => { + const owner = await createTestUser(env.db, 'credential-owner'); + const project = await createTestProject(env.db, owner.id, 'credential-project'); + const cloudflare = accessApi(); + let oldTokenRevoked = false; + let resumedOperation: ZeroTrustOperation | null = null; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const token = new Headers(init?.headers).get('Authorization'); + if (oldTokenRevoked && token === 'Bearer old-token') { + return providerError('Authentication error', 403); + } + const response = await cloudflare.fetcher(input, init); + if ( + token === 'Bearer old-token' && + init?.method === 'POST' && + JSON.parse(String(init.body)).domain === '*.apps.example.com' + ) { + oldTokenRevoked = true; + // The provider committed the app, but Void never received its ID. + return providerError('Authentication error', 403); + } + return response; + }); + const platform = bindings(); + const input = configurationInput({ existingProjects: 'public' }); + await expect(configurePlatformZeroTrust(platform, env.db, input)).rejects.toThrow( + 'Authentication error', + ); + const failed = (await readZeroTrustConfigurationStrict(env.db))!; + expect(failed).toMatchObject({ + status: 'error', + wildcardApplication: null, + operation: { kind: 'configure', phase: 'wildcard', owner: null }, + }); + const originalBypass = (await getProjectZeroTrustState(env.db, project.id))! + .bypass_application_id; + const originalWildcard = [...cloudflare.applications.values()].find( + (app) => app.name === failed.wildcardApplicationName, + )!; + expect(originalBypass).toBeTruthy(); + expect(originalWildcard).toBeDefined(); + await expect(reconcilePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Authentication error', + ); + + const originalTransaction = env.db.transaction.bind(env.db); + const transactionSpy = vi + .spyOn(env.db, 'transaction') + .mockImplementationOnce(async (...args) => { + const result = await originalTransaction(...args); + // The replacement claim has committed, and no provider mutation holds a + // transaction yet. Observe that checkpoint before reconciliation resumes. + resumedOperation = (await readZeroTrustConfigurationStrict(env.db))!.operation; + return result; + }); + try { + await configurePlatformZeroTrust(platform, env.db, { + ...input, + apiToken: 'replacement-token', + }); + } finally { + transactionSpy.mockRestore(); + } + + const recovered = (await readZeroTrustConfigurationStrict(env.db))!; + expect(resumedOperation).toMatchObject({ + kind: 'configure', + phase: 'wildcard', + startedAt: failed.operation!.startedAt, + existingProjects: 'public', + }); + expect(recovered).toMatchObject({ + enabled: true, + status: 'ready', + operation: null, + wildcardApplication: { id: originalWildcard.id, name: failed.wildcardApplicationName }, + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: false, + state: 'public', + bypass_application_id: originalBypass, + }); + expect(cloudflare.applications.size).toBe(2); + expect( + await decryptPlatformCredential( + platform, + 'installation-test', + ZERO_TRUST_CREDENTIAL_ID, + recovered.credential, + ), + ).toBe('replacement-token'); + }); + + it.each([ + { label: 'identity providers', change: { identityProviderIds: ['idp-2'] } }, + { label: 'policies', change: { policyIds: ['policy-2'] } }, + { label: 'new-project protection', change: { protectNewProjects: false } }, + { label: 'existing-project selection', change: { existingProjects: 'public' as const } }, + ])('rejects changed $label when replacing a pending operation token', async ({ change }) => { + const cloudflare = accessApi(); + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if ( + new Headers(init?.headers).get('Authorization') === 'Bearer old-token' && + init?.method === 'POST' + ) { + return providerError('Authentication error', 403); + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + const input = configurationInput(); + await expect(configurePlatformZeroTrust(platform, env.db, input)).rejects.toThrow( + 'Authentication error', + ); + const checkpoint = await readZeroTrustConfigurationStrict(env.db); + + await expect( + configurePlatformZeroTrust(platform, env.db, { + ...input, + ...change, + apiToken: 'replacement-token', + }), + ).rejects.toThrow('same account, identity providers, policies, and project selections'); + expect(await readZeroTrustConfigurationStrict(env.db)).toEqual(checkpoint); + expect(cloudflare.applications.size).toBe(0); + }); + + it('rejects token replacement and disable while another reconcile request holds an active lease', async () => { + const cloudflare = accessApi(); + const reachedValidation = deferred(); + const releaseValidation = deferred(); + let pauseValidation = false; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if ( + pauseValidation && + new Headers(init?.headers).get('Authorization') === 'Bearer old-token' && + new URL(input.toString()).pathname.endsWith('/organizations') + ) { + reachedValidation.resolve(); + await releaseValidation.promise; + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + const input = configurationInput(); + await configurePlatformZeroTrust(platform, env.db, input); + // Reconciliation commits its lease before selection validation, so another + // request can inspect and reject that lease without waiting on a DB lock. + pauseValidation = true; + const reconciling = reconcilePlatformZeroTrust(platform, env.db); + try { + await reachedValidation.promise; + const checkpoint = await readZeroTrustConfigurationStrict(env.db); + expect(checkpoint?.operation?.owner).toBeTruthy(); + await expect( + configurePlatformZeroTrust(platform, env.db, { ...input, apiToken: 'replacement-token' }), + ).rejects.toThrow('Another Zero Trust operation is already running'); + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Another Zero Trust operation is already running', + ); + expect(await readZeroTrustConfigurationStrict(env.db)).toEqual(checkpoint); + } finally { + releaseValidation.resolve(); + await reconciling; + } + }); + + it.each([null, 'identity_providers/idp-1', 'policies/policy-1'])( + 'resumes a failed disable with a replacement token when the missing selection is %s', + async (missingSelection) => { + const owner = await createTestUser(env.db, 'disable-token-owner'); + const project = await createTestProject(env.db, owner.id, 'disable-token-project'); + const cloudflare = accessApi(); + let oldTokenRevoked = false; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if ( + oldTokenRevoked && + new Headers(init?.headers).get('Authorization') === 'Bearer old-token' + ) { + return providerError('Authentication error', 403); + } + if ( + oldTokenRevoked && + missingSelection && + new URL(input.toString()).pathname.endsWith(`/${missingSelection}`) + ) { + return providerError('Selected resource was deleted', 404); + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + const input = configurationInput({ existingProjects: 'public' }); + await configurePlatformZeroTrust(platform, env.db, input); + const bypassId = (await getProjectZeroTrustState(env.db, project.id))!.bypass_application_id; + oldTokenRevoked = true; + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Authentication error', + ); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + status: 'error', + operation: { kind: 'disable', phase: 'wildcard-delete', owner: null }, + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + bypass_application_id: bypassId, + }); + cloudflare.fetcher.mockClear(); + + await configurePlatformZeroTrust(platform, env.db, { + ...input, + apiToken: 'replacement-token', + }); + + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + operation: null, + wildcardApplication: null, + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + bypass_application_id: null, + }); + expect(cloudflare.applications.size).toBe(0); + expect( + cloudflare.fetcher.mock.calls.some( + ([, init]) => init?.method === 'POST' || init?.method === 'PUT', + ), + ).toBe(false); + }, + ); + + it.each([ + { + kind: 'configure', + missing: 'identity_providers/idp-1', + replacement: { identityProviderIds: ['idp-2'] }, + }, + { kind: 'configure', missing: 'policies/policy-1', replacement: { policyIds: ['policy-2'] } }, + { + kind: 'reconcile', + missing: 'identity_providers/idp-1', + replacement: { identityProviderIds: ['idp-2'] }, + }, + { kind: 'reconcile', missing: 'policies/policy-1', replacement: { policyIds: ['policy-2'] } }, + ])( + 'can disable a failed $kind after $missing is deleted and configure valid replacements', + async ({ kind, missing, replacement }) => { + const owner = await createTestUser(env.db, 'deleted-selection-owner'); + const project = await createTestProject(env.db, owner.id, 'deleted-selection-project'); + const cloudflare = accessApi(); + let removed = false; + let failCreate = kind === 'configure'; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if (removed && new URL(input.toString()).pathname.endsWith(`/${missing}`)) { + return providerError('Selected resource was deleted', 404); + } + if ( + failCreate && + init?.method === 'POST' && + JSON.parse(String(init.body)).domain === '*.apps.example.com' + ) { + return providerError('Temporary provider failure', 500); + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + const input = configurationInput({ existingProjects: 'public' }); + if (kind === 'configure') { + await expect(configurePlatformZeroTrust(platform, env.db, input)).rejects.toThrow( + 'Temporary provider failure', + ); + failCreate = false; + } else { + await configurePlatformZeroTrust(platform, env.db, input); + } + const bypassId = (await getProjectZeroTrustState(env.db, project.id))!.bypass_application_id; + expect(bypassId).toBeTruthy(); + expect(cloudflare.applications.has(bypassId!)).toBe(true); + removed = true; + await expect(reconcilePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Selected resource was deleted', + ); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + status: 'error', + operation: { kind, owner: null }, + }); + const replacementInput = { ...input, ...replacement, apiToken: 'replacement-token' }; + await expect(configurePlatformZeroTrust(platform, env.db, replacementInput)).rejects.toThrow( + 'same account, identity providers, policies, and project selections', + ); + + await disablePlatformZeroTrust(platform, env.db); + + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + operation: null, + wildcardApplication: null, + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + bypass_application_id: null, + bypass_application_name: null, + }); + expect(cloudflare.applications.size).toBe(0); + await configurePlatformZeroTrust(platform, env.db, replacementInput); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: true, + status: 'ready', + operation: null, + identityProviderIds: replacementInput.identityProviderIds, + policyIds: replacementInput.policyIds, + }); + expect(cloudflare.applications.size).toBe(2); + }, + ); + + it('does not enable unvalidated selections when disable completes during credential validation', async () => { + const cloudflare = accessApi(); + const reachedValidation = deferred(); + const releaseValidation = deferred(); + let failDeletes = false; + let selectionDeleted = false; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const token = new Headers(init?.headers).get('Authorization'); + if (token === 'Bearer replacement-token' && url.pathname.endsWith('/organizations')) { + reachedValidation.resolve(); + await releaseValidation.promise; + } + if (selectionDeleted && url.pathname.endsWith('/policies/policy-1')) { + return providerError('Selected resource was deleted', 404); + } + if (failDeletes && init?.method === 'DELETE') { + return providerError('Temporary deletion failure', 500); + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + const input = configurationInput(); + await configurePlatformZeroTrust(platform, env.db, input); + failDeletes = true; + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Temporary deletion failure', + ); + selectionDeleted = true; + const replacing = configurePlatformZeroTrust(platform, env.db, { + ...input, + apiToken: 'replacement-token', + }); + const rejectedReplacement = expect(replacing).rejects.toThrow( + 'saved Zero Trust operation changed', + ); + try { + await reachedValidation.promise; + failDeletes = false; + await disablePlatformZeroTrust(platform, env.db); + expect(cloudflare.applications.size).toBe(0); + const disabled = await readZeroTrustConfigurationStrict(env.db); + expect(disabled).toMatchObject({ enabled: false, status: 'ready', operation: null }); + cloudflare.fetcher.mockClear(); + releaseValidation.resolve(); + await rejectedReplacement; + expect(await readZeroTrustConfigurationStrict(env.db)).toEqual(disabled); + expect( + cloudflare.fetcher.mock.calls.some( + ([, init]) => init?.method === 'POST' || init?.method === 'PUT', + ), + ).toBe(false); + } finally { + releaseValidation.resolve(); + await replacing.catch(() => undefined); + } + }); + + it.each(['domain reconciliation', 'new project', 'saved bypass checkpoint'] as const)( + 'drains %s after wildcard deletion fails before completing disable', + async (lateChange) => { + const owner = await createTestUser(env.db, 'late-disable-owner'); + const project = await createTestProject(env.db, owner.id, 'late-disable-project'); + const cloudflare = accessApi(); + let failWildcardDelete = false; + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + const id = new URL(input.toString()).pathname.split('/').at(-1)!; + if ( + failWildcardDelete && + init?.method === 'DELETE' && + cloudflare.applications.get(id)?.domain === '*.apps.example.com' + ) { + return providerError('Temporary wildcard deletion failure', 503); + } + return cloudflare.fetcher(input, init); + }); + const platform = bindings(); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + const wildcard = (await readZeroTrustConfigurationStrict(env.db))!.wildcardApplication!; + failWildcardDelete = true; + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'Temporary wildcard deletion failure', + ); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: true, + status: 'error', + operation: { kind: 'disable', phase: 'wildcard-delete', owner: null }, + }); + const cleanedProject = await getProjectZeroTrustState(env.db, project.id); + expect(cleanedProject).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + bypass_application_id: null, + }); + cloudflare.fetcher.mockClear(); + let lateProjectId: string | null = null; + + if (lateChange === 'domain reconciliation') { + await env.db.insert(domains).values({ + id: 'late-disable-domain', + project_id: project.id, + hostname: 'late-disable.example.net', + cf_hostname_id: 'cf-late-disable-domain', + status: 'pending', + }); + await expect( + reconcileProjectZeroTrustDomains(platform, env.db, project.id), + ).resolves.toEqual(cleanedProject); + expect(await getProjectZeroTrustState(env.db, project.id)).toEqual(cleanedProject); + expect(cloudflare.fetcher).not.toHaveBeenCalled(); + } else if (lateChange === 'new project') { + const lateProject = await createTestProject(env.db, owner.id, 'created-during-disable'); + lateProjectId = lateProject.id; + await expect( + initializeNewProjectZeroTrust(platform, env.db, lateProject.id), + ).resolves.toBeNull(); + expect(await getProjectZeroTrustState(env.db, lateProject.id)).toBeNull(); + expect(cloudflare.fetcher).not.toHaveBeenCalled(); + } else { + // A prior API version could create and checkpoint a public exception + // while wildcard teardown was failed. Preserve this reachable upgrade + // state to prove finalization revisits outstanding cleanup handles. + const bypassName = 'void-late-disable-public-checkpoint'; + cloudflare.applications.set('late-bypass', { + id: 'late-bypass', + name: bypassName, + aud: 'late-bypass-audience', + type: 'self_hosted', + domain: `${project.slug}.apps.example.com`, + policies: [{ decision: 'bypass', include: [{ everyone: {} }] }], + }); + await env.db + .update(projectZeroTrust) + .set({ + bypass_application_id: 'late-bypass', + bypass_application_name: bypassName, + }) + .where(eq(projectZeroTrust.project_id, project.id)); + } + + failWildcardDelete = false; + await reconcilePlatformZeroTrust(platform, env.db); + + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + wildcardApplication: null, + operation: null, + }); + for (const projectId of [project.id, ...(lateProjectId ? [lateProjectId] : [])]) { + expect(await getProjectZeroTrustState(env.db, projectId)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + bypass_application_id: null, + bypass_application_name: null, + protected_application_id: null, + }); + } + expect(cloudflare.applications.size).toBe(0); + const deletedIds = cloudflare.fetcher.mock.calls + .filter(([, init]) => init?.method === 'DELETE') + .map(([input]) => new URL(input.toString()).pathname.split('/').at(-1)); + expect(deletedIds).toEqual( + lateChange === 'saved bypass checkpoint' ? [wildcard.id, 'late-bypass'] : [wildcard.id], + ); + expect( + cloudflare.fetcher.mock.calls.some( + ([, init]) => init?.method === 'POST' || init?.method === 'PUT', + ), + ).toBe(false); + }, + ); + + it.each(['reconcile', 'disable'] as const)( + 'clears 601 public custom-domain routes across bounded %s calls', + async (operation) => { + const owner = await createTestUser(env.db, 'many-domains-owner'); + const project = await createTestProject(env.db, owner.id, `many-domains-${operation}`); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + await configurePlatformZeroTrust( + platform, + env.db, + configurationInput({ protectNewProjects: false, existingProjects: 'public' }), + ); + const hostnames = Array.from( + { length: 601 }, + (_, index) => `public-${String(index).padStart(3, '0')}.example.net`, + ); + await env.db.insert(domains).values( + hostnames.map((hostname, index) => ({ + id: `public-domain-${index}`, + project_id: project.id, + hostname, + cf_hostname_id: `cf-public-domain-${index}`, + status: 'active' as const, + })), + ); + const routingKeys = [project.slug, ...hostnames.map((hostname) => `domain:${hostname}`)]; + const staleProtection = { + teamDomain: 'https://company.cloudflareaccess.com', + audiences: [{ hostname: '*.apps.example.com', audience: 'stale-audience' }], + }; + for (const key of routingKeys) { + await env.ROUTING.put( + key, + JSON.stringify({ + worker: project.id, + customProperty: 'keep', + zeroTrust: staleProtection, + }), + ); + } + const originalRow = await getProjectZeroTrustState(env.db, project.id); + await expect( + setProjectZeroTrustProtection(platform, env.db, project.id, true), + ).rejects.toThrow('at most 100 custom domains'); + expect(await getProjectZeroTrustState(env.db, project.id)).toEqual(originalRow); + expect(cloudflare.applications.size).toBe(2); + + const routingPut = vi.spyOn(env.ROUTING, 'put'); + const rewrittenKeys = new Set(); + let remaining = routingKeys.length; + let calls = 0; + for (; calls < 5 && remaining > 0; calls++) { + routingPut.mockClear(); + if (calls === 0 && operation === 'disable') { + await disablePlatformZeroTrust(platform, env.db); + } else { + await reconcilePlatformZeroTrust(platform, env.db); + } + expect( + routingPut.mock.calls.length, + `cleanup call ${calls + 1}: ${remaining} routes remain`, + ).toBeGreaterThan(0); + expect(routingPut.mock.calls.length).toBeLessThanOrEqual(300); + for (const [key] of routingPut.mock.calls) { + expect(rewrittenKeys.has(key)).toBe(false); + rewrittenKeys.add(key); + } + const entries = await Promise.all( + routingKeys.map( + async (key) => JSON.parse((await env.ROUTING.get(key))!) as Record, + ), + ); + const nextRemaining = entries.filter((entry) => Object.hasOwn(entry, 'zeroTrust')).length; + expect(nextRemaining).toBeLessThan(remaining); + remaining = nextRemaining; + for (const entry of entries) { + expect(entry).toMatchObject({ worker: project.id, customProperty: 'keep' }); + } + if (remaining > 0) { + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: true, + operation: { kind: operation, owner: null }, + }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + state: 'reconciling', + bypass_application_id: originalRow!.bypass_application_id, + }); + expect(cloudflare.applications.has(originalRow!.bypass_application_id!)).toBe(true); + } + } + expect(calls).toBeGreaterThanOrEqual(3); + expect(remaining).toBe(0); + expect(rewrittenKeys.size).toBe(routingKeys.length); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + state: 'public', + desired_protected: false, + effective_protected: false, + routing_cursor: null, + }); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: operation !== 'disable', + status: 'ready', + operation: null, + }); + expect(cloudflare.applications.size).toBe(operation === 'disable' ? 0 : 2); + }, + ); +}); diff --git a/platform/packages/api/test/integration/zero-trust-route-metadata.test.ts b/platform/packages/api/test/integration/zero-trust-route-metadata.test.ts new file mode 100644 index 000000000..3afd18a78 --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-route-metadata.test.ts @@ -0,0 +1,397 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import { beginDeploymentCutover, deleteProjectCascade, getProject, getUser } from '../../src/db.ts'; +import type { ApiEnv } from '../../src/env.ts'; +import { app } from '../../src/index.ts'; +import { domains, deployments } from '../../src/schema.ts'; +import { + configurePlatformZeroTrust, + getProjectZeroTrustState, + reconcilePlatformZeroTrust, + setProjectZeroTrustProtection, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createOperatorToken, + createTestDeployment, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; +} +function configurationInput(protectedProject: boolean) { + return { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'test-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: protectedProject, + existingProjects: protectedProject ? ('protected' as const) : ('public' as const), + }; +} + +async function operatorRequest(token: string, path: string, body: Record) { + const response = await app.fetch( + new Request('http://localhost/operator/v1' + path, { + method: 'POST', + headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + bindings(), + ); + expect(response.status, await response.text()).toBe(200); +} + +function staleRouteRead(keys: Array, raw: string) { + const originalGet = env.ROUTING.get.bind(env.ROUTING); + return vi + .spyOn(env.ROUTING, 'get') + .mockImplementation((async (key: string | Array, ...args: Array) => + typeof key === 'string' && keys.includes(key) + ? raw + : Reflect.apply(originalGet, env.ROUTING, [key, ...args])) as typeof env.ROUTING.get); +} + +function providerError(message: string, status: number): Response { + return Response.json({ success: false, errors: [{ message }] }, { status }); +} +function accessApi() { + const applications = new Map>(); + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1' || path === 'identity_providers/idp-2') { + return Response.json({ success: true, result: { id: path.split('/').at(-1) } }); + } + if (path === 'policies/policy-1' || path === 'policies/policy-2') { + return Response.json({ + success: true, + result: { + id: path.split('/').at(-1), + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return providerError('application not found', 404); + } + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, fetcher }; +} +describe('Zero Trust authoritative route metadata', () => { + it.each( + (['worker', 'static'] as const).flatMap((type) => + [ + { mutation: 'suspend', protectedAfter: true, customCpuMs: undefined }, + { mutation: 'restore', protectedAfter: false, customCpuMs: undefined }, + { mutation: 'owner', protectedAfter: true, customCpuMs: undefined }, + { mutation: 'owner', protectedAfter: false, customCpuMs: 30 }, + { mutation: 'upgrade', protectedAfter: true, customCpuMs: 30 }, + { mutation: 'downgrade', protectedAfter: false, customCpuMs: 30 }, + { mutation: 'unlimited plan', protectedAfter: true, customCpuMs: undefined }, + ].map((testCase) => ({ ...testCase, type })), + ), + )( + 'preserves completed $mutation on $type routes with protection=$protectedAfter and custom CPU=$customCpuMs', + async ({ mutation, protectedAfter, customCpuMs, type }) => { + const admin = await createTestUser( + env.db, + 'metadata-admin', + 'github', + undefined, + 'free', + 'admin', + ); + const token = await createOperatorToken(env.db, admin); + const owner = await createTestUser( + env.db, + 'metadata-owner', + 'github', + undefined, + mutation === 'downgrade' ? 'pro' : 'free', + ); + const target = await createTestUser(env.db, 'metadata-target', 'github', undefined, 'pro'); + const project = await createTestProject(env.db, owner.id, 'metadata-project'); + const deployment = await createTestDeployment(env.db, project.id, 'live'); + const manifest = { + version: 3, + type: type === 'worker' ? 'void' : 'static', + bindings: {}, + ...(customCpuMs ? { limits: { cpuMs: customCpuMs } } : {}), + }; + await env.db + .update(deployments) + .set({ manifest: JSON.stringify(manifest) }) + .where(eq(deployments.id, deployment.id)); + const initialLimits = + mutation === 'downgrade' + ? { cpuMs: customCpuMs ?? 50, subRequests: 1000 } + : { cpuMs: 10, subRequests: 50 }; + const initial = JSON.stringify({ + ...(type === 'worker' + ? { workerName: 'user-' + project.slug + '-' + deployment.id, limits: initialLimits } + : { staticDeploymentId: deployment.id, assetConfig: { not_found_handling: '404-page' } }), + projectId: project.id, + userId: owner.id, + version: deployment.id, + type: type === 'worker' ? 'void' : 'static', + customMetadata: { preserve: true }, + }); + const domainKey = 'domain:metadata.example.com'; + await env.db.insert(domains).values({ + id: 'dom_metadata', + project_id: project.id, + hostname: 'metadata.example.com', + cf_hostname_id: 'cf-metadata', + status: 'active', + }); + await env.ROUTING.put(project.slug, initial); + await env.ROUTING.put(domainKey, initial); + vi.stubGlobal('fetch', accessApi().fetcher); + await configurePlatformZeroTrust(bindings(), env.db, configurationInput(!protectedAfter)); + if (mutation === 'restore') { + await operatorRequest(token, '/users/' + owner.id + '/suspend', { + reason: 'operator_review', + }); + } + if (mutation === 'owner') { + await operatorRequest(token, '/users/' + target.id + '/suspend', { + reason: 'target_owner_review', + }); + } + const stale = (await env.ROUTING.get(project.slug))!; + if (mutation === 'suspend') { + await operatorRequest(token, '/users/' + owner.id + '/suspend', { + reason: 'operator_review', + }); + } else if (mutation === 'restore') { + await operatorRequest(token, '/users/' + owner.id + '/restore', {}); + } else if (mutation === 'owner') { + await operatorRequest(token, '/projects/' + project.id + '/owner', { userId: target.id }); + } else { + await operatorRequest(token, '/users/' + owner.id + '/plan', { + plan: mutation === 'upgrade' ? 'pro' : mutation === 'downgrade' ? 'free' : 'custom', + }); + } + const currentOwnerId = mutation === 'owner' ? target.id : owner.id; + const expectedSuspension = + mutation === 'suspend' + ? 'operator_review' + : mutation === 'owner' + ? 'target_owner_review' + : undefined; + const expectedLimits = + type === 'static' || mutation === 'unlimited plan' + ? undefined + : mutation === 'owner' || mutation === 'upgrade' + ? { cpuMs: customCpuMs ?? 50, subRequests: 1000 } + : { cpuMs: 10, subRequests: 50 }; + expect((await getProject(env.db, project.id))!.user_id).toBe(currentOwnerId); + expect((await getUser(env.db, currentOwnerId))!.suspended_reason).toBe( + expectedSuspension ?? null, + ); + const completed = JSON.parse((await env.ROUTING.get(project.slug))!); + expect({ + userId: completed.userId, + limits: completed.limits, + suspended: completed.suspended, + }).toEqual({ userId: currentOwnerId, limits: expectedLimits, suspended: expectedSuspension }); + const read = staleRouteRead([project.slug, domainKey], stale); + try { + await setProjectZeroTrustProtection(bindings(), env.db, project.id, protectedAfter); + } finally { + read.mockRestore(); + } + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + effective_protected: protectedAfter, + state: protectedAfter ? 'protected' : 'public', + }); + for (const key of [project.slug, domainKey]) { + const actual = JSON.parse((await env.ROUTING.get(key))!); + expect({ + userId: actual.userId, + limits: actual.limits, + suspended: actual.suspended, + }).toEqual({ + userId: currentOwnerId, + limits: expectedLimits, + suspended: expectedSuspension, + }); + expect(actual).toMatchObject({ + projectId: project.id, + version: deployment.id, + ...(type === 'worker' + ? { workerName: 'user-' + project.slug + '-' + deployment.id } + : { staticDeploymentId: deployment.id }), + customMetadata: { preserve: true }, + }); + if (protectedAfter) { + expect(actual.zeroTrust.audiences.length).toBeGreaterThan(0); + } else { + expect(actual.zeroTrust).toBeUndefined(); + } + } + }, + ); + + it('uses the cutover deployment manifest when refreshing Worker limits', async () => { + const owner = await createTestUser(env.db, 'cutover-limits-owner', 'github', undefined, 'pro'); + const project = await createTestProject(env.db, owner.id, 'cutover-limits-project'); + const source = await createTestDeployment(env.db, project.id, 'live'); + await env.db + .update(deployments) + .set({ manifest: JSON.stringify({ version: 3, bindings: {}, limits: { cpuMs: 10 } }) }) + .where(eq(deployments.id, source.id)); + const domainKey = 'domain:cutover-limits.example.com'; + await env.db.insert(domains).values({ + id: 'dom_cutover_limits', + project_id: project.id, + hostname: 'cutover-limits.example.com', + cf_hostname_id: 'cf-cutover-limits', + status: 'active', + }); + const route = (id: string) => + JSON.stringify({ + workerName: 'user-' + project.slug + '-' + id, + projectId: project.id, + userId: owner.id, + version: id, + limits: { cpuMs: 10, subRequests: 1000 }, + }); + await env.ROUTING.put(project.slug, route(source.id)); + await env.ROUTING.put(domainKey, route(source.id)); + vi.stubGlobal('fetch', accessApi().fetcher); + await configurePlatformZeroTrust(bindings(), env.db, configurationInput(false)); + const candidate = await createTestDeployment(env.db, project.id, 'pending'); + await env.db + .update(deployments) + .set({ manifest: JSON.stringify({ version: 3, bindings: {}, limits: { cpuMs: 35 } }) }) + .where(eq(deployments.id, candidate.id)); + expect(await beginDeploymentCutover(env.db, candidate.id)).toBe(true); + await env.ROUTING.put(project.slug, route(candidate.id)); + await env.ROUTING.put(domainKey, route(candidate.id)); + await setProjectZeroTrustProtection(bindings(), env.db, project.id, true); + expect((await getProject(env.db, project.id))!.active_deployment_id).toBe(source.id); + for (const key of [project.slug, domainKey]) { + const published = JSON.parse((await env.ROUTING.get(key))!); + expect(published).toMatchObject({ + version: candidate.id, + limits: { cpuMs: 35, subRequests: 1000 }, + }); + expect(published.zeroTrust.audiences.length).toBeGreaterThan(0); + } + }); + + it.each([true, false])( + 'never republishes a deleted project after slug reuse with protection=%s', + async (protectedProject) => { + const owner = await createTestUser(env.db, 'reuse-owner'); + const previous = await createTestProject(env.db, owner.id, 'reused-project'); + const deployment = await createTestDeployment(env.db, previous.id, 'live'); + const stale = JSON.stringify({ + workerName: 'user-' + previous.slug + '-' + deployment.id, + projectId: previous.id, + userId: owner.id, + version: deployment.id, + }); + await env.ROUTING.put(previous.slug, stale); + await env.ROUTING.delete(previous.slug); + await deleteProjectCascade(env.db, previous.id); + const current = await createTestProject(env.db, owner.id, previous.slug); + vi.stubGlobal('fetch', accessApi().fetcher); + const put = vi.spyOn(env.ROUTING, 'put'); + const remove = vi.spyOn(env.ROUTING, 'delete'); + const read = staleRouteRead([current.slug], stale); + try { + await expect( + configurePlatformZeroTrust(bindings(), env.db, configurationInput(protectedProject)), + ).rejects.toThrow('Live routing'); + } finally { + read.mockRestore(); + } + expect(put.mock.calls.some(([key]) => key === current.slug)).toBe(false); + expect(remove.mock.calls.some(([key]) => key === current.slug)).toBe(false); + expect(await env.ROUTING.get(current.slug)).toBeNull(); + expect((await getProject(env.db, current.id))!.active_deployment_id).toBeNull(); + await reconcilePlatformZeroTrust(bindings(), env.db); + expect(await env.ROUTING.get(current.slug)).toBeNull(); + expect(await getProjectZeroTrustState(env.db, current.id)).toMatchObject({ + state: protectedProject ? 'protected' : 'public', + }); + }, + ); +}); diff --git a/platform/packages/api/test/integration/zero-trust-routing-backfill.test.ts b/platform/packages/api/test/integration/zero-trust-routing-backfill.test.ts new file mode 100644 index 000000000..43dd933f5 --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-routing-backfill.test.ts @@ -0,0 +1,386 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import * as database from '../../src/db.ts'; +import type { ApiEnv } from '../../src/env.ts'; +import { scheduled } from '../../src/index.ts'; +import { transferOperatorProjectOwner } from '../../src/operator/tenant-service.ts'; +import { setProjectsRoutingSuspension } from '../../src/routing.ts'; +import { domains, projects, projectZeroTrust, users } from '../../src/schema.ts'; +import { + configurePlatformZeroTrust, + getProjectZeroTrustState, + setProjectZeroTrustProtection, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + PLATFORM_CAPABILITIES: JSON.stringify({ + email: false, + customDomains: false, + managedBuilds: false, + ai: false, + githubActions: false, + githubOAuth: false, + googleOAuth: false, + sandbox: false, + }), + } as unknown as ApiEnv['Bindings']; +} + +function runHourly() { + return scheduled( + { cron: '0 * * * *', scheduledTime: Date.now() } as ScheduledController, + bindings(), + { waitUntil() {}, passThroughOnException() {} } as unknown as ExecutionContext, + ); +} + +async function seedRoutes(project: { id: string; slug: string }) { + const domainKey = 'domain:backfill.example.com'; + await env.db.insert(domains).values({ + id: 'dom_backfill', + project_id: project.id, + hostname: 'backfill.example.com', + cf_hostname_id: 'cf-backfill', + status: 'active', + }); + const route = JSON.stringify({ + workerName: 'live-worker', + projectId: project.id, + version: 'live-deployment', + suspended: 'admin_suspended', + }); + await env.ROUTING.put(project.slug, route); + await env.ROUTING.put(domainKey, route); + return { domainKey, route }; +} + +function providerError(message: string, status: number): Response { + return Response.json({ success: false, errors: [{ message }] }, { status }); +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function accessApi() { + const applications = new Map>(); + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1' || path === 'identity_providers/idp-2') { + return Response.json({ success: true, result: { id: path.split('/').at(-1) } }); + } + if (path === 'policies/policy-1' || path === 'policies/policy-2') { + return Response.json({ + success: true, + result: { + id: path.split('/').at(-1), + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return providerError('application not found', 404); + } + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, fetcher }; +} + +describe('legacy routing owner backfill', () => { + it.each([ + { operation: 'backfill', protectedState: true }, + { operation: 'backfill', protectedState: false }, + { operation: 'suspension', protectedState: true }, + { operation: 'suspension', protectedState: false }, + { operation: 'ownership transfer', protectedState: true }, + { operation: 'ownership transfer', protectedState: false }, + { operation: 'suspension', protectedState: 'pending' }, + ] as const)( + 'uses database protection=$protectedState during $operation despite stale route reads', + async ({ operation, protectedState }) => { + const owner = await createTestUser(env.db, 'stale-route-owner'); + const newOwner = await createTestUser(env.db, 'stale-route-new-owner'); + const project = await createTestProject(env.db, owner.id, 'stale-route-project'); + const { domainKey, route } = await seedRoutes(project); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + await configurePlatformZeroTrust(bindings(), env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'test-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: Boolean(protectedState), + existingProjects: protectedState ? 'protected' : 'public', + }); + const completedProtection = JSON.parse((await env.ROUTING.get(project.slug))!).zeroTrust; + if (protectedState === 'pending') { + await env.db + .update(projectZeroTrust) + .set({ state: 'reconciling', effective_protected: false }) + .where(eq(projectZeroTrust.project_id, project.id)); + } + const stale = JSON.stringify({ + ...JSON.parse(route), + ...(!protectedState + ? { zeroTrust: { teamDomain: 'stale.cloudflareaccess.com', audiences: [] } } + : {}), + }); + const get = env.ROUTING.get.bind(env.ROUTING); + const staleRead = vi.spyOn(env.ROUTING, 'get').mockImplementation((async ( + key: string, + ...args: Array + ) => { + if (key === project.slug || key === domainKey) { + return stale; + } + return Reflect.apply(get, env.ROUTING, [key, ...args]); + }) as typeof env.ROUTING.get); + try { + if (operation === 'backfill') { + await runHourly(); + } else if (operation === 'suspension') { + await setProjectsRoutingSuspension( + env.ROUTING, + env.db, + [{ ...project, user_id: owner.id }], + { op: 'unsuspend', matchReason: null }, + ); + } else { + await transferOperatorProjectOwner( + { + env: bindings(), + get: (key: string) => (key === 'db' ? env.db : { id: owner.id }), + } as never, + project.id, + newOwner.id, + ); + } + } finally { + staleRead.mockRestore(); + } + for (const key of [project.slug, domainKey]) { + const published = JSON.parse((await env.ROUTING.get(key))!); + expect(published).toMatchObject({ + workerName: 'live-worker', + projectId: project.id, + version: 'live-deployment', + }); + expect(published.zeroTrust).toEqual( + protectedState === 'pending' ? { teamDomain: '', audiences: [] } : completedProtection, + ); + if (operation === 'backfill') { + expect(published.userId).toBe(owner.id); + } else if (operation === 'ownership transfer') { + expect(published.userId).toBe(newOwner.id); + } else { + expect(published.suspended).toBeUndefined(); + } + } + }, + ); + + it.each(['slug', 'domain'] as const)( + 'serializes the %s backfill with a concurrent protection change', + async (surface) => { + const owner = await createTestUser(env.db, 'backfill-owner'); + await env.db + .update(users) + .set({ suspended_reason: 'admin_suspended' }) + .where(eq(users.id, owner.id)); + const project = await createTestProject(env.db, owner.id, 'backfill-project'); + const { domainKey } = await seedRoutes(project); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + await configurePlatformZeroTrust(bindings(), env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'test-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'public', + }); + // Configuration now republishes authoritative ownership. Seed legacy + // entries explicitly so this scheduled run still exercises the backfill. + for (const key of [project.slug, domainKey]) { + const legacy = JSON.parse((await env.ROUTING.get(key))!); + delete legacy.userId; + await env.ROUTING.put(key, JSON.stringify(legacy)); + } + + let transactionDepth = 0; + const transaction = env.db.transaction.bind(env.db); + vi.spyOn(env.db, 'transaction').mockImplementation((callback, options) => + transaction(async (tx) => { + transactionDepth++; + try { + return await callback(tx); + } finally { + transactionDepth--; + } + }, options), + ); + const entered = deferred(); + const release = deferred(); + const targetKey = surface === 'slug' ? project.slug : domainKey; + const originalPut = env.ROUTING.put.bind(env.ROUTING); + let paused = false; + vi.spyOn(env.ROUTING, 'put').mockImplementation(async (key, value, options) => { + if ( + !paused && + key === targetKey && + typeof value === 'string' && + JSON.parse(value).userId === owner.id + ) { + paused = true; + entered.resolve(); + await release.promise; + } + return originalPut(key, value, options); + }); + const rollover = runHourly(); + await entered.promise; + const depthAtBackfillWrite = transactionDepth; + let protectionSettled = false; + const protection = setProjectZeroTrustProtection( + bindings(), + env.db, + project.id, + true, + ).finally(() => { + protectionSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + const settledBeforeRelease = protectionSettled; + release.resolve(); + await Promise.all([rollover, protection]); + + expect(depthAtBackfillWrite).toBe(1); + expect(settledBeforeRelease).toBe(false); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + effective_protected: true, + state: 'protected', + }); + for (const key of [project.slug, domainKey]) { + const route = JSON.parse((await env.ROUTING.get(key))!); + expect(route).toMatchObject({ + workerName: 'live-worker', + projectId: project.id, + userId: owner.id, + version: 'live-deployment', + suspended: 'admin_suspended', + }); + expect(route.zeroTrust).toBeDefined(); + } + }, + ); + + it.each(['transfer', 'deletion'] as const)( + 'leaves slug and domain routes unchanged after a %s before the claim', + async (change) => { + const owner = await createTestUser(env.db, 'backfill-stale-owner'); + const newOwner = await createTestUser(env.db, 'backfill-new-owner'); + const project = await createTestProject(env.db, owner.id, 'backfill-stale-project'); + const { domainKey, route } = await seedRoutes(project); + const getBatch = database.getUsersWithProjectsBatch; + let changed = false; + vi.spyOn(database, 'getUsersWithProjectsBatch').mockImplementation(async (...args) => { + const batch = await getBatch(...args); + if (!changed && batch.some((item) => item.projects.some((p) => p.id === project.id))) { + changed = true; + // The scheduled job has its old ownership snapshot, but no project lock yet. + await env.db + .update(projects) + .set(change === 'transfer' ? { user_id: newOwner.id } : { status: 'deleting' }) + .where(eq(projects.id, project.id)); + } + return batch; + }); + await runHourly(); + expect(changed).toBe(true); + expect(await env.ROUTING.get(project.slug)).toBe(route); + expect(await env.ROUTING.get(domainKey)).toBe(route); + }, + ); +}); diff --git a/platform/packages/api/test/integration/zero-trust-routing-publication.test.ts b/platform/packages/api/test/integration/zero-trust-routing-publication.test.ts new file mode 100644 index 000000000..d44d7332b --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-routing-publication.test.ts @@ -0,0 +1,293 @@ +import type { ProjectZeroTrustRouting } from '@void/edge'; +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import { ProjectAccessVerifier } from '../../../dispatch/src/access.ts'; +import { getDomain, getProject } from '../../src/db.ts'; +import type { ApiEnv } from '../../src/env.ts'; +import { tryActivateDomain } from '../../src/provisioning/activate-domain.ts'; +import { domains, platformSettings, projectZeroTrust } from '../../src/schema.ts'; +import { + configurePlatformZeroTrust, + getProjectZeroTrustState, + reconcilePendingProjectZeroTrust, + resolveProjectZeroTrustRouting, + resolveProjectZeroTrustRoutingForPublication, + setProjectZeroTrustProtection, + withProjectZeroTrustRouting, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; +} + +function configurationInput() { + return { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'test-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected' as const, + }; +} + +function providerError(message: string, status: number): Response { + return Response.json({ success: false, errors: [{ message }] }, { status }); +} + +function accessApi() { + const applications = new Map>(); + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1' || path === 'identity_providers/idp-2') { + return Response.json({ success: true, result: { id: path.split('/').at(-1) } }); + } + if (path === 'policies/policy-1' || path === 'policies/policy-2') { + return Response.json({ + success: true, + result: { + id: path.split('/').at(-1), + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return providerError('application not found', 404); + } + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, fetcher }; +} + +describe('authoritative Zero Trust routing publication', () => { + it.each([true, false])( + 'activates a domain using database protection=%s despite an older slug route', + async (protectedProject) => { + await env.ROUTING.delete('domain:stale-route.example.net'); + const owner = await createTestUser(env.db, 'stale-route-owner'); + const project = await createTestProject(env.db, owner.id, 'stale-route-project'); + const cloudflare = accessApi(); + const platform = bindings(); + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { + if (new URL(input.toString()).pathname.endsWith('/custom_hostnames/cf-stale-route')) { + return Response.json({ + success: true, + result: { + id: 'cf-stale-route', + hostname: 'stale-route.example.net', + status: 'active', + ssl: { status: 'active', method: 'http', type: 'dv' }, + }, + }); + } + return cloudflare.fetcher(input, init); + }); + const metadata = { + worker: 'live-worker', + projectId: project.id, + userId: owner.id, + customMetadata: { preserve: true }, + }; + const publicRoute = JSON.stringify(metadata); + await env.ROUTING.put(project.slug, publicRoute); + await env.db.insert(domains).values({ + id: 'domain-stale-route', + project_id: project.id, + hostname: 'stale-route.example.net', + cf_hostname_id: 'cf-stale-route', + status: 'pending', + }); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + const protectedRoute = (await env.ROUTING.get(project.slug))!; + expect(JSON.parse(protectedRoute).zeroTrust).toBeTruthy(); + if (!protectedProject) { + await setProjectZeroTrustProtection(platform, env.db, project.id, false); + } + const expectedProtection = await resolveProjectZeroTrustRouting(env.db, project.id); + const staleRoute = protectedProject ? publicRoute : protectedRoute; + const originalGet = env.ROUTING.get.bind(env.ROUTING); + let staleReads = 0; + const getSpy = vi.spyOn(env.ROUTING, 'get').mockImplementation((async ( + key: string | Array, + ...rest: Array + ) => { + if (key === project.slug) { + staleReads += 1; + // Writes cannot invalidate an older edge read, even under a DB lock. + return staleRoute; + } + return Reflect.apply(originalGet, env.ROUTING, [key, ...rest]); + }) as typeof env.ROUTING.get); + try { + const activated = await tryActivateDomain( + platform, + env.db, + (await getDomain(env.db, 'domain-stale-route'))!, + (await getProject(env.db, project.id))!, + ); + expect(activated.activated).toBe(true); + } finally { + getSpy.mockRestore(); + } + expect(staleReads).toBeGreaterThanOrEqual(2); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: protectedProject, + effective_protected: protectedProject, + state: protectedProject ? 'protected' : 'public', + }); + await reconcilePendingProjectZeroTrust(platform, env.db); + expect((await getDomain(env.db, 'domain-stale-route'))!.status).toBe('active'); + const published = JSON.parse((await env.ROUTING.get('domain:stale-route.example.net'))!); + expect(published).toEqual({ + ...metadata, + ...(expectedProtection ? { zeroTrust: expectedProtection } : {}), + }); + if (protectedProject) { + expect(published.zeroTrust.audiences).toContainEqual({ + hostname: 'stale-route.example.net', + audience: (await getProjectZeroTrustState(env.db, project.id))! + .protected_application_audience, + }); + } else { + expect(Object.hasOwn(published, 'zeroTrust')).toBe(false); + } + }, + ); + + it.each(['reconciling', 'error'] as const)( + 'publishes a deny-all marker for unresolved %s protection without weakening the deployment gate', + async (state) => { + const owner = await createTestUser(env.db, 'pending-publication-owner'); + const project = await createTestProject(env.db, owner.id, 'pending-publication-project'); + vi.stubGlobal('fetch', accessApi().fetcher); + await configurePlatformZeroTrust(bindings(), env.db, configurationInput()); + await env.db + .update(projectZeroTrust) + .set({ state, effective_protected: false }) + .where(eq(projectZeroTrust.project_id, project.id)); + + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).rejects.toThrow( + 'still reconciling', + ); + const protection = await resolveProjectZeroTrustRoutingForPublication(env.db, project.id); + expect(protection).toEqual({ teamDomain: '', audiences: [] }); + const rewritten = JSON.parse( + withProjectZeroTrustRouting( + JSON.stringify({ worker: 'live-worker', suspended: true }), + protection, + ), + ); + expect(rewritten).toEqual({ worker: 'live-worker', suspended: true, zeroTrust: protection }); + const fetcher = vi.fn(); + const verifier = new ProjectAccessVerifier(fetcher); + await expect( + verifier.verify( + 'pending-publication-project.apps.example.com', + 'untrusted.assertion.value', + rewritten.zeroTrust as ProjectZeroTrustRouting, + ), + ).resolves.toBe(false); + expect(fetcher).not.toHaveBeenCalled(); + }, + ); + + it('publishes a deny-all marker for malformed saved configuration', async () => { + const owner = await createTestUser(env.db, 'invalid-publication-owner'); + const project = await createTestProject(env.db, owner.id, 'invalid-publication-project'); + await env.db + .insert(platformSettings) + .values({ key: 'zeroTrust.configuration', value: { version: 999 } }); + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).rejects.toThrow( + 'unsupported Zero Trust configuration version', + ); + await expect(resolveProjectZeroTrustRoutingForPublication(env.db, project.id)).resolves.toEqual( + { teamDomain: '', audiences: [] }, + ); + }); + + it('propagates database failures instead of converting them to a publishable route', async () => { + const failure = new Error('database connection unavailable'); + const executeSpy = vi.spyOn(env.db, 'execute').mockRejectedValueOnce(failure); + await expect( + resolveProjectZeroTrustRoutingForPublication(env.db, 'unreadable-project'), + ).rejects.toBe(failure); + expect(executeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/platform/packages/api/test/integration/zero-trust-routing-retry.test.ts b/platform/packages/api/test/integration/zero-trust-routing-retry.test.ts new file mode 100644 index 000000000..baa3dd3fe --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-routing-retry.test.ts @@ -0,0 +1,626 @@ +import { eq } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import type { ApiEnv } from '../../src/env.ts'; +import { setProjectsRoutingSuspension } from '../../src/routing.ts'; +import { domains, platformSettings, projects, projectZeroTrust, users } from '../../src/schema.ts'; +import { + readZeroTrustConfigurationStrict, + ZeroTrustConfigurationError, +} from '../../src/zero-trust/configuration.ts'; +import { + configurePlatformZeroTrust, + disablePlatformZeroTrust, + getProjectZeroTrustState, + reconcilePendingProjectZeroTrust, + resolveProjectZeroTrustRouting, + resolveProjectZeroTrustRoutingForPublication, + setProjectZeroTrustProtection, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestDeployment, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; +let env: TestEnv; +beforeAll(async () => { + env = await setupEnv(); +}); +afterAll(async () => { + await teardownEnv(); +}); +beforeEach(async () => { + await clearTables(env.db); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +function bindings(): ApiEnv['Bindings'] { + return { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; +} +function configurationInput(existingProjects: 'protected' | 'public' = 'protected') { + return { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'test-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects, + }; +} +function providerError(message: string, status: number): Response { + return Response.json({ success: false, errors: [{ message }] }, { status }); +} +function accessApi() { + const applications = new Map>(); + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1' || path === 'identity_providers/idp-2') { + return Response.json({ success: true, result: { id: path.split('/').at(-1) } }); + } + if (path === 'policies/policy-1' || path === 'policies/policy-2') { + return Response.json({ + success: true, + result: { + id: path.split('/').at(-1), + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match) { + const id = match[1]!; + const previous = applications.get(id); + if (!previous) { + return providerError('application not found', 404); + } + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const application = { ...body, id, aud: previous.aud }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (init?.method === 'DELETE') { + applications.delete(id); + return Response.json({ success: true, result: { id } }); + } + return Response.json({ success: true, result: previous }); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, fetcher }; +} + +describe('Zero Trust routing read retries', () => { + it.each(['disabling', 'error', 'provider deletion error', 'legacy cursor'] as const)( + 'keeps completed routing public across metadata writes while platform disable is %s', + async (status) => { + const owner = await createTestUser(env.db, 'disable-cursor-owner'); + const publicProject = await createTestProject(env.db, owner.id, 'disable-first-public'); + const protectedProject = await createTestProject(env.db, owner.id, 'disable-last-protected'); + await env.db + .update(projects) + .set({ created_at: '2020-01-01 00:00:00' }) + .where(eq(projects.id, publicProject.id)); + await env.db + .update(projects) + .set({ created_at: '2020-01-02 00:00:00' }) + .where(eq(projects.id, protectedProject.id)); + const deployment = await createTestDeployment(env.db, protectedProject.id, 'live'); + const raw = JSON.stringify({ + projectId: protectedProject.id, + userId: owner.id, + workerName: `user-${protectedProject.slug}-${deployment.id}`, + version: deployment.id, + }); + await env.ROUTING.put(protectedProject.slug, raw); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + await setProjectZeroTrustProtection(platform, env.db, publicProject.id, false); + // The first project consumes 299 shared routing slots, leaving just the + // protected project's slug to be cleared before its domain page yields. + await env.db.insert(domains).values( + Array.from({ length: 298 }, (_, index) => ({ + id: `disable-pending-${index}`, + project_id: publicProject.id, + hostname: `disable-pending-${String(index).padStart(3, '0')}.example.net`, + cf_hostname_id: `cf-disable-pending-${index}`, + status: 'pending' as const, + })), + ); + const domainKey = 'domain:disable-protected.example.net'; + await env.db.insert(domains).values({ + id: 'disable-protected-domain', + project_id: protectedProject.id, + hostname: 'disable-protected.example.net', + cf_hostname_id: 'cf-disable-protected', + status: 'active', + }); + await env.ROUTING.put(domainKey, raw); + await setProjectZeroTrustProtection(platform, env.db, protectedProject.id, true); + const protectedApplicationId = (await getProjectZeroTrustState(env.db, protectedProject.id))! + .protected_application_id; + expect(await disablePlatformZeroTrust(platform, env.db)).toMatchObject({ + enabled: true, + status: 'disabling', + operation: { kind: 'disable', phase: 'projects' }, + }); + expect(await getProjectZeroTrustState(env.db, protectedProject.id)).toMatchObject({ + desired_protected: false, + effective_protected: true, + state: 'reconciling', + routing_cursor: '', + }); + expect(JSON.parse((await env.ROUTING.get(protectedProject.slug))!).zeroTrust).toBeUndefined(); + expect(JSON.parse((await env.ROUTING.get(domainKey))!).zeroTrust).toBeDefined(); + + if (status === 'error') { + const get = env.ROUTING.get.bind(env.ROUTING); + const miss = vi + .spyOn(env.ROUTING, 'get') + .mockImplementation((async (key: string | Array, ...args: Array) => + key === domainKey + ? null + : Reflect.apply(get, env.ROUTING, [key, ...args])) as typeof env.ROUTING.get); + try { + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow('Live routing'); + } finally { + miss.mockRestore(); + } + } else if (status === 'provider deletion error') { + vi.stubGlobal('fetch', (input: string | URL | Request, init?: RequestInit) => { + if ( + init?.method === 'DELETE' && + String(input).endsWith(`/apps/${protectedApplicationId}`) + ) { + return providerError('injected application deletion failure', 403); + } + return cloudflare.fetcher(input, init); + }); + try { + await expect(disablePlatformZeroTrust(platform, env.db)).rejects.toThrow( + 'injected application deletion failure', + ); + } finally { + vi.stubGlobal('fetch', cloudflare.fetcher); + } + // Routing finished before provider deletion failed. Retain the intent + // and cleanup handle even though the completed cursor is now null. + expect(await getProjectZeroTrustState(env.db, protectedProject.id)).toMatchObject({ + desired_protected: false, + effective_protected: true, + state: 'error', + routing_cursor: null, + protected_application_id: protectedApplicationId, + }); + } + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + status: status === 'error' || status === 'provider deletion error' ? 'error' : 'disabling', + operation: { kind: 'disable' }, + }); + const initializing = await createTestProject(env.db, owner.id, 'disable-new-project'); + expect(await getProjectZeroTrustState(env.db, initializing.id)).toBeNull(); + await expect(resolveProjectZeroTrustRouting(env.db, protectedProject.id)).resolves.toBeNull(); + await expect( + resolveProjectZeroTrustRoutingForPublication(env.db, protectedProject.id), + ).resolves.toBeNull(); + await expect(resolveProjectZeroTrustRouting(env.db, initializing.id)).rejects.toThrow( + 'still being initialized', + ); + await expect( + resolveProjectZeroTrustRoutingForPublication(env.db, initializing.id), + ).resolves.toEqual({ teamDomain: '', audiences: [] }); + + if (status === 'legacy cursor') { + // An older binary could persist progress without changing desired + // intent. Its readers may then protect a previously cleared slug. + await env.db + .update(projectZeroTrust) + .set({ desired_protected: true, routing_cursor: '' }) + .where(eq(projectZeroTrust.project_id, protectedProject.id)); + expect(await resolveProjectZeroTrustRouting(env.db, protectedProject.id)).not.toBeNull(); + } + + await env.db + .update(users) + .set({ suspended_reason: 'admin_suspended' }) + .where(eq(users.id, owner.id)); + const puts = vi.spyOn(env.ROUTING, 'put'); + await setProjectsRoutingSuspension( + env.ROUTING, + env.db, + [{ ...protectedProject, user_id: owner.id }], + { op: 'suspend', reason: 'admin_suspended', force: true }, + ); + expect(puts).toHaveBeenCalledTimes(2); + puts.mockRestore(); + for (const key of [protectedProject.slug, domainKey]) { + const protection = JSON.parse((await env.ROUTING.get(key))!).zeroTrust; + if (status === 'legacy cursor') { + expect(protection).toBeDefined(); + } else { + expect(protection).toBeUndefined(); + } + } + const retryPuts = vi.spyOn(env.ROUTING, 'put'); + await reconcilePendingProjectZeroTrust(platform, env.db); + expect(retryPuts.mock.calls.some(([key]) => key === protectedProject.slug)).toBe( + status === 'legacy cursor' || status === 'provider deletion error', + ); + retryPuts.mockRestore(); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + operation: null, + }); + expect(cloudflare.applications.size).toBe(0); + expect(await getProjectZeroTrustState(env.db, protectedProject.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + routing_cursor: null, + }); + for (const key of [protectedProject.slug, domainKey]) { + const published = JSON.parse((await env.ROUTING.get(key))!); + expect(published.zeroTrust).toBeUndefined(); + expect(published.version).toBe(deployment.id); + expect(published.suspended).toBe('admin_suspended'); + } + }, + ); + + it.each(['status', 'required field'] as const)( + 'does not treat a disable marker with invalid %s as public intent', + async (invalid) => { + const owner = await createTestUser(env.db, 'invalid-disable-owner'); + const project = await createTestProject(env.db, owner.id, 'invalid-disable-project'); + vi.stubGlobal('fetch', accessApi().fetcher); + await configurePlatformZeroTrust(bindings(), env.db, configurationInput()); + const configuration = (await readZeroTrustConfigurationStrict(env.db))!; + await env.db + .update(platformSettings) + .set({ + value: { + ...configuration, + status: invalid === 'status' ? 'ready' : 'disabling', + ...(invalid === 'required field' ? { teamDomain: null } : {}), + operation: { + kind: 'disable', + phase: 'projects', + startedAt: new Date().toISOString(), + owner: null, + leaseExpiresAt: null, + existingProjects: null, + }, + }, + }) + .where(eq(platformSettings.key, 'zeroTrust.configuration')); + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).rejects.toBeInstanceOf( + ZeroTrustConfigurationError, + ); + await expect( + resolveProjectZeroTrustRoutingForPublication(env.db, project.id), + ).resolves.toEqual({ teamDomain: '', audiences: [] }); + }, + ); + + describe.each(['public', 'disable'] as const)('%s cleanup', (operation) => { + it.each(['active deployment', 'legacy live deployment', 'active domain'] as const)( + 'retries a cached KV miss for an %s before completing', + async (liveTarget) => { + const owner = await createTestUser(env.db, 'negative-route-owner'); + const project = await createTestProject(env.db, owner.id, 'negative-route-project'); + const deployment = await createTestDeployment(env.db, project.id); + await env.db + .update(projects) + .set({ + active_deployment_id: liveTarget === 'legacy live deployment' ? null : deployment.id, + }) + .where(eq(projects.id, project.id)); + const route = JSON.stringify({ + workerName: `user-${project.slug}-${deployment.id}`, + projectId: project.id, + userId: owner.id, + version: deployment.id, + limits: { cpuMs: 10, subRequests: 50 }, + }); + await env.ROUTING.put(project.slug, route); + let missingKey = project.slug; + if (liveTarget === 'active domain') { + missingKey = 'domain:negative-route.example.net'; + await env.db.insert(domains).values({ + id: 'negative-route-domain', + project_id: project.id, + hostname: 'negative-route.example.net', + cf_hostname_id: 'cf-negative-route', + status: 'active', + }); + await env.ROUTING.put(missingKey, route); + } + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + const protectedRoute = (await env.ROUTING.get(missingKey))!; + expect(JSON.parse(protectedRoute).zeroTrust).toBeTruthy(); + const originalGet = env.ROUTING.get.bind(env.ROUTING); + const getSpy = vi + .spyOn(env.ROUTING, 'get') + .mockImplementation((async (key: string | Array, ...args: Array) => + key === missingKey + ? null + : Reflect.apply(originalGet, env.ROUTING, [key, ...args])) as typeof env.ROUTING.get); + try { + const cleanup = + operation === 'public' + ? setProjectZeroTrustProtection(platform, env.db, project.id, false) + : disablePlatformZeroTrust(platform, env.db); + await expect(cleanup).rejects.toThrow('Live routing'); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + state: 'error', + routing_cursor: null, + }); + if (operation === 'disable') { + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: true, + status: 'error', + operation: { kind: 'disable' }, + }); + } + } finally { + getSpy.mockRestore(); + } + expect(await env.ROUTING.get(missingKey)).toBe(protectedRoute); + + await reconcilePendingProjectZeroTrust(platform, env.db); + + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + routing_cursor: null, + }); + expect(JSON.parse((await env.ROUTING.get(missingKey))!)).toEqual(JSON.parse(route)); + if (operation === 'disable') { + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + status: 'ready', + operation: null, + }); + expect(cloudflare.applications.size).toBe(0); + } + }, + ); + }); + + it('does not complete protection while a live slug is hidden by a cached miss', async () => { + const owner = await createTestUser(env.db, 'negative-enable-owner'); + const project = await createTestProject(env.db, owner.id, 'negative-enable-project'); + const deployment = await createTestDeployment(env.db, project.id); + await env.db + .update(projects) + .set({ active_deployment_id: deployment.id }) + .where(eq(projects.id, project.id)); + await env.ROUTING.put( + project.slug, + JSON.stringify({ + workerName: `user-${project.slug}-${deployment.id}`, + projectId: project.id, + version: deployment.id, + }), + ); + vi.stubGlobal('fetch', accessApi().fetcher); + const platform = bindings(); + await configurePlatformZeroTrust(platform, env.db, configurationInput('public')); + const originalGet = env.ROUTING.get.bind(env.ROUTING); + const getSpy = vi + .spyOn(env.ROUTING, 'get') + .mockImplementation((async (key: string | Array, ...args: Array) => + key === project.slug + ? null + : Reflect.apply(originalGet, env.ROUTING, [key, ...args])) as typeof env.ROUTING.get); + try { + await expect( + setProjectZeroTrustProtection(platform, env.db, project.id, true), + ).rejects.toThrow('Live routing'); + } finally { + getSpy.mockRestore(); + } + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: true, + effective_protected: false, + state: 'error', + }); + expect(JSON.parse((await env.ROUTING.get(project.slug))!).zeroTrust).toBeUndefined(); + await reconcilePendingProjectZeroTrust(platform, env.db); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: true, + effective_protected: true, + state: 'protected', + }); + expect(JSON.parse((await env.ROUTING.get(project.slug))!).zeroTrust).toBeTruthy(); + }); + + it('does not block undeployed projects or missing pending-domain routes', async () => { + const owner = await createTestUser(env.db, 'undeployed-route-owner'); + const project = await createTestProject(env.db, owner.id, 'undeployed-route-project'); + await createTestDeployment(env.db, project.id, 'failed'); + await env.db.insert(domains).values({ + id: 'pending-route-domain', + project_id: project.id, + hostname: 'pending-route.example.net', + cf_hostname_id: 'cf-pending-route', + status: 'pending', + }); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const platform = bindings(); + + await configurePlatformZeroTrust(platform, env.db, configurationInput()); + await expect( + setProjectZeroTrustProtection(platform, env.db, project.id, false), + ).resolves.toMatchObject({ state: 'public' }); + await expect(disablePlatformZeroTrust(platform, env.db)).resolves.toMatchObject({ + enabled: false, + status: 'ready', + operation: null, + }); + expect(cloudflare.applications.size).toBe(0); + expect(await env.ROUTING.get(project.slug)).toBeNull(); + expect(await env.ROUTING.get('domain:pending-route.example.net')).toBeNull(); + }); + + it.each(['reconciling', 'error'] as const)( + 'keeps completed public routing public when metadata changes during %s cleanup', + async (state) => { + const owner = await createTestUser(env.db, 'public-cursor-owner'); + const project = await createTestProject(env.db, owner.id, 'public-cursor-project'); + const deployment = await createTestDeployment(env.db, project.id); + await env.db + .update(users) + .set({ suspended_reason: 'admin_suspended' }) + .where(eq(users.id, owner.id)); + const route = { + workerName: `user-${project.slug}-${deployment.id}`, + projectId: project.id, + userId: owner.id, + version: deployment.id, + suspended: 'admin_suspended', + }; + await env.ROUTING.put(project.slug, JSON.stringify(route)); + vi.stubGlobal('fetch', accessApi().fetcher); + const platform = bindings(); + await configurePlatformZeroTrust(platform, env.db, configurationInput('public')); + const domainRows = Array.from({ length: 305 }, (_, index) => ({ + id: `public-cursor-${index}`, + project_id: project.id, + hostname: `${String(index).padStart(4, '0')}.cursor.example.net`, + cf_hostname_id: `cf-public-cursor-${index}`, + status: 'active' as const, + })); + await env.db.insert(domains).values(domainRows); + const keys = [project.slug, ...domainRows.map((domain) => `domain:${domain.hostname}`)]; + for (const key of keys) { + await env.ROUTING.put( + key, + JSON.stringify({ ...route, zeroTrust: { teamDomain: '', audiences: [] } }), + ); + } + + const puts = vi.spyOn(env.ROUTING, 'put'); + await setProjectZeroTrustProtection(platform, env.db, project.id, false); + expect(puts).toHaveBeenCalledTimes(300); + puts.mockRestore(); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + state: 'reconciling', + routing_cursor: domainRows[298]!.hostname, + bypass_application_id: expect.any(String), + }); + if (state === 'error') { + // A failed subsequent page retains the same durable bypass and cursor. + const originalGet = env.ROUTING.get.bind(env.ROUTING); + const getSpy = vi + .spyOn(env.ROUTING, 'get') + .mockImplementation((async (key: string | Array, ...args: Array) => + key === `domain:${domainRows[299]!.hostname}` + ? null + : Reflect.apply(originalGet, env.ROUTING, [key, ...args])) as typeof env.ROUTING.get); + await reconcilePendingProjectZeroTrust(platform, env.db); + getSpy.mockRestore(); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ state }); + } + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).rejects.toThrow( + 'public exception is still reconciling', + ); + await expect( + resolveProjectZeroTrustRoutingForPublication(env.db, project.id), + ).resolves.toBeNull(); + + await env.db.update(users).set({ suspended_reason: null }).where(eq(users.id, owner.id)); + const metadataPuts = vi.spyOn(env.ROUTING, 'put'); + await setProjectsRoutingSuspension(env.ROUTING, env.db, [{ ...project, user_id: owner.id }], { + op: 'unsuspend', + matchReason: null, + }); + expect(metadataPuts).toHaveBeenCalledTimes(keys.length); + metadataPuts.mockRestore(); + await reconcilePendingProjectZeroTrust(platform, env.db); + + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + routing_cursor: null, + }); + for (const key of keys) { + const published = JSON.parse((await env.ROUTING.get(key))!); + expect(published.version).toBe(deployment.id); + expect(published.zeroTrust).toBeUndefined(); + expect(published.suspended).toBeUndefined(); + } + }, + ); + + it('keeps public intent denied until its bypass handle is durable', async () => { + const owner = await createTestUser(env.db, 'missing-bypass-owner'); + const project = await createTestProject(env.db, owner.id, 'missing-bypass-project'); + vi.stubGlobal('fetch', accessApi().fetcher); + await configurePlatformZeroTrust(bindings(), env.db, configurationInput('public')); + await env.db + .update(projectZeroTrust) + .set({ bypass_application_id: null, state: 'reconciling' }) + .where(eq(projectZeroTrust.project_id, project.id)); + + expect(await resolveProjectZeroTrustRoutingForPublication(env.db, project.id)).toEqual({ + teamDomain: '', + audiences: [], + }); + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).rejects.toThrow( + 'public exception is still reconciling', + ); + }); +}); diff --git a/platform/packages/api/test/integration/zero-trust-service.test.ts b/platform/packages/api/test/integration/zero-trust-service.test.ts new file mode 100644 index 000000000..3b923bd65 --- /dev/null +++ b/platform/packages/api/test/integration/zero-trust-service.test.ts @@ -0,0 +1,551 @@ +import { eq, isNull } from 'drizzle-orm'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vite-plus/test'; +import type { ApiEnv } from '../../src/env.ts'; +import { FAST_CRON, scheduled } from '../../src/index.ts'; +import { + domains, + platformSettings, + projects as projectTable, + projectZeroTrust, +} from '../../src/schema.ts'; +import { readZeroTrustConfigurationStrict } from '../../src/zero-trust/configuration.ts'; +import { + cleanupProjectZeroTrust, + configurePlatformZeroTrust, + getProjectZeroTrustState, + initializeNewProjectZeroTrust, + resolveProjectZeroTrustRouting, + setProjectZeroTrustProtection, + zeroTrustStatus, +} from '../../src/zero-trust/service.ts'; +import { + clearTables, + createTestProject, + createTestUser, + setupEnv, + teardownEnv, + type TestEnv, +} from './helpers.ts'; + +let env: TestEnv; + +beforeAll(async () => { + env = await setupEnv(); +}); + +afterAll(async () => { + await teardownEnv(); +}); + +beforeEach(async () => { + await clearTables(env.db); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function accessApi(options?: { + beforeCreateApplication?: (application: Record) => Promise | void; +}) { + const applications = new Map>(); + let nextApplication = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const path = url.pathname.split('/access/')[1] ?? ''; + if (path === 'organizations') { + return Response.json({ + success: true, + result: { auth_domain: 'company.cloudflareaccess.com' }, + }); + } + if (path === 'identity_providers/idp-1') { + return Response.json({ success: true, result: { id: 'idp-1', name: 'Company SSO' } }); + } + if (path === 'policies/policy-1' || path === 'policies/policy-2') { + const id = path.split('/').at(-1)!; + return Response.json({ + success: true, + result: { + id, + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }, + }); + } + if (path === 'apps' && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + await options?.beforeCreateApplication?.(body); + const id = `app-${nextApplication++}`; + const application = { ...body, id, aud: `aud-${id}` }; + applications.set(id, application); + return Response.json({ success: true, result: application }); + } + if (path === 'apps') { + const page = Number(url.searchParams.get('page') ?? '1'); + const perPage = Number(url.searchParams.get('per_page') ?? '100'); + const start = (page - 1) * perPage; + return Response.json({ + success: true, + result: [...applications.values()].slice(start, start + perPage), + }); + } + const match = path.match(/^apps\/([^/]+)$/); + if (match && init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const previous = applications.get(match[1]!); + const application = { ...body, id: match[1], aud: previous?.aud ?? `aud-${match[1]}` }; + applications.set(match[1]!, application); + return Response.json({ success: true, result: application }); + } + if (match && init?.method === 'DELETE') { + applications.delete(match[1]!); + return Response.json({ success: true, result: { id: match[1] } }); + } + if (match) { + const application = applications.get(match[1]!); + return application + ? Response.json({ success: true, result: application }) + : Response.json( + { success: false, errors: [{ message: 'application not found' }] }, + { status: 404 }, + ); + } + throw new Error(`unexpected Cloudflare request: ${url}`); + }); + return { applications, fetcher }; +} + +describe('project Zero Trust reconciliation', () => { + it('fails closed when saved routing configuration is malformed', async () => { + const owner = await createTestUser(env.db, 'zero-trust-malformed-owner'); + const project = await createTestProject(env.db, owner.id, 'zero-trust-malformed-app'); + await env.db.insert(projectZeroTrust).values({ + project_id: project.id, + desired_protected: true, + effective_protected: true, + state: 'protected', + }); + await env.db.insert(platformSettings).values({ + key: 'zeroTrust.configuration', + value: { version: 999 }, + }); + + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).rejects.toThrow( + 'unsupported Zero Trust configuration version', + ); + }); + + it('rejects an existing-project selection on a resumed reconcile operation', async () => { + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const bindings = { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; + await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected', + }); + const configuration = (await readZeroTrustConfigurationStrict(env.db))!; + await env.db + .update(platformSettings) + .set({ + value: { + ...configuration, + status: 'configuring', + operation: { + kind: 'reconcile', + phase: 'seed', + owner: null, + leaseExpiresAt: null, + startedAt: new Date().toISOString(), + existingProjects: 'protected', + }, + }, + }) + .where(eq(platformSettings.key, 'zeroTrust.configuration')); + + await expect(readZeroTrustConfigurationStrict(env.db)).rejects.toThrow( + 'Zero Trust existing-project selection is inconsistent', + ); + }); + + it('blocks routing while enabled project protection state is missing or incomplete', async () => { + const owner = await createTestUser(env.db, 'zero-trust-routing-guard-owner'); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const bindings = { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; + await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected', + }); + + const missing = await createTestProject(env.db, owner.id, 'zero-trust-missing-state'); + await expect(resolveProjectZeroTrustRouting(env.db, missing.id)).rejects.toThrow( + 'still being initialized', + ); + + const incomplete = await createTestProject(env.db, owner.id, 'zero-trust-incomplete-state'); + await env.db.insert(projectZeroTrust).values({ + project_id: incomplete.id, + desired_protected: true, + effective_protected: false, + state: 'reconciling', + }); + await expect(resolveProjectZeroTrustRouting(env.db, incomplete.id)).rejects.toThrow( + 'still reconciling', + ); + }); + + it('keeps protected routing until the project disable batch processes it', async () => { + const owner = await createTestUser(env.db, 'zero-trust-disable-routing-owner'); + const project = await createTestProject(env.db, owner.id, 'zero-trust-disable-routing'); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const bindings = { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; + await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected', + }); + const configuration = (await readZeroTrustConfigurationStrict(env.db))!; + await env.db + .update(platformSettings) + .set({ + value: { + ...configuration, + status: 'disabling', + operation: { + kind: 'disable', + phase: 'projects', + owner: null, + leaseExpiresAt: null, + startedAt: new Date().toISOString(), + existingProjects: null, + }, + }, + }) + .where(eq(platformSettings.key, 'zeroTrust.configuration')); + + await expect(resolveProjectZeroTrustRouting(env.db, project.id)).resolves.toMatchObject({ + teamDomain: 'https://company.cloudflareaccess.com', + audiences: [ + { + hostname: '*.apps.example.com', + audience: expect.stringMatching(/^aud-app-/), + }, + ], + }); + }); + + it('reports wildcard drift when the platform application domain changes', async () => { + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const bindings = { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; + await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected', + }); + + await expect( + zeroTrustStatus({ ...bindings, SITE_DOMAIN: 'new.example.com' }, env.db), + ).resolves.toMatchObject({ live: { present: true, drifted: true } }); + }); + + // Exercises several persisted reconciliation batches across more than 100 projects. + it('bounds installation work and resumes it on the fast maintenance tier', async () => { + const owner = await createTestUser(env.db, 'zero-trust-bounded-owner'); + await env.db.insert(projectTable).values( + Array.from({ length: 101 }, (_, index) => ({ + id: `proj_bounded${index}`, + user_id: owner.id, + slug: `zero-trust-bounded-${index}`, + })), + ); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const bindings = { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; + + const initial = await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'public', + }); + expect(initial).toMatchObject({ + status: 'configuring', + operation: { kind: 'configure', phase: 'seed' }, + }); + const [unseeded] = await env.db + .select({ id: projectTable.id }) + .from(projectTable) + .leftJoin(projectZeroTrust, eq(projectZeroTrust.project_id, projectTable.id)) + .where(isNull(projectZeroTrust.project_id)) + .limit(1); + expect(unseeded).toBeDefined(); + await expect( + initializeNewProjectZeroTrust(bindings, env.db, unseeded!.id), + ).resolves.toMatchObject({ desired_protected: false, state: 'reconciling' }); + + const controller = { + cron: FAST_CRON, + scheduledTime: Date.now(), + } as ScheduledController; + const context = { + waitUntil() {}, + passThroughOnException() {}, + } as unknown as ExecutionContext; + await scheduled(controller, bindings, context); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + status: 'configuring', + operation: { kind: 'configure', phase: 'public-bypasses' }, + }); + + await scheduled(controller, bindings, context); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + status: 'configuring', + operation: { phase: 'projects' }, + }); + await scheduled(controller, bindings, context); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + status: 'ready', + operation: null, + }); + }, 10_000); + + it('creates public exceptions before the wildcard app and preserves owner opt-outs on reconfigure', async () => { + const owner = await createTestUser(env.db, 'zero-trust-owner'); + const protectedProject = await createTestProject(env.db, owner.id, 'protected-app'); + const publicProject = await createTestProject(env.db, owner.id, 'public-app'); + const cloudflare = accessApi(); + vi.stubGlobal('fetch', cloudflare.fetcher); + const bindings = { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; + + await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'public', + }); + + expect(await getProjectZeroTrustState(env.db, protectedProject.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + }); + expect(await getProjectZeroTrustState(env.db, publicProject.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + }); + expect( + [...cloudflare.applications.values()].filter((application) => + String(application.name).includes('-public-'), + ), + ).toHaveLength(2); + expect( + [...cloudflare.applications.values()].some((application) => + String(application.domain).startsWith('*.'), + ), + ).toBe(true); + expect( + [...cloudflare.applications.values()].find((application) => + String(application.domain).startsWith('*.'), + )?.name, + ).toMatch(/^void-installation-test-[a-f0-9]{16}-[a-f0-9]{32}-project-zero-trust$/); + + await setProjectZeroTrustProtection(bindings, env.db, protectedProject.id, true); + expect(await getProjectZeroTrustState(env.db, protectedProject.id)).toMatchObject({ + desired_protected: true, + effective_protected: true, + state: 'protected', + }); + + await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'replacement-access-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-2'], + protectNewProjects: false, + existingProjects: 'public', + }); + + expect(await getProjectZeroTrustState(env.db, protectedProject.id)).toMatchObject({ + desired_protected: true, + effective_protected: true, + state: 'protected', + }); + expect(await getProjectZeroTrustState(env.db, publicProject.id)).toMatchObject({ + desired_protected: false, + effective_protected: false, + state: 'public', + }); + expect( + [...cloudflare.applications.values()].find((application) => + String(application.domain).startsWith('*.'), + )?.policies, + ).toEqual([{ id: 'policy-2', precedence: 1 }]); + }); + + it('serializes a newer protected intent behind an in-flight public transition', async () => { + const owner = await createTestUser(env.db, 'zero-trust-race-owner'); + const project = await createTestProject(env.db, owner.id, 'zero-trust-race-app'); + await env.db.insert(domains).values({ + id: 'domain-zero-trust-race', + project_id: project.id, + hostname: 'race.example.com', + cf_hostname_id: 'cf-domain-zero-trust-race', + status: 'active', + }); + await env.ROUTING.put(project.slug, JSON.stringify({ worker: 'test' })); + await env.ROUTING.put('domain:race.example.com', JSON.stringify({ worker: 'test' })); + + let releaseBypassCreation = () => {}; + const bypassCreationReleased = new Promise((resolve) => { + releaseBypassCreation = resolve; + }); + let reportBypassCreation = () => {}; + const bypassCreationStarted = new Promise((resolve) => { + reportBypassCreation = resolve; + }); + const cloudflare = accessApi({ + beforeCreateApplication: async (application) => { + if (String(application.name).includes('-public-')) { + reportBypassCreation(); + await bypassCreationReleased; + } + }, + }); + vi.stubGlobal('fetch', cloudflare.fetcher); + const bindings = { + ...env, + SITE_DOMAIN: 'apps.example.com', + PLATFORM_INSTALLATION_ID: 'installation-test', + } as unknown as ApiEnv['Bindings']; + + await configurePlatformZeroTrust(bindings, env.db, { + accountId: env.CF_ACCOUNT_ID, + apiToken: 'access-management-token', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: true, + existingProjects: 'protected', + }); + + const publicTransition = setProjectZeroTrustProtection(bindings, env.db, project.id, false); + await bypassCreationStarted; + + const protectedTransition = setProjectZeroTrustProtection(bindings, env.db, project.id, true); + releaseBypassCreation(); + + await expect(publicTransition).resolves.toMatchObject({ state: 'public' }); + await expect(protectedTransition).resolves.toMatchObject({ state: 'protected' }); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + desired_protected: true, + effective_protected: true, + state: 'protected', + bypass_application_id: null, + bypass_application_name: null, + }); + expect( + [...cloudflare.applications.values()].some((application) => + String(application.name).includes('-public-'), + ), + ).toBe(false); + expect(JSON.parse((await env.ROUTING.get(project.slug))!)).toMatchObject({ + zeroTrust: { + teamDomain: 'https://company.cloudflareaccess.com', + audiences: [ + { + hostname: '*.apps.example.com', + audience: expect.stringMatching(/^aud-app-/), + }, + { + hostname: 'race.example.com', + audience: expect.stringMatching(/^aud-app-/), + }, + ], + }, + }); + expect(JSON.parse((await env.ROUTING.get('domain:race.example.com'))!)).toMatchObject({ + zeroTrust: { + audiences: expect.arrayContaining([ + { + hostname: 'race.example.com', + audience: expect.stringMatching(/^aud-app-/), + }, + ]), + }, + }); + }); + + it('preserves cleanup handles when the management configuration is unavailable', async () => { + const owner = await createTestUser(env.db, 'zero-trust-cleanup-owner'); + const project = await createTestProject(env.db, owner.id, 'zero-trust-cleanup-app'); + await env.db.insert(projectZeroTrust).values({ + project_id: project.id, + desired_protected: false, + effective_protected: false, + state: 'reconciling', + bypass_application_id: 'app-cleanup', + bypass_application_name: 'void-installation-public-cleanup', + }); + + await expect( + cleanupProjectZeroTrust(env as unknown as ApiEnv['Bindings'], env.db, project.id), + ).rejects.toThrow('managed Access applications cannot be safely deleted'); + expect(await getProjectZeroTrustState(env.db, project.id)).toMatchObject({ + bypass_application_id: 'app-cleanup', + }); + }); +}); diff --git a/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts b/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts index c5af04921..1cb17ea2a 100644 --- a/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts +++ b/platform/packages/api/test/unit/admin-upgrade-compatibility.test.ts @@ -149,3 +149,39 @@ it('allows upgrading the 0.21.2 schema after adding project cleanup jobs', () => schemaDigest, }); }); + +it('allows upgrading the 0.21.3 through 0.21.9 schema after adding project Zero Trust', () => { + const root = resolve(import.meta.dirname, '../../drizzle'); + const journal = JSON.parse(readFileSync(resolve(root, 'meta/_journal.json'), 'utf8')) as { + entries: Array<{ tag: string; idx: number; when: number }>; + }; + const compatibility = JSON.parse(readFileSync(resolve(root, 'compatibility.json'), 'utf8')) as { + lineage: string; + rollbackSafeFrom: Array<{ platformVersion: string; schemaDigest: string }>; + }; + const previous = journal.entries.filter((entry) => entry.idx <= 51); + expect(previous.at(-1)?.tag).toBe('0051_icy_psylocke'); + const schemaDigest = platformDatabaseSchemaDigest( + compatibility.lineage, + previous.map((entry) => ({ + index: entry.idx, + tag: entry.tag, + timestampMillis: entry.when, + sha256: createHash('sha256') + .update(readFileSync(resolve(root, `${entry.tag}.sql`))) + .digest('hex'), + })), + ); + expect(schemaDigest).toBe('ac70deb5847c71ece9938edd27e5dbc50ce41e16771926c78e757850baf15bfa'); + for (const platformVersion of [ + '0.21.3', + '0.21.4', + '0.21.5', + '0.21.6', + '0.21.7', + '0.21.8', + '0.21.9', + ]) { + expect(compatibility.rollbackSafeFrom).toContainEqual({ platformVersion, schemaDigest }); + } +}); diff --git a/platform/packages/api/test/unit/routing.test.ts b/platform/packages/api/test/unit/routing.test.ts index aa43cdf3e..cfc4cdf22 100644 --- a/platform/packages/api/test/unit/routing.test.ts +++ b/platform/packages/api/test/unit/routing.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vite-plus/test'; +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import * as dbModule from '../../src/db.ts'; import { applyRoutingSuspension, @@ -13,6 +13,7 @@ import { } from '../../src/routing.ts'; import { PLAN_LIMITS, type PlanLimits } from '../../src/tiers.ts'; import type { DeployManifest } from '../../src/types.ts'; +import * as zeroTrust from '../../src/zero-trust/service.ts'; function createKvStore(initial: Record = {}): { store: Map; @@ -74,6 +75,63 @@ describe('resolveRoutingUndo', () => { false, ); }); + + const oldProtection = { + teamDomain: 'company.cloudflareaccess.com', + audiences: [{ hostname: '*.apps.example.com', audience: 'old-audience' }], + }; + const newProtection = { + teamDomain: 'company.cloudflareaccess.com', + audiences: [{ hostname: '*.apps.example.com', audience: 'new-audience' }], + }; + + it.each([ + { change: 'addition', before: undefined, after: newProtection }, + { change: 'removal', before: oldProtection, after: undefined }, + { change: 'audience replacement', before: oldProtection, after: newProtection }, + ])('preserves Zero Trust $change when restoring the prior route', ({ before, after }) => { + const result = resolveRoutingUndo( + JSON.stringify({ deploymentId: 'b', zeroTrust: after, suspended: 'admin_suspended' }), + JSON.stringify({ deploymentId: 'b', zeroTrust: before }), + JSON.stringify({ deploymentId: 'a', zeroTrust: before }), + ); + expect(result.restore).toBe(true); + expect(JSON.parse(result.value!)).toEqual({ + deploymentId: 'a', + ...(after ? { zeroTrust: after } : {}), + suspended: 'admin_suspended', + }); + }); + + it.each([ + { workerName: 'new-worker' }, + { staticDeploymentId: 'new-static-deployment' }, + { version: 'new-deployment' }, + ])('does not overwrite newer deployment fields %j when protection also changes', (newFields) => { + const written = { + workerName: 'attempted-worker', + staticDeploymentId: 'attempted-static-deployment', + version: 'attempted-deployment', + zeroTrust: oldProtection, + }; + expect( + resolveRoutingUndo( + JSON.stringify({ ...written, ...newFields, zeroTrust: newProtection }), + JSON.stringify(written), + '{"workerName":"previous-worker"}', + ).restore, + ).toBe(false); + }); + + it('removes a failed first-deployment route even when its protection changed', () => { + expect( + resolveRoutingUndo( + JSON.stringify({ workerName: 'attempted-worker', zeroTrust: newProtection }), + JSON.stringify({ workerName: 'attempted-worker' }), + null, + ), + ).toEqual({ restore: true, value: null }); + }); }); describe('computeDispatchLimits', () => { @@ -369,6 +427,10 @@ describe('applyRoutingSuspension', () => { }); describe('setProjectsRoutingSuspension', () => { + beforeEach(() => { + vi.spyOn(zeroTrust, 'resolveProjectZeroTrustRoutingForPublication').mockResolvedValue(null); + }); + it('reads domains only after acquiring the project lock', async () => { const events: Array = []; const { kv } = createKvStore({ diff --git a/platform/packages/api/test/unit/usage-counter.test.ts b/platform/packages/api/test/unit/usage-counter.test.ts index ed69e2a81..0c568b3a0 100644 --- a/platform/packages/api/test/unit/usage-counter.test.ts +++ b/platform/packages/api/test/unit/usage-counter.test.ts @@ -38,6 +38,13 @@ vi.mock('../../src/db.ts', () => ({ getDomainsByProjectIds: vi.fn(async () => new Map()), })); +vi.mock('../../src/zero-trust/service.ts', async (importOriginal) => ({ + ...(await importOriginal()), + // These counter tests use public projects and a DB sentinel; the integration + // routing tests cover authoritative protection reads under the project lock. + resolveProjectZeroTrustRoutingForPublication: vi.fn(async () => null), +})); + import { getProject, getProjectOwnershipPeriodsByProjectIds, diff --git a/platform/packages/api/test/unit/zero-trust-cloudflare.test.ts b/platform/packages/api/test/unit/zero-trust-cloudflare.test.ts new file mode 100644 index 000000000..71f06effb --- /dev/null +++ b/platform/packages/api/test/unit/zero-trust-cloudflare.test.ts @@ -0,0 +1,523 @@ +import { describe, expect, it, vi } from 'vite-plus/test'; +import { + bypassPolicy, + reusablePolicyReferences, + ZeroTrustCloudflareClient, +} from '../../src/zero-trust/cloudflare.ts'; + +function envelope(result: unknown, status = 200): Response { + return Response.json( + status >= 400 + ? { success: false, errors: [{ message: 'not found' }] } + : { success: true, result }, + { status }, + ); +} + +function applicationSpec(name: string) { + return { + name, + destinations: ['project.apps.example.com'], + policies: bypassPolicy('Public project'), + }; +} + +function applicationFixture() { + const applications = new Map>(); + let nextId = 1; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const path = new URL(input.toString()).pathname; + const id = path.match(/\/apps\/([^/]+)$/)?.[1]; + if (path.endsWith('/apps') && !init?.method) { + return envelope([...applications.values()]); + } + if (id && init?.method === 'DELETE') { + return applications.delete(id) ? envelope({ id }) : envelope(null, 404); + } + if (init?.method === 'POST' || (id && init?.method === 'PUT')) { + const appId = id ?? `app-${nextId++}`; + const application = { + ...JSON.parse(String(init.body)), + id: appId, + aud: `aud-${appId}`, + }; + applications.set(appId, application); + return envelope(application); + } + if (id && !init?.method) { + const application = applications.get(id); + return application ? envelope(application) : envelope(null, 404); + } + throw new Error(`unexpected request: ${init?.method ?? 'GET'} ${path}`); + }); + return { + applications, + fetcher, + client: new ZeroTrustCloudflareClient('account-1', 'secret', fetcher as typeof fetch), + }; +} + +describe('ZeroTrustCloudflareClient', () => { + it('validates the organization, selected identity providers, and reusable policies', async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => { + const path = new URL(input.toString()).pathname; + if (path.endsWith('/organizations')) { + return envelope({ auth_domain: 'company.cloudflareaccess.com' }); + } + if (path.endsWith('/identity_providers/idp-1')) { + return envelope({ id: 'idp-1' }); + } + if (path.endsWith('/policies/policy-1')) { + return envelope({ + id: 'policy-1', + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }], + }); + } + throw new Error(`unexpected request: ${input}`); + }); + const client = new ZeroTrustCloudflareClient('account-1', 'secret', fetcher as typeof fetch); + + await expect( + client.validateSelection({ identityProviderIds: ['idp-1'], policyIds: ['policy-1'] }), + ).resolves.toEqual({ teamDomain: 'https://company.cloudflareaccess.com' }); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it('rejects policies that can bypass company identity', async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => { + const path = new URL(input.toString()).pathname; + if (path.endsWith('/organizations')) { + return envelope({ auth_domain: 'company.cloudflareaccess.com' }); + } + if (path.endsWith('/identity_providers/idp-1')) { + return envelope({ id: 'idp-1' }); + } + return envelope({ decision: 'allow', include: [{ everyone: {} }] }); + }); + const client = new ZeroTrustCloudflareClient('account-1', 'secret', fetcher as typeof fetch); + + await expect( + client.validateSelection({ identityProviderIds: ['idp-1'], policyIds: ['unsafe'] }), + ).rejects.toThrow('must not allow Everyone'); + }); + + it('rejects an allow policy with a non-identity alternative', async () => { + const fetcher = vi.fn(async (input: string | URL | Request) => { + const path = new URL(input.toString()).pathname; + if (path.endsWith('/organizations')) { + return envelope({ auth_domain: 'company.cloudflareaccess.com' }); + } + if (path.endsWith('/identity_providers/idp-1')) { + return envelope({ id: 'idp-1' }); + } + return envelope({ + decision: 'allow', + include: [{ email_domain: { domain: 'example.com' } }, { country: { country_code: 'US' } }], + }); + }); + const client = new ZeroTrustCloudflareClient('account-1', 'secret', fetcher as typeof fetch); + + await expect( + client.validateSelection({ identityProviderIds: ['idp-1'], policyIds: ['mixed'] }), + ).rejects.toThrow('must restrict company identities'); + }); + + it('creates, verifies, updates, and deletes a self-hosted application', async () => { + const applications = new Map>(); + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()); + const appMatch = url.pathname.match(/\/apps\/([^/]+)$/); + if (url.pathname.endsWith('/apps') && init?.method === 'POST') { + const body = JSON.parse(String(init.body)) as Record; + const app = { ...body, id: 'app-1', aud: 'aud-1' }; + applications.set('app-1', app); + return envelope(app); + } + if (url.pathname.endsWith('/apps')) { + return envelope([...applications.values()]); + } + if (appMatch && init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) as Record; + const app = { ...body, id: appMatch[1], aud: 'aud-1' }; + applications.set(appMatch[1]!, app); + return envelope(app); + } + if (appMatch && init?.method === 'DELETE') { + applications.delete(appMatch[1]!); + return envelope({ id: appMatch[1] }); + } + if (appMatch) { + const app = applications.get(appMatch[1]!); + return app ? envelope(app) : envelope(null, 404); + } + throw new Error(`unexpected request: ${input}`); + }); + const client = new ZeroTrustCloudflareClient('account-1', 'secret', fetcher as typeof fetch); + const spec = { + name: 'void-installation-project-zero-trust', + destinations: ['*.apps.example.com'], + allowedIdentityProviderIds: ['idp-1'], + policies: reusablePolicyReferences(['policy-1']), + }; + + const created = await client.ensureApplication(spec); + expect(created).toEqual({ + id: 'app-1', + name: spec.name, + audience: 'aud-1', + destinations: spec.destinations, + }); + const createCall = fetcher.mock.calls.find(([, init]) => init?.method === 'POST'); + expect(JSON.parse(String(createCall?.[1]?.body))).toMatchObject({ + type: 'self_hosted', + domain: '*.apps.example.com', + allowed_idps: ['idp-1'], + policies: [{ id: 'policy-1', precedence: 1 }], + }); + + await client.ensureApplication(spec, { id: created.id, name: created.name }); + expect(fetcher.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true); + await expect(client.inspectApplication(created, spec)).resolves.toEqual({ + present: true, + drifted: false, + }); + applications.set('app-1', { ...applications.get('app-1'), allowed_idps: ['other-idp'] }); + await expect(client.inspectApplication(created, spec)).resolves.toEqual({ + present: true, + drifted: true, + }); + applications.set('app-1', { + ...applications.get('app-1'), + allowed_idps: ['idp-1'], + destinations: [ + { type: 'public', uri: '*.apps.example.com' }, + { type: 'public', uri: 'unexpected.example.com' }, + ], + }); + await expect(client.inspectApplication(created, spec)).resolves.toEqual({ + present: true, + drifted: true, + }); + await client.deleteApplication(created.id, created.name); + expect(applications.size).toBe(0); + expect( + fetcher.mock.calls.filter( + ([input, init]) => new URL(input.toString()).pathname.endsWith('/apps') && !init?.method, + ), + ).toHaveLength(1); + }); + + it('recovers and deletes an application from a durable name checkpoint', async () => { + const application = { + id: 'created-before-checkpoint', + name: 'void-installation-public-project', + aud: 'aud-recovered', + domain: 'project.apps.example.com', + destinations: [{ type: 'public', uri: 'project.apps.example.com' }], + }; + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const path = new URL(input.toString()).pathname; + if (path.endsWith('/apps') && !init?.method) { + return envelope([application]); + } + if (path.endsWith(`/apps/${application.id}`) && init?.method === 'PUT') { + return envelope({ + ...JSON.parse(String(init.body)), + id: application.id, + aud: application.aud, + }); + } + if (path.endsWith(`/apps/${application.id}`) && init?.method === 'DELETE') { + return envelope({ id: application.id }); + } + throw new Error(`unexpected request: ${init?.method ?? 'GET'} ${path}`); + }); + const client = new ZeroTrustCloudflareClient('account-1', 'secret', fetcher as typeof fetch); + const recovered = await client.ensureApplication( + { + name: application.name, + destinations: ['project.apps.example.com'], + policies: bypassPolicy('Public project'), + }, + { name: application.name }, + ); + expect(recovered.id).toBe(application.id); + + await client.deleteApplication(null, application.name); + expect(fetcher.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(true); + }); + + it('rejects a mutation response that retains a stale protection policy', async () => { + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const path = new URL(input.toString()).pathname; + if (path.endsWith('/apps') && !init?.method) { + return envelope([]); + } + if (path.endsWith('/apps') && init?.method === 'POST') { + return envelope({ + ...JSON.parse(String(init.body)), + id: 'app-stale-policy', + aud: 'aud-stale-policy', + policies: [{ id: 'old-policy', precedence: 1 }], + }); + } + throw new Error(`unexpected request: ${init?.method ?? 'GET'} ${path}`); + }); + const client = new ZeroTrustCloudflareClient('account-1', 'secret', fetcher as typeof fetch); + + await expect( + client.ensureApplication({ + name: 'void-protected-project', + destinations: ['project.apps.example.com'], + allowedIdentityProviderIds: ['idp-1'], + policies: reusablePolicyReferences(['policy-1']), + }), + ).rejects.toThrow('did not retain the requested application configuration'); + }); + + it('does not adopt or mutate an unrecorded same-name application', async () => { + const fetcher = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const path = new URL(input.toString()).pathname; + if (path.endsWith('/apps') && !init?.method) { + return envelope([ + { + id: 'unowned-app', + name: 'void-installation-project-zero-trust', + aud: 'unowned-audience', + domain: '*.apps.example.com', + }, + ]); + } + throw new Error(`unexpected mutation: ${init?.method ?? 'GET'} ${path}`); + }); + const client = new ZeroTrustCloudflareClient('account-1', 'secret', fetcher as typeof fetch); + + await expect( + client.ensureApplication({ + name: 'void-installation-project-zero-trust', + destinations: ['*.apps.example.com'], + policies: reusablePolicyReferences(['policy-1']), + }), + ).rejects.toThrow('exists without a recorded Void creation intent'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + describe.each(['ensure', 'delete'] as const)( + '%s with concurrent application changes', + (operation) => { + it('reads a recorded ID created after an earlier inventory instead of treating it as absent', async () => { + const { applications, client, fetcher } = applicationFixture(); + await client.ensureApplication(applicationSpec('void-earlier-project')); + const spec = applicationSpec('void-concurrent-project'); + applications.set('concurrent-app', { id: 'concurrent-app', name: spec.name }); + fetcher.mockClear(); + + if (operation === 'ensure') { + await expect( + client.ensureApplication(spec, { id: 'concurrent-app', name: spec.name }), + ).resolves.toMatchObject({ id: 'concurrent-app' }); + expect(applications.size).toBe(2); + } else { + await client.deleteApplication('concurrent-app', spec.name); + expect(applications.has('concurrent-app')).toBe(false); + } + expect( + fetcher.mock.calls.map(([input, init]) => [ + new URL(input.toString()).pathname, + init?.method ?? 'GET', + ]), + ).toEqual([ + ['/client/v4/accounts/account-1/access/apps/concurrent-app', 'GET'], + [ + '/client/v4/accounts/account-1/access/apps/concurrent-app', + operation === 'ensure' ? 'PUT' : 'DELETE', + ], + ]); + }); + + it('recovers a durable name checkpoint from a fresh inventory', async () => { + const { applications, client, fetcher } = applicationFixture(); + await client.ensureApplication(applicationSpec('void-earlier-project')); + const spec = applicationSpec('void-concurrent-project'); + applications.set('checkpoint-app', { id: 'checkpoint-app', name: spec.name }); + fetcher.mockClear(); + + if (operation === 'ensure') { + await expect(client.ensureApplication(spec, { name: spec.name })).resolves.toMatchObject({ + id: 'checkpoint-app', + }); + expect(applications.size).toBe(2); + } else { + await client.deleteApplication(null, spec.name); + expect(applications.has('checkpoint-app')).toBe(false); + } + expect(fetcher).toHaveBeenCalledTimes(2); + expect(new URL(fetcher.mock.calls[0]![0].toString()).pathname).toMatch(/\/apps$/); + expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false); + }); + + it('does not recover an earlier name match after the application was renamed', async () => { + const { applications, client, fetcher } = applicationFixture(); + const spec = applicationSpec('void-project'); + const created = await client.ensureApplication(spec); + applications.set(created.id, { + ...applications.get(created.id), + name: 'unowned-application', + }); + fetcher.mockClear(); + + if (operation === 'ensure') { + const recovered = await client.ensureApplication(spec, { name: spec.name }); + expect(recovered.id).not.toBe(created.id); + } else { + await client.deleteApplication(null, spec.name); + } + expect(applications.get(created.id)?.name).toBe('unowned-application'); + expect( + fetcher.mock.calls.every( + ([input]) => !new URL(input.toString()).pathname.endsWith(`/apps/${created.id}`), + ), + ).toBe(true); + }); + + it('rejects ambiguous name recovery after another application acquires the same name', async () => { + const { applications, client, fetcher } = applicationFixture(); + const spec = applicationSpec('void-project'); + await client.ensureApplication(spec); + applications.set('duplicate-app', { id: 'duplicate-app', name: spec.name }); + fetcher.mockClear(); + + const result = + operation === 'ensure' + ? client.ensureApplication(spec, { name: spec.name }) + : client.deleteApplication(null, spec.name); + await expect(result).rejects.toThrow('Multiple Cloudflare Access applications'); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(applications.size).toBe(2); + }); + + it('preserves inventory errors during name checkpoint recovery', async () => { + const { client, fetcher } = applicationFixture(); + const spec = applicationSpec('void-project'); + await client.ensureApplication(spec); + fetcher.mockClear(); + fetcher.mockResolvedValueOnce(envelope(null, 429)); + + const result = + operation === 'ensure' + ? client.ensureApplication(spec, { name: spec.name }) + : client.deleteApplication(null, spec.name); + await expect(result).rejects.toThrow('HTTP 429'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it('refuses a recorded ID that was renamed after an earlier inventory', async () => { + const { applications, client, fetcher } = applicationFixture(); + const spec = applicationSpec('void-project'); + const created = await client.ensureApplication(spec); + applications.set(created.id, { + ...applications.get(created.id), + name: 'unowned-application', + }); + fetcher.mockClear(); + + const result = + operation === 'ensure' + ? client.ensureApplication(spec, created) + : client.deleteApplication(created.id, spec.name); + await expect(result).rejects.toThrow('no longer has its Void-owned name'); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls[0]![1]?.method).toBeUndefined(); + }); + + it("does not adopt a different application with a missing recorded ID's name", async () => { + const { applications, client, fetcher } = applicationFixture(); + const spec = applicationSpec('void-project'); + const created = await client.ensureApplication(spec); + applications.delete(created.id); + applications.set('replacement-app', { id: 'replacement-app', name: spec.name }); + fetcher.mockClear(); + + const result = + operation === 'ensure' + ? client.ensureApplication(spec, created) + : client.deleteApplication(created.id, spec.name); + await expect(result).rejects.toThrow('its name is now owned by another application'); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher.mock.calls.every(([, init]) => !init?.method)).toBe(true); + expect(applications.has('replacement-app')).toBe(true); + }); + + it('treats an authoritative 404 as an absent recorded application', async () => { + const { applications, client } = applicationFixture(); + const spec = applicationSpec('void-project'); + const created = await client.ensureApplication(spec); + applications.delete(created.id); + + if (operation === 'ensure') { + const recreated = await client.ensureApplication(spec, created); + expect(recreated.id).not.toBe(created.id); + expect(applications.size).toBe(1); + } else { + await expect(client.deleteApplication(created.id, spec.name)).resolves.toBeUndefined(); + expect(applications.size).toBe(0); + } + }); + + it.each([403, 429, 500])( + 'preserves provider failure HTTP %s instead of treating the ID as absent', + async (status) => { + const fetcher = vi.fn(async () => envelope(null, status)); + const client = new ZeroTrustCloudflareClient( + 'account-1', + 'secret', + fetcher as typeof fetch, + ); + const spec = applicationSpec('void-project'); + const result = + operation === 'ensure' + ? client.ensureApplication(spec, { id: 'recorded-app', name: spec.name }) + : client.deleteApplication('recorded-app', spec.name); + + await expect(result).rejects.toThrow(`HTTP ${status}`); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(new URL(fetcher.mock.calls[0]![0].toString()).pathname).toMatch( + /\/apps\/recorded-app$/, + ); + }, + ); + + it('rejects a read response for a different application ID', async () => { + const spec = applicationSpec('void-project'); + const fetcher = vi.fn(async () => envelope({ id: 'different-app', name: spec.name })); + const client = new ZeroTrustCloudflareClient( + 'account-1', + 'secret', + fetcher as typeof fetch, + ); + const result = + operation === 'ensure' + ? client.ensureApplication(spec, { id: 'recorded-app', name: spec.name }) + : client.deleteApplication('recorded-app', spec.name); + + await expect(result).rejects.toThrow('different Access application id'); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + }, + ); + + it('rejects an unowned same-name application added after an earlier inventory', async () => { + const { applications, client, fetcher } = applicationFixture(); + await client.ensureApplication(applicationSpec('void-earlier-project')); + const spec = applicationSpec('void-project'); + applications.set('unowned-app', { id: 'unowned-app', name: spec.name }); + fetcher.mockClear(); + + await expect(client.ensureApplication(spec)).rejects.toThrow( + 'without a recorded Void creation intent', + ); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(applications.size).toBe(2); + }); +}); diff --git a/platform/packages/api/test/unit/zero-trust-configuration.test.ts b/platform/packages/api/test/unit/zero-trust-configuration.test.ts new file mode 100644 index 000000000..5cdbda001 --- /dev/null +++ b/platform/packages/api/test/unit/zero-trust-configuration.test.ts @@ -0,0 +1,70 @@ +import type { PlatformApplication } from '@void/platform'; +import { platformProjectHostnames } from '@void/platform'; +import { describe, expect, it } from 'vite-plus/test'; +import { resolveAccessAudience } from '../../../dispatch/src/access.ts'; +import { zeroTrustWildcardDestinations } from '../../src/zero-trust/configuration.ts'; + +const application: PlatformApplication = { + baseDomain: 'my-account.workers.dev', + zoneName: '', + dedicatedZone: false, + workersDev: { subdomain: 'my-account', installationId: 'team-123456abcdef' }, +}; + +describe('Zero Trust wildcard destinations', () => { + it('keeps domain-only installations scoped to their application domain', () => { + expect( + zeroTrustWildcardDestinations({ + baseDomain: 'apps.example.com', + zoneName: 'example.com', + dedicatedZone: false, + }), + ).toEqual(['*.apps.example.com']); + }); + + it('protects project forwarders without covering other account Workers', () => { + const destinations = zeroTrustWildcardDestinations(application); + expect(destinations).toEqual(['void-123456abcdef-*.my-account.workers.dev']); + const routing = { + teamDomain: 'https://team.cloudflareaccess.com', + audiences: destinations.map((hostname) => ({ hostname, audience: 'project-aud' })), + }; + for (const hostname of platformProjectHostnames( + { id: 'proj_123abc', slug: 'blog' }, + application, + )) { + expect(resolveAccessAudience(hostname, routing)).toBe('project-aud'); + } + for (const hostname of [ + 'void-team-api.my-account.workers.dev', + 'void-team-proxy.my-account.workers.dev', + 'unrelated-worker.my-account.workers.dev', + 'void-fedcba654321-blog-123abc.my-account.workers.dev', + 'nested.void-123456abcdef-blog-123abc.my-account.workers.dev', + ]) { + expect(resolveAccessAudience(hostname, routing)).toBeNull(); + } + }); + + it('keeps existing project aliases protected when a domain becomes canonical', () => { + const attached = { ...application, baseDomain: 'apps.example.com', zoneName: 'example.com' }; + const destinations = zeroTrustWildcardDestinations(attached); + expect(destinations).toEqual([ + '*.apps.example.com', + 'void-123456abcdef-*.my-account.workers.dev', + ]); + const routing = { + teamDomain: 'https://team.cloudflareaccess.com', + audiences: destinations.map((hostname) => ({ hostname, audience: 'project-aud' })), + }; + const hostnames = platformProjectHostnames({ id: 'proj_123abc', slug: 'blog' }, attached); + expect(hostnames).toEqual([ + 'blog.apps.example.com', + 'void-123456abcdef-blog-123abc.my-account.workers.dev', + ]); + expect(hostnames.map((hostname) => resolveAccessAudience(hostname, routing))).toEqual([ + 'project-aud', + 'project-aud', + ]); + }); +}); diff --git a/platform/packages/dispatch/package.json b/platform/packages/dispatch/package.json index 3d6c5d53f..0c477e542 100644 --- a/platform/packages/dispatch/package.json +++ b/platform/packages/dispatch/package.json @@ -11,7 +11,8 @@ "@void/deploy-core": "workspace:*", "@void/edge": "workspace:*", "@void/isr": "workspace:*", - "@voidcloud/api": "workspace:*" + "@voidcloud/api": "workspace:*", + "jose": "6.2.12" }, "devDependencies": { "@cloudflare/workers-types": "catalog:platform", diff --git a/platform/packages/dispatch/src/access.ts b/platform/packages/dispatch/src/access.ts new file mode 100644 index 000000000..c2fb060fe --- /dev/null +++ b/platform/packages/dispatch/src/access.ts @@ -0,0 +1,98 @@ +import type { ProjectZeroTrustRouting } from '@void/edge'; +import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose'; + +function matchesHostname(pattern: string, hostname: string): boolean { + const expected = pattern.toLowerCase(); + const actual = hostname.toLowerCase(); + const wildcard = expected.indexOf('*'); + if (wildcard === -1) { + return expected === actual; + } + // Managed destinations use one wildcard at the end of the first label. + const prefix = expected.slice(0, wildcard); + const suffix = expected.slice(wildcard + 1); + if (prefix.includes('.') || !suffix.startsWith('.') || suffix.includes('*')) { + return false; + } + if (!actual.startsWith(prefix) || !actual.endsWith(suffix)) { + return false; + } + const label = actual.slice(0, -suffix.length); + return label.length > 0 && !label.includes('.'); +} + +export function resolveAccessAudience( + hostname: string, + routing: ProjectZeroTrustRouting, +): string | null { + const exact = routing.audiences.find( + (entry) => !entry.hostname.includes('*') && matchesHostname(entry.hostname, hostname), + ); + if (exact) { + return exact.audience; + } + return ( + routing.audiences.find((entry) => matchesHostname(entry.hostname, hostname))?.audience ?? null + ); +} + +export class ProjectAccessVerifier { + private readonly keys = new Map>(); + + constructor(private readonly request: typeof fetch = fetch) {} + + async verify( + hostname: string, + assertion: string | null, + routing: ProjectZeroTrustRouting, + ): Promise { + try { + const audience = resolveAccessAudience(hostname, routing); + if (!audience || !assertion || assertion.length > 32 * 1024) { + return false; + } + const teamDomain = routing.teamDomain.replace(/\/+$/, ''); + const url = new URL(teamDomain); + if ( + url.protocol !== 'https:' || + !url.hostname.endsWith('.cloudflareaccess.com') || + url.origin !== teamDomain + ) { + return false; + } + let keys = this.keys.get(teamDomain); + if (!keys) { + if (this.keys.size >= 16) { + this.keys.delete(this.keys.keys().next().value!); + } + keys = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`), { + timeoutDuration: 10_000, + [customFetch]: async (input, init) => { + const response = await this.request(input, { ...init, redirect: 'manual' }); + if (response.status >= 300 && response.status < 400) { + await response.body?.cancel(); + throw new Error('Access key endpoint redirected.'); + } + return response; + }, + }); + this.keys.set(teamDomain, keys); + } + const { payload } = await jwtVerify(assertion, keys, { + issuer: teamDomain, + audience, + algorithms: ['RS256'], + requiredClaims: ['iss', 'aud', 'iat', 'exp', 'type'], + }); + return ( + payload.type === 'app' && + typeof payload.iat === 'number' && + payload.iat <= Date.now() / 1000 + 30 + ); + } catch { + return false; + } + } +} + +export const projectAccessVerifier = new ProjectAccessVerifier(); diff --git a/platform/packages/dispatch/src/index.ts b/platform/packages/dispatch/src/index.ts index eefc0b88b..f74a05565 100644 --- a/platform/packages/dispatch/src/index.ts +++ b/platform/packages/dispatch/src/index.ts @@ -1,4 +1,6 @@ +import type { ProjectZeroTrustRouting } from '@void/edge'; import { resolvePlatformApplicationEnvironment } from '@void/platform'; +import { projectAccessVerifier } from './access.ts'; import type { Env } from './env.ts'; import { createErrorResponse } from './error-page.ts'; import { applyHeaderRules, type HeaderRule } from './headers.ts'; @@ -78,6 +80,7 @@ type RoutingEntry = Readonly<{ staticDeploymentId?: string; assetManifestId?: string; assetConfig?: AssetConfig; + zeroTrust?: ProjectZeroTrustRouting; }>; type QueueRoutingEntry = Readonly<{ @@ -685,6 +688,22 @@ export default { return createErrorResponse(request, 404, 'Not Found'); } + // Access normally rejects unauthenticated traffic before dispatch. This + // independent assertion check makes an accidentally deleted or drifted + // Access application fail closed instead of publishing the project. + if ( + routing.zeroTrust && + !(await projectAccessVerifier.verify( + url.hostname, + request.headers.get('Cf-Access-Jwt-Assertion'), + routing.zeroTrust, + )) + ) { + return createErrorResponse(request, 403, 'Cloudflare Access authentication required', { + 'Cache-Control': 'no-store', + }); + } + // Check if project is suspended if (routing.suspended) { return Response.json( diff --git a/platform/packages/dispatch/test/unit/access-cache.test.ts b/platform/packages/dispatch/test/unit/access-cache.test.ts new file mode 100644 index 000000000..12a3e69ec --- /dev/null +++ b/platform/packages/dispatch/test/unit/access-cache.test.ts @@ -0,0 +1,198 @@ +import { decodeJwt, exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vite-plus/test'; +import { ProjectAccessVerifier, projectAccessVerifier } from '../../src/access.ts'; +import type { Env } from '../../src/env.ts'; +import app from '../../src/index.ts'; +import { + isrCacheKey, + isrEdgeCacheKey, + packIsrValue, + SHARED_CACHE_POLICY_HEADER, + SHARED_CACHE_POLICY_VERSION, +} from '../../src/isr.ts'; + +const teamDomain = 'https://company.cloudflareaccess.com'; +const protection = { + teamDomain, + audiences: [{ hostname: '*.void.app', audience: 'project-audience' }], +}; +let jwk: Record; +let assertions: Record<'alice' | 'bob', string>; + +beforeAll(async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256', { extractable: true }); + jwk = { ...(await exportJWK(publicKey)), alg: 'RS256', kid: 'access-cache', use: 'sig' }; + const sign = (subject: string) => + new SignJWT({ type: 'app' }) + .setSubject(subject) + .setIssuer(teamDomain) + .setAudience('project-audience') + .setIssuedAt() + .setExpirationTime('10m') + .setProtectedHeader({ alg: 'RS256', kid: 'access-cache' }) + .sign(privateKey); + assertions = { alice: await sign('alice'), bob: await sign('bob') }; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function createHarness(mode: 'hashed' | 'isr') { + const edgeEntries = new Map(); + const edgeCache = { + match: vi.fn(async (request: Request) => edgeEntries.get(request.url)?.clone()), + put: vi.fn(async (request: Request, response: Response) => { + edgeEntries.set(request.url, response.clone()); + }), + delete: vi.fn(async (request: Request) => edgeEntries.delete(request.url)), + }; + vi.stubGlobal('caches', { default: edgeCache }); + const isrEntries = new Map(); + const isrCache = { + getWithMetadata: vi.fn( + async (key: string) => isrEntries.get(key) ?? { value: null, metadata: null }, + ), + put: vi.fn(async (key: string, value: ArrayBuffer, options?: { metadata?: unknown }) => { + isrEntries.set(key, { value, metadata: options?.metadata }); + }), + delete: vi.fn(async () => {}), + list: vi.fn(async () => ({ keys: [], list_complete: true, cursor: '' })), + }; + const origin = vi.fn(async (request: Request) => { + const assertion = request.headers.get('Cf-Access-Jwt-Assertion'); + const subject = assertion ? decodeJwt(assertion).sub : 'anonymous'; + // The app personalizes its response without sending any cache opt-out. + return new Response(`profile: ${subject}`, { + headers: { 'Content-Type': mode === 'isr' ? 'text/html' : 'application/json' }, + }); + }); + const routing = { + workerName: 'user-profile-deployment', + projectId: 'project-profile', + version: 'deployment', + ...(mode === 'hashed' + ? { hashedAssetsPrefix: 'assets' } + : { ssr: true, pages: [{ pattern: '/profile', revalidate: 60 }] }), + zeroTrust: undefined as typeof protection | undefined, + }; + const env = { + ROUTING: { get: async () => JSON.stringify(routing) }, + DISPATCH: { get: () => ({ fetch: origin }) }, + ISR_CACHE: isrCache, + ANALYTICS: { writeDataPoint() {} }, + } as unknown as Env; + const url = `https://profile.void.app${mode === 'hashed' ? '/assets/profile-a1.json' : '/profile'}`; + const request = async (subject?: keyof typeof assertions) => { + const work: Array> = []; + const response = await app.fetch( + new Request(url, { + headers: subject ? { 'Cf-Access-Jwt-Assertion': assertions[subject] } : {}, + }), + env, + { + waitUntil: (promise: Promise) => work.push(promise), + } as unknown as ExecutionContext, + ); + await Promise.all(work); + return response; + }; + return { edgeEntries, edgeCache, isrEntries, isrCache, origin, routing, request, url }; +} + +describe.each(['hashed', 'isr'] as const)('Access assertion isolation for %s responses', (mode) => { + it.each(['empty', 'populated'] as const)( + 'bypasses all shared cache reads and writes with %s caches', + async (cacheState) => { + const { edgeEntries, edgeCache, isrCache, origin, routing, request } = createHarness(mode); + if (cacheState === 'populated') { + expect(await (await request()).text()).toBe('profile: anonymous'); + expect(edgeEntries.size).toBe(1); + vi.clearAllMocks(); + } + routing.zeroTrust = protection; + const signingKeys = vi.fn(async () => Response.json({ keys: [jwk] })); + const verifier = new ProjectAccessVerifier(signingKeys as typeof fetch); + const verify = vi + .spyOn(projectAccessVerifier, 'verify') + .mockImplementation(verifier.verify.bind(verifier)); + + for (const subject of ['alice', 'bob'] as const) { + const response = await request(subject); + expect(response.status).toBe(200); + expect(await response.text()).toBe(`profile: ${subject}`); + expect(response.headers.get('x-isr-cache')).toBeNull(); + } + expect(verify).toHaveBeenCalledTimes(2); + expect(signingKeys).toHaveBeenCalledOnce(); + expect(origin).toHaveBeenCalledTimes(2); + for (const [request] of origin.mock.calls) { + expect(request.headers.has('Cookie')).toBe(false); + expect(request.headers.has('Authorization')).toBe(false); + } + for (const operation of [...Object.values(edgeCache), ...Object.values(isrCache)]) { + expect(operation).not.toHaveBeenCalled(); + } + expect(edgeEntries.size).toBe(cacheState === 'populated' ? 1 : 0); + for (const cached of edgeEntries.values()) { + expect(await cached.clone().text()).toBe('profile: anonymous'); + } + }, + ); + + it('still shares public responses without credentials', async () => { + const { edgeCache, isrCache, origin, request } = createHarness(mode); + + expect(await (await request()).text()).toBe('profile: anonymous'); + expect(await (await request()).text()).toBe('profile: anonymous'); + + expect(origin).toHaveBeenCalledOnce(); + expect(edgeCache.match).toHaveBeenCalledTimes(2); + expect(edgeCache.put).toHaveBeenCalledOnce(); + expect(isrCache.getWithMetadata).toHaveBeenCalledTimes(mode === 'isr' ? 1 : 0); + expect(isrCache.put).toHaveBeenCalledTimes(mode === 'isr' ? 1 : 0); + }); + + it('replaces edge responses admitted before Access assertions bypassed caching', async () => { + const { edgeEntries, origin, request, url } = createHarness(mode); + const key = + mode === 'hashed' ? url : isrEdgeCacheKey(new Request(url), '/profile', 'deployment').url; + edgeEntries.set( + key, + new Response('profile: alice', { + headers: { + 'Content-Type': mode === 'isr' ? 'text/html' : 'application/json', + 'Cache-Control': 'public, max-age=60', + [SHARED_CACHE_POLICY_HEADER]: '1', + ...(mode === 'isr' ? { 'x-isr-cache': 'HIT' } : {}), + }, + }), + ); + + expect(await (await request()).text()).toBe('profile: anonymous'); + expect(origin).toHaveBeenCalledOnce(); + expect(await edgeEntries.get(key)!.clone().text()).toBe('profile: anonymous'); + expect(edgeEntries.get(key)!.headers.get(SHARED_CACHE_POLICY_HEADER)).toBe( + String(SHARED_CACHE_POLICY_VERSION), + ); + }); +}); + +it('replaces ISR KV responses admitted before Access assertions bypassed caching', async () => { + const { isrEntries, isrCache, origin, request } = createHarness('isr'); + const key = isrCacheKey('project-profile', 'deployment', 'profile.void.app', '/profile'); + isrEntries.set(key, { + value: packIsrValue( + { 'content-type': 'text/html' }, + new TextEncoder().encode('profile: alice').buffer as ArrayBuffer, + ), + metadata: { s: 200, c: Date.now(), t: 60, p: 1 }, + }); + + expect(await (await request()).text()).toBe('profile: anonymous'); + expect(origin).toHaveBeenCalledOnce(); + expect(isrCache.getWithMetadata).toHaveBeenCalledOnce(); + expect(isrCache.put).toHaveBeenCalledOnce(); + expect(isrEntries.get(key)!.metadata).toMatchObject({ p: SHARED_CACHE_POLICY_VERSION }); +}); diff --git a/platform/packages/dispatch/test/unit/access.test.ts b/platform/packages/dispatch/test/unit/access.test.ts new file mode 100644 index 000000000..f71b072ed --- /dev/null +++ b/platform/packages/dispatch/test/unit/access.test.ts @@ -0,0 +1,152 @@ +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { beforeAll, describe, expect, it, vi } from 'vite-plus/test'; +import { ProjectAccessVerifier, resolveAccessAudience } from '../../src/access.ts'; + +const teamDomain = 'https://company.cloudflareaccess.com'; +let privateKey: CryptoKey; +let jwk: Record; + +beforeAll(async () => { + const pair = await generateKeyPair('RS256', { extractable: true }); + privateKey = pair.privateKey; + jwk = { ...(await exportJWK(pair.publicKey)), alg: 'RS256', kid: 'key-1', use: 'sig' }; +}); + +const routing = { + teamDomain, + audiences: [ + { hostname: '*.apps.example.com', audience: 'wildcard-aud' }, + { hostname: 'void-123456abcdef-*.my-account.workers.dev', audience: 'workers-dev-aud' }, + { hostname: 'custom.example.net', audience: 'custom-aud' }, + { hostname: 'custom.apps.example.com', audience: 'custom-aud' }, + { + hostname: 'void-123456abcdef-custom-123456abcdef.my-account.workers.dev', + audience: 'custom-aud', + }, + ], +}; + +async function assertion(audience = 'wildcard-aud', claims: Record = {}) { + return new SignJWT({ + iss: teamDomain, + aud: [audience], + sub: 'employee', + type: 'app', + iat: Math.floor(Date.now() / 1_000), + exp: Math.floor(Date.now() / 1_000) + 600, + ...claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: 'key-1' }) + .sign(privateKey); +} + +describe('project Access verification', () => { + it('matches exact destinations before one-label wildcards', () => { + expect(resolveAccessAudience('custom.example.net', routing)).toBe('custom-aud'); + expect(resolveAccessAudience('custom.apps.example.com', routing)).toBe('custom-aud'); + expect( + resolveAccessAudience( + 'void-123456abcdef-custom-123456abcdef.my-account.workers.dev', + routing, + ), + ).toBe('custom-aud'); + expect(resolveAccessAudience('project.apps.example.com', routing)).toBe('wildcard-aud'); + expect(resolveAccessAudience('nested.project.apps.example.com', routing)).toBeNull(); + expect(resolveAccessAudience('apps.example.com', routing)).toBeNull(); + }); + + it('matches the installation project prefix case-insensitively', () => { + expect( + resolveAccessAudience('void-123456abcdef-blog-789abc.my-account.workers.dev', routing), + ).toBe('workers-dev-aud'); + expect( + resolveAccessAudience('VOID-123456ABCDEF-BLOG-789ABC.MY-ACCOUNT.WORKERS.DEV', routing), + ).toBe('workers-dev-aud'); + }); + + it.each([ + 'void-team-api.my-account.workers.dev', + 'void-team-proxy.my-account.workers.dev', + 'unrelated-worker.my-account.workers.dev', + 'void-fedcba654321-blog-789abc.my-account.workers.dev', + 'void-123456abcdefother-blog-789abc.my-account.workers.dev', + 'other-void-123456abcdef-blog-789abc.my-account.workers.dev', + 'nested.void-123456abcdef-blog-789abc.my-account.workers.dev', + 'void-123456abcdef-blog.nested.my-account.workers.dev', + 'void-123456abcdef-blog-789abc.other-account.workers.dev', + 'void-123456abcdef-blog-789abc.my-account.workers.dev.attacker.test', + 'my-account.workers.dev', + ])('excludes hosts outside the project wildcard: %s', (hostname) => { + expect(resolveAccessAudience(hostname, routing)).toBeNull(); + }); + + it.each([ + 'void-*-*.my-account.workers.dev', + 'void-*.nested.*.workers.dev', + 'void.*.my-account.workers.dev', + ])('does not accept unsupported wildcard patterns: %s', (hostname) => { + expect( + resolveAccessAudience('void-123456abcdef-blog.my-account.workers.dev', { + teamDomain, + audiences: [{ hostname, audience: 'invalid-aud' }], + }), + ).toBeNull(); + }); + + it('verifies the scoped workers.dev audience', async () => { + const request = vi.fn(async () => Response.json({ keys: [jwk] })); + const verifier = new ProjectAccessVerifier(request as typeof fetch); + const hostname = 'void-123456abcdef-blog-789abc.my-account.workers.dev'; + + await expect( + verifier.verify(hostname, await assertion('workers-dev-aud'), routing), + ).resolves.toBe(true); + await expect(verifier.verify(hostname, await assertion(), routing)).resolves.toBe(false); + }); + + it('verifies Access app assertions and caches the signing keys', async () => { + const request = vi.fn(async () => Response.json({ keys: [jwk] })); + const verifier = new ProjectAccessVerifier(request as typeof fetch); + const token = await assertion(); + + await expect(verifier.verify('project.apps.example.com', token, routing)).resolves.toBe(true); + await expect(verifier.verify('project.apps.example.com', token, routing)).resolves.toBe(true); + expect(request).toHaveBeenCalledOnce(); + }); + + it('fails closed for absent, mismatched, future, or non-app assertions', async () => { + const request = vi.fn(async () => Response.json({ keys: [jwk] })); + const verifier = new ProjectAccessVerifier(request as typeof fetch); + + await expect(verifier.verify('project.apps.example.com', null, routing)).resolves.toBe(false); + await expect( + verifier.verify('project.apps.example.com', await assertion('another-aud'), routing), + ).resolves.toBe(false); + await expect( + verifier.verify( + 'project.apps.example.com', + await assertion('wildcard-aud', { iat: Math.floor(Date.now() / 1_000) + 120 }), + routing, + ), + ).resolves.toBe(false); + await expect( + verifier.verify( + 'project.apps.example.com', + await assertion('wildcard-aud', { type: 'org' }), + routing, + ), + ).resolves.toBe(false); + }); + + it('rejects redirects from the signing-key endpoint', async () => { + const request = vi.fn( + async () => + new Response(null, { status: 302, headers: { Location: 'https://attacker.test' } }), + ); + const verifier = new ProjectAccessVerifier(request as typeof fetch); + + await expect( + verifier.verify('project.apps.example.com', await assertion(), routing), + ).resolves.toBe(false); + }); +}); diff --git a/platform/packages/dispatch/test/unit/index.test.ts b/platform/packages/dispatch/test/unit/index.test.ts index ff2ae840d..791bb1eb4 100644 --- a/platform/packages/dispatch/test/unit/index.test.ts +++ b/platform/packages/dispatch/test/unit/index.test.ts @@ -53,6 +53,10 @@ type RoutingEntry = { redirectRules?: Array<{ source: string; destination: string; status: number; host?: string }>; fallbackRules?: Array<{ source: string; destination: string; status: number; host?: string }>; queryAllowlist?: Record>; + zeroTrust?: { + teamDomain: string; + audiences: Array<{ hostname: string; audience: string }>; + }; }; function createCtx(): ExecutionContext { @@ -199,6 +203,29 @@ function createEnv(options: { } describe('dispatch fetch handler', () => { + it('fails closed before dispatch when a protected route has no Access assertion', async () => { + const { env, workerFetch } = createEnv({ + routing: { + workerName: 'user-app', + projectId: 'proj_1', + zeroTrust: { + teamDomain: 'https://company.cloudflareaccess.com', + audiences: [{ hostname: '*.void.app', audience: 'project-audience' }], + }, + }, + }); + + const response = await app.fetch( + new Request('https://my-app.void.app/private'), + env, + createCtx(), + ); + + expect(response.status).toBe(403); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(workerFetch).not.toHaveBeenCalled(); + }); + it('forwards /api requests to user worker', async () => { const { env, workerFetch } = createEnv({ routing: { workerName: 'user-app', projectId: 'proj_1' }, diff --git a/platform/packages/dispatch/test/unit/isr.test.ts b/platform/packages/dispatch/test/unit/isr.test.ts index b369865ec..8d0c595e3 100644 --- a/platform/packages/dispatch/test/unit/isr.test.ts +++ b/platform/packages/dispatch/test/unit/isr.test.ts @@ -538,6 +538,16 @@ describe('shouldBypassIsr', () => { expect(shouldBypassIsr(request)).toBe(true); }); + it.each(['signed-access-assertion', ''])( + 'returns true when Cf-Access-Jwt-Assertion is present: %j', + (assertion) => { + const request = new Request('https://example.com', { + headers: { 'Cf-Access-Jwt-Assertion': assertion }, + }); + expect(shouldBypassIsr(request)).toBe(true); + }, + ); + it('returns false for X-VoidPages header (uses JSON edge cache instead)', () => { const request = new Request('https://example.com', { headers: { 'X-VoidPages': 'true' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64bdb6ce8..8df4e8132 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -794,6 +794,9 @@ importers: '@void/deploy-core': specifier: workspace:* version: link:../../../packages/deploy-core + '@void/edge': + specifier: workspace:* + version: link:../../../packages/edge '@void/isr': specifier: workspace:* version: link:../../../packages/isr @@ -953,6 +956,9 @@ importers: '@voidcloud/api': specifier: workspace:* version: link:../api + jose: + specifier: 6.2.12 + version: 6.2.12 devDependencies: '@cloudflare/workers-types': specifier: catalog:platform From 2e5dd858cc504d6b2744a14512075411a4c1f065 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 30 Sep 2026 13:26:35 +0800 Subject: [PATCH 02/25] feat(platform): add Zero Trust pages to the React admin Co-Authored-By: Claude Opus 5.5 (1M context) --- platform/packages/api/README.md | 1 + .../api/src/admin/fate/client.generated.ts | 9 +- .../packages/api/src/admin/fate/page-data.ts | 75 +++- .../api/src/admin/fate/page-server.ts | 44 +++ .../packages/api/src/admin/fate/render.ts | 7 +- .../packages/api/src/admin/fate/server.ts | 1 + platform/packages/api/src/admin/fate/views.ts | 24 ++ .../admin/react/AdminNavigationFallback.tsx | 1 + .../packages/api/src/admin/react/pages.ts | 4 + .../packages/api/src/admin/react/styles.css | 68 ++++ .../api/src/admin/react/views/Layout.tsx | 7 + .../admin/react/views/ProjectDetailPage.tsx | 4 + .../react/views/ProjectZeroTrustSection.tsx | 143 ++++++++ .../src/admin/react/views/ZeroTrustPage.tsx | 270 ++++++++++++++ .../api/src/admin/routes/projects.tsx | 4 +- .../api/src/admin/routes/zero-trust.tsx | 233 +----------- .../packages/api/src/zero-trust/service.ts | 10 + .../test/integration/admin-dashboard.test.ts | 345 +++++++++++++++--- .../admin-zero-trust-actions.test.ts | 98 +++++ .../unit/admin-navigation-fallback.test.ts | 28 ++ 20 files changed, 1100 insertions(+), 276 deletions(-) create mode 100644 platform/packages/api/src/admin/react/views/ProjectZeroTrustSection.tsx create mode 100644 platform/packages/api/src/admin/react/views/ZeroTrustPage.tsx create mode 100644 platform/packages/api/test/integration/admin-zero-trust-actions.test.ts diff --git a/platform/packages/api/README.md b/platform/packages/api/README.md index bb1b158e3..cdef657bf 100644 --- a/platform/packages/api/README.md +++ b/platform/packages/api/README.md @@ -354,6 +354,7 @@ Notable sections: - per-project Builds section (ongoing build highlighted) plus a build detail page with a client-side polling live-log tail for container builds (mirrors the deployment live-tail; not a WebSocket), or a GitHub Actions link for `github_actions` builds. The tail polls `GET /admin/builds/:id/logs?since=N`, which fronts the `BuildManager`/`BuildManagerLarge` DO's read-only `GET /logs?since=N` (DO chosen by `instance_type` via `buildManagerNamespace`). - per-project email page (linked from the project detail view): usage, caps, inbound/outbound suspension, destinations - signup restrictions and allowlists +- project Zero Trust settings, reconcile, and disable, plus a per-project Zero Trust section on the project detail page (reads saved state only, no live Cloudflare calls) - CLI version deployment history Admin writes are same-origin guarded: `admin/middleware/auth.ts` rejects any non-`GET` request whose `Origin`/`Referer` host does not match the request host, before the cookie is even read. diff --git a/platform/packages/api/src/admin/fate/client.generated.ts b/platform/packages/api/src/admin/fate/client.generated.ts index 7deb563dc..36a547f8e 100644 --- a/platform/packages/api/src/admin/fate/client.generated.ts +++ b/platform/packages/api/src/admin/fate/client.generated.ts @@ -1,5 +1,5 @@ // @generated by @nkzw/fate/vite -import type { AdminAuthEdit, AdminAuthNew, AdminAuthSettings, AdminBuildDetail, AdminBuildLogs, AdminBuildStatus, AdminCliVersions, AdminDashboard, AdminDeploymentDetail, AdminDeploymentLogs, AdminDeploymentsPage, AdminEmailProject, AdminEmailSettings, AdminHealth, AdminInvitations, AdminProjectDetail, AdminProjectsPage, AdminSignup, AdminTopProject, AdminUserDetail, AdminUsersPage, fateServer } from './server.ts'; +import type { AdminAuthEdit, AdminAuthNew, AdminAuthSettings, AdminBuildDetail, AdminBuildLogs, AdminBuildStatus, AdminCliVersions, AdminDashboard, AdminDeploymentDetail, AdminDeploymentLogs, AdminDeploymentsPage, AdminEmailProject, AdminEmailSettings, AdminHealth, AdminInvitations, AdminProjectDetail, AdminProjectsPage, AdminSignup, AdminTopProject, AdminUserDetail, AdminUsersPage, AdminZeroTrust, fateServer } from './server.ts'; import { clientRoot, createClient, createHTTPTransport, mutation, type InferFateAPI } from 'react-fate'; @@ -31,6 +31,7 @@ const roots = { 'adminTopProject': clientRoot, 'AdminTopProject'>('AdminTopProject'), 'adminUserDetail': clientRoot, 'AdminUserDetail'>('AdminUserDetail'), 'adminUsersPage': clientRoot, 'AdminUsersPage'>('AdminUsersPage'), + 'adminZeroTrust': clientRoot, 'AdminZeroTrust'>('AdminZeroTrust'), 'authEdit': clientRoot('AdminAuthEdit'), 'authNew': clientRoot('AdminAuthNew'), 'authSettings': clientRoot('AdminAuthSettings'), @@ -51,11 +52,12 @@ const roots = { 'signupControl': clientRoot('AdminSignup'), 'userDetail': clientRoot('AdminUserDetail'), 'usersPage': clientRoot('AdminUsersPage'), + 'zeroTrust': clientRoot('AdminZeroTrust'), } as const; export type GeneratedClientMutations = typeof mutations; export type GeneratedClientRoots = typeof roots; -const hydrationScope = "{\"moduleName\":\"./server.ts\",\"roots\":{\"authEdit\":{\"kind\":\"query\",\"procedure\":\"authEdit\",\"router\":\"adminAuthEdit\",\"type\":\"AdminAuthEdit\"},\"authNew\":{\"kind\":\"query\",\"procedure\":\"authNew\",\"router\":\"adminAuthNew\",\"type\":\"AdminAuthNew\"},\"authSettings\":{\"kind\":\"query\",\"procedure\":\"authSettings\",\"router\":\"adminAuthSettings\",\"type\":\"AdminAuthSettings\"},\"buildDetail\":{\"kind\":\"query\",\"procedure\":\"buildDetail\",\"router\":\"adminBuildDetail\",\"type\":\"AdminBuildDetail\"},\"buildLogs\":{\"kind\":\"query\",\"procedure\":\"buildLogs\",\"router\":\"adminBuildLogs\",\"type\":\"AdminBuildLogs\"},\"buildStatus\":{\"kind\":\"query\",\"procedure\":\"buildStatus\",\"router\":\"adminBuildStatus\",\"type\":\"AdminBuildStatus\"},\"cliVersions\":{\"kind\":\"query\",\"procedure\":\"cliVersions\",\"router\":\"adminCliVersions\",\"type\":\"AdminCliVersions\"},\"dashboard\":{\"kind\":\"query\",\"procedure\":\"dashboard\",\"router\":\"adminDashboard\",\"type\":\"AdminDashboard\"},\"deploymentDetail\":{\"kind\":\"query\",\"procedure\":\"deploymentDetail\",\"router\":\"adminDeploymentDetail\",\"type\":\"AdminDeploymentDetail\"},\"deploymentLogs\":{\"kind\":\"query\",\"procedure\":\"deploymentLogs\",\"router\":\"adminDeploymentLogs\",\"type\":\"AdminDeploymentLogs\"},\"deploymentsPage\":{\"kind\":\"query\",\"procedure\":\"deploymentsPage\",\"router\":\"adminDeploymentsPage\",\"type\":\"AdminDeploymentsPage\"},\"emailProject\":{\"kind\":\"query\",\"procedure\":\"emailProject\",\"router\":\"adminEmailProject\",\"type\":\"AdminEmailProject\"},\"emailSettings\":{\"kind\":\"query\",\"procedure\":\"emailSettings\",\"router\":\"adminEmailSettings\",\"type\":\"AdminEmailSettings\"},\"health\":{\"kind\":\"query\",\"procedure\":\"health\",\"router\":\"adminHealth\",\"type\":\"AdminHealth\"},\"invitations\":{\"kind\":\"query\",\"procedure\":\"invitations\",\"router\":\"adminInvitations\",\"type\":\"AdminInvitations\"},\"projectDetail\":{\"kind\":\"query\",\"procedure\":\"projectDetail\",\"router\":\"adminProjectDetail\",\"type\":\"AdminProjectDetail\"},\"projectsPage\":{\"kind\":\"query\",\"procedure\":\"projectsPage\",\"router\":\"adminProjectsPage\",\"type\":\"AdminProjectsPage\"},\"signupControl\":{\"kind\":\"query\",\"procedure\":\"signupControl\",\"router\":\"adminSignup\",\"type\":\"AdminSignup\"},\"userDetail\":{\"kind\":\"query\",\"procedure\":\"userDetail\",\"router\":\"adminUserDetail\",\"type\":\"AdminUserDetail\"},\"usersPage\":{\"kind\":\"query\",\"procedure\":\"usersPage\",\"router\":\"adminUsersPage\",\"type\":\"AdminUsersPage\"}},\"types\":[{\"type\":\"AdminAuthEdit\"},{\"type\":\"AdminAuthNew\"},{\"type\":\"AdminAuthSettings\"},{\"type\":\"AdminBuildDetail\"},{\"type\":\"AdminBuildLogs\"},{\"type\":\"AdminBuildStatus\"},{\"type\":\"AdminCliVersions\"},{\"fields\":{\"topProjects\":{\"listOf\":\"AdminTopProject\"}},\"type\":\"AdminDashboard\"},{\"type\":\"AdminDeploymentDetail\"},{\"type\":\"AdminDeploymentLogs\"},{\"type\":\"AdminDeploymentsPage\"},{\"type\":\"AdminEmailProject\"},{\"type\":\"AdminEmailSettings\"},{\"type\":\"AdminHealth\"},{\"type\":\"AdminInvitations\"},{\"type\":\"AdminProjectDetail\"},{\"type\":\"AdminProjectsPage\"},{\"type\":\"AdminSignup\"},{\"type\":\"AdminTopProject\"},{\"type\":\"AdminUserDetail\"},{\"type\":\"AdminUsersPage\"}]}" as const; +const hydrationScope = "{\"moduleName\":\"./server.ts\",\"roots\":{\"authEdit\":{\"kind\":\"query\",\"procedure\":\"authEdit\",\"router\":\"adminAuthEdit\",\"type\":\"AdminAuthEdit\"},\"authNew\":{\"kind\":\"query\",\"procedure\":\"authNew\",\"router\":\"adminAuthNew\",\"type\":\"AdminAuthNew\"},\"authSettings\":{\"kind\":\"query\",\"procedure\":\"authSettings\",\"router\":\"adminAuthSettings\",\"type\":\"AdminAuthSettings\"},\"buildDetail\":{\"kind\":\"query\",\"procedure\":\"buildDetail\",\"router\":\"adminBuildDetail\",\"type\":\"AdminBuildDetail\"},\"buildLogs\":{\"kind\":\"query\",\"procedure\":\"buildLogs\",\"router\":\"adminBuildLogs\",\"type\":\"AdminBuildLogs\"},\"buildStatus\":{\"kind\":\"query\",\"procedure\":\"buildStatus\",\"router\":\"adminBuildStatus\",\"type\":\"AdminBuildStatus\"},\"cliVersions\":{\"kind\":\"query\",\"procedure\":\"cliVersions\",\"router\":\"adminCliVersions\",\"type\":\"AdminCliVersions\"},\"dashboard\":{\"kind\":\"query\",\"procedure\":\"dashboard\",\"router\":\"adminDashboard\",\"type\":\"AdminDashboard\"},\"deploymentDetail\":{\"kind\":\"query\",\"procedure\":\"deploymentDetail\",\"router\":\"adminDeploymentDetail\",\"type\":\"AdminDeploymentDetail\"},\"deploymentLogs\":{\"kind\":\"query\",\"procedure\":\"deploymentLogs\",\"router\":\"adminDeploymentLogs\",\"type\":\"AdminDeploymentLogs\"},\"deploymentsPage\":{\"kind\":\"query\",\"procedure\":\"deploymentsPage\",\"router\":\"adminDeploymentsPage\",\"type\":\"AdminDeploymentsPage\"},\"emailProject\":{\"kind\":\"query\",\"procedure\":\"emailProject\",\"router\":\"adminEmailProject\",\"type\":\"AdminEmailProject\"},\"emailSettings\":{\"kind\":\"query\",\"procedure\":\"emailSettings\",\"router\":\"adminEmailSettings\",\"type\":\"AdminEmailSettings\"},\"health\":{\"kind\":\"query\",\"procedure\":\"health\",\"router\":\"adminHealth\",\"type\":\"AdminHealth\"},\"invitations\":{\"kind\":\"query\",\"procedure\":\"invitations\",\"router\":\"adminInvitations\",\"type\":\"AdminInvitations\"},\"projectDetail\":{\"kind\":\"query\",\"procedure\":\"projectDetail\",\"router\":\"adminProjectDetail\",\"type\":\"AdminProjectDetail\"},\"projectsPage\":{\"kind\":\"query\",\"procedure\":\"projectsPage\",\"router\":\"adminProjectsPage\",\"type\":\"AdminProjectsPage\"},\"signupControl\":{\"kind\":\"query\",\"procedure\":\"signupControl\",\"router\":\"adminSignup\",\"type\":\"AdminSignup\"},\"userDetail\":{\"kind\":\"query\",\"procedure\":\"userDetail\",\"router\":\"adminUserDetail\",\"type\":\"AdminUserDetail\"},\"usersPage\":{\"kind\":\"query\",\"procedure\":\"usersPage\",\"router\":\"adminUsersPage\",\"type\":\"AdminUsersPage\"},\"zeroTrust\":{\"kind\":\"query\",\"procedure\":\"zeroTrust\",\"router\":\"adminZeroTrust\",\"type\":\"AdminZeroTrust\"}},\"types\":[{\"type\":\"AdminAuthEdit\"},{\"type\":\"AdminAuthNew\"},{\"type\":\"AdminAuthSettings\"},{\"type\":\"AdminBuildDetail\"},{\"type\":\"AdminBuildLogs\"},{\"type\":\"AdminBuildStatus\"},{\"type\":\"AdminCliVersions\"},{\"fields\":{\"topProjects\":{\"listOf\":\"AdminTopProject\"}},\"type\":\"AdminDashboard\"},{\"type\":\"AdminDeploymentDetail\"},{\"type\":\"AdminDeploymentLogs\"},{\"type\":\"AdminDeploymentsPage\"},{\"type\":\"AdminEmailProject\"},{\"type\":\"AdminEmailSettings\"},{\"type\":\"AdminHealth\"},{\"type\":\"AdminInvitations\"},{\"type\":\"AdminProjectDetail\"},{\"type\":\"AdminProjectsPage\"},{\"type\":\"AdminSignup\"},{\"type\":\"AdminTopProject\"},{\"type\":\"AdminUserDetail\"},{\"type\":\"AdminUsersPage\"},{\"type\":\"AdminZeroTrust\"}]}" as const; export const createFateClient = (options: { fetch?: typeof fetch; @@ -138,6 +140,9 @@ export const createFateClient = (options: { { type: 'AdminEmailProject', }, + { + type: 'AdminZeroTrust', + }, { type: 'AdminBuildLogs', }, diff --git a/platform/packages/api/src/admin/fate/page-data.ts b/platform/packages/api/src/admin/fate/page-data.ts index b8e18e985..9936508c4 100644 --- a/platform/packages/api/src/admin/fate/page-data.ts +++ b/platform/packages/api/src/admin/fate/page-data.ts @@ -23,6 +23,7 @@ import { searchProjects, searchUsers, } from '../../db.ts'; +import type { Db } from '../../db.ts'; import type { ApiEnv } from '../../env.ts'; import { OperatorError } from '../../operator/common.ts'; import { cliVersionStats } from '../../operator/system-operations.ts'; @@ -148,14 +149,46 @@ export async function loadUserDetailPage(context: AdminContext, path: string) { }; } +/** + * Saved Zero Trust state for the project detail page. It reads only the + * database, because page navigations load it often; the live Access check + * belongs to the Zero Trust page. + */ +async function loadProjectZeroTrust(db: Db, projectId: string) { + const { ZeroTrustConfigurationError, readZeroTrustConfigurationStrict } = + await import('../../zero-trust/configuration.ts'); + const { getProjectZeroTrustState, projectZeroTrustAvailable, publicProjectZeroTrustState } = + await import('../../zero-trust/service.ts'); + const [row, configuration] = await Promise.all([ + getProjectZeroTrustState(db, projectId), + readZeroTrustConfigurationStrict(db).catch((error: unknown) => { + if (error instanceof ZeroTrustConfigurationError) { + return 'invalid' as const; + } + throw error; + }), + ]); + const saved = configuration === 'invalid' ? null : configuration; + return { + configured: configuration !== null, + invalid: configuration === 'invalid', + enabled: saved?.enabled ?? false, + status: saved?.status ?? null, + operation: saved?.operation?.kind ?? null, + available: projectZeroTrustAvailable(saved), + state: publicProjectZeroTrustState(row), + }; +} + export async function loadProjectDetailPage(context: AdminContext, path: string) { const { id } = detailLocation(path, '/admin/projects/'); const { getOperatorProject } = await import('../../operator/tenant-service.ts'); const db = context.get('db'); - const [detail, members, invitations] = await Promise.all([ + const [detail, members, invitations, zeroTrust] = await Promise.all([ getOperatorProject(db, id), getProjectMembers(db, id), getPendingProjectInvitations(db, id), + loadProjectZeroTrust(db, id), ]); return { id: path, @@ -172,6 +205,7 @@ export async function loadProjectDetailPage(context: AdminContext, path: string) role: invitation.role, expiresAt: invitation.expires_at, })), + zeroTrust, cfAccountId: context.env.CF_ACCOUNT_ID, deployUrl: projectUrl(detail.project, context.env), tz: (context.req.raw.cf?.timezone as string | undefined) ?? undefined, @@ -398,3 +432,42 @@ export async function loadEmailProjectPage(context: AdminContext, path: string) const { loadEmailProjectData } = await import('../routes/email.tsx'); return { id: path, ...(await loadEmailProjectData(context, id)) }; } + +/** + * A fixed, redacted view of the Zero Trust status. It leaves out the stored + * credential, the configuration revision, and the managed Access application + * identifiers. + */ +export async function loadZeroTrustPage(context: AdminContext, path: string) { + pageLocation(path, '/admin/zero-trust'); + const { zeroTrustStatus } = await import('../../zero-trust/service.ts'); + const status = await zeroTrustStatus(context.env, context.get('db')); + const saved = 'accountId' in status ? status : null; + return { + id: path, + configured: status.configured, + enabled: status.enabled, + status: 'status' in status ? status.status : null, + accountId: saved?.accountId ?? null, + defaultAccountId: context.env.CF_ACCOUNT_ID, + teamDomain: saved?.teamDomain ?? null, + identityProviderIds: [...(saved?.identityProviderIds ?? [])], + policyIds: [...(saved?.policyIds ?? [])], + protectNewProjects: saved?.protectNewProjects ?? null, + operation: saved?.operation + ? { + kind: saved.operation.kind, + phase: saved.operation.phase, + existingProjects: saved.operation.existingProjects, + } + : null, + lastError: 'lastError' in status ? status.lastError : null, + live: status.live ? { present: status.live.present, drifted: status.live.drifted } : null, + projects: status.projects.map((entry) => ({ + desiredProtected: entry.desiredProtected, + effectiveProtected: entry.effectiveProtected, + state: entry.state, + count: entry.count, + })), + }; +} diff --git a/platform/packages/api/src/admin/fate/page-server.ts b/platform/packages/api/src/admin/fate/page-server.ts index c5cd8a6e5..5dbfd2420 100644 --- a/platform/packages/api/src/admin/fate/page-server.ts +++ b/platform/packages/api/src/admin/fate/page-server.ts @@ -25,6 +25,7 @@ import { loadSignupPage, loadUserDetailPage, loadUsersPage, + loadZeroTrustPage, } from './page-data.ts'; type AdminContext = Context; @@ -43,6 +44,7 @@ type AuthNewRecord = Awaited>; type AuthEditRecord = Awaited>; type EmailSettingsRecord = Awaited>; type EmailProjectRecord = Awaited>; +type ZeroTrustRecord = Awaited>; export const usersPageDataView = dataView('AdminUsersPage')({ id: true, @@ -87,6 +89,7 @@ export const projectDetailDataView = dataView('AdminProject pendingTeamInvitations: true, domains: true, builds: true, + zeroTrust: true, cfAccountId: true, deployUrl: true, tz: true, @@ -176,6 +179,22 @@ export const emailProjectDataView = dataView('AdminEmailProj senderDomain: true, tz: true, }); +export const zeroTrustDataView = dataView('AdminZeroTrust')({ + id: true, + configured: true, + enabled: true, + status: true, + accountId: true, + defaultAccountId: true, + teamDomain: true, + identityProviderIds: true, + policyIds: true, + protectNewProjects: true, + operation: true, + lastError: true, + live: true, + projects: true, +}); export type AdminUsersPage = Entity; export type AdminProjectsPage = Entity; @@ -195,6 +214,7 @@ export type AdminAuthNew = Entity; export type AdminAuthEdit = Entity; export type AdminEmailSettings = Entity; export type AdminEmailProject = Entity; +export type AdminZeroTrust = Entity; const usersSource: SourceDefinition = { id: 'id', view: usersPageDataView }; const projectsSource: SourceDefinition = { @@ -241,6 +261,7 @@ const emailProjectSource: SourceDefinition = { id: 'id', view: emailProjectDataView, }; +const zeroTrustSource: SourceDefinition = { id: 'id', view: zeroTrustDataView }; export const pageSourceByType = new Map([ ['AdminUsersPage', usersSource], @@ -258,6 +279,7 @@ export const pageSourceByType = new Map([ ['AdminAuthEdit', authEditSource], ['AdminEmailSettings', emailSettingsSource], ['AdminEmailProject', emailProjectSource], + ['AdminZeroTrust', zeroTrustSource], ]); export const pageRegistry: SourceRegistry = new Map(); @@ -326,6 +348,9 @@ pageRegistry.set(emailSettingsSource, { pageRegistry.set(emailProjectSource, { byIds: async ({ ctx, ids }) => loadPages(ctx, ids, loadEmailProjectPage), }); +pageRegistry.set(zeroTrustSource, { + byIds: async ({ ctx, ids }) => loadPages(ctx, ids, loadZeroTrustPage), +}); function pagePath(input: { args?: Record }) { const path = input.args?.path; @@ -351,6 +376,7 @@ export const PageRoot = { authEdit: authEditDataView, emailSettings: emailSettingsDataView, emailProject: emailProjectDataView, + zeroTrust: zeroTrustDataView, }; export const pageQueries = { @@ -624,4 +650,22 @@ export const pageQueries = { source: emailProjectSource, }), }, + zeroTrust: { + resolve: ({ + ctx, + input, + select, + }: { + ctx: AdminContext; + input: { args?: Record }; + select: Array; + }) => + resolveSourceById({ + ctx, + id: pagePath(input), + input: { select }, + registry: pageRegistry, + source: zeroTrustSource, + }), + }, }; diff --git a/platform/packages/api/src/admin/fate/render.ts b/platform/packages/api/src/admin/fate/render.ts index 2bd4b67bd..ecf0d23a3 100644 --- a/platform/packages/api/src/admin/fate/render.ts +++ b/platform/packages/api/src/admin/fate/render.ts @@ -28,6 +28,7 @@ import { signupRequest, userDetailRequest, usersPageRequest, + zeroTrustRequest, } from './views.ts'; function createServerClient(context: Context) { @@ -68,7 +69,8 @@ export async function renderFateListPage( | 'authSettings' | 'authNew' | 'authEdit' - | 'email', + | 'email' + | 'zeroTrust', ): Promise { const title = { users: 'Users', @@ -81,6 +83,7 @@ export async function renderFateListPage( authNew: 'Add login method', authEdit: 'Edit login method', email: 'Email', + zeroTrust: 'Zero Trust', }[page]; const path = new URL(context.req.url).pathname + new URL(context.req.url).search; const props = { @@ -115,6 +118,8 @@ export async function renderFateListPage( await client.request(authNewRequest(path)); } else if (page === 'authEdit') { await client.request(authEditRequest(path)); + } else if (page === 'zeroTrust') { + await client.request(zeroTrustRequest(path)); } else { await client.request(emailSettingsRequest(path)); } diff --git a/platform/packages/api/src/admin/fate/server.ts b/platform/packages/api/src/admin/fate/server.ts index 71699e54f..18e64ddb3 100644 --- a/platform/packages/api/src/admin/fate/server.ts +++ b/platform/packages/api/src/admin/fate/server.ts @@ -41,6 +41,7 @@ export type { AdminAuthEdit, AdminEmailSettings, AdminEmailProject, + AdminZeroTrust, } from './page-server.ts'; export type { AdminBuildLogs, AdminBuildStatus, AdminDeploymentLogs } from './telemetry.ts'; diff --git a/platform/packages/api/src/admin/fate/views.ts b/platform/packages/api/src/admin/fate/views.ts index 404f5049c..5a9d31f0e 100644 --- a/platform/packages/api/src/admin/fate/views.ts +++ b/platform/packages/api/src/admin/fate/views.ts @@ -18,6 +18,7 @@ import type { AdminAuthEdit, AdminEmailSettings, AdminEmailProject, + AdminZeroTrust, AdminBuildLogs, AdminBuildStatus, AdminDeploymentLogs, @@ -123,6 +124,7 @@ export const ProjectDetailView = view()({ pendingTeamInvitations: true, domains: true, builds: true, + zeroTrust: true as never, cfAccountId: true, deployUrl: true, tz: true, @@ -267,3 +269,25 @@ export const emailProjectRequest = (path: string) => ({ emailProject: { view: EmailProjectView, args: { path } }, }) as const; + +export const ZeroTrustView = view()({ + id: true, + configured: true, + enabled: true, + status: true, + accountId: true, + defaultAccountId: true, + teamDomain: true, + identityProviderIds: true, + policyIds: true, + protectNewProjects: true, + operation: true as never, + lastError: true, + live: true as never, + projects: true, +}); + +export const zeroTrustRequest = (path: string) => + ({ + zeroTrust: { view: ZeroTrustView, args: { path } }, + }) as const; diff --git a/platform/packages/api/src/admin/react/AdminNavigationFallback.tsx b/platform/packages/api/src/admin/react/AdminNavigationFallback.tsx index 059ec1918..30b5d67b9 100644 --- a/platform/packages/api/src/admin/react/AdminNavigationFallback.tsx +++ b/platform/packages/api/src/admin/react/AdminNavigationFallback.tsx @@ -27,6 +27,7 @@ const TITLES: Partial> = { emailProject: 'Project email', emailOperation: 'Email operation', invitations: 'Invitations', + zeroTrust: 'Zero Trust', }; export function AdminNavigationFallback({ initialPageData }: { initialPageData: PageObject }) { diff --git a/platform/packages/api/src/admin/react/pages.ts b/platform/packages/api/src/admin/react/pages.ts index 7ae62ca2a..6f6f5485a 100644 --- a/platform/packages/api/src/admin/react/pages.ts +++ b/platform/packages/api/src/admin/react/pages.ts @@ -56,6 +56,7 @@ export const adminComponents = { invitations: async () => ({ default: (await import('./views/InvitationsPage.tsx')).InvitationsPage, }), + zeroTrust: async () => ({ default: (await import('./views/ZeroTrustPage.tsx')).ZeroTrustPage }), }; export type AdminPageId = Exclude; @@ -122,5 +123,8 @@ export function matchAdminRoute(url: string): AdminPageId | null { if (path === '/admin/invitations') { return 'invitations'; } + if (path === '/admin/zero-trust') { + return 'zeroTrust'; + } return null; } diff --git a/platform/packages/api/src/admin/react/styles.css b/platform/packages/api/src/admin/react/styles.css index 7851dcb0d..3bfa06de9 100644 --- a/platform/packages/api/src/admin/react/styles.css +++ b/platform/packages/api/src/admin/react/styles.css @@ -311,6 +311,74 @@ body { width: 0.375rem; height: 0.375rem; } +.void-admin-zero-trust { + display: grid; + gap: var(--spacing-5); + max-width: 960px; +} +.void-admin-zero-trust h1, +.void-admin-zero-trust h2, +.void-admin-zero-trust p { + margin: 0; +} +.void-admin-zero-trust-intro { + display: grid; + gap: var(--spacing-2); +} +.void-admin-zero-trust-intro p, +.void-admin-zero-trust .astryx-card p { + color: var(--color-text-secondary); +} +.void-admin-zero-trust .astryx-card { + display: grid; + gap: var(--spacing-4); +} +.void-admin-zero-trust-section-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-3); +} +.void-admin-zero-trust-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); + gap: var(--spacing-4); + margin: 0; +} +.void-admin-zero-trust-details dt { + color: var(--color-text-secondary); + font-size: var(--text-supporting-size); +} +.void-admin-zero-trust-details dd { + margin: var(--spacing-1) 0 0; + overflow-wrap: anywhere; + font-weight: 600; +} +.void-admin-zero-trust-form { + display: grid; + justify-items: start; + gap: var(--spacing-4); +} +.void-admin-zero-trust-actions { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); +} +.void-admin-project-zero-trust { + display: grid; + gap: var(--spacing-3); + min-width: 0; +} +.void-admin-project-zero-trust > * { + margin: 0; +} +.void-admin-project-zero-trust-state { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} .void-admin-access-page { display: grid; gap: var(--spacing-5); diff --git a/platform/packages/api/src/admin/react/views/Layout.tsx b/platform/packages/api/src/admin/react/views/Layout.tsx index acb6eb4de..4b6b13a72 100644 --- a/platform/packages/api/src/admin/react/views/Layout.tsx +++ b/platform/packages/api/src/admin/react/views/Layout.tsx @@ -12,6 +12,7 @@ import { Mail, Rocket, ShieldCheck, + ShieldLock, Terminal, Users, } from 'lucide-react'; @@ -140,6 +141,12 @@ export function Layout({ href="/admin/email" isSelected={path.startsWith('/admin/email')} /> + Builds ({builds.length}) + + | null; +}>; + +const REVIEW = '/admin/zero-trust'; + +function protection(value: boolean | undefined): string { + return value === undefined ? 'not initialized' : value ? 'protected' : 'public'; +} + +function statusLabel(status: string): string { + return status === 'error' ? 'needs attention' : status; +} + +function platformLabel(zeroTrust: ProjectZeroTrust): string { + if (zeroTrust.invalid) { + return 'invalid configuration'; + } + if (!zeroTrust.configured) { + return 'not configured'; + } + const enabled = zeroTrust.enabled ? 'enabled' : 'disabled'; + return zeroTrust.status && zeroTrust.status !== 'ready' + ? `${enabled}, ${statusLabel(zeroTrust.status)}` + : enabled; +} + +function unavailableReason(zeroTrust: ProjectZeroTrust) { + if (zeroTrust.invalid) { + return ( + <> + The saved Zero Trust configuration is invalid. Review it. + + ); + } + if (!zeroTrust.configured || (!zeroTrust.enabled && !zeroTrust.operation)) { + return ( + <> + Project Zero Trust is not enabled. Configure it. + + ); + } + if (zeroTrust.operation) { + return ( + <> + A Zero Trust {zeroTrust.operation} operation is running. Project changes are available after + it finishes. View status. + + ); + } + return ( + <> + Platform Zero Trust status: {zeroTrust.status ? statusLabel(zeroTrust.status) : 'not ready'}.{' '} + Review it. + + ); +} + +export function ProjectZeroTrustSection({ + projectId, + zeroTrust, +}: { + projectId: string; + zeroTrust: ProjectZeroTrust; +}) { + const { state } = zeroTrust; + return ( +
+

Zero Trust

+
+ Platform: {platformLabel(zeroTrust)} + {(state !== null || zeroTrust.enabled) && ( + <> + Desired: {protection(state?.desired_protected)} + Effective: {protection(state?.effective_protected)} + + + )} +
+ {state?.last_error && ( + + )} + {zeroTrust.available ? ( +
+
+ +
+ ) : ( +

{unavailableReason(zeroTrust)}

+ )} +
+ ); +} diff --git a/platform/packages/api/src/admin/react/views/ZeroTrustPage.tsx b/platform/packages/api/src/admin/react/views/ZeroTrustPage.tsx new file mode 100644 index 000000000..34f921817 --- /dev/null +++ b/platform/packages/api/src/admin/react/views/ZeroTrustPage.tsx @@ -0,0 +1,270 @@ +/** @jsxImportSource @voidcloud/api/admin-jsx */ + +import { Badge } from '@astryxdesign/core/Badge'; +import { Banner } from '@astryxdesign/core/Banner'; +import { Button } from '@astryxdesign/core/Button'; +import { Card } from '@astryxdesign/core/Card'; +import { RadioList, RadioListItem } from '@astryxdesign/core/RadioList'; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from '@astryxdesign/core/Table'; +import { Heading } from '@astryxdesign/core/Text'; +import { useState } from 'react'; +import { useRequest, useView } from 'react-fate'; +import { ZeroTrustView, zeroTrustRequest } from '../../fate/views.ts'; +import { type AdminPageProps } from '../page-utils.ts'; +import { FormCheckbox, FormTextInput } from './FormControls.tsx'; +import { Layout } from './Layout.tsx'; + +const HOME = '/admin/zero-trust'; + +type StatusBadge = Readonly<{ + label: string; + variant: 'neutral' | 'info' | 'success' | 'warning' | 'error'; +}>; + +function statusBadge(configured: boolean, enabled: boolean, status: string | null): StatusBadge { + if (!configured) { + return { label: 'Not configured', variant: 'neutral' }; + } + if (status === 'invalid_configuration') { + return { label: 'Invalid configuration', variant: 'error' }; + } + if (status === 'error') { + return { label: 'Needs attention', variant: 'error' }; + } + if (status === 'configuring') { + return { label: 'Configuring', variant: 'info' }; + } + if (status === 'disabling') { + return { label: 'Disabling', variant: 'warning' }; + } + return enabled + ? { label: 'Enabled', variant: 'success' } + : { label: 'Disabled', variant: 'neutral' }; +} + +function liveLabel(live: Readonly<{ present: boolean; drifted: boolean }> | null): string { + if (!live) { + return 'Not active'; + } + return live.present && !live.drifted ? 'Healthy' : 'Missing or drifted'; +} + +export function ZeroTrustPage({ path, platformName, username }: AdminPageProps) { + const { zeroTrust: ref } = useRequest(zeroTrustRequest(path), { + mode: 'stale-while-revalidate', + }); + const pageData = useView(ZeroTrustView, ref); + if (!pageData) { + throw new Error('Zero Trust settings are unavailable.'); + } + const { + configured, + enabled, + status, + accountId, + defaultAccountId, + teamDomain, + identityProviderIds, + policyIds, + protectNewProjects, + operation, + lastError, + live, + projects, + } = pageData; + const [existingProjects, setExistingProjects] = useState<'protected' | 'public'>( + operation?.existingProjects === 'public' ? 'public' : 'protected', + ); + const badge = statusBadge(configured, enabled, status ?? null); + return ( + +
+
+ Project Zero Trust +

+ Void manages a wildcard Cloudflare Access application for protected projects and + exact-host bypass applications for public exceptions. The Cloudflare API token is + encrypted and never shown again. +

+
+ + {lastError && } + +
+ +
+ + Status + + +
+
+
+
Current operation
+
{operation ? `${operation.kind} · ${operation.phase}` : 'None'}
+
+
+
Cloudflare account
+
{accountId ?? '—'}
+
+
+
Access team domain
+
{teamDomain ?? '—'}
+
+
+
Protect new projects
+
{protectNewProjects === null ? '—' : protectNewProjects ? 'Yes' : 'No'}
+
+
+
Live application
+
{liveLabel(live)}
+
+
+
+
+ +
+ + + Project states + + {projects.length > 0 ? ( + + + + Desired + Effective + State + Projects + + + + {projects.map((entry) => ( + + {entry.desiredProtected ? 'protected' : 'public'} + {entry.effectiveProtected ? 'protected' : 'public'} + {entry.state} + {entry.count} + + ))} + +
+ ) : ( +

No project protection state has been recorded.

+ )} +
+
+ +
+ +
+ + Configure + +

+ The token needs Access Apps and Policies Write plus Organizations, Identity + Providers, and Groups Read for this account. +

+ {operation && ( +

+ To replace an expired or revoked token, keep the saved settings and submit a new + token. The pending operation will resume. +

+ )} +
+
+ + + + + + + setExistingProjects(value === 'public' ? 'public' : 'protected') + } + > + + + +
+ + {configured && ( +
+ + + Maintenance + +
+
+
+
+
+ )} +
+
+ ); +} diff --git a/platform/packages/api/src/admin/routes/projects.tsx b/platform/packages/api/src/admin/routes/projects.tsx index 2b18c0014..7ee8b14e2 100644 --- a/platform/packages/api/src/admin/routes/projects.tsx +++ b/platform/packages/api/src/admin/routes/projects.tsx @@ -245,7 +245,7 @@ app.post('/:id/zero-trust', async (context) => { projectZeroTrustError(error); } return { - response: context.redirect(`/admin/projects/${encodeURIComponent(projectId)}`), + response: context.redirect(`/admin/projects/${encodeURIComponent(projectId)}#zero-trust`), succeeded: true, }; }, @@ -264,7 +264,7 @@ app.post('/:id/zero-trust/reconcile', async (context) => { projectZeroTrustError(error); } return { - response: context.redirect(`/admin/projects/${encodeURIComponent(projectId)}`), + response: context.redirect(`/admin/projects/${encodeURIComponent(projectId)}#zero-trust`), succeeded: true, }; }, diff --git a/platform/packages/api/src/admin/routes/zero-trust.tsx b/platform/packages/api/src/admin/routes/zero-trust.tsx index 60a3b8b31..bfd54aae6 100644 --- a/platform/packages/api/src/admin/routes/zero-trust.tsx +++ b/platform/packages/api/src/admin/routes/zero-trust.tsx @@ -11,17 +11,17 @@ import { disablePlatformZeroTrust, reconcilePlatformZeroTrust, ZeroTrustUnavailableError, - zeroTrustStatus, } from '../../zero-trust/service.ts'; +import { renderFateListPage } from '../fate/render.ts'; import { renderOperationError } from '../operation-error.tsx'; -// TODO(zt-admin-port): port the GET page to the React admin; views/layout.tsx was removed. -import { Layout } from '../views/layout.tsx'; const app = new Hono(); +// Set on the final response: admin pages and error pages return their own +// Response objects, which would drop headers prepared with context.header(). app.use('*', async (context, next) => { - context.header('Cache-Control', 'no-store'); - context.header('Referrer-Policy', 'no-referrer'); await next(); + context.res.headers.set('Cache-Control', 'no-store'); + context.res.headers.set('Referrer-Policy', 'no-referrer'); }); app.onError(renderOperationError); @@ -49,228 +49,7 @@ function adminError(error: unknown): never { ); } -app.get('/', async (context) => { - const status = await zeroTrustStatus(context.env, context.get('db')); - return context.html( - -

Project Zero Trust

-

- Void manages a wildcard Cloudflare Access application for protected projects and exact-host - bypass applications for public exceptions. Credentials are encrypted and never returned. -

- -

Status

- - - - - - - - - - - {'status' in status && ( - - - - - )} - {'operation' in status && status.operation && ( - - - - - )} - {'accountId' in status && ( - - - - - )} - {'teamDomain' in status && ( - - - - - )} - {'protectNewProjects' in status && ( - - - - - )} - - - - - {'lastError' in status && status.lastError && ( - - - - - )} - -
Configured{status.configured ? 'Yes' : 'No'}
Enabled{status.enabled ? 'Yes' : 'No'}
Reconciliation{String(status.status)}
Current operation - {status.operation.kind} · {status.operation.phase} -
Cloudflare account{String(status.accountId)}
Access team domain{String(status.teamDomain)}
Protect new projects{status.protectNewProjects ? 'Yes' : 'No'}
Live application - {status.live - ? status.live.present && !status.live.drifted - ? 'Healthy' - : 'Missing or drifted' - : 'Not active'} -
Last error{String(status.lastError)}
- -

Project states

- {status.projects.length > 0 ? ( - - - - - - - - - - - {status.projects.map((entry) => ( - - - - - - - ))} - -
DesiredEffectiveStateCount
{entry.desiredProtected ? 'protected' : 'public'}{entry.effectiveProtected ? 'protected' : 'public'}{entry.state}{entry.count}
- ) : ( -

No project protection state has been recorded.

- )} - -

Configure

-

- The token needs Access Apps and Policies Write plus Organizations, Identity Providers, and - Groups Read for this account. -

- {'operation' in status && status.operation && ( -

- To replace an expired or revoked token, keep the saved settings and submit a new token. - The pending operation will resume. -

- )} -
-

- -

-

- -

-

- -

-

- -

-

- -

-

- -
- This selection is used only when Zero Trust is first enabled or re-enabled. -

- -
- - {status.configured && ( - <> -

Maintenance

-
- -
{' '} - {(status.enabled || ('operation' in status && status.operation)) && ( -
- -
- )} - - )} -
, - ); -}); +app.get('/', (context) => renderFateListPage(context, 'zeroTrust')); app.post('/configure', async (context) => { const form = await context.req.parseBody(); diff --git a/platform/packages/api/src/zero-trust/service.ts b/platform/packages/api/src/zero-trust/service.ts index 61fb57f11..b7c0c9e14 100644 --- a/platform/packages/api/src/zero-trust/service.ts +++ b/platform/packages/api/src/zero-trust/service.ts @@ -166,6 +166,16 @@ export function publicProjectZeroTrustState( }; } +/** Project protection can change only on a ready installation with no running operation. */ +export function projectZeroTrustAvailable(configuration: ZeroTrustConfiguration | null): boolean { + return ( + !!configuration?.enabled && + configuration.status === 'ready' && + !configuration.operation && + !!configuration.wildcardApplication + ); +} + async function updateProjectState( db: DbOrTx, projectId: string, diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 502b35968..1d39b0f2b 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -450,8 +450,83 @@ describe('auth middleware', () => { ); }); -describe('GET /admin/ (dashboard)', () => { - test('renders Zero Trust configuration without exposing a credential', async () => { +const zeroTrustCredential = 'stored-access-management-credential'; + +function zeroTrustSetting(overrides: Record = {}) { + return { + key: 'zeroTrust.configuration', + value: { + version: 1, + revision: 918273645, + status: 'ready', + enabled: true, + protectNewProjects: true, + accountId: 'zero-trust-account', + teamDomain: 'https://company.cloudflareaccess.com', + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + credential: { ciphertext: zeroTrustCredential, iv: 'credential-iv', keyVersion: 'v1' }, + wildcardApplicationName: 'managed-wildcard-name', + wildcardApplication: { + id: 'managed-wildcard-id', + name: 'managed-wildcard-name', + audience: 'managed-wildcard-audience', + destinations: ['*.example.com'], + }, + operation: null, + lastError: null, + updatedAt: '2026-09-22T00:00:00.000Z', + ...overrides, + }, + }; +} + +const configuringZeroTrust = { + status: 'configuring', + enabled: false, + wildcardApplicationName: null, + wildcardApplication: null, + operation: { + kind: 'configure', + phase: 'public-bypasses', + owner: null, + leaseExpiresAt: null, + startedAt: '2026-09-22T00:00:00.000Z', + existingProjects: 'public', + }, +}; + +function pageSnapshot(html: string): string { + const match = + /