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..57bd5c403 --- /dev/null +++ b/docs/guide/platform/administration/zero-trust.md @@ -0,0 +1,318 @@ +--- +outline: deep +--- + +# Project Zero Trust + +Project Zero Trust puts Cloudflare Access in front of the hostnames of projects deployed to your platform, including custom domains. Visitors sign in with one of your identity providers before they reach a protected project. Each project can still be made public. + +This is separate from the Access login and protection for the platform API. Projects deployed directly to Cloudflare are not affected. + +The steps below set it up. [How Protection Works](#how-protection-works) explains what visitors see, which Access applications Void creates, and what happens while settings change. + +## Requirements + +- A Cloudflare Zero Trust organization in the account that hosts the platform, with the identity providers and reusable Access policies you want to use. Select at least one Allow policy that matches people by identity. Void rejects Bypass and Service Auth policies, and rules that allow Everyone or any service token. +- A Cloudflare API token for that account with **Access: Apps and Policies Write** and **Access: Organizations, Identity Providers, and Groups Read**. Void keeps it separate from the platform runtime token, stores it encrypted, and never shows it again. +- Free Access applications in the account. Cloudflare's default limit is 500 per account. Count the applications you already manage outside Void, then add the ones Void needs, as described in [Access Applications Void Creates](#access-applications-void-creates). +- Platform API, proxy, and email gateway hostnames outside the project application domain. Configuration is rejected when project protection would cover one of them. Any other hostname under the project application domain also asks for sign-in, except the `/health` path of `void-platform-health.`, which Void keeps open for its health checks and never serves from a project. New projects cannot use that name. A project that already uses it keeps every other path, protected or public like any other project. + +## Enable Zero Trust + +Open **Zero Trust** in the administrator UI, or use the operator CLI: + +```sh +printf '%s' "$ACCESS_API_TOKEN" | void platform zero-trust configure \ + --identity-providers \ + --policies \ + --existing-projects public \ + --protect-new-projects \ + --token-stdin \ + --yes +``` + +- `--existing-projects protected|public` decides what happens to the projects that already exist. It is required when you enable Zero Trust. +- `--protect-new-projects` protects projects created from now on. Omit it to make new projects public. +- Use `--plan` instead of `--yes` to check the change without applying it. + +To change the identity providers, policies, token, or new-project default later, run the same command without `--existing-projects`. Existing projects keep their protection; use the project overrides below to change one project. + +## Check Status + +```sh +void platform zero-trust status +void platform zero-trust status --check +``` + +`status` shows the saved settings, the current operation, and how many projects are protected, public, or waiting. `--check` also compares the settings with Cloudflare Access. In the administrator UI, use **Check with Cloudflare**. + +## Project Overrides + +Project owners and project administrators can protect a project or make it public. Readers can see its state. + +```sh +void project zero-trust status +void project zero-trust protect +void project zero-trust public +void project zero-trust reconcile +``` + +`status` also tells you whether protection can be changed right now. Making a project public does not change the default for new projects. + +Installation administrators can do the same from the project page in the administrator UI or with `void platform zero-trust project-status|project-protect|project-public|project-reconcile `. + +Deploys and rollbacks wait until a project's protection change is finished. If one is refused, run `void project zero-trust status` to see why; the owner or a project administrator can run `void project zero-trust reconcile` before you try again. A protected project can have up to 100 custom domains. + +## Disable Zero Trust + +```sh +void platform zero-trust disable +``` + +This removes the Access applications Void created and makes all projects public. On large installations it runs in steps; run it again, or check `void platform zero-trust status`, until Zero Trust shows as disabled with no operation running. + +Uninstalling the platform does not remove these applications. `void platform uninstall` refuses to start while Zero Trust is enabled, a change is still running, or some Void-managed Access applications are left. Its error lists their names. Disable Zero Trust first, then uninstall. Once the uninstall starts turning the platform off, Zero Trust changes and deployments are refused. If it stops after that point, run it again, or bring the platform back with `void platform enable` or `void platform repair`. If a project's applications could not be removed, Void retries every hour while the platform is enabled. If the error says a project deletion stopped partway, run `void project delete` for that project again to remove them. + +## How Protection Works + +Every request to a protected project passes two checks before your code runs: + +```text +Visitor + │ + ▼ +Cloudflare Access ─── not signed in ─────────▶ sign-in page + │ signed in and allowed by your policies + ▼ +Void checks the Access token ─── rejected ───▶ 403 page + │ token is valid for this hostname + ▼ +Your project: routes, pages, assets, WebSockets +``` + +A public project skips both. Access lets every visitor through, and Void does not look for a token. + +### What Visitors See + +A protected project asks visitors to sign in with one of the identity providers you selected. If you selected exactly one, visitors go straight to it. Otherwise, they pick one of the selected providers. A sign-in lasts 12 hours. After that, Access asks again. + +Protection follows the project to every hostname it answers on: + +| Hostname | Protected project | Public project | +| ----------------------------------------- | ----------------- | -------------- | +| `.` | Sign-in required | Open to anyone | +| The project's `workers.dev` testing URL | Sign-in required | Open to anyone | +| Custom domains, such as `app.example.com` | Sign-in required | Open to anyone | + +Void only covers the `workers.dev` testing URLs of this installation. Other Workers in the account are not affected. On an installation without a domain, the `workers.dev` testing URL is the only project URL, and the shared project application covers only those URLs. + +#### The 403 Page + +Void answers `403 Cloudflare Access authentication required` when a request reaches a protected project without a valid Access token for it. Page requests get a short HTML error page. Requests under `/api`, requests for files, requests that are not `GET` or `HEAD`, and requests that do not accept HTML get the same message as plain text. The response is never cached. + +Visitors who went through the sign-in page normally never see it. They can see it: + +- for a short time while the project is switching between protected and public; +- while a Zero Trust change is still being applied to the project; +- when a Void-managed Access application was deleted, or its hostnames were changed, in the Cloudflare dashboard. See [Editing Applications in the Dashboard](#editing-applications-in-the-dashboard). + +#### Every Path Is Covered + +Protection applies to the whole hostname. There are no path exceptions. `/api/*` routes, static files, hashed assets, `robots.txt`, `favicon.ico`, server-sent events, and WebSockets all require a signed-in visitor. + +#### Webhooks and Other Non-Browser Clients + +Void only accepts policies that match people by identity. It rejects Bypass and Service Auth policies, and rules that allow Everyone or any service token. So a machine cannot pass on its own. Payment and Git webhooks, CI jobs, uptime checks, and plain `curl` calls get the sign-in page or the 403 response. + +If a project must accept such requests: + +- make the project public with `void project zero-trust public` and check callers in your own code, or +- move the endpoints that machines call into a separate project, and make only that project public. + +### Two Checks on Every Request + +Cloudflare Access is the first check. It runs at Cloudflare's edge, shows the sign-in page, and applies your policies. Only visitors your policies allow get an Access token for the project. + +Void is the second check. Before a protected project runs, Void confirms that the request carries an Access token that: + +- was issued by your Zero Trust team; +- belongs to one of the Access applications Void created for this project's hostname; +- has not expired. + +If any of this fails, the visitor gets the [403 page](#the-403-page). Your routes, assets, and caches are never reached. + +This gives you one guarantee: **deleting a Void-managed Access application, or changing its hostnames, cannot make a protected project public.** If someone deletes Void's application, or points it at other hostnames, visitors are refused instead of let in. Loosening the policies of Void's application in the dashboard does widen who can sign in, until the next update puts Void's policies back. + +The second check does not look at your policies again. Who may sign in is decided only by the policies in Cloudflare Access. If you loosen a selected policy, the change applies to every protected project. + +### Access Applications Void Creates + +Void creates and owns these self-hosted applications in your Zero Trust organization: + +| Application | Covers | Policy | Exists | +| -------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------ | +| Shared project application | `*.` and this installation's `workers.dev` testing URLs | Your selected policies and identity providers | Once, while Zero Trust is enabled | +| Public exception | One public project's `.` and `workers.dev` testing URL | A Bypass policy for Everyone, named `Void project is public` | One for each public project | +| Custom domain application | All custom domains of one protected project | Your selected policies and identity providers | One for each protected project that has custom domains | +| Health check exception | `void-platform-health./health` | A Bypass policy for Everyone, named `Void platform health check` | Once, while Zero Trust is enabled on an installation with a domain | + +Custom domains of a public project need no Access application. + +The number of applications Void needs is: + +```text +2 + public projects + protected projects that have custom domains +``` + +Use 1 instead of 2 on an installation without a domain. For example, 40 projects with 10 public and 5 protected projects that have custom domains need 2 + 10 + 5 = 17 applications. Void stops with an error when the account has more than 500 Access applications. + +#### Recognizing Void's Applications + +Void adds no tags. Its application names follow this pattern: + +```text +void----project-zero-trust shared project application +void----public- public exception +void----protected- custom domain application +void----platform-health health check exception +``` + +- `` is the start of your installation ID, which begins with your installation name. +- `` is 16 characters. It is the same for every application of one installation. +- `` is 32 random characters. +- `` is the project ID, such as `proj_abc123def456`. The project slug is not part of the name. + +To see the exact names Void recorded, run `void platform zero-trust status` for the shared application and the health check exception, and `void platform zero-trust project-status ` for a project's applications. There, the public exception is listed as `bypass application name` and the custom domain application as `protected application name`. + +### Editing Applications in the Dashboard + +Do not edit, rename, or delete Void's applications in the Cloudflare dashboard. Change the identity providers and policies with `void platform zero-trust configure` instead. See [Enable Zero Trust](#enable-zero-trust). + +You can still edit the rules inside a selected reusable policy. Void's applications use your policies as they are. Void checks them again during `void platform zero-trust configure` and `void platform zero-trust reconcile`, which stop with an error, and during `status --check`, which reports `present: false`. A policy fails this check when it now allows Everyone or any service token, or uses a Bypass or Service Auth decision. + +#### What Happens If You Do + +Void writes an application when it updates it: during platform `configure` or `reconcile`, during a project's `protect`, `public`, or `reconcile`, when a custom domain is added to or removed from that project, and while it retries a project that is not settled. Scheduled maintenance does not look for dashboard changes on settled projects. Until one of these runs, your change stays in effect. + +| Change in the dashboard | Effect | Repair | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| Edit hostnames, policies, providers, or session length | Your change stays until the next update, which puts Void's settings back. | Run the reconcile command for that application | +| Delete the shared project application | Protected projects answer 403 on `.` and `workers.dev` URLs. Their custom domains and public projects keep working. | `void platform zero-trust reconcile` | +| Delete a public exception | Visitors of that public project are asked to sign in, because the shared application now covers it. | `void project zero-trust reconcile` in that project | +| Delete a custom domain application | That protected project answers 403 on its custom domains. | `void project zero-trust reconcile` in that project | +| Delete the health check exception | Platform health checks fail with a redirect to sign-in, so the admin dashboard shows `dispatch` as unhealthy and `void platform upgrade` and `repair` stop at their health check. | `void platform zero-trust reconcile` | +| Rename an application | Void stops updating or deleting it. Reconcile fails with `The recorded Access application no longer has its Void-owned name.` For the shared application, platform status then shows `error`. | Rename it back to the recorded name, then reconcile | +| Copy an application with the same name | Void ignores the copy while the original exists, and `disable` does not remove it. If the original is later deleted, reconcile fails with `Multiple Cloudflare Access applications are named .` | Delete the copy, then reconcile | + +When Void recreates a deleted shared project application, the new application issues different Access tokens. Void then moves each protected project to it. On large installations this runs in steps, and protected projects answer 403 until Void reaches them. + +#### Checking for Changes + +```sh +void platform zero-trust status --check +``` + +This compares the shared project application and the health check exception with the saved settings, and checks your selected identity providers and policies again. The output includes: + +```text +checked: true +live: + present: true + drifted: false +``` + +- `drifted: true` means the shared application or the health check exception no longer matches. Run `void platform zero-trust reconcile`. +- `present: false` means Void could not read it. The application may be deleted, a selected identity provider or policy may be gone or no longer allowed, or the token may not work. Check the token and selections, then run `void platform zero-trust reconcile`. + +`--check` only reads. It changes nothing. It does not check public exceptions or custom domain applications. If you think one was changed, run a reconcile command. + +#### Repair Commands + +| Command | Rewrites | Who can run it | +| --------------------------------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `void platform zero-trust reconcile` | The shared project application, the health check exception, and every project's applications | Installation administrators | +| `void platform zero-trust project-reconcile ` | One project's applications | Installation administrators | +| `void project zero-trust reconcile` | The linked project's applications, or pass `--project ` | The project owner and project administrators | + +The two project commands work only when platform status is `ready`. While a platform change runs, or after it fails, run `void platform zero-trust reconcile` first. + +### What Happens During Changes + +Void orders every change so that a protected project never becomes public by mistake. If a step fails, the project keeps its protection, or refuses visitors with the 403 page, until the change can finish. + +Void's router can keep a project's previous setting for up to about a minute. During that time, some visitors may see the 403 page instead of the sign-in page. + +#### Protecting or Making a Project Public + +`void project zero-trust protect` and `void project zero-trust public` usually finish before the command returns. + +- **Protect.** Void covers the custom domains first, then turns on its own check, then removes the public exception. Visitors may see the 403 page for a moment before Access starts asking them to sign in. +- **Public.** Void adds the public exception first, then turns off its own check, then removes the custom domain application. Visitors may see the 403 page for a moment before the project opens. + +If the command prints `Zero Trust update is still in progress`, run `void project zero-trust reconcile` to continue, or wait for the hourly maintenance. If it fails with `Cloudflare Access could not be updated. Protection remains fail closed.`, fix the cause, then run `void project zero-trust reconcile`. Until then, a project that was protected stays protected. A project you were protecting may already ask for sign-in, or answer with the 403 page, but it is never more open than before. A project you were making public may already be open on some hostnames. + +Run `void project zero-trust status` to follow a change. `State:` shows `reconciling` while it runs, `error` when it needs attention, and `protected` or `public` when it is done. `Last error:` explains a failure. + +#### Enabling and Disabling Zero Trust + +Large changes run in steps. Platform status shows `configuring` or `disabling`, and project owners see `Available: no`. Scheduled maintenance continues within a few minutes. + +When you **enable** Zero Trust: + +1. Void adds the public exception for each project that stays public. These projects never ask visitors to sign in. +2. Void creates the health check exception, then the shared project application. From here, the `.` and `workers.dev` URLs of protected projects require sign-in. +3. Void goes through the protected projects and covers their custom domains. A project's custom domains stay open until Void reaches it. + +If Void cannot add a project's public exception, the project shows `error`, and its visitors are asked to sign in until the next retry succeeds. Void retries every hour, or run `void project zero-trust reconcile` once platform status is `ready`. + +When you **disable** Zero Trust: + +1. Void turns off its own check for each protected project and removes its custom domain application. Custom domains open here. +2. Void deletes the shared project application, then the health check exception. `.` and `workers.dev` URLs open here. +3. Void deletes the public exceptions. Public projects stay open the whole time. + +#### Custom Domains + +A custom domain on a protected project goes live only after Void adds it to the project's custom domain application. If Access cannot be updated, the domain stays pending and is not reachable. Void tries again each time it checks the domain. + +When you remove a custom domain, Void removes it from the project first, then from the Access application. If the Access update fails, the project shows `reconciling` or `error` and Void retries every hour. A protected project can have up to 100 custom domains. Custom domains on public projects need no Access change. + +#### New Projects + +A new project starts protected or public based on the default for new projects. If Void cannot set up its protection, creating the project still succeeds with a warning. See [Recovery](#recovery). + +#### Deploys and Rollbacks + +Deploys and rollbacks wait while a project's protection is not settled, so a deploy cannot publish a project before its protection is in place. They are refused with `Zero Trust protection for this project is not ready`, followed by the reason: + +- `Project Zero Trust is still being initialized.` +- `The project public exception is still reconciling.` +- `Project Zero Trust is still reconciling.` + +If the saved platform configuration is invalid, they are refused with `The platform Zero Trust configuration is invalid.` instead. Ask a platform administrator to repair it. + +A project that is already protected can still deploy during a platform-wide change. While it is being made public, deploys wait until that finishes. See [Project Overrides](#project-overrides) for what to do when a deploy is refused. + +### Caching + +Responses for signed-in visitors are never shared between visitors. Void skips its shared caches for every request that carries an Access token: [ISR](/guide/edge/revalidation#cache-bypass) and the [static asset edge cache](/guide/edge/static-assets#non-hashed-assets). Each visitor gets a response made for their request. + +On a protected project, this means: + +- pages render on every request, and ISR never serves a cached page; +- static files and hashed assets skip the edge cache; +- the project handles more requests and may respond more slowly than a public project. + +Public projects keep using the shared caches as usual. + +## Recovery + +A project never becomes public by mistake while a change is in progress. See [What Happens During Changes](#what-happens-during-changes). + +- **Status stays `configuring` or `disabling`.** Large changes run in steps. Scheduled maintenance continues them within a few minutes, or run `void platform zero-trust reconcile`. +- **Status shows an error.** Fix the cause shown, such as token permissions or the application limit, then run `void platform zero-trust reconcile`. Otherwise Void retries every hour. +- **One project shows an error.** The rest of the change still finishes. Void retries the project every hour, or its owner or a project administrator can run `void project zero-trust reconcile`. Until then, a project that was already protected stays protected, and a project being newly protected is never more open than before. +- **Adding a domain stays pending because of a project.** Void publishes the new domain only after every project is ready for it. Platform status shows an error that names the projects it could not update. Check each one with `void platform zero-trust project-status `, fix the cause, then run `void platform zero-trust reconcile` or rerun the domain command. Void also retries every hour. Your `workers.dev` URLs keep working meanwhile. +- **A new project could not be set up.** Creating the project still succeeds with a warning. Void retries every hour, or run `void project zero-trust reconcile`. +- **The saved API token expired or was revoked.** Run `void platform zero-trust configure --token-stdin` with a replacement token and the same identity providers, policies, and `--protect-new-projects` choice. Omit `--existing-projects`. Void continues the unfinished change, including an unfinished disable. Finish it before changing other settings. +- **A selected identity provider or policy was deleted.** Run `void platform zero-trust disable` to stop the unfinished setup and remove its applications. This makes projects public. Then configure Zero Trust again with the new 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..35563e73b 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, and it needs its own Cloudflare API token. See [Project Zero Trust](/guide/platform/administration/zero-trust) for the requirements, setup command, and recovery. diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index d0b46aa8d..de11b74a8 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -56,6 +56,8 @@ Cloudflare refuses to delete a resource while a platform Worker still uses it. I If installation configured Cloudflare Access, the uninstall plan lists its recorded applications and service tokens with their ownership and IDs. Review them in Zero Trust after uninstall. Remove installer-created resources only when nothing else uses them; resources connected from an existing company setup remain under their owner's control. +Uninstall does not remove the applications created for [project Zero Trust](/guide/platform/administration/zero-trust). While Zero Trust is enabled or still has Access applications, uninstall and `--plan` stop before any change and list those applications. Run `void platform zero-trust disable` first. Uninstall reads this from the installation database, so it also stops when the database cannot be reached. + ::: details Why some resources require manual cleanup Resources that may have been shared or repurposed require manual review before deletion. Void verifies ownership, keeps those resources in place, and blocks platform traffic. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 6130ffcca..73c09f877 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,27 @@ 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 protect [--project ] +void project zero-trust public [--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. `public` does not change the +platform default for future projects. Deploys and rollbacks wait until a +protection change is finished; if one is refused, the owner or a project +administrator can run `void project zero-trust reconcile` before you retry. +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 +607,46 @@ 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 [--check] +printf '%s' "$ACCESS_API_TOKEN" | void platform zero-trust configure \ + --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-protect +void platform zero-trust project-public +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. Zero Trust uses the Cloudflare account that +hosts the platform. Omit `--protect-new-projects` to make new projects public +by default. `--existing-projects` is required when you enable Zero Trust and +rejected once it is enabled; later configuration changes keep each project's +protection. `status` reports saved state; `--check` also compares it with +Cloudflare Access. 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. Select at least one Allow policy that matches people by +identity; Void rejects Bypass and Service Auth policies and rules that allow +Everyone or any service token. See [Project Zero Trust](../guide/platform/administration/zero-trust.md) +for requirements and recovery. + #### Deployments {#operator-deployments} Find a deployment, inspect its manifest, or request cancellation: @@ -818,7 +880,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..c519a25b7 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}`; @@ -93,6 +99,22 @@ export function platformProjectWorkersDevHostname( return `${platformProjectIngressName(project, workersDev)}.${workersDev.subdomain}.workers.dev`; } +/** + * Project slug the platform keeps for its own health probe. Project Zero Trust + * leaves only `/health` on this hostname outside Cloudflare Access so the + * authenticated probe reaches dispatch, which never routes that path to a + * project. New projects cannot claim the slug; a project that already owns it + * keeps every other path, behind Access like any other project hostname. + */ +export const PLATFORM_HEALTH_HOSTNAME_LABEL = 'void-platform-health'; + +/** The application-domain hostname for dispatch health probes; null without a zone. */ +export function platformHealthHostname(application: PlatformApplication): string | null { + return application.zoneName + ? `${PLATFORM_HEALTH_HOSTNAME_LABEL}.${application.baseDomain}` + : null; +} + export function platformProjectHostname( project: PlatformApplicationProject, application: PlatformApplication, diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index d2cbd6c46..c54b93da7 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -83,9 +83,12 @@ export { type PlatformWorkerConfigSet, } from './worker-config.ts'; export { + PLATFORM_HEALTH_HOSTNAME_LABEL, + platformHealthHostname, 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..e92d09ecf 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|protect|public|reconcile`; these commands do not apply to direct Cloudflare deployments. When a deploy or rollback is refused because protection is not settled, check `void project zero-trust status`; an owner or project administrator can run `void project zero-trust reconcile` before retrying. + 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..bb9ed3dc4 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -172,6 +172,36 @@ 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; +}; + +/** `GET /projects/:id/zero-trust`. */ +export type ProjectZeroTrustResponse = { + /** Whether a project owner or admin can change protection now. */ + available: boolean; + platformStatus: + | 'not_configured' + | 'invalid_configuration' + | 'configuring' + | 'ready' + | 'disabling' + | 'error'; + protectNewProjects: boolean; + project: ProjectZeroTrustState | null; +}; + +/** `PUT /projects/:id/zero-trust` and `POST /projects/:id/zero-trust/reconcile`. */ +export type ProjectZeroTrustUpdateResponse = { + ok: true; + project: ProjectZeroTrustState; +}; + export type IssuedProjectDeployCredentialResponse = ProjectDeployCredentialResponse & { token: string; }; @@ -180,6 +210,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 +700,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, 'read'); + } + 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, 'settings'); + } + 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, 'settings'); + } + 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..dfed058ce 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -97,11 +97,17 @@ 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 === 'check' && group === 'zero-trust' + ? 'Also check the saved settings against Cloudflare Access (several API calls)' + : 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 +502,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 +601,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: 'protect', description: 'Protect the project' }, + { command: 'public', description: 'Make the project public' }, + { command: 'reconcile', description: 'Repair Cloudflare Access configuration drift' }, + ], + options: [{ command: '--project ', description: 'Project name override' }], + children: Object.fromEntries( + ['status', 'protect', 'public', 'reconcile'].map((action) => [ + action, + withProjectOption( + action === 'status' + ? 'Show desired and effective Zero Trust protection.' + : action === 'protect' + ? 'Protect the project with the platform Access policy.' + : action === 'public' + ? '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..1441c6160 100644 --- a/packages/void/src/cli/operator-args.ts +++ b/packages/void/src/cli/operator-args.ts @@ -119,6 +119,71 @@ export const operatorCommands: Array = [ positional: ['id', 'userId'], method: 'POST', }, + { + group: 'zero-trust', + action: 'status', + description: 'Inspect saved platform Zero Trust configuration and project state counts', + path: '/zero-trust', + booleans: ['check'], + }, + { + group: 'zero-trust', + action: 'configure', + description: 'Configure and reconcile project Zero Trust protection', + path: '/zero-trust/configure', + values: { + 'identity-providers': 'Comma-separated Access identity provider IDs', + policies: 'Comma-separated reusable Access policy IDs', + 'existing-projects': 'Required when enabling: protected or public for existing projects', + }, + 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-protect', + description: 'Protect one project, overriding the platform default', + path: '/zero-trust/projects/:id', + positional: ['id'], + method: 'POST', + }, + { + group: 'zero-trust', + action: 'project-public', + 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', + }, { group: 'deployment', action: 'list', @@ -471,7 +536,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 +722,48 @@ 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 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 (!identityProviderIds?.length || !policyIds?.length) { + throw new Error( + 'platform: zero-trust configure requires --identity-providers and --policies.', + ); + } + // Required only when enabling; the platform rejects it once enabled. + if ( + existingProjects !== undefined && + existingProjects !== 'protected' && + existingProjects !== 'public' + ) { + throw new Error('platform: --existing-projects must be protected or public.'); + } + parsed.body = { + identityProviderIds, + policyIds, + ...(existingProjects ? { existingProjects } : {}), + protectNewProjects: flags.has('protect-new-projects'), + }; + } else if (group === 'zero-trust' && action === 'status') { + // A GET carries its options in the query string. + delete parsed.body.check; + if (flags.has('check')) { + parsed.query.check = 'true'; + } + } else if (group === 'zero-trust' && action === 'project-protect') { + parsed.body.protected = true; + } else if (group === 'zero-trust' && action === 'project-public') { + 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-access-provision.ts b/packages/void/src/cli/platform-access-provision.ts index 44e62e7d2..b16f709b6 100644 --- a/packages/void/src/cli/platform-access-provision.ts +++ b/packages/void/src/cli/platform-access-provision.ts @@ -112,6 +112,11 @@ export class PlatformAccessClient { } } +/** + * Project Zero Trust keeps its own, broader copy of this list in + * platform/packages/api/src/zero-trust/cloudflare.ts; review both when adding + * a rule type. + */ function identityRule(rule: ObjectValue): boolean { if (rule.email_domain) { const domain = object(rule.email_domain, 'email domain').domain; 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..79d6872ee 100644 --- a/packages/void/src/cli/platform-domain.ts +++ b/packages/void/src/cli/platform-domain.ts @@ -1,10 +1,9 @@ -import { randomUUID } from 'node:crypto'; import { confirm, isCancel, log, note, text } from '@clack/prompts'; import { createApplicationRoutes, createPlatformDeploymentManifest, normalizeApplicationDomain, - PLATFORM_APPLICATION_KEY, + PLATFORM_HEALTH_HOSTNAME_LABEL, 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'); @@ -179,26 +188,72 @@ export async function applyPlatformDomain( ); await save({ ...manifest, resources: observed.resources, updatedAt: now() }); // The dispatcher health endpoint works while traffic is disabled and before the domain is canonical. + // Project Zero Trust leaves only this hostname of the domain outside Access. await (dependencies.probe ?? probePlatformDomain)( - `void-check-${randomUUID().slice(0, 8)}.${target.baseDomain}`, + `${PLATFORM_HEALTH_HOSTNAME_LABEL}.${target.baseDomain}`, manifest, ); await dependencies.verifyOwnership(manifest); + // The API publishes the application to routing storage during activation. + // Stop with a repair hint before that call if the installation record says + // the storage is not ready. const routing = manifest.resources.find( (resource) => resource.key === 'routing-kv' && resource.state === 'ready', ); 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: [] }; +} + +export 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 }), + }); + // An older runtime has no activation handoff and answers with a non-JSON 404. + const body = (await response.json().catch(() => null)) as { + ok?: boolean; + installationId?: string; + ready?: boolean; + error?: string; + } | null; + if (!body || typeof body !== 'object' || response.status === 404) { + throw new Error( + `platform: The running platform cannot activate this domain (HTTP ${response.status}). Update the platform runtime with \`void platform upgrade ${manifest.id}\` (or repair it with \`void platform repair ${manifest.id}\`), then rerun this command.`, + ); + } + 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 +391,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/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index 72d18e39a..c178fb369 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -39,6 +39,7 @@ import { parsePlatformResourceOwnershipMarker, PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE, + PLATFORM_HEALTH_HOSTNAME_LABEL, PLATFORM_R2_OWNERSHIP_MARKER_KEY, PLATFORM_REMOTE_INSTALLATION_MANIFEST_KEY, PLATFORM_SANDBOX_DRAIN_KEY, @@ -114,6 +115,13 @@ import { trackedPlatformWorkerTraffic, } from './platform-traffic.ts'; import { platformWorkerLiveTriggersMatch } from './platform-trigger-proof.ts'; +import { + fencePlatformProjectZeroTrustConnection, + liftPlatformProjectZeroTrustFenceConnection, + lockPlatformProjectZeroTrustConnection, + readPlatformProjectZeroTrustStateConnection, + type PlatformProjectZeroTrustState, +} from './platform-zero-trust-uninstall.ts'; import { resolveWranglerArgv } from './preset.ts'; import { createDefaultRunWrangler, resolvePinnedApiBaseUrl } from './provision.ts'; @@ -197,6 +205,14 @@ export type PlatformDatabaseLifecycleLock = (() => Promise) & { prepareSandboxDrain?: ( requiredAdmissionProtocol?: 0 | 2 | 3, ) => Promise; + /** Project Zero Trust state that decides whether uninstall may start. */ + readProjectZeroTrust?: () => Promise; + /** Block Zero Trust writers in the API until the returned release runs. */ + lockProjectZeroTrust?: () => Promise<() => Promise>; + /** Make API Zero Trust writers refuse until the fence is lifted. */ + fenceProjectZeroTrust?: () => Promise; + /** Let API Zero Trust writers run again; called before traffic is enabled. */ + liftProjectZeroTrustFence?: () => Promise; requiredSafetyProtocols?: Readonly<{ sandboxDrain: 0 | 1 | 2 | 3 }>; verifyDatabase?: ( runtimeDirectory: string, @@ -2711,6 +2727,9 @@ export async function installPlatform( createPlatformHealthProbeToken(manifest.ownershipNonce), ); } + // A failed uninstall, or one whose database is reused, leaves its record + // behind. Remove it before traffic opens so Zero Trust writers run again. + await releaseDatabaseLock.liftProjectZeroTrustFence?.(); if (dependencies.client.deleteKvValue) { await dependencies.client.deleteKvValue( plan.account.id, @@ -3608,6 +3627,26 @@ export async function readPlatformDatabaseLifecycleManifest( } } +/** Read project Zero Trust state for a plan, which holds no lifecycle lock. */ +export async function readPlatformProjectZeroTrustState( + databaseUrl: string, + installationId: string, + ownershipNonce: string, +): Promise { + if (!databaseUrl) { + throw new Error('the installation database URL is not recorded on this machine'); + } + const { Pool } = await import('pg'); + const pool = new Pool({ connectionString: platformDatabaseClientUrl(databaseUrl), max: 1 }); + try { + // Throws unless the database is claimed by this installation. + await readPlatformDatabaseLifecycleManifestConnection(pool, installationId, ownershipNonce); + return await readPlatformProjectZeroTrustStateConnection(pool); + } finally { + await pool.end(); + } +} + type DatabaseClaimConnection = Readonly<{ query: = Record>( sql: string, @@ -3897,6 +3936,10 @@ export async function acquirePlatformDatabaseLifecycleLockConnection( } : null; }; + release.readProjectZeroTrust = () => readPlatformProjectZeroTrustStateConnection(client); + release.lockProjectZeroTrust = () => lockPlatformProjectZeroTrustConnection(client); + release.fenceProjectZeroTrust = () => fencePlatformProjectZeroTrustConnection(client); + release.liftProjectZeroTrustFence = () => liftPlatformProjectZeroTrustFenceConnection(client); release.requiredSafetyProtocols = await readRequiredPlatformSafetyProtocols(client); release.checkpoint = async (manifest) => { if (released) { @@ -6456,7 +6499,9 @@ export async function verifyPlatformHealth( redirect: 'manual', signal: AbortSignal.timeout(30_000), }); - const dispatchUrl = dispatchOrigin ?? `https://test.${applicationBaseDomain}`; + // Project Zero Trust leaves only this application-domain hostname outside Access. + const dispatchUrl = + dispatchOrigin ?? `https://${PLATFORM_HEALTH_HOSTNAME_LABEL}.${applicationBaseDomain}`; const [health, discovery, proxy, dispatch, emailGateway] = await Promise.all([ request(`${apiUrl}/health`), request(`${apiUrl}/.well-known/void-platform`), diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index faf226d94..87c5f8a92 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -33,6 +33,7 @@ import { createPlatformInstallationSecrets, installPlatform, loadPlatformRuntime, + readPlatformProjectZeroTrustState, REMOTE_INSTALLATION_MANIFEST_KEY, resolveBootstrapGitHubId, type PlatformDatabaseLifecycleLock, @@ -56,6 +57,10 @@ import { writeInstallationManifest, } from './platform-registry.ts'; import { trackedPlatformWorkerTraffic } from './platform-traffic.ts'; +import { + assertProjectZeroTrustAllowsUninstall, + type PlatformProjectZeroTrustState, +} from './platform-zero-trust-uninstall.ts'; export type PlatformManagementClient = Pick< PlatformCloudflareClient, @@ -144,6 +149,14 @@ export type PlatformManagementDependencies = Readonly<{ confirm?: (message: string, initialValue: boolean) => Promise; databaseUrl?: string; interactive?: boolean; + /** Uninstall refuses to start when this is missing or cannot read the state. */ + readProjectZeroTrust?: () => Promise; + /** Holds off Zero Trust writers from the final check until traffic is disabled. */ + lockProjectZeroTrust?: () => Promise<() => Promise>; + /** Makes Zero Trust writers that later get the lock refuse; called while it is held. */ + fenceProjectZeroTrust?: () => Promise; + /** Removes that fence when uninstall stops before it disables traffic. */ + liftProjectZeroTrustFence?: () => Promise; }>; export function selectAuthoritativePlatformManifest( @@ -228,6 +241,14 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise + readPlatformProjectZeroTrustState(databaseUrl, manifest.id, manifest.ownershipNonce), + lockProjectZeroTrust: databaseLock?.lockProjectZeroTrust, + fenceProjectZeroTrust: databaseLock?.fenceProjectZeroTrust, + liftProjectZeroTrustFence: databaseLock?.liftProjectZeroTrustFence, }); return; } @@ -393,6 +414,7 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise Object.assign(async () => {}, { checkpoint: databaseLock?.checkpoint, + liftProjectZeroTrustFence: databaseLock?.liftProjectZeroTrustFence, prepareDatabase: databaseLock?.prepareDatabase, prepareSandboxDrain: databaseLock?.prepareSandboxDrain, readAuthenticationState: databaseLock?.readAuthenticationState, @@ -792,6 +814,7 @@ export async function uninstallPlatform( `platform: Refusing to delete Cloudflare zone ${JSON.stringify(createdZone.name)} automatically. A zone can contain unrelated DNS, settings, and products that the installer cannot exhaustively prove ownership of. Uninstall with --keep-zone, inspect the retained zone in Cloudflare, and delete it manually if appropriate.`, ); } + await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); const dataMessage = retainData ? 'Retain installer-owned Workers, storage, dispatch, AI logs, and Hyperdrive by default.' : args.purgeData @@ -864,13 +887,48 @@ export async function uninstallPlatform( missingResources = new Set(); } - let next = transitionInstallation(manifest, 'uninstalling', new Date().toISOString()); - writeInstallationManifest(next); + // Zero Trust writers in the API take this lock and never the lifecycle lock. + // Holding it from the final check until traffic is disabled keeps them from + // creating Access applications the check did not see. A request admitted + // before the disable may already be waiting on the lock, so the fence makes + // every later holder refuse. Only enabling the platform again lifts it once + // the disable write has started. + const { fenceProjectZeroTrust, liftProjectZeroTrustFence, lockProjectZeroTrust } = dependencies; + if (!lockProjectZeroTrust || !fenceProjectZeroTrust || !liftProjectZeroTrustFence) { + throw new Error( + 'platform: Refusing to uninstall because no platform database connection is available to hold off project Zero Trust changes.', + ); + } + const releaseProjectZeroTrust = await lockProjectZeroTrust(); + let fenced = false; + let disablingTraffic = false; + const stopProjectZeroTrustHold = async () => { + if (fenced && !disablingTraffic) { + await liftProjectZeroTrustFence().catch((error: unknown) => { + log.warn( + `Could not reopen project Zero Trust changes after the uninstall stopped: ${error instanceof Error ? error.message : String(error)}\nRun \`void platform uninstall\` again, or \`void platform repair\` to keep the platform.`, + ); + }); + } + await releaseProjectZeroTrust(); + }; + let next = manifest; + try { + await assertProjectZeroTrustAllowsUninstall(dependencies.readProjectZeroTrust); + fenced = true; + await fenceProjectZeroTrust(); + next = transitionInstallation(manifest, 'uninstalling', new Date().toISOString()); + writeInstallationManifest(next); + } catch (error) { + await stopProjectZeroTrustHold(); + throw error; + } try { await writeRemoteManifestIfPresent(client, next); const routing = next.resources.find( (resource) => resource.key === 'routing-kv' && resource.state === 'ready', ); + disablingTraffic = true; if (routing) { await client.putKvValue( next.account.id, @@ -879,6 +937,7 @@ export async function uninstallPlatform( PLATFORM_DISABLED_VALUE, ); } + await releaseProjectZeroTrust(); for (const key of missingResources) { const resource = requireResource(next, key); next = updateResourceState( @@ -994,6 +1053,7 @@ export async function uninstallPlatform( : `${next.deployment.displayName} was uninstalled.`, ); } catch (error) { + await stopProjectZeroTrustHold().catch(() => {}); next = transitionInstallation(next, 'failed', new Date().toISOString()); writeInstallationManifest(next); await writeRemoteManifestIfPresent(client, next).catch(() => {}); diff --git a/packages/void/src/cli/platform-zero-trust-uninstall.ts b/packages/void/src/cli/platform-zero-trust-uninstall.ts new file mode 100644 index 000000000..8c8571793 --- /dev/null +++ b/packages/void/src/cli/platform-zero-trust-uninstall.ts @@ -0,0 +1,339 @@ +/** + * Uninstall turns off the platform API, and only that API can remove the + * Cloudflare Access applications project Zero Trust created. The shared + * project application covers every hostname under the application domain, so + * a left-over one keeps asking for sign-in on hosts Void no longer serves. + * Uninstall therefore reads the saved Zero Trust state straight from + * PostgreSQL, which works whether or not the API still answers, and refuses + * while anything remains to clean up or the state cannot be read. + */ + +// Mirrors the platform setting key in platform/packages/api/src/zero-trust/configuration.ts. +const ZERO_TRUST_SETTING = 'zeroTrust.configuration'; +// Mirrors lockZeroTrustConfiguration in platform/packages/api/src/zero-trust/lock.ts. +const ZERO_TRUST_LOCK = "hashtext('zero-trust:configuration')"; +// Mirrors PLATFORM_UNINSTALL_SETTING in platform/packages/api/src/zero-trust/lock.ts. +const UNINSTALL_SETTING = 'platform.uninstall'; +const LISTED_APPLICATIONS = 20; + +type QueryConnection = Readonly<{ + query: = Record>( + sql: string, + values?: ReadonlyArray, + ) => Promise<{ rows: ReadonlyArray }>; +}>; + +export type PlatformZeroTrustApplication = Readonly<{ + role: string; + id: string | null; + name: string | null; + /** The owning project stopped partway through deletion. */ + deleting?: boolean; +}>; + +export type PlatformProjectZeroTrustState = Readonly<{ + /** Zero Trust is enabled, or a configure, reconcile, or disable operation is unfinished. */ + active: boolean; + /** Hostname patterns of the shared project application, when it is recorded. */ + destinations: ReadonlyArray; + /** Void-managed Access applications that may still exist in Cloudflare. */ + applications: ReadonlyArray; + /** Projects whose deletion stopped before it removed their applications. */ + unfinishedDeletions: ReadonlyArray; +}>; + +type ProjectApplicationRow = Readonly<{ + project_id: string; + project_slug?: string | null; + project_status?: string | null; + bypass_application_id: string | null; + bypass_application_name: string | null; + protected_application_id: string | null; + protected_application_name: string | null; +}>; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function optionalText(value: unknown, label: string): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value !== 'string') { + throw new Error(`the saved ${label} is malformed`); + } + return value; +} + +function savedApplication( + value: unknown, + label: string, +): Readonly<{ id: string; name: string; destinations: ReadonlyArray }> | null { + if (value === null || value === undefined) { + return null; + } + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.name !== 'string') { + throw new Error(`the saved ${label} is malformed`); + } + const destinations = Array.isArray(value.destinations) + ? value.destinations.filter((entry): entry is string => typeof entry === 'string') + : []; + return { id: value.id, name: value.name, destinations }; +} + +/** + * Interpret the saved configuration and project rows. Anything that does not + * have the expected shape throws, so the caller refuses instead of guessing. + */ +export function projectZeroTrustUninstallState( + setting: unknown, + projectRows: ReadonlyArray, +): PlatformProjectZeroTrustState { + const applications: Array = []; + let active = false; + let destinations: ReadonlyArray = []; + if (setting !== null && setting !== undefined) { + if (!isRecord(setting) || typeof setting.enabled !== 'boolean') { + throw new Error('the saved Zero Trust configuration is malformed'); + } + const operation = setting.operation ?? null; + if (operation !== null && !isRecord(operation)) { + throw new Error('the saved Zero Trust operation is malformed'); + } + active = setting.enabled || operation !== null; + const wildcard = savedApplication(setting.wildcardApplication, 'shared project application'); + const health = savedApplication(setting.healthApplication, 'health check exception'); + // Names are saved before the application is created, so an unfinished + // operation may own an application whose id was never recorded. + const wildcardName = optionalText(setting.wildcardApplicationName, 'application name'); + const healthName = optionalText(setting.healthApplicationName, 'application name'); + if (wildcard || (active && wildcardName)) { + applications.push({ + role: 'shared project application', + id: wildcard?.id ?? null, + name: wildcard?.name ?? wildcardName, + }); + } + if (health || (active && healthName)) { + applications.push({ + role: 'health check exception', + id: health?.id ?? null, + name: health?.name ?? healthName, + }); + } + destinations = wildcard?.destinations ?? []; + } + const unfinishedDeletions: Array = []; + for (const row of projectRows) { + const deleting = row.project_status === 'deleting'; + const owned = applications.length; + if (row.bypass_application_id || row.bypass_application_name) { + applications.push({ + role: `public exception for ${row.project_id}`, + id: row.bypass_application_id, + name: row.bypass_application_name, + ...(deleting ? { deleting } : {}), + }); + } + if (row.protected_application_id || row.protected_application_name) { + applications.push({ + role: `custom domain application for ${row.project_id}`, + id: row.protected_application_id, + name: row.protected_application_name, + ...(deleting ? { deleting } : {}), + }); + } + if (deleting && applications.length > owned) { + unfinishedDeletions.push(row.project_slug ?? row.project_id); + } + } + return { active, destinations, applications, unfinishedDeletions }; +} + +/** Read the project Zero Trust state from the platform database. */ +export async function readPlatformProjectZeroTrustStateConnection( + client: QueryConnection, +): Promise { + const tables = await client.query<{ settings: boolean; projects: boolean }>( + `SELECT to_regclass('public.platform_settings') IS NOT NULL AS "settings", + to_regclass('public.project_zero_trust') IS NOT NULL AS "projects"`, + ); + const present = tables.rows[0]; + if (!present) { + throw new Error('the database did not report its Zero Trust tables'); + } + const setting = present.settings + ? ( + await client.query<{ value: unknown }>( + 'SELECT "value" FROM "platform_settings" WHERE "key" = $1', + [ZERO_TRUST_SETTING], + ) + ).rows[0]?.value + : undefined; + const projectRows = present.projects + ? ( + await client.query( + `SELECT z."project_id", p."slug" AS "project_slug", p."status" AS "project_status", + z."bypass_application_id", z."bypass_application_name", + z."protected_application_id", z."protected_application_name" + FROM "project_zero_trust" z + LEFT JOIN "projects" p ON p."id" = z."project_id" + WHERE z."bypass_application_id" IS NOT NULL + OR z."bypass_application_name" IS NOT NULL + OR z."protected_application_id" IS NOT NULL + OR z."protected_application_name" IS NOT NULL + ORDER BY z."project_id"`, + ) + ).rows + : []; + return projectZeroTrustUninstallState(setting, projectRows); +} + +/** + * Take the Zero Trust configuration lock exclusively on this session. Every + * API path that records or creates a Void-managed Access application holds + * the same lock, so none can commit until the returned release runs. + */ +export async function lockPlatformProjectZeroTrustConnection( + client: QueryConnection, +): Promise<() => Promise> { + await client.query(`SELECT pg_advisory_lock(${ZERO_TRUST_LOCK})`); + let released = false; + return async () => { + if (released) { + return; + } + released = true; + await client.query(`SELECT pg_advisory_unlock(${ZERO_TRUST_LOCK})`); + }; +} + +async function hasSettingsTable(client: QueryConnection): Promise { + const result = await client.query<{ present: boolean }>( + `SELECT to_regclass('public.platform_settings') IS NOT NULL AS "present"`, + ); + return result.rows[0]?.present === true; +} + +/** + * Record that uninstall passed its final check. Runs while the configuration + * lock is held. An API request admitted before traffic was disabled may be + * waiting on that lock; once it gets the lock it reads this record and + * refuses. Without the settings table the API has no Zero Trust writers. + */ +export async function fencePlatformProjectZeroTrustConnection( + client: QueryConnection, +): Promise { + if (!(await hasSettingsTable(client))) { + return; + } + await client.query( + `INSERT INTO "platform_settings" ("key", "value") VALUES ($1, $2::jsonb) + ON CONFLICT ("key") DO UPDATE SET "value" = EXCLUDED."value", "updated_at" = now()`, + [UNINSTALL_SETTING, JSON.stringify({ startedAt: new Date().toISOString() })], + ); +} + +/** + * Remove the uninstall record. Uninstall does this when it stops before + * disabling traffic; installing or enabling does it before traffic opens. + */ +export async function liftPlatformProjectZeroTrustFenceConnection( + client: QueryConnection, +): Promise { + if (!(await hasSettingsTable(client))) { + return; + } + await client.query('DELETE FROM "platform_settings" WHERE "key" = $1', [UNINSTALL_SETTING]); +} + +function formatApplications(applications: ReadonlyArray): string { + const lines = applications + .slice(0, LISTED_APPLICATIONS) + .map( + (application) => + ` ${application.role}: ${application.name ?? '(name not recorded)'}${application.id ? ` (${application.id})` : ''}`, + ); + if (applications.length > LISTED_APPLICATIONS) { + lines.push(` …and ${applications.length - LISTED_APPLICATIONS} more`); + } + return lines.join('\n'); +} + +const DASHBOARD_HINT = + 'To lift sign-in right away, delete these applications in the Cloudflare dashboard under Zero Trust → Access → Applications.'; + +// Hourly maintenance skips projects being deleted, so only finishing the +// deletion removes their applications and records. +function unfinishedDeletionLines(state: PlatformProjectZeroTrustState): ReadonlyArray { + if (!state.unfinishedDeletions.length) { + return []; + } + return [ + `Deletion of ${state.unfinishedDeletions.length === 1 ? 'project' : 'projects'} ${state.unfinishedDeletions.join(', ')} stopped before it removed their Access applications. Rerun \`void project delete \` for each, or delete it again from the admin dashboard. This removes their applications, including ones already deleted in the Cloudflare dashboard, and Void's records of them.`, + ]; +} + +/** + * Refuse to uninstall while project Zero Trust still owns Access applications, + * or while its state cannot be read. Runs before uninstall changes anything. + */ +export async function assertProjectZeroTrustAllowsUninstall( + read: (() => Promise) | undefined, +): Promise { + let state: PlatformProjectZeroTrustState; + try { + if (!read) { + throw new Error('no platform database connection is available'); + } + state = await read(); + } catch (error) { + const reason = (error instanceof Error ? error.message : String(error)).replace( + /^platform: /, + '', + ); + throw new Error( + [ + `platform: Refusing to uninstall because the project Zero Trust state could not be read from the platform database: ${reason}`, + 'Uninstall turns off the platform API, which Void needs to remove the Cloudflare Access applications that project Zero Trust created.', + 'Make the installation database reachable (export VOID_PLATFORM_DATABASE_URL if this machine has not recorded it), then retry. If project Zero Trust was ever enabled, run `void platform zero-trust disable` first.', + ].join('\n'), + ); + } + if (state.active) { + const covered = state.destinations.length + ? ` on ${state.destinations.join(' and ')}` + : ' on every hostname under the application domain'; + throw new Error( + [ + 'platform: Refusing to uninstall while project Zero Trust is enabled or still changing.', + `Uninstall turns off the platform API, so Void could no longer remove its Cloudflare Access applications, and Access would keep asking for sign-in${covered}, including hostnames Void does not serve.`, + 'Run `void platform zero-trust disable`, rerun it until `void platform zero-trust status` reports Zero Trust disabled with no operation running, then retry the uninstall.', + 'If the platform API does not answer, bring it back with `void platform enable` or `void platform repair` first.', + ...(state.applications.length + ? ['Void-managed Access applications:', formatApplications(state.applications)] + : []), + ...unfinishedDeletionLines(state), + DASHBOARD_HINT, + ].join('\n'), + ); + } + if (state.applications.length) { + throw new Error( + [ + 'platform: Refusing to uninstall while Void-managed Access applications from project Zero Trust remain.', + 'Zero Trust is disabled, but it could not remove the applications of some projects. Retry the uninstall once they are gone.', + 'Void-managed Access applications:', + formatApplications(state.applications), + ...(state.applications.some((application) => !application.deleting) + ? [ + `Hourly maintenance retries ${state.unfinishedDeletions.length ? 'the other applications' : 'them'} while the platform is enabled, and clears Void's records of any you delete in the dashboard.`, + ] + : []), + ...unfinishedDeletionLines(state), + DASHBOARD_HINT, + ].join('\n'), + ); + } +} diff --git a/packages/void/src/cli/project-args.ts b/packages/void/src/cli/project-args.ts index c187462dc..6ea07dc3c 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 | protect | public | 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' | 'protect' | 'public' | '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', 'protect', 'public', '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' | 'protect' | 'public' | '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..6130c2f07 --- /dev/null +++ b/packages/void/src/cli/project-zero-trust.ts @@ -0,0 +1,97 @@ +import { intro, log, note, outro } from '@clack/prompts'; +import { getToken } from './auth.ts'; +import { + PlatformClient, + type ProjectZeroTrustResponse, + type ProjectZeroTrustUpdateResponse, +} 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' | 'protect' | 'public' | 'reconcile'; +}; + +async function resolveTarget(root: string, args: ZeroTrustArgs) { + const token = getToken(root); + if (!token) { + throw new Error('project: Not logged in. Run `void account login` first.'); + } + 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); + 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}`, + ...(result.platformStatus === 'not_configured' || + result.platformStatus === 'invalid_configuration' + ? [] + : [`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; + } + const result: ProjectZeroTrustUpdateResponse = + args.action === 'reconcile' + ? await client.reconcileProjectZeroTrust(projectId) + : await client.setProjectZeroTrust(projectId, args.action === 'protect'); + 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 === 'protect' + ? `${slug} is protected by Zero Trust.` + : args.action === 'public' + ? `${slug} is public.` + : `${slug} Zero Trust reconciled (${result.project.state}).`, + ); +} diff --git a/packages/void/test/integration/cli-help.test.ts b/packages/void/test/integration/cli-help.test.ts index 6bc08ae44..302e9248b 100644 --- a/packages/void/test/integration/cli-help.test.ts +++ b/packages/void/test/integration/cli-help.test.ts @@ -120,6 +120,20 @@ describe('void help (subprocess)', () => { expect(owner.status).toBe(0); expect(owner.output).toContain('void platform project owner '); expect(owner.output).toContain('--plan'); + + const projectZeroTrust = runVoid(root, ['project', 'zero-trust', 'protect', '--help']); + expect(projectZeroTrust.status).toBe(0); + expect(projectZeroTrust.output).toContain('void project zero-trust protect'); + 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 '); + expect(platformZeroTrust.output).not.toContain('--account'); + const platformZeroTrustStatus = runVoid(root, ['platform', 'zero-trust', 'status', '--help']); + expect(platformZeroTrustStatus.status).toBe(0); + expect(platformZeroTrustStatus.output).toContain('--check'); }); 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..4938184e6 100644 --- a/packages/void/test/unit/operator-cli.test.ts +++ b/packages/void/test/unit/operator-cli.test.ts @@ -61,7 +61,24 @@ 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' + ? [ + '--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 +86,103 @@ 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', + '--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: { + identityProviderIds: ['idp-1', 'idp-2'], + policyIds: ['policy-1', 'policy-2'], + existingProjects: 'public', + protectNewProjects: true, + }, + }); + expect(parseOperatorArgs(['zero-trust', 'project-protect', 'project/id'])).toMatchObject({ + path: '/zero-trust/projects/project%2Fid', + body: { protected: true }, + }); + expect(parseOperatorArgs(['zero-trust', 'project-public', 'project/id'])).toMatchObject({ + body: { protected: false }, + }); + // Reconfiguring an enabled installation omits the existing-project choice. + const reconfigure = parseOperatorArgs([ + 'zero-trust', + 'configure', + '--identity-providers', + 'idp-1', + '--policies', + 'policy-1', + '--token-stdin', + ]); + expect(reconfigure.body).toEqual({ + identityProviderIds: ['idp-1'], + policyIds: ['policy-1'], + protectNewProjects: false, + }); + expect(() => + parseOperatorArgs([ + 'zero-trust', + 'configure', + '--account', + 'account-1', + '--identity-providers', + 'idp-1', + '--policies', + 'policy-1', + '--token-stdin', + ]), + ).toThrow("Unknown flag '--account'"); + expect(parseOperatorArgs(['zero-trust', 'status'])).toMatchObject({ + method: 'GET', + path: '/zero-trust', + query: {}, + body: {}, + }); + expect(parseOperatorArgs(['zero-trust', 'status', '--check'])).toMatchObject({ + method: 'GET', + query: { check: 'true' }, + body: {}, + }); + expect(() => + parseOperatorArgs([ + 'zero-trust', + 'configure', + '--identity-providers', + 'idp-1', + '--policies', + 'policy-1', + '--existing-projects', + 'public', + ]), + ).toThrow('requires --token-stdin'); + expect(() => + parseOperatorArgs([ + 'zero-trust', + 'configure', + '--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..7ac841220 100644 --- a/packages/void/test/unit/platform-domain.test.ts +++ b/packages/void/test/unit/platform-domain.test.ts @@ -10,9 +10,13 @@ import { resolvePlatformApplicationEnvironment, type PlatformInstallationManifest, } from '@void/platform'; -import { describe, expect, it, vi } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import type { PlatformCloudflareClient } from '../../src/cli/platform-cloudflare.ts'; -import { applyPlatformDomain, platformDomainTarget } from '../../src/cli/platform-domain.ts'; +import { + activatePlatformDomain, + applyPlatformDomain, + platformDomainTarget, +} from '../../src/cli/platform-domain.ts'; import type { PlatformInstallPlan } from '../../src/cli/platform-plan.ts'; function fixture() { @@ -103,6 +107,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 +179,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 = { @@ -184,8 +193,14 @@ describe('platform domain activation', () => { await expect( applyPlatformDomain(client, f.current(), f.target, resumedPlan, f.dependencies), ).rejects.toThrow('TLS pending'); + // Project Zero Trust leaves only the platform health hostname outside Access. + expect(f.dependencies.probe).toHaveBeenLastCalledWith( + 'void-platform-health.example.com', + expect.anything(), + ); 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 +238,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(); @@ -278,3 +315,25 @@ describe('platform domain activation', () => { ).toThrow('pending setup'); }); }); + +describe('platform domain activation handoff', () => { + afterEach(() => vi.unstubAllGlobals()); + + it.each([ + [ + 'an older runtime without the handoff', + new Response('Not Found', { status: 404 }), + ], + ['a non-JSON response', new Response('upstream unavailable', { status: 502 })], + ])('asks for a runtime update after %s', async (_name, response) => { + const f = fixture(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => response), + ); + + await expect(activatePlatformDomain(f.target, f.manifest)).rejects.toThrow( + `void platform upgrade ${f.manifest.id}`, + ); + }); +}); diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index dae984566..eed19d589 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -576,6 +576,85 @@ describe('platform installer', () => { }, ); + it('lifts a left-over uninstall fence before traffic opens', async () => { + const directory = mkdtempSync(join(tmpdir(), 'void-platform-uninstall-fence-test-')); + directories.push(directory); + process.env.VOID_PLATFORM_REGISTRY_DIR = directory; + const plan = await createPlatformInstallPlan( + planningClient(), + { + accountId: 'account', + applicationDomain: '', + zoneName: '', + workersDev: true, + controlPlaneDomain: '', + dedicatedZone: false, + displayName: 'Fenced', + installationSlug: 'fenced', + }, + null, + ); + let index = 0; + const create = async (_account: string, name: string) => ({ + id: `resource-${index++}`, + name, + }); + const events: Array = []; + const result = await installPlatform( + plan, + createPlatformInstallationSecrets({ + bootstrapGitHubId: '583231', + bootstrapGitHubLogin: 'octocat', + cloudflareApiToken: 'test-token', + databaseUrl: 'postgres://user:password@db.example.com/fenced', + githubClientId: 'id', + githubClientSecret: 'secret', + jwtSecret: TEST_PLATFORM_JWT_SECRET, + projectSecretKey: TEST_PLATFORM_PROJECT_SECRET_KEY, + r2AccessKeyId: 'id', + r2SecretAccessKey: 'secret', + }), + null, + { + claimDatabase, + acquireDatabaseLifecycleLock: async () => + Object.assign(async () => {}, { + prepareSandboxDrain: async () => sandboxDrainPreparation('complete'), + liftProjectZeroTrustFence: async () => { + events.push('lift fence'); + }, + }), + client: { + createAiGateway: create, + createDnsRecord: async () => { + throw new Error('no DNS expected'); + }, + createZone: async () => { + throw new Error('no zone expected'); + }, + createKvNamespace: create, + createR2Bucket: create, + createQueue: create, + createDispatchNamespace: create, + createHyperdrive: create, + putKvValue: async () => {}, + deleteKvValue: async (_account, _namespace, key) => { + if (key === PLATFORM_DISABLED_KEY) { + events.push('enable traffic'); + } + }, + putR2Object: async () => {}, + }, + deployWorker: async () => {}, + migrateDatabase: async () => {}, + revalidateBeforeWorkerDeploy: async () => {}, + verifyHealth: async () => {}, + }, + ); + expect(result.manifest.status).toBe('installed'); + expect(events).toEqual(['lift fence', 'enable traffic']); + }); + it('keeps traffic disabled and the verified Worker set when shared email bootstrap blocks', async () => { const directory = mkdtempSync(join(tmpdir(), 'void-platform-email-bootstrap-test-')); directories.push(directory); @@ -2139,7 +2218,7 @@ describe('platform installer', () => { 'https://api.example.com/health', 'https://api.example.com/.well-known/void-platform', 'https://proxy.example.com/health', - 'https://test.apps.example.com/health', + 'https://void-platform-health.apps.example.com/health', 'https://email.example.com/health', ]); expect( @@ -2964,6 +3043,79 @@ describe('platform installer', () => { await lock(); }); + it('reads project Zero Trust state on the held lifecycle connection', async () => { + const queries: Array<{ sql: string; values?: ReadonlyArray }> = []; + const connection = { + query: async >( + sql: string, + values?: ReadonlyArray, + ) => { + queries.push({ sql, values }); + if (sql.includes('FROM "__void_platform_installation"')) { + return { + rows: [ + { installation_id: 'install-one', ownership_nonce: 'nonce-one' }, + ] as unknown as ReadonlyArray, + }; + } + if (sql.includes('pg_try_advisory_lock')) { + return { rows: [{ acquired: true }] as unknown as ReadonlyArray }; + } + if (sql.includes('public.platform_settings')) { + return { + rows: [ + { settings: true, projects: true, present: true }, + ] as unknown as ReadonlyArray, + }; + } + if (sql.includes('FROM "platform_settings"')) { + return { + rows: [ + { value: { enabled: false, operation: { kind: 'disable' } } }, + ] as unknown as ReadonlyArray, + }; + } + return { rows: [] as ReadonlyArray }; + }, + }; + const lock = await acquirePlatformDatabaseLifecycleLockConnection( + connection, + 'install-one', + 'nonce-one', + ); + await expect(lock.readProjectZeroTrust!()).resolves.toEqual({ + active: true, + applications: [], + destinations: [], + unfinishedDeletions: [], + }); + expect(queries.find(({ sql }) => sql.includes('FROM "platform_settings"'))?.values).toEqual([ + 'zeroTrust.configuration', + ]); + expect(queries.some(({ sql }) => sql.includes('FROM "project_zero_trust"'))).toBe(true); + + // The same key the API's Zero Trust writers lock, held on this session. + const unlock = await lock.lockProjectZeroTrust!(); + await unlock(); + await unlock(); + expect( + queries.filter(({ sql }) => sql.includes("hashtext('zero-trust:configuration')")), + ).toEqual([ + { sql: "SELECT pg_advisory_lock(hashtext('zero-trust:configuration'))", values: undefined }, + { sql: "SELECT pg_advisory_unlock(hashtext('zero-trust:configuration'))", values: undefined }, + ]); + + // Uninstall records itself where the API lock holders read it, and removes it. + await lock.fenceProjectZeroTrust!(); + await lock.liftProjectZeroTrustFence!(); + const fence = queries.find(({ sql }) => sql.startsWith('INSERT INTO "platform_settings"')); + expect(fence?.values?.[0]).toBe('platform.uninstall'); + expect( + queries.find(({ sql }) => sql.startsWith('DELETE FROM "platform_settings"'))?.values, + ).toEqual(['platform.uninstall']); + await lock(); + }); + it('claims only an empty database or an existing matching claim', async () => { const queries: Array = []; const connection = (claim: ReadonlyArray>, hasContents: boolean) => ({ diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index 1b9139cfb..da3882dbb 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -15,6 +15,8 @@ const state = vi.hoisted(() => ({ manifest: undefined as PlatformInstallationManifest | undefined, prepareDatabase: vi.fn(), readAuthenticationState: vi.fn(), + readPlanZeroTrust: vi.fn(), + readProjectZeroTrust: vi.fn(), releaseDatabase: vi.fn(), spinner: vi.fn(), progress: { start: vi.fn(), message: vi.fn(), stop: vi.fn(), error: vi.fn(), clear: vi.fn() }, @@ -73,6 +75,10 @@ vi.mock('../../src/cli/platform-cloudflare.ts', async (importOriginal) => { async listWorkerNames() { return []; } + + async putKvValue(_account: string, _namespace: string, key: string) { + state.events.push(`put:${key}`); + } }, }; }); @@ -92,6 +98,7 @@ vi.mock('../../src/cli/platform-installer.ts', async (importOriginal) => { checkpoint: vi.fn(), prepareDatabase: state.prepareDatabase, readAuthenticationState: state.readAuthenticationState, + readProjectZeroTrust: state.readProjectZeroTrust, }, ); return release; @@ -129,6 +136,7 @@ vi.mock('../../src/cli/platform-installer.ts', async (importOriginal) => { }; }), loadPlatformRuntime: vi.fn(() => state.runtime), + readPlatformProjectZeroTrustState: state.readPlanZeroTrust, resolveBootstrapGitHubId: vi.fn(), }; }); @@ -179,7 +187,10 @@ vi.mock('../../src/cli/platform-registry.ts', async (importOriginal) => { }); import { log, note } from '@clack/prompts'; -import { installPlatform } from '../../src/cli/platform-installer.ts'; +import { + acquirePlatformDatabaseLifecycleLock, + installPlatform, +} from '../../src/cli/platform-installer.ts'; import { runInstalledPlatformCommand } from '../../src/cli/platform-management.ts'; import { createPlatformInstallPlan } from '../../src/cli/platform-plan.ts'; @@ -500,4 +511,65 @@ describe('installed platform upgrade', () => { expect(onCancel).toThrow('test exit'); expect(exit).toHaveBeenCalledWith(130); }); + + describe('uninstall with project Zero Trust', () => { + const enabled = { + active: true, + destinations: ['*.apps.example.com'], + applications: [{ role: 'shared project application', id: 'app', name: 'void-app' }], + unfinishedDeletions: [], + }; + const uninstall = (plan: boolean) => + runInstalledPlatformCommand({ + deleteZone: null, + plan, + purgeData: true, + selector: state.manifest!.id, + subcommand: 'uninstall', + yes: true, + }); + + it('reads the state under the held lifecycle lock and refuses before disabling traffic', async () => { + state.readProjectZeroTrust.mockResolvedValueOnce(enabled); + await expect(uninstall(false)).rejects.toThrow( + 'Refusing to uninstall while project Zero Trust is enabled', + ); + expect(state.readProjectZeroTrust).toHaveBeenCalledOnce(); + expect(state.readPlanZeroTrust).not.toHaveBeenCalled(); + expect(state.events).toEqual(['outer-release', 'local-release']); + }); + + it('refuses when the held lock cannot read the state', async () => { + state.readProjectZeroTrust.mockRejectedValueOnce(new Error('relation is locked')); + await expect(uninstall(false)).rejects.toThrow( + 'could not be read from the platform database: relation is locked', + ); + expect(state.events).toEqual(['outer-release', 'local-release']); + }); + + it('reads the state for a plan without taking the lifecycle lock', async () => { + state.readPlanZeroTrust.mockResolvedValueOnce(enabled); + await expect(uninstall(true)).rejects.toThrow( + 'Refusing to uninstall while project Zero Trust is enabled', + ); + expect(state.readPlanZeroTrust).toHaveBeenCalledExactlyOnceWith( + 'postgres://database', + state.manifest!.id, + state.manifest!.ownershipNonce, + ); + expect(acquirePlatformDatabaseLifecycleLock).not.toHaveBeenCalled(); + expect(state.events).toEqual([]); + }); + + it('plans the uninstall when Zero Trust owns nothing', async () => { + state.readPlanZeroTrust.mockResolvedValueOnce({ + active: false, + destinations: [], + applications: [], + }); + await uninstall(true); + expect(note).toHaveBeenCalledWith(expect.any(String), 'Uninstall plan'); + expect(state.events).toEqual([]); + }); + }); }); diff --git a/packages/void/test/unit/platform-management.test.ts b/packages/void/test/unit/platform-management.test.ts index 6203b89b2..242f17815 100644 --- a/packages/void/test/unit/platform-management.test.ts +++ b/packages/void/test/unit/platform-management.test.ts @@ -36,6 +36,10 @@ import { writePendingInstallationSecrets, } from '../../src/cli/platform-registry.ts'; import { recordPlatformWorkerTraffic } from '../../src/cli/platform-traffic.ts'; +import { + projectZeroTrustUninstallState, + readPlatformProjectZeroTrustStateConnection, +} from '../../src/cli/platform-zero-trust-uninstall.ts'; const directories: Array = []; @@ -301,6 +305,489 @@ const uninstallArgs = { yes: true, }; +const unlockedProjectZeroTrust = { + lockProjectZeroTrust: async () => async () => {}, + fenceProjectZeroTrust: async () => {}, + liftProjectZeroTrustFence: async () => {}, +}; + +const uninstallDependencies = { + interactive: false, + ...unlockedProjectZeroTrust, + readProjectZeroTrust: async () => projectZeroTrustUninstallState(undefined, []), +}; + +const wildcardName = 'void-example-a1b2c3d4-0123456789abcdef-aaaa-project-zero-trust'; +const healthName = 'void-example-a1b2c3d4-0123456789abcdef-bbbb-platform-health'; + +function zeroTrustSetting(overrides: Record = {}) { + return { + version: 1, + revision: 4, + enabled: true, + protectNewProjects: false, + accountId: 'account', + teamDomain: 'https://example.cloudflareaccess.com', + identityProviderIds: ['idp'], + policyIds: ['policy'], + credential: { ciphertext: 'c', iv: 'i', keyVersion: 'v1' }, + wildcardApplicationName: wildcardName, + wildcardApplication: { + id: 'wildcard-app', + name: wildcardName, + audience: 'wildcard-aud', + destinations: ['*.apps.example.com'], + }, + healthApplicationName: healthName, + healthApplication: { + id: 'health-app', + name: healthName, + audience: 'health-aud', + destinations: ['void-platform-health.apps.example.com/health'], + }, + operation: null, + lastError: null, + updatedAt: '2026-09-30T00:00:00.000Z', + ...overrides, + }; +} + +const disabledZeroTrust = zeroTrustSetting({ + enabled: false, + wildcardApplication: null, + healthApplication: null, +}); + +function projectApplications(projectId: string, bypass: string | null, custom: string | null) { + return { + project_id: projectId, + bypass_application_id: bypass ? `${bypass}-id` : null, + bypass_application_name: bypass, + protected_application_id: custom ? `${custom}-id` : null, + protected_application_name: custom, + }; +} + +describe('uninstall with project Zero Trust', () => { + function expectNothingChanged(fake: ReturnType) { + expect(fake.disabledWrites).toEqual([]); + expect(fake.remoteWrites).toEqual([]); + expect(fake.deleted).toEqual([]); + expect(fake.removedRoutes).toBe(0); + expect(listInstallationManifests()[0]?.status).toBe('installed'); + } + + it('refuses before any change while Zero Trust is enabled', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const error = await uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + readProjectZeroTrust: async () => + projectZeroTrustUninstallState(zeroTrustSetting(), [ + projectApplications('proj_public', 'void-example-public-proj_public', null), + ]), + }).catch((reason: unknown) => reason as Error); + + expect(error).toBeInstanceOf(Error); + expect(error!.message).toContain('Refusing to uninstall while project Zero Trust is enabled'); + expect(error!.message).toContain('`void platform zero-trust disable`'); + expect(error!.message).toContain('*.apps.example.com'); + expect(error!.message).toContain(`shared project application: ${wildcardName} (wildcard-app)`); + expect(error!.message).toContain(`health check exception: ${healthName} (health-app)`); + expect(error!.message).toContain( + 'public exception for proj_public: void-example-public-proj_public', + ); + expect(error!.message).toContain('Zero Trust → Access → Applications'); + expectNothingChanged(fake); + }); + + it('shows the same refusal in the plan', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + await expect( + uninstallPlatform( + fake, + manifest, + { ...uninstallArgs, plan: true }, + { + interactive: false, + readProjectZeroTrust: async () => projectZeroTrustUninstallState(zeroTrustSetting(), []), + }, + ), + ).rejects.toThrow('Refusing to uninstall while project Zero Trust is enabled'); + expectNothingChanged(fake); + }); + + it('refuses while an unfinished operation may still own applications', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const error = await uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + readProjectZeroTrust: async () => + projectZeroTrustUninstallState( + zeroTrustSetting({ + enabled: false, + wildcardApplication: null, + healthApplication: null, + operation: { id: 'operation', kind: 'configure', phase: 'wildcard' }, + }), + [], + ), + }).catch((reason: unknown) => reason as Error); + expect(error!.message).toContain('Refusing to uninstall while project Zero Trust is enabled'); + expect(error!.message).toContain(`shared project application: ${wildcardName}`); + expectNothingChanged(fake); + }); + + it('refuses while a finished disable left project applications behind', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const error = await uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + readProjectZeroTrust: async () => + projectZeroTrustUninstallState(disabledZeroTrust, [ + projectApplications('proj_skipped', 'void-example-public-proj_skipped', null), + ]), + }).catch((reason: unknown) => reason as Error); + expect(error!.message).toContain( + 'Refusing to uninstall while Void-managed Access applications from project Zero Trust remain', + ); + expect(error!.message).toContain( + 'public exception for proj_skipped: void-example-public-proj_skipped (void-example-public-proj_skipped-id)', + ); + expect(error!.message).not.toContain(wildcardName); + expectNothingChanged(fake); + }); + + it('points to finishing the deletion of a project that stopped partway', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const read = (rows: Parameters[1]) => () => + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...unlockedProjectZeroTrust, + readProjectZeroTrust: async () => projectZeroTrustUninstallState(disabledZeroTrust, rows), + }).catch((reason: unknown) => reason as Error); + const deleting = { + ...projectApplications('proj_gone', 'void-example-public-proj_gone', null), + project_slug: 'gone-app', + project_status: 'deleting', + }; + + const onlyDeleting = (await read([deleting])())!; + expect(onlyDeleting.message).toContain( + 'public exception for proj_gone: void-example-public-proj_gone', + ); + expect(onlyDeleting.message).toContain('Deletion of project gone-app stopped'); + expect(onlyDeleting.message).toContain('Rerun `void project delete `'); + expect(onlyDeleting.message).not.toContain('Hourly maintenance'); + + const mixed = (await read([ + deleting, + { + ...projectApplications('proj_live', 'void-example-public-proj_live', null), + project_slug: 'live-app', + project_status: 'active', + }, + ])())!; + expect(mixed.message).toContain('Deletion of project gone-app stopped'); + expect(mixed.message).not.toContain('live-app'); + expect(mixed.message).toContain('Hourly maintenance retries the other applications'); + expectNothingChanged(fake); + }); + + it('refuses when the Zero Trust state cannot be read', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const error = await uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + readProjectZeroTrust: async () => { + throw new Error('platform: connect ECONNREFUSED'); + }, + }).catch((reason: unknown) => reason as Error); + expect(error!.message).toContain( + 'Refusing to uninstall because the project Zero Trust state could not be read from the platform database: connect ECONNREFUSED', + ); + expect(error!.message).toContain('VOID_PLATFORM_DATABASE_URL'); + expectNothingChanged(fake); + + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { interactive: false }), + ).rejects.toThrow('no platform database connection is available'); + expectNothingChanged(fake); + }); + + it('refuses when the saved configuration is malformed', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + readProjectZeroTrust: async () => + projectZeroTrustUninstallState(zeroTrustSetting({ enabled: 'yes' }), []), + }), + ).rejects.toThrow('the saved Zero Trust configuration is malformed'); + expectNothingChanged(fake); + }); + + it.each([ + ['was never configured', undefined], + ['is fully disabled', disabledZeroTrust], + ])('proceeds when Zero Trust %s', async (_label, setting) => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + await uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...unlockedProjectZeroTrust, + readProjectZeroTrust: async () => + projectZeroTrustUninstallState(setting, [projectApplications('proj_settled', null, null)]), + }); + expect(fake.disabledWrites).toEqual([PLATFORM_DISABLED_VALUE]); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + // Models the PostgreSQL advisory lock and the uninstall fence: an API Zero + // Trust writer that asks for the lock while uninstall holds it gets it only + // after the release, then refuses if the fence is still in place. + function zeroTrustLock(events: Array) { + let release: (() => void) | undefined; + let free = Promise.resolve(); + let fence = false; + return { + get fenced() { + return fence; + }, + dependencies: { + lockProjectZeroTrust: async () => { + await free; + free = new Promise((resolve) => { + release = resolve; + }); + events.push('lock'); + return async () => { + events.push('unlock'); + release!(); + }; + }, + fenceProjectZeroTrust: async () => { + fence = true; + events.push('fence'); + }, + liftProjectZeroTrustFence: async () => { + fence = false; + events.push('lift fence'); + }, + }, + writer: async () => { + await free; + events.push(fence ? 'zero-trust write refused' : 'zero-trust write'); + }, + }; + } + + it('refuses Zero Trust writers that were waiting on the lock once traffic is disabled', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const events: Array = []; + const putKvValue = fake.putKvValue; + fake.putKvValue = async (account, namespace, key, value) => { + if (key === PLATFORM_DISABLED_KEY) { + events.push('disable traffic'); + } + await putKvValue(account, namespace, key, value); + }; + const gate = zeroTrustLock(events); + let writer: Promise | undefined; + await uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...gate.dependencies, + readProjectZeroTrust: async () => { + events.push('check'); + if (events.includes('lock')) { + // A configure request admitted before traffic was disabled arrives + // right after the final check and waits on the lock. + writer = gate.writer(); + } + return projectZeroTrustUninstallState(disabledZeroTrust, []); + }, + }); + await writer; + expect(events).toEqual([ + 'check', + 'lock', + 'check', + 'fence', + 'disable traffic', + 'unlock', + 'zero-trust write refused', + ]); + expect(gate.fenced).toBe(true); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('lifts the fence when uninstall stops before it disables traffic', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const events: Array = []; + fake.putKvValue = async () => { + throw new Error('KV write failed'); + }; + const gate = zeroTrustLock(events); + let writer: Promise | undefined; + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...gate.dependencies, + readProjectZeroTrust: async () => { + if (events.includes('lock')) { + writer = gate.writer(); + } + return projectZeroTrustUninstallState(disabledZeroTrust, []); + }, + }), + ).rejects.toThrow('KV write failed'); + await writer; + // The platform still serves traffic, so the waiting writer proceeds. + expect(events).toEqual(['lock', 'fence', 'lift fence', 'unlock', 'zero-trust write']); + expect(gate.fenced).toBe(false); + expect(fake.disabledWrites).toEqual([]); + expect(listInstallationManifests()[0]?.status).toBe('failed'); + }); + + it('keeps the fence when uninstall fails after it started disabling traffic', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const events: Array = []; + const putKvValue = fake.putKvValue; + fake.putKvValue = async (account, namespace, key, value) => { + if (key === PLATFORM_DISABLED_KEY) { + events.push('disable traffic'); + throw new Error('KV write failed after it was applied'); + } + await putKvValue(account, namespace, key, value); + }; + const gate = zeroTrustLock(events); + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...gate.dependencies, + readProjectZeroTrust: async () => projectZeroTrustUninstallState(disabledZeroTrust, []), + }), + ).rejects.toThrow('KV write failed after it was applied'); + // Only enabling the platform again lifts the fence. + expect(events).toEqual(['lock', 'fence', 'disable traffic', 'unlock']); + expect(gate.fenced).toBe(true); + expect(listInstallationManifests()[0]?.status).toBe('failed'); + }); + + it('checks again under the lock and refuses when Zero Trust changed after the first check', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + const events: Array = []; + const gate = zeroTrustLock(events); + const states = [ + projectZeroTrustUninstallState(disabledZeroTrust, []), + projectZeroTrustUninstallState( + zeroTrustSetting({ + enabled: false, + wildcardApplication: null, + healthApplication: null, + operation: { id: 'operation', kind: 'configure', phase: 'seed' }, + }), + [], + ), + ]; + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...gate.dependencies, + readProjectZeroTrust: async () => states.shift()!, + }), + ).rejects.toThrow('Refusing to uninstall while project Zero Trust is enabled'); + expect(events).toEqual(['lock', 'unlock']); + expect(gate.fenced).toBe(false); + expectNothingChanged(fake); + }); + + it('refuses without a database session that can hold off Zero Trust changes', async () => { + const manifest = installedManifest(); + persist(manifest); + const fake = client(manifest); + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + readProjectZeroTrust: async () => projectZeroTrustUninstallState(undefined, []), + }), + ).rejects.toThrow('no platform database connection is available'); + const { fenceProjectZeroTrust: _fence, ...withoutFence } = unlockedProjectZeroTrust; + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...withoutFence, + readProjectZeroTrust: async () => projectZeroTrustUninstallState(undefined, []), + }), + ).rejects.toThrow('no platform database connection is available'); + expectNothingChanged(fake); + }); +}); + +describe('project Zero Trust database read', () => { + function connection(tables: { settings: boolean; projects: boolean }, setting: unknown) { + const queries: Array = []; + return { + queries, + query: async (sql: string, values?: ReadonlyArray) => { + queries.push(sql); + if (sql.includes('to_regclass')) { + return { rows: [tables] }; + } + if (sql.includes('"platform_settings"')) { + expect(values).toEqual(['zeroTrust.configuration']); + return { rows: setting === undefined ? [] : [{ value: setting }] }; + } + return { + rows: [projectApplications('proj_public', 'void-example-public-proj_public', null)], + }; + }, + }; + } + + it('treats a database without Zero Trust tables as never configured', async () => { + const db = connection({ settings: false, projects: false }, undefined); + await expect(readPlatformProjectZeroTrustStateConnection(db as never)).resolves.toEqual({ + active: false, + applications: [], + destinations: [], + unfinishedDeletions: [], + }); + expect(db.queries).toHaveLength(1); + }); + + it('reads the saved configuration and project applications', async () => { + const db = connection({ settings: true, projects: true }, zeroTrustSetting()); + const state = await readPlatformProjectZeroTrustStateConnection(db as never); + expect(state.active).toBe(true); + expect(state.destinations).toEqual(['*.apps.example.com']); + expect(state.applications.map((application) => application.role)).toEqual([ + 'shared project application', + 'health check exception', + 'public exception for proj_public', + ]); + }); +}); + describe('platform lifecycle management', () => { it('preflights only a runtime token that will be applied', () => { const updatePlan = { @@ -344,7 +831,7 @@ describe('platform lifecycle management', () => { const fake = client(manifest); await expect( - uninstallPlatform(fake, manifest, { ...uninstallArgs, yes: false }, { interactive: false }), + uninstallPlatform(fake, manifest, { ...uninstallArgs, yes: false }, uninstallDependencies), ).rejects.toThrow('requires --yes'); expect(fake.deleted).toEqual([]); expect(fake.removedRoutes).toBe(0); @@ -791,9 +1278,7 @@ describe('platform lifecycle management', () => { const manifest = installedManifest(); persist(manifest); const fake = client(manifest); - await uninstallPlatform(fake, manifest, uninstallArgs, { - interactive: false, - }); + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); expect(fake.deleted).toEqual([]); expect(fake.deleted.some(([kind]) => kind === 'zone')).toBe(false); const result = listInstallationManifests()[0]!; @@ -861,7 +1346,7 @@ describe('platform lifecycle management', () => { { hostname: 'manual.example.com', id: 'manual-domain', service: 'proxy-worker' }, ]; - await uninstallPlatform(fake, manifest, uninstallArgs, { interactive: false }); + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); expect(fake.deleted.some(([kind]) => kind === 'worker')).toBe(false); expect(fake.removedRoutes).toBe(4); @@ -882,7 +1367,7 @@ describe('platform lifecycle management', () => { type: 'CNAME', })); - await uninstallPlatform(fake, manifest, uninstallArgs, { interactive: false }); + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); expect(fake.deleted).toEqual([]); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -897,9 +1382,7 @@ describe('platform lifecycle management', () => { const manifest = installedManifest(); persist(manifest); const fake = client(manifest); - await uninstallPlatform(fake, manifest, uninstallArgs, { - interactive: false, - }); + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); expect(fake.deleted).toEqual([]); expect(fake.remoteWrites.map((value) => JSON.parse(value).status)).toContain('uninstalled'); const retained = listInstallationManifests()[0]!.resources.filter( @@ -918,7 +1401,7 @@ describe('platform lifecycle management', () => { fake, manifest, { ...uninstallArgs, deleteZone: true }, - { interactive: false }, + uninstallDependencies, ), ).rejects.toThrow('Refusing to delete Cloudflare zone'); expect(fake.deleted).toEqual([]); @@ -934,9 +1417,7 @@ describe('platform lifecycle management', () => { fake, manifest, { ...uninstallArgs, purgeData: true, deleteZone: true }, - { - interactive: false, - }, + uninstallDependencies, ); expect(fake.deleted).toHaveLength(5); expect(fake.deleted.some(([kind]) => kind === 'queue')).toBe(false); @@ -957,7 +1438,7 @@ describe('platform lifecycle management', () => { fake, manifest, { ...uninstallArgs, purgeData: true }, - { interactive: false }, + uninstallDependencies, ); expect(fake.emptiedBuckets).toEqual([]); expect(fake.deletedBucketMarkers).toEqual([]); @@ -976,7 +1457,7 @@ describe('platform lifecycle management', () => { JSON.stringify({ ...manifest, ownershipNonce: 'different-ownership-nonce-000000' }); await expect( - uninstallPlatform(fake, manifest, uninstallArgs, { interactive: false }), + uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies), ).rejects.toThrow('remote installation identity does not match'); expect(fake.removedRoutes).toBe(0); expect(fake.deleted).toEqual([]); @@ -999,7 +1480,7 @@ describe('platform lifecycle management', () => { }; await expect( - uninstallPlatform(fake, manifest, uninstallArgs, { interactive: false }), + uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies), ).rejects.toThrow('belongs to another installation'); expect(fake.removedRoutes).toBe(0); expect(fake.deleted).toEqual([]); @@ -1016,7 +1497,7 @@ describe('platform lifecycle management', () => { fake, manifest, { ...uninstallArgs, purgeData: true }, - { interactive: false }, + uninstallDependencies, ); expect(fake.emptiedBuckets).toEqual([]); expect(fake.deleted).not.toContainEqual(['r2-bucket', 'assets-bucket-id']); @@ -1039,7 +1520,7 @@ describe('platform lifecycle management', () => { first, manifest, { ...uninstallArgs, purgeData: true }, - { interactive: false }, + uninstallDependencies, ), ).rejects.toThrow('temporary deletion failure'); const checkpoint = listInstallationManifests()[0]!; @@ -1051,7 +1532,7 @@ describe('platform lifecycle management', () => { second, checkpoint, { ...uninstallArgs, purgeData: true }, - { interactive: false }, + uninstallDependencies, ); expect(second.deleted.filter(([kind]) => kind === 'worker')).toEqual([]); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1066,7 +1547,7 @@ describe('platform lifecycle management', () => { fake, manifest, { ...uninstallArgs, purgeData: true }, - { interactive: false }, + uninstallDependencies, ); expect(fake.deleted.some(([kind]) => kind === 'queue')).toBe(false); @@ -1103,7 +1584,7 @@ describe('platform lifecycle management', () => { fake, manifest, { ...uninstallArgs, purgeData: true }, - { interactive: false }, + uninstallDependencies, ); expect(fake.deleted.some(([kind]) => kind === 'queue')).toBe(false); @@ -1125,7 +1606,7 @@ describe('platform lifecycle management', () => { persist(manifest); const fake = client(manifest); - await uninstallPlatform(fake, manifest, uninstallArgs, { interactive: false }); + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); expect(fake.deleted).toEqual([ ['queue', 'cron-queue-id'], @@ -1150,7 +1631,7 @@ describe('platform lifecycle management', () => { first, manifest, { ...uninstallArgs, purgeData: true }, - { interactive: false }, + uninstallDependencies, ), ).rejects.toThrow('connection closed after routing deletion'); const checkpoint = listInstallationManifests()[0]!; @@ -1174,7 +1655,7 @@ describe('platform lifecycle management', () => { second, checkpoint, { ...uninstallArgs, purgeData: true }, - { interactive: false }, + uninstallDependencies, ); expect(second.deleted).toEqual([]); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1187,7 +1668,7 @@ describe('platform lifecycle management', () => { }; persist(manifest); const fake = client(manifest); - await uninstallPlatform(fake, manifest, uninstallArgs, { interactive: false }); + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); expect(fake.removedRoutes).toBe(0); expect(fake.deleted.some(([kind]) => kind === 'worker')).toBe(false); expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); @@ -1207,7 +1688,7 @@ describe('platform lifecycle management', () => { }; persist(manifest); const fake = client(manifest); - await uninstallPlatform(fake, manifest, uninstallArgs, { interactive: false }); + await uninstallPlatform(fake, manifest, uninstallArgs, uninstallDependencies); expect(fake.deleted).toEqual([]); expect(listInstallationManifests()[0]).toMatchObject({ status: 'uninstalled' }); expect(listInstallationManifests()[0]!.resources[0]).toMatchObject({ state: 'retained' }); diff --git a/packages/void/test/unit/platform-permission-errors.test.ts b/packages/void/test/unit/platform-permission-errors.test.ts index 3ea04b5b4..7ebb5897c 100644 --- a/packages/void/test/unit/platform-permission-errors.test.ts +++ b/packages/void/test/unit/platform-permission-errors.test.ts @@ -66,6 +66,9 @@ describe('PlatformClient permission errors', () => { ['email usage', (c) => c.getEmailUsage(project), reader], ['email destination add', (c) => c.addEmailDestination(project, 'target@example.test'), admin], ['email domain removal', (c) => c.removeEmailDomain(project, 'example.test'), admin], + ['Zero Trust status', (c) => c.getProjectZeroTrust(project), reader], + ['Zero Trust update', (c) => c.setProjectZeroTrust(project, true), admin], + ['Zero Trust reconcile', (c) => c.reconcileProjectZeroTrust(project), admin], ]; it.each(cases)( 'explains required access for %s on older APIs', diff --git a/packages/void/test/unit/project-args.test.ts b/packages/void/test/unit/project-args.test.ts index 8f0d04670..d6773aee2 100644 --- a/packages/void/test/unit/project-args.test.ts +++ b/packages/void/test/unit/project-args.test.ts @@ -120,6 +120,37 @@ 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', 'protect', 'public', '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', () => { + for (const action of ['remove', 'enable', 'disable']) { + expect(() => parseProjectArgs(['zero-trust', action])).toThrow( + 'status|protect|public|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..02baaaab5 --- /dev/null +++ b/packages/void/test/unit/project-zero-trust-command.test.ts @@ -0,0 +1,257 @@ +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: 'public', 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'); + // The default for new projects is meaningful only for a readable configuration. + expect(rendered.includes('Default for new projects: protected')).toBe( + platformStatus !== 'invalid_configuration', + ); + }); + + 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: 'protect', + projectSlug: '', + }); + await runProjectCommand(root, { + subcommand: 'zero-trust', + action: 'public', + 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..129fbc3ce --- /dev/null +++ b/platform/meta/design-docs/0083-project-zero-trust.md @@ -0,0 +1,215 @@ +--- +title: Project Cloudflare Zero Trust protection +status: implemented +created: 2026-09-22 +--- + +# Project Cloudflare Zero Trust protection + +## Decision + +A self-hosted installation can put Cloudflare Access in front of project +hostnames. Installation administrators choose identity providers, reusable +policies, and the default for new projects. Owners and project administrators +override one project (`protect` / `public`); readers inspect. This is separate +from the Access applications that protect the platform API or provide an OIDC +login method. + +Direct Cloudflare deployments get no project protection. They share the cache +change below: a request with `Cf-Access-Jwt-Assertion` bypasses the direct ISR +KV and edge caches, and `SHARED_CACHE_POLICY_VERSION` 2 makes direct ISR entries +written under version 1 unreadable, so they render once after upgrade. + +## Resources and credentials + +Void owns only applications recorded in PostgreSQL by ID or by a name committed +before the create request (names carry a random nonce, so recovery never adopts +a pre-existing application). When a recorded ID is gone, the single application +holding its name is Void's own replacement and is adopted: + +- one wildcard application for the project domain and workers.dev project + origins (limited to the installation's ingress prefix); +- one exact-host bypass per public project; +- one exact application per protected project with custom domains (at most + 100 domains); +- with a zone, one host-and-path bypass for the health probe path + `void-platform-health./health` (`healthApplicationName`, + `healthApplication`). + +Footprint: 1 + (1 with a zone) + public projects + protected projects with +custom domains, against Cloudflare's default 500 applications per account. + +Health probes (admin system health, installer verification, domain +activation) send the probe token to `/health` on the health hostname. Access +treats the more specific host-and-path application as overriding the wildcard +for that path and its subpaths +([application paths](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/app-paths/)); +every other path on the health hostname, like every other project-domain +hostname, gets the wildcard's login redirect. Dispatch answers the token probe +before routing and answers every other request under `/health` on this hostname +with a non-cacheable 404, matching the broadest reading of Access path matching +(raw and normalized URL, any case, plain prefixes such as `/healthz`). All other +paths are routed normally. The slug is reserved for new projects, but an +installation may already have a project that owns it. That project keeps every +path except `/health`, under the same Access applications as any other +project hostname, so dispatch's route-based Access check is not its only gate +while route metadata propagates. A health application saved with the whole +hostname as its destination is drift: `status --check` reports it and +reconcile updates it in place. The wildcard phase creates the +bypass before the wildcard; `wildcard-delete` deletes it right after the +wildcard. A configuration saved without its name gets one committed (separate +CAS write) before the create. `status --check` reports it missing as drift. + +The Access token is separate from the runtime token, always used with the +installation's `CF_ACCOUNT_ID`, encrypted with the platform credential keyring, +and redacted from reads, previews, and audit targets. Protection policies need +an identity-bound Allow policy; Bypass, Service Auth, Everyone, and +any-service-token rules are rejected. + +## State + +`zeroTrust.configuration` (platform setting) holds: `revision` (compare-and-swap; +every write increments it, so equal revision means equal snapshot), `enabled`, +`protectNewProjects`, selections, account/team, credential, the lifetime +`wildcardApplicationName`, the wildcard handle, `lastError`, and `operation` +(`id`, `kind`, `phase`, lease owner/expiry, database `startedAt`, +`existingProjects`, optional `applicationTarget`). Status is not stored: +`error` if `lastError`, else `ready` without an operation, else `configuring` +or `disabling` by kind. A corrupt saved value is server state: reads report +`invalid_configuration`; operator/admin mutations return 500 naming the +setting, project mutations return 409. + +`project_zero_trust` holds desired/effective protection, `status` +(`ready|reconciling|error`, with `ready` implying desired = effective), +application handles, routing cursor, `reconciled_operation`, and last error. +Users see one label: `public`/`protected` when ready, otherwise the status. + +`--existing-projects` is required when enabling from disabled, rejected once +enabled, and must be omitted or equal while an operation is pending (token +replacement). Projects that existed when an enabling operation started follow +that choice; later projects follow `protectNewProjects`. + +## Locks + +- `lockZeroTrustConfiguration(db, 'shared' | 'exclusive')`: project work, + deploy fences, and route publication hold it shared; installation claims, + checkpoints, wildcard mutations, and completion hold it exclusive. +- `withProjectZeroTrustLock(db, projectId, work)`: `projects FOR UPDATE` plus + the shared lock in one transaction. Errors from `work` roll back; errors + inside its `recordErrors(step)` mark the row failed, commit, then rethrow. + User mutations recheck the `settings` capability under this lock. + +## Ordering (fail closed) + +Public: bypass, then remove JWT enforcement from live routes, then mark public, +then delete the protected application. Protected: exact application if needed, +then publish audiences into live routes, then mark protected, then delete the +bypass. A project is never public before its bypass exists. + +Every route rewrite (deploy, rollback, suspension, ownership, domain +activation, backfill, compensation) takes Zero Trust metadata from PostgreSQL, +not from KV. The publication resolver never fails open: unsettled or invalid +protection publishes an empty audience set (dispatch denies) for desired or +effective protected rows; a public row with a recorded bypass stays public; +unknown rows make domain activation keep the domain pending and every other +writer publish deny-all. A cached route whose `projectId` names another project +(reused slug or hostname) is never rewritten; the writer fails and retries. + +Dispatch re-verifies `Cf-Access-Jwt-Assertion` against the team JWKS (no +redirects; RS256, issuer, audience, `iat`, `exp`, `type=app`; exact custom +domain audience before wildcard). Failures return a non-cacheable 403 before +the user Worker or assets run, so a deleted or drifted Access application +cannot silently expose a protected project. + +## Admission and control plane + +Deploys run the strict check after manifest validation and before paid +provisioning, and again inside the cutover fence; rollbacks check too. +Recovery that restores already-committed code uses the publication resolver, +so pending protection cannot block it. Project creation succeeds with a warning +when initialization fails. + +Configure and first-domain activation reject a wildcard that would cover the +API (request URL on configure, `AUTH_REDIRECT_URI`), `PROXY_URL`, or `EMAIL_GATEWAY_ORIGIN` +(`*` matches across labels). A replacement token for a pending disable skips +this check. First-domain activation is an API handoff: the operation saves its +target, prepares bypasses, updates the wildcard and protected routes, and only +then publishes `PLATFORM_APPLICATION_KEY`. + +## Operations and retries + +Configure: `seed`, `public-bypasses` (finishes each public project), `wildcard`, +`projects` (protected projects, after the audience is final). Disable: `seed`, +`projects` (clear enforcement and protected applications, keep bypasses), +`wildcard-delete`, `bypass-delete`. One run handles at most 100 projects and +300 routing keys, then releases its lease. + +Per-project failures are recorded on the row with a `skipped:` +marker and skipped. Cloudflare 401/403/429/5xx, network errors, timeouts, and +a replaced operation fail the whole operation. During configure, a skipped +protected row keeps its protection and a row becoming protected keeps its +previous routes. During disable, the row is already public by intent: routes +cleared before the failure open with every other project at wildcard +deletion, and uncleared routes stay denied until the post-disable cleanup. +First-domain activation never publishes its target while a project is skipped: +such a project lacks the new hostnames (a protected route has no audience for +them, a public bypass does not cover them). Completion instead clears the skip +markers, records an error naming the projects and releases the lease, so the +hourly tier, `reconcile` or the rerun domain command retries those projects. +The fast tier does not, because the operation is in error. + +The two-minute tier resumes an operation with a free lease that has not failed +and then owns that invocation. The hourly tier also retries failed operations, +pending project rows (errored rows move behind others), and, after a finished +disable, clears protection left on skipped rows. + +Custom domain removal, by a user or by the hourly cleanup of pending domains +that never validated, takes the shared configuration lock right after the +project row lock, before it deletes the Cloudflare hostname or the route, so a +refusal during uninstall leaves the domain whole. It marks a settled row that +records a custom domain application as `reconciling` in the transaction that +deletes the domain; a pending domain can already be listed there, because +activation adds it before it publishes the route. The follow-up reconcile then +rewrites the application; if it fails or never runs, the hourly tier does. A freed hostname left in the old application is fail +closed: a new owner's visitors meet the installation's policies, and dispatch +checks the new owner's audience. + +## Status and removal + +Status reads return saved state. The live check (account, selections, +wildcard) runs in configure and reconcile, and only on request for status +(`status --check`, admin `?check=1`). The project detail page never calls +Cloudflare. + +Project deletion removes its Access applications after retryable cleanup and +before the database cascade. Uninstall does not remove Void-managed Access +applications; administrators disable first. Uninstall turns off the API that +can remove them, so before any change, and in `--plan`, the CLI reads +`zeroTrust.configuration` and `project_zero_trust` straight from PostgreSQL and +refuses while Zero Trust is enabled, an operation is pending, or any row still +records an application handle (rows a finished disable skipped). It also +refuses when the state cannot be read. The error lists the recorded +application names for dashboard cleanup. After confirmation, +uninstall takes `zero-trust:configuration` exclusively as a session lock on +its lifecycle connection, checks again, and holds it until the routing KV +disable write. API Zero Trust writers take that lock, never the lifecycle +lock, so none can commit between the check and the disable write. A request +that passed the routing KV disable check before that write, or at a colo that +has not seen it yet, can still be waiting on the lock. So once the check +passes, uninstall writes the `platform.uninstall` platform setting on the same +session before it disables traffic. `lockZeroTrustConfiguration` reads that +setting in a new statement after it gets the lock, in both modes, and refuses +with `ZeroTrustUnavailableError` while it exists. Every Zero Trust writer, +deployment, and route publication goes through that function, so nothing +creates or records an Access application after the final check. If uninstall +stops before it starts the disable write, it removes the setting before it +releases the lock. After that point only installing or enabling the platform +removes it, just before it deletes the routing KV disable key; this also clears +a setting left in a database reused after an uninstall. Disable and +hourly maintenance skip projects marked `deleting`, so for those rows the +error asks for the project deletion to be rerun; its Access cleanup is +idempotent and the cascade drops the row. + +Code: `platform/packages/api/src/zero-trust/` (`installation`, `operation`, +`project`, `routing`, `maintenance`, `status`, `lock`, `state`); the uninstall +check is `packages/void/src/cli/platform-zero-trust-uninstall.ts`. 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..bde71a37a 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) @@ -353,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 page (saved state; the "Check with Cloudflare" link adds `?check=1` for a live Access check) with configure, reconcile, and disable, plus a Zero Trust section on the project detail page (saved state only) with protect, make public, and reconcile - 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/drizzle/0053_project_zero_trust.sql b/platform/packages/api/drizzle/0053_project_zero_trust.sql new file mode 100644 index 000000000..ad7ec9fbf --- /dev/null +++ b/platform/packages/api/drizzle/0053_project_zero_trust.sql @@ -0,0 +1,21 @@ +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, + "status" text DEFAULT 'ready' 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_status_check" CHECK ("project_zero_trust"."status" IN ('ready', 'reconciling', 'error')), + CONSTRAINT "project_zero_trust_ready_check" CHECK ("project_zero_trust"."status" <> 'ready' OR "project_zero_trust"."desired_protected" = "project_zero_trust"."effective_protected") +); +--> 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_status" ON "project_zero_trust" USING btree ("status","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..81b780b24 --- /dev/null +++ b/platform/packages/api/drizzle/meta/0053_snapshot.json @@ -0,0 +1,6153 @@ +{ + "id": "bf6f46b9-6b39-4a16-9eae-71897ae0882b", + "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 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "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_status": { + "name": "idx_project_zero_trust_status", + "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": {} + } + }, + "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_status_check": { + "name": "project_zero_trust_status_check", + "value": "\"project_zero_trust\".\"status\" IN ('ready', 'reconciling', 'error')" + }, + "project_zero_trust_ready_check": { + "name": "project_zero_trust_ready_check", + "value": "\"project_zero_trust\".\"status\" <> 'ready' OR \"project_zero_trust\".\"desired_protected\" = \"project_zero_trust\".\"effective_protected\"" + } + }, + "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/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..a31c5709b 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,49 @@ 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, + zeroTrustConfigurationStatus, + } = await import('../../zero-trust/configuration.ts'); + const { getProjectZeroTrustState, projectZeroTrustAvailable, publicProjectZeroTrustState } = + await import('../../zero-trust/state.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 ? zeroTrustConfigurationStatus(saved) : 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 +208,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 +435,44 @@ 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. `?check=1` adds the live Cloudflare Access check. + */ +export async function loadZeroTrustPage(context: AdminContext, path: string) { + const url = pageLocation(path, '/admin/zero-trust'); + const { zeroTrustStatus } = await import('../../zero-trust/status.ts'); + const status = await zeroTrustStatus(context.env, context.get('db'), { + check: url.searchParams.get('check') === '1', + }); + 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, + 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, + checked: 'checked' in status ? status.checked : false, + 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..3ce5c8730 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, + teamDomain: true, + identityProviderIds: true, + policyIds: true, + protectNewProjects: true, + operation: true, + lastError: true, + checked: 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..f7744c150 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, + teamDomain: true, + identityProviderIds: true, + policyIds: true, + protectNewProjects: true, + operation: true as never, + lastError: true, + checked: 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..3d2899dd0 --- /dev/null +++ b/platform/packages/api/src/admin/react/views/ZeroTrustPage.tsx @@ -0,0 +1,283 @@ +/** @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( + enabled: boolean, + checked: boolean, + live: Readonly<{ present: boolean; drifted: boolean }> | null, +): string { + if (!enabled) { + return 'Not active'; + } + if (!checked || !live) { + return 'Not checked'; + } + 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, + teamDomain, + identityProviderIds, + policyIds, + protectNewProjects, + operation, + lastError, + checked, + live, + projects, + } = pageData; + // The existing-project choice applies only when enabling, or when a + // replacement token resumes an enabling operation. + const choosesExistingProjects = operation ? operation.existingProjects !== null : !enabled; + 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(enabled, checked, live)} + {enabled && !checked && ( + <> + {' '} + Check with Cloudflare + + )} +
+
+
+
+
+ +
+ + + 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 on the Cloudflare account that hosts this platform. +

+ {operation && ( +

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

+ )} +
+
+ + + + + {choosesExistingProjects && ( + + 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 efa46c933..5a44a341d 100644 --- a/platform/packages/api/src/admin/routes/projects.tsx +++ b/platform/packages/api/src/admin/routes/projects.tsx @@ -17,9 +17,14 @@ import { previewOperatorProjectOwner, transferOperatorProjectOwner, } from '../../operator/tenant-service.ts'; +import { zeroTrustOperatorError } from '../../operator/zero-trust-routes.ts'; import { getPlatformDisplayName } from '../../platform-identity.ts'; import { deliverInvitation } from '../../routes/project-team.ts'; import type { ProjectMemberRole } from '../../types.ts'; +import { + reconcileProjectZeroTrust, + setProjectZeroTrustProtection, +} from '../../zero-trust/project.ts'; import { renderFateDetailPage, renderFateListPage } from '../fate/render.ts'; import { renderOperationError } from '../operation-error.tsx'; import { renderAdminPage } from '../react/server.ts'; @@ -62,6 +67,10 @@ function teamMutationError(error: unknown): never { throw error; } +function projectZeroTrustError(error: unknown): never { + throw zeroTrustOperatorError(error, 'admin project operation failed'); +} + // GET /projects app.get('/', (context) => renderFateListPage(context, 'projects')); @@ -206,6 +215,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)}#zero-trust`), + 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)}#zero-trust`), + 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..d9719baf6 --- /dev/null +++ b/platform/packages/api/src/admin/routes/zero-trust.tsx @@ -0,0 +1,91 @@ +import { Hono } from 'hono'; +import type { ApiEnv } from '../../env.ts'; +import { executeAuditedMutation, operatorAuditIntent } from '../../operator/audit.ts'; +import { zeroTrustOperatorError } from '../../operator/zero-trust-routes.ts'; +import { normalizeZeroTrustConfigureInput } from '../../zero-trust/configuration.ts'; +import { + configurePlatformZeroTrust, + disablePlatformZeroTrust, + reconcilePlatformZeroTrust, +} from '../../zero-trust/installation.ts'; +import { renderFateListPage } from '../fate/render.ts'; +import { renderOperationError } from '../operation-error.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) => { + await next(); + context.res.headers.set('Cache-Control', 'no-store'); + context.res.headers.set('Referrer-Policy', 'no-referrer'); +}); +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 { + throw zeroTrustOperatorError(error, 'admin operation failed'); +} + +app.get('/', (context) => renderFateListPage(context, 'zeroTrust')); + +app.post('/configure', async (context) => { + const form = await context.req.parseBody(); + let input; + try { + input = normalizeZeroTrustConfigureInput({ + 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, { + requestUrl: context.req.url, + }); + } 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..9b8313ee9 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,15 @@ 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 { resumePlatformZeroTrustOperation } from './zero-trust/installation.ts'; +import { lockZeroTrustConfiguration } from './zero-trust/lock.ts'; +import { reconcilePendingProjectZeroTrust } from './zero-trust/maintenance.ts'; +import { reconcileProjectZeroTrustDomains } from './zero-trust/project.ts'; +import { + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from './zero-trust/routing.ts'; +import { markProjectCustomDomainsChanged } from './zero-trust/state.ts'; function isSandboxDrainMaintenanceRequest(request: Request): boolean { const path = new URL(request.url).pathname; @@ -173,6 +187,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 +308,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 +488,7 @@ ${ .route('/projects', projects) .route('/projects', projectTokens) .route('/projects', projectTeamRoutes) + .route('/projects', projectZeroTrustRoutes) .route('/projects', deploy) .route('/projects', deleteRoute) .route('/projects', revalidate) @@ -495,6 +513,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 +554,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 +570,20 @@ 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. An operation + // already in error waits for the hourly tier or an explicit reconcile. + 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 +609,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 +639,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 +889,31 @@ 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`); + // Like domain removal: refuse during uninstall before any cleanup, and + // record the change for Zero Trust retry with the deletion, because the + // project's custom domain application may already list this hostname. + await lockZeroTrustConfiguration(tx, 'shared'); + 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 markProjectCustomDomainsChanged(txDb, domain.project_id); + 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 +948,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 +995,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 +1043,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, current.id), + ); } } - } + }); } } catch (err) { console.error(`[backfill] userId backfill failed for user ${user.id}:`, err); @@ -1125,4 +1189,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/system-operations.ts b/platform/packages/api/src/operator/system-operations.ts index 6a5243df6..92dfc97d3 100644 --- a/platform/packages/api/src/operator/system-operations.ts +++ b/platform/packages/api/src/operator/system-operations.ts @@ -1,4 +1,4 @@ -import { readPlatformApplication } from '@void/platform'; +import { platformHealthHostname, readPlatformApplication } from '@void/platform'; import { sql } from 'drizzle-orm'; import { backfillQueueTokens } from '../admin/backfill-queue-tokens.ts'; import { getPlatformCapabilities } from '../capabilities.ts'; @@ -150,10 +150,12 @@ export async function systemHealth( const siteDomain = env.SITE_DOMAIN ?? SITE_DOMAIN; const proxyUrl = env.PROXY_URL ?? `https://proxy.${cloudDomain}`; let dispatchUrl: string | null = `https://test.${siteDomain}/health`; - if (installed && env.PLATFORM_APPLICATION) { + if (installed) { const application = readPlatformApplication(env); - if (application.zoneName) { - dispatchUrl = `https://test.${application.baseDomain}/health`; + // Project Zero Trust covers every other project-domain hostname with Access. + const healthHostname = platformHealthHostname(application); + if (healthHostname) { + dispatchUrl = `https://${healthHostname}/health`; } else { const worker = env.DISPATCH_WORKER_NAME; const workersDev = application.workersDev; diff --git a/platform/packages/api/src/operator/tenant-service.ts b/platform/packages/api/src/operator/tenant-service.ts index 21f9b467c..683f33bf8 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, + rewriteProjectRouteMetadata, +} from '../zero-trust/routing.ts'; import { OperatorError, preview } from './common.ts'; type Page = { page: number; limit: number; offset: number }; @@ -671,27 +675,22 @@ 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) { continue; } - const entry = JSON.parse(raw) as Record; - entry.userId = owner.id; - if ('workerName' in entry) { - if (dispatchLimits) { - entry.limits = dispatchLimits; - } else { - delete entry.limits; - } - } - const suspensionReason = routingSuspensionReason(owner.suspended_reason); - if (suspensionReason) { - entry.suspended = suspensionReason; - } else { - delete entry.suspended; - } - await context.env.ROUTING.put(key, JSON.stringify(entry)); + await context.env.ROUTING.put( + key, + rewriteProjectRouteMetadata(JSON.parse(raw) as Record, { + projectId: project.id, + userId: owner.id, + limits: dispatchLimits, + suspended: routingSuspensionReason(owner.suspended_reason), + zeroTrust: 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..d8f4bccb7 --- /dev/null +++ b/platform/packages/api/src/operator/zero-trust-routes.ts @@ -0,0 +1,218 @@ +import { Hono } from 'hono'; +import { getProject, type Db } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { + ZeroTrustConfigurationError, + ZeroTrustSavedConfigurationError, + normalizeZeroTrustConfigureInput, + readZeroTrustConfigurationStrict, + redactedZeroTrustConfiguration, +} from '../zero-trust/configuration.ts'; +import { ZeroTrustUnavailableError } from '../zero-trust/errors.ts'; +import { + assertExistingProjectsChoice, + configurePlatformZeroTrust, + disablePlatformZeroTrust, + reconcilePlatformZeroTrust, +} from '../zero-trust/installation.ts'; +import { reconcileProjectZeroTrust, setProjectZeroTrustProtection } from '../zero-trust/project.ts'; +import { getProjectZeroTrustState, operatorProjectZeroTrustState } from '../zero-trust/state.ts'; +import { zeroTrustStatus } from '../zero-trust/status.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'); + } +} + +/** Map Zero Trust failures for operators; the admin UI uses the same mapping. */ +export function zeroTrustOperatorError(error: unknown, failure: string): OperatorError { + if (error instanceof ZeroTrustSavedConfigurationError) { + // Stored state, not the request, is wrong; retrying the request cannot help. + return new OperatorError(500, error.message, 'invalid_saved_zero_trust_configuration'); + } + if (error instanceof ZeroTrustConfigurationError) { + return new OperatorError(400, error.message, 'invalid_zero_trust_configuration'); + } + if (error instanceof ZeroTrustUnavailableError) { + return new OperatorError(error.status, error.message, 'zero_trust_unavailable'); + } + console.error(`[zero-trust] ${failure}`, error); + return new OperatorError( + 502, + 'Cloudflare Zero Trust reconciliation failed. Inspect the saved status before retrying.', + 'zero_trust_reconciliation_failed', + ); +} + +function operationError(error: unknown): never { + throw zeroTrustOperatorError(error, 'operator operation failed'); +} + +zeroTrustOperatorRoutes.get('/zero-trust', async (context) => + context.json( + await zeroTrustStatus(context.env, context.get('db'), { + check: context.req.query('check') === 'true', + }), + ), +); + +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)) { + // A preview must fail wherever the real configure would reject the choice. + try { + assertExistingProjectsChoice( + await readZeroTrustConfigurationStrict(context.get('db')), + input, + ); + } catch (error) { + operationError(error); + } + return context.json( + preview('zero-trust.configure', 'platform', { + accountId: context.env.CF_ACCOUNT_ID, + identityProviderIds: input.identityProviderIds, + policyIds: input.policyIds, + protectNewProjects: input.protectNewProjects, + existingProjects: input.existingProjects ?? null, + credential: 'replace-encrypted', + }), + ); + } + try { + return context.json({ + ok: true, + configuration: await configurePlatformZeroTrust(context.env, context.get('db'), input, { + requestUrl: context.req.url, + }), + }); + } 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: operatorProjectZeroTrustState( + 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: operatorProjectZeroTrustState( + await getProjectZeroTrustState(context.get('db'), projectId), + ), + protected: body.protected, + }), + ); + } + try { + return context.json({ + ok: true, + project: operatorProjectZeroTrustState( + 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: operatorProjectZeroTrustState( + await getProjectZeroTrustState(context.get('db'), projectId), + ), + }), + ); + } + try { + return context.json({ + ok: true, + project: operatorProjectZeroTrustState( + 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..9a0edcb3d --- /dev/null +++ b/platform/packages/api/src/platform-domain-activation.ts @@ -0,0 +1,47 @@ +import type { Context } from 'hono'; +import type { ApiEnv } from './env.ts'; +import { + parseZeroTrustApplicationTarget, + ZeroTrustConfigurationError, +} from './zero-trust/configuration.ts'; +import { ZeroTrustUnavailableError } from './zero-trust/errors.ts'; +import { activatePlatformApplication } from './zero-trust/installation.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..63d962689 100644 --- a/platform/packages/api/src/provisioning/activate-domain.ts +++ b/platform/packages/api/src/provisioning/activate-domain.ts @@ -9,6 +9,12 @@ import { import type { ApiEnv } from '../env.ts'; import type { DomainVerificationData } from '../schema.ts'; import type { Domain, Project } from '../types.ts'; +import { ZeroTrustUnavailableError } from '../zero-trust/errors.ts'; +import { reconcileProjectZeroTrustDomains } from '../zero-trust/project.ts'; +import { + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from '../zero-trust/routing.ts'; import { type CustomHostnameInfo, getCustomHostname, @@ -93,18 +99,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 +128,22 @@ export async function tryActivateDomain( if (!slugRouting) { return; } - await env.ROUTING.put(`domain:${lockedDomain.hostname}`, slugRouting); + // Activation is retried while the domain stays pending, so unknown + // protection, or a cached slug route that still belongs to a deleted + // project, keeps it pending instead of publishing a route. + let published; + try { + const protection = await resolveProjectZeroTrustRoutingForPublication(txDb, project.id, { + retryable: true, + }); + published = withProjectZeroTrustRouting(slugRouting, protection, project.id); + } catch (error) { + if (error instanceof ZeroTrustUnavailableError) { + return; + } + throw error; + } + await env.ROUTING.put(`domain:${lockedDomain.hostname}`, published); 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..f4c9e7969 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/project.ts'; import { CloudflareApiError, deleteCustomHostname, @@ -801,12 +802,14 @@ 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). 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. + * Then delete the project's Zero Trust Access applications, whose + * handles exist only on the project row. * 4. Cascade-delete the project from the DB (terminalize any build that raced * in after step 1 → domains → resources → secrets → builds → deployments → * project), then best-effort stop the container DOs of any such late build @@ -870,6 +873,11 @@ export async function teardownProject( // here rather than in `routes/delete.ts` so the two admin teardown paths // (admin project delete, admin user delete) get it too. await cleanupProjectEmailDestinations(context, projectId); + // Access application IDs live only on the project row, so deletion must + // succeed before the cascade. It runs after every retryable step above: a + // retried teardown must never leave a routed public project without its + // bypass or a protected one without its exact application. + await cleanupProjectZeroTrust(context.env, context.get('db'), projectId); // deleteProjectCascade re-terminalizes under the project lock to catch a build // that raced in after step 1 committed (build creation takes the same lock), // and returns both that build and every resource row it deleted. diff --git a/platform/packages/api/src/routes/deploy.ts b/platform/packages/api/src/routes/deploy.ts index d3ea3993e..9169d36d9 100644 --- a/platform/packages/api/src/routes/deploy.ts +++ b/platform/packages/api/src/routes/deploy.ts @@ -147,6 +147,12 @@ import { type SandboxMigrationMeta, type WebSocketMigrationMeta, } from '../types.ts'; +import { zeroTrustNotReadyMessage } from '../zero-trust/errors.ts'; +import { + resolveProjectZeroTrustRouting, + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from '../zero-trust/routing.ts'; import { requireProjectRouteCapability } from './project-access.ts'; export { MAX_REVALIDATE_QUERY_ALLOWLIST_ENTRIES, MAX_ROUTING_RULES, validateManifestShape }; @@ -311,6 +317,7 @@ async function replayDeploymentCutover( deployment: Deployment, plan: PlanId, committedProjectType?: Project['type'], + options: Readonly<{ restoreCommitted?: boolean }> = {}, ): Promise { const planLimits = getPlanLimits(plan); if (!deployment.manifest) { @@ -324,6 +331,12 @@ async function replayDeploymentCutover( const existingEntry = existingRouting ? (JSON.parse(existingRouting) as { suspended?: string }) : {}; + // Restoring committed code must not wait for Zero Trust reconciliation. The + // publication resolver fails closed, as deploy compensation does. Promoting + // a new deployment keeps the strict readiness gate. + const zeroTrust = options.restoreCommitted + ? await resolveProjectZeroTrustRoutingForPublication(db, project.id) + : await resolveProjectZeroTrustRouting(db, project.id); let hasAssets = false; if (deployment.asset_hashes) { const assetHashes = JSON.parse(deployment.asset_hashes) as Record; @@ -341,6 +354,7 @@ async function replayDeploymentCutover( } as const), manifest, suspended: existingEntry.suspended, + zeroTrust, }) : buildRoutingJson({ workerName, @@ -353,6 +367,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 @@ -595,6 +610,7 @@ export async function settleDeploymentForSandboxDrain( committedActive, owner.plan, project.type, + { restoreCommitted: true }, ); } else { await clearProjectExternalState(env, txDb, project); @@ -705,6 +721,7 @@ export async function settleDeploymentForSandboxDrain( committedActive, owner.plan, project.type, + { restoreCommitted: true }, ); } else { await clearProjectExternalState(env, txDb, project); @@ -2258,6 +2275,18 @@ async function handleManifestOnlyDeploy(context: Context): Promise): Promise): 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 +3016,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise( @@ -3102,7 +3134,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 +3153,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 +4902,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( @@ -5432,6 +5474,7 @@ async function handleManifestOnlyDeploy(context: Context): Promise): 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 +100,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 +223,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 @@ -308,8 +333,18 @@ const app = new Hono() // CF cleanup is best-effort. KV + DB deletion serialize with deploy, // rollback, activation, and failed-cutover compensation through the // project row so none can resurrect this hostname from a stale snapshot. + // The shared Zero Trust lock comes before any cleanup: when it refuses + // during uninstall, nothing outside the database has been removed yet. const lockedResponse = await context.get('db').transaction(async (tx) => { await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + try { + await lockZeroTrustConfiguration(tx, 'shared'); + } catch (error) { + if (error instanceof ZeroTrustUnavailableError) { + return context.json({ error: error.message }, error.status); + } + throw error; + } const txDb = tx as unknown as Db; const lockedAccess = await requireProjectRouteCapability( context, @@ -334,6 +369,10 @@ const app = new Hono() `Failed to delete routing entry: ${err instanceof Error ? err.message : String(err)}`, ); }); + // The project's custom domain application still lists this hostname. + // Record that with the deletion, so the hostname is never freed while + // no retry would remove it from that application. + await markProjectCustomDomainsChanged(txDb, projectId); await deleteDomain(txDb, domain.id); return null; }); @@ -341,6 +380,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..e831b2420 --- /dev/null +++ b/platform/packages/api/src/routes/project-zero-trust.ts @@ -0,0 +1,153 @@ +import { type Context, Hono } from 'hono'; +import type { ApiEnv } from '../env.ts'; +import { + ProjectAccessDeniedError, + ProjectNotFoundError, + projectAccessErrorBody, +} from '../project-access.ts'; +import { + ZeroTrustConfigurationError, + readZeroTrustConfigurationStrict, + zeroTrustConfigurationStatus, +} from '../zero-trust/configuration.ts'; +import { zeroTrustNotReadyMessage, ZeroTrustUnavailableError } from '../zero-trust/errors.ts'; +import { reconcileProjectZeroTrust, setProjectZeroTrustProtection } from '../zero-trust/project.ts'; +import { + getProjectZeroTrustState, + projectZeroTrustAvailable, + publicProjectZeroTrustState, +} from '../zero-trust/state.ts'; +import { requireProjectRouteCapability } from './project-access.ts'; + +const RECONCILIATION_FAILED_MESSAGE = + 'Cloudflare Access could not be updated. Protection remains fail closed. Run `void project zero-trust reconcile` to retry, or ask a platform administrator to check the Access token.'; + +function zeroTrustErrorResponse( + context: Context, + error: unknown, + failure: string, + projectId: string, +): Response { + if (error instanceof ProjectNotFoundError || error instanceof ProjectAccessDeniedError) { + return context.json(projectAccessErrorBody(error), error.status); + } + if (error instanceof ZeroTrustUnavailableError) { + return context.json( + { + error: error.message, + message: `${error.message} Run \`void project zero-trust status\` for details.`, + }, + error.status, + ); + } + if (error instanceof ZeroTrustConfigurationError) { + // Only a platform administrator can repair the saved configuration. + return context.json( + { error: 'zero trust configuration invalid', message: zeroTrustNotReadyMessage(error) }, + 409, + ); + } + console.error(`[zero-trust] ${failure}`, { projectId, error }); + return context.json( + { error: 'Zero Trust reconciliation failed.', message: RECONCILIATION_FAILED_MESSAGE }, + 502, + ); +} + +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({ + // Whether a project admin can change protection now, not merely whether + // the installation has Zero Trust enabled. + available: projectZeroTrustAvailable(configuration), + platformStatus: configuration + ? zeroTrustConfigurationStatus(configuration) + : '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', + message: 'Send a JSON body such as {"protected":true}.', + }, + 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', message: 'Set "protected" to true or false.' }, + 400, + ); + } + try { + const project = await setProjectZeroTrustProtection( + context.env, + context.get('db'), + projectId, + protectedProject, + { actorUserId: context.get('user').id }, + ); + return context.json({ ok: true, project: publicProjectZeroTrustState(project) }); + } catch (error) { + return zeroTrustErrorResponse(context, error, 'project update failed', projectId); + } + }) + .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, { + actorUserId: context.get('user').id, + }), + ), + }); + } catch (error) { + return zeroTrustErrorResponse(context, error, 'project reconcile failed', projectId); + } + }); + +export default app; diff --git a/platform/packages/api/src/routes/projects.ts b/platform/packages/api/src/routes/projects.ts index 6899fed09..2c34a034d 100644 --- a/platform/packages/api/src/routes/projects.ts +++ b/platform/packages/api/src/routes/projects.ts @@ -1,3 +1,5 @@ +import type { ProjectZeroTrustRouting } from '@void/edge'; +import { PLATFORM_HEALTH_HOSTNAME_LABEL } from '@void/platform'; import { sql as drizzleSql } from 'drizzle-orm'; import { Hono, type Context } from 'hono'; import { deployTokenTtlSecondsForBuildMinutes, signPrerenderToken } from '../auth/deploy-token.ts'; @@ -84,6 +86,14 @@ import { import { tokenizeSql } from '../sql/tokenize.ts'; import { getPlanLimits } from '../tiers.ts'; import type { Build, DeployManifest, Project, ProjectRole, User } from '../types.ts'; +import { zeroTrustNotReadyMessage } from '../zero-trust/errors.ts'; +import { initializeNewProjectZeroTrust } from '../zero-trust/project.ts'; +import { + resolveProjectZeroTrustRouting, + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from '../zero-trust/routing.ts'; +import { publicProjectZeroTrustState } from '../zero-trust/state.ts'; import { rollbackCfDestination } from './email.ts'; import { requireProjectRouteCapability } from './project-access.ts'; import { getRequestsDataset, queryProjectRequestOverview } from './usage.ts'; @@ -91,7 +101,8 @@ import { getRequestsDataset, queryProjectRequestOverview } from './usage.ts'; /** * Slugs no project may claim. Two overlapping reasons: * - * - hostname collisions on `*.void.app` (`www`, `api`, `dash`, `cname`, …); + * - hostname collisions on `*.void.app` (`www`, `api`, `dash`, `cname`, …), + * including the hostname dispatch keeps for platform health probes; * - EMAIL sender identity: `EMAIL_RESERVED_SLUGS` (`../reserved-slugs.ts`). * A project's outbound `from` is `[+tag]@`, * so owning a slug owns that mailbox, and a user registering `noreply` @@ -102,6 +113,7 @@ import { getRequestsDataset, queryProjectRequestOverview } from './usage.ts'; */ export const RESERVED_SLUGS = new Set([ ...EMAIL_RESERVED_SLUGS, + PLATFORM_HEALTH_HOSTNAME_LABEL, 'admin', 'api', 'app', @@ -729,6 +741,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 is not set up yet, so deploys will wait. Void retries within an hour. To retry now, a project owner or admin can run `void project zero-trust reconcile`; if that reports the platform is not ready, ask a platform administrator.'; + 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 +792,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 +1049,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 +1352,14 @@ 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).catch( + (error: unknown) => { + const message = zeroTrustNotReadyMessage(error); + throw message + ? new RollbackError(409, 'zero trust not ready', undefined, message) + : error; + }, + ); const targetType = manifest?.type ?? (manifest?.framework @@ -1354,6 +1401,7 @@ const app = new Hono() assetConfig, manifest, suspended: existingEntry?.suspended, + zeroTrust, }); } else { routingJson = buildRoutingJson({ @@ -1367,6 +1415,7 @@ const app = new Hono() hasCrons: !!manifest?.schedules?.length, assetManifestId: targetHasAssets ? target.id : undefined, assetConfig: targetHasAssets ? manifest?.assetConfig : undefined, + zeroTrust, }); } @@ -1382,7 +1431,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 +1446,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, lockedProject.id) + : undo.value, + ); } }); }; @@ -1598,7 +1652,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 +1752,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 +1772,11 @@ 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, projectId); } 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..9c24f60dc 100644 --- a/platform/packages/api/src/routing.ts +++ b/platform/packages/api/src/routing.ts @@ -1,7 +1,12 @@ +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 { + assertRouteBelongsToProject, + resolveProjectZeroTrustRoutingForPublication, + withProjectZeroTrustRouting, +} from './zero-trust/routing.ts'; /** * KV key + payload shape for the per-project routing entry consumed by the @@ -21,6 +26,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}`; } @@ -94,8 +101,9 @@ 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. + * newest `suspended` marker. A Zero Trust rewrite does not block the undo + * either; callers set `zeroTrust` on the restored route from PostgreSQL. + * Any other change (including deletion) wins and suppresses the stale undo. */ export function resolveRoutingUndo( current: string | null, @@ -115,6 +123,8 @@ export function resolveRoutingUndo( const currentSuspension = currentEntry.suspended; delete currentEntry.suspended; delete writtenEntry.suspended; + delete currentEntry.zeroTrust; + delete writtenEntry.zeroTrust; if (JSON.stringify(currentEntry) !== JSON.stringify(writtenEntry)) { return { restore: false, value: null }; } @@ -143,18 +153,33 @@ 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 the project and its protection read under the + * project lock, because a stale KV value must never decide protection for a + * newly published route. A cached route of another project (reused slug or + * hostname) is left untouched; the remaining keys are still updated, and the + * first rejection is thrown afterwards so the caller retries. */ export async function applyRoutingSuspension( routing: KVNamespace, keys: ReadonlyArray, update: RoutingSuspensionUpdate, + project?: Readonly<{ id: string; protection: ProjectZeroTrustRouting | null }>, ): Promise { + let rejected: unknown = null; for (const key of keys) { const raw = await routing.get(key); if (!raw) { continue; } const entry = JSON.parse(raw) as Record; + if (project) { + try { + assertRouteBelongsToProject(entry, project.id); + } catch (error) { + rejected ??= error; + continue; + } + } if (update.op === 'suspend') { if (entry.suspended === update.reason) { continue; // already carries this reason @@ -172,7 +197,14 @@ export async function applyRoutingSuspension( } delete entry.suspended; } - await routing.put(key, JSON.stringify(entry)); + const next = JSON.stringify(entry); + await routing.put( + key, + project ? withProjectZeroTrustRouting(next, project.protection, project.id) : next, + ); + } + if (rejected) { + throw rejected; } } @@ -195,8 +227,9 @@ export async function applyRoutingSuspension( * * Each project's KV mutation runs under its project-row lock so deploy, * rollback, domain lifecycle, and failure compensation cannot overwrite a - * newer suspension decision with a stale routing snapshot. No-ops for an - * empty project list. + * newer suspension decision with a stale routing snapshot. One project's + * failure does not stop the others; the first failure is thrown after every + * project was attempted. No-ops for an empty project list. */ export async function setProjectsRoutingSuspension( routing: KVNamespace, @@ -207,74 +240,38 @@ export async function setProjectsRoutingSuspension( if (projects.length === 0) { return; } + let failure: unknown = null; for (const project of projects) { - await db.transaction(async (tx) => { - await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${project.id} FOR UPDATE`); - 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) { - return; - } - // Resolve domains only after the project lock. Domain activation takes - // the same lock before publishing a route, so this read must happen - // 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 domainKeys = (domainMap.get(project.id) ?? []).map( - (domain) => `domain:${domain.hostname}`, - ); - await applyRoutingSuspension(routing, [project.slug, ...domainKeys], update); - }); + await db + .transaction(async (tx) => { + await tx.execute(drizzleSql`SELECT id FROM projects WHERE id = ${project.id} FOR UPDATE`); + 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 || current.user_id !== project.user_id || current.status === 'deleting') { + return; + } + // Resolve domains only after the project lock. Domain activation takes + // the same lock before publishing a route, so this read must happen + // 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, { + id: project.id, + protection, + }); + }) + .catch((error: unknown) => { + failure ??= error; + }); } -} - -/** - * 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; + if (failure) { + throw failure; } - return { - ...(cpuMs ? { cpuMs } : {}), - ...(subRequests ? { subRequests } : {}), - }; } export function buildRoutingJson(opts: { @@ -288,6 +285,7 @@ export function buildRoutingJson(opts: { hasCrons?: boolean; assetManifestId?: string; assetConfig?: AssetConfig; + zeroTrust?: ProjectZeroTrustRouting | null; }): string { const { manifest } = opts; return JSON.stringify({ @@ -314,6 +312,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 +323,7 @@ export function buildStaticRoutingJson(opts: { assetConfig: AssetConfig; manifest: DeployManifest | null; suspended?: string; + zeroTrust?: ProjectZeroTrustRouting | null; }): string { const { manifest } = opts; return JSON.stringify({ @@ -341,5 +341,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..a4bed5611 100644 --- a/platform/packages/api/src/schema.ts +++ b/platform/packages/api/src/schema.ts @@ -472,6 +472,51 @@ 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), + // `ready` means routes and Access applications match the desired protection. + status: text('status').notNull().default('ready').$type<'ready' | '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'), + // Id of the installation operation this row last completed. Public rows + // complete with their bypass; protected rows only after the wildcard 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_status').on(table.status, table.updated_at), + check( + 'project_zero_trust_status_check', + sql`${table.status} IN ('ready', 'reconciling', 'error')`, + ), + check( + 'project_zero_trust_ready_check', + sql`${table.status} <> 'ready' OR ${table.desired_protected} = ${table.effective_protected}`, + ), + ], +); + // --------------------------------------------------------------------------- // 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/candidates.ts b/platform/packages/api/src/zero-trust/candidates.ts new file mode 100644 index 000000000..1b02f1144 --- /dev/null +++ b/platform/packages/api/src/zero-trust/candidates.ts @@ -0,0 +1,168 @@ +import { and, eq, isNull, lt, lte, or, sql } from 'drizzle-orm'; +import type { Db } from '../db.ts'; +import { projects, projectZeroTrust } from '../schema.ts'; +import type { Project } from '../types.ts'; +import type { ZeroTrustOperation } from './configuration.ts'; + +type ProjectCandidate = Readonly<{ project: Project }>; + +export function candidateQuery(db: Db) { + return db + .select({ project: projects }) + .from(projects) + .leftJoin(projectZeroTrust, eq(projectZeroTrust.project_id, projects.id)); +} + +export 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); +} + +/** Marks a project this operation skipped after a project-specific failure. */ +export function skippedOperationKey(operation: ZeroTrustOperation): string { + return `skipped:${operation.id}`; +} + +/** + * A project skipped by this operation keeps its error for the scheduled + * per-project retry. Selecting it again would stall the operation. Failures + * that stop the whole operation leave no marker, so a resumed run retries them. + */ +function notSkippedByOperation(operation: ZeroTrustOperation) { + return sql`${projectZeroTrust.reconciled_operation} IS DISTINCT FROM ${skippedOperationKey(operation)}`; +} + +/** Active projects this operation skipped, oldest first. */ +export async function skippedProjectIds( + db: Db, + operation: ZeroTrustOperation, + limit: number, +): Promise> { + const rows = await candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + eq(projectZeroTrust.reconciled_operation, skippedOperationKey(operation)), + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); + return rows.map((row) => row.project.id); +} + +/** Make the projects this operation skipped candidates again for its next run. */ +export async function retrySkippedProjects(db: Db, operation: ZeroTrustOperation): Promise { + await db + .update(projectZeroTrust) + .set({ reconciled_operation: null }) + .where(eq(projectZeroTrust.reconciled_operation, skippedOperationKey(operation))); +} + +export async function publicBypassCandidates( + db: Db, + limit: number, + operation: ZeroTrustOperation, +): Promise> { + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + eq(projectZeroTrust.desired_protected, false), + notSkippedByOperation(operation), + or( + isNull(projectZeroTrust.bypass_application_id), + sql`${projectZeroTrust.status} <> 'ready'`, + ...(operation.applicationTarget + ? [lt(projectZeroTrust.updated_at, operation.startedAt)] + : []), + ), + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); +} + +export async function reconciliationCandidates( + db: Db, + operation: ZeroTrustOperation, + limit: number, +): Promise> { + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + notSkippedByOperation(operation), + or( + isNull(projectZeroTrust.project_id), + sql`${projectZeroTrust.reconciled_operation} IS DISTINCT FROM ${operation.id}`, + sql`${projectZeroTrust.status} <> 'ready'`, + sql`${projectZeroTrust.desired_protected} <> ${projectZeroTrust.effective_protected}`, + ), + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); +} + +export async function disableCandidates( + db: Db, + operation: ZeroTrustOperation, + limit: number, +): Promise> { + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + notSkippedByOperation(operation), + or( + isNull(projectZeroTrust.project_id), + sql`${projectZeroTrust.reconciled_operation} IS DISTINCT FROM ${operation.id}`, + eq(projectZeroTrust.effective_protected, true), + eq(projectZeroTrust.desired_protected, true), + sql`${projectZeroTrust.status} <> 'ready'`, + sql`${projectZeroTrust.protected_application_id} IS NOT NULL`, + sql`${projectZeroTrust.protected_application_name} IS NOT NULL`, + ), + ), + ) + .orderBy(projects.created_at, projects.id) + .limit(limit); +} + +export async function bypassCleanupCandidates( + db: Db, + operation: ZeroTrustOperation, + limit: number, +): Promise> { + return candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + notSkippedByOperation(operation), + 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); +} 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..e1ec5c89c --- /dev/null +++ b/platform/packages/api/src/zero-trust/cloudflare.ts @@ -0,0 +1,547 @@ +import type { ManagedAccessApplication } from './configuration.ts'; + +type ObjectValue = Record; + +type CloudflareEnvelope = Readonly<{ + success?: boolean; + result?: T; + errors?: ReadonlyArray<{ code?: number; message?: string }>; +}>; + +export 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); +} + +/** + * A rule that names company identities. The CLI checks the Access policies + * that protect the platform installation itself with a separate copy in + * packages/void/src/cli/platform-access-provision.ts, because the public CLI + * cannot import platform code. This list also accepts azureAD, login_method + * and auth_context rules; review both when adding a rule type. + */ +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 = (input, options) => fetch(input, options), + 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, + }; + } + + /** + * Find the application Void recorded: by id first, then by its Void-owned + * name. The name carries a random nonce committed before any create, so when + * the recorded id is gone, the single application holding that name is + * Void's own replacement whose create response was lost. `inventory` is + * set when the name lookup ran. + */ + private async locateOwnedApplication( + id: string | null | undefined, + name: string | null | undefined, + ): Promise<{ application: ObjectValue | null; inventory?: Map }> { + const application = id ? await this.application(id) : null; + if (application) { + if (name && application.name !== name) { + throw new Error('The recorded Access application no longer has its Void-owned name.'); + } + return { application }; + } + if (!name) { + return { application: null }; + } + const inventory = await this.applications(); + const sameName = [...inventory.values()].filter((entry) => entry.name === name); + if (sameName.length > 1) { + throw new Error(`Multiple Cloudflare Access applications are named ${name}.`); + } + return { application: sameName[0] ?? null, inventory }; + } + + async ensureApplication( + spec: AccessApplicationSpec, + current?: Readonly<{ id?: string | null; name?: string | null }>, + ): Promise { + if (current?.name && current.name !== spec.name) { + throw new Error('The recorded Access application no longer has its Void-owned name.'); + } + let application: ObjectValue | null = null; + let inventory: Map | undefined; + if (current?.id || current?.name) { + ({ application, inventory } = await this.locateOwnedApplication(current.id, spec.name)); + } else { + // No recorded creation intent: the name must still be free. + inventory = await this.applications(); + if ([...inventory.values()].some((entry) => entry.name === spec.name)) { + 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; + } + const { application } = await this.locateOwnedApplication(id, expectedName); + if (!application) { + return; + } + const resolvedId = text(application.id, 'application id'); + 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: {} }], + }, + ]; +} + +/** + * The bypass that keeps platform health probes outside project Access. It + * covers only `/health` (and, as Access matches paths, its subpaths) on the + * health hostname; every other path there stays under the wildcard application. + */ +export function healthCheckApplication(name: string, hostname: string): AccessApplicationSpec { + return { + name, + destinations: [`${hostname}/health`], + policies: bypassPolicy('Void platform health check'), + }; +} 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..effe8c6e9 --- /dev/null +++ b/platform/packages/api/src/zero-trust/configuration.ts @@ -0,0 +1,426 @@ +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; + /** Compare-and-swap token; every write increments it. */ + revision: number; + enabled: boolean; + protectNewProjects: boolean; + accountId: string; + teamDomain: string; + identityProviderIds: ReadonlyArray; + policyIds: ReadonlyArray; + credential: EncryptedPlatformCredential; + /** Committed before the wildcard application is created, so a retry can recover it. */ + wildcardApplicationName: string; + wildcardApplication: ManagedAccessApplication | null; + /** + * Exact-host bypass for the platform health hostname, so health probes reach + * dispatch through the wildcard. Its name is committed before it is created. + */ + healthApplicationName: string | null; + healthApplication: ManagedAccessApplication | null; + operation: ZeroTrustOperation | null; + lastError: string | null; + updatedAt: string; +}>; + +export type ZeroTrustStatus = 'configuring' | 'ready' | 'disabling' | 'error'; + +/** The installation status follows from the saved operation and its last failure. */ +export function zeroTrustConfigurationStatus( + configuration: ZeroTrustConfiguration, +): ZeroTrustStatus { + if (configuration.lastError !== null) { + return 'error'; + } + if (!configuration.operation) { + return 'ready'; + } + return configuration.operation.kind === 'disable' ? 'disabling' : 'configuring'; +} + +export type ZeroTrustConfigureInput = Readonly<{ + apiToken: string; + identityProviderIds: ReadonlyArray; + policyIds: ReadonlyArray; + protectNewProjects: boolean; + /** Required when enabling from disabled; rejected otherwise. */ + existingProjects?: 'protected' | 'public'; +}>; + +export class ZeroTrustConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ZeroTrustConfigurationError'; + } +} + +/** + * The configuration saved in PostgreSQL cannot be read. This is a server + * state problem, not a bad request; only a database repair can clear it. + */ +export class ZeroTrustSavedConfigurationError extends ZeroTrustConfigurationError { + constructor(detail: string) { + super( + `The saved Zero Trust configuration is invalid (${detail}). Zero Trust changes are blocked until the \`${ZERO_TRUST_SETTING}\` platform setting is repaired or restored from a database backup.`, + ); + this.name = 'ZeroTrustSavedConfigurationError'; + } +} + +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, + 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 (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); + // Configurations saved before the health exception existed have neither field. + const healthApplication = parseApplication(input.healthApplication ?? null); + const operation = parseOperation(input.operation); + if (operation?.phase === 'bypass-delete' && (input.enabled || wildcardApplication)) { + throw new ZeroTrustConfigurationError('Zero Trust bypass cleanup requires wildcard removal'); + } + if (input.enabled && !wildcardApplication) { + throw new ZeroTrustConfigurationError( + 'enabled Zero Trust configuration needs a managed Access application', + ); + } + return { + version: 1, + revision, + 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: requiredText( + input.wildcardApplicationName, + 'managed application name', + 128, + ), + wildcardApplication, + healthApplicationName: + input.healthApplicationName === undefined || input.healthApplicationName === null + ? null + : requiredText(input.healthApplicationName, 'managed application name', 128), + healthApplication, + 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'); + } + const existingProjects = value.existingProjects ?? undefined; + if ( + existingProjects !== undefined && + existingProjects !== 'protected' && + existingProjects !== 'public' + ) { + throw new ZeroTrustConfigurationError('existingProjects must be protected or public'); + } + const apiToken = requiredText(value.apiToken, 'Cloudflare API token', 8192); + return { + apiToken, + identityProviderIds: ids(value.identityProviderIds, 'identity providers'), + policyIds: ids(value.policyIds, 'policies'), + protectNewProjects: value.protectNewProjects, + ...(existingProjects ? { existingProjects } : {}), + }; +} + +export async function readZeroTrustConfigurationStrict( + db: DbOrTx, +): Promise { + const value = await getPlatformSetting(db, ZERO_TRUST_SETTING); + try { + return parseZeroTrustConfiguration(value); + } catch (error) { + if (error instanceof ZeroTrustConfigurationError) { + throw new ZeroTrustSavedConfigurationError(error.message); + } + throw error; + } +} + +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, + status: zeroTrustConfigurationStatus(configuration), + operation: operation + ? { + kind: operation.kind, + phase: operation.phase, + existingProjects: operation.existingProjects, + } + : null, + } as const; +} diff --git a/platform/packages/api/src/zero-trust/errors.ts b/platform/packages/api/src/zero-trust/errors.ts new file mode 100644 index 000000000..cf9e7e3b9 --- /dev/null +++ b/platform/packages/api/src/zero-trust/errors.ts @@ -0,0 +1,24 @@ +import { ZeroTrustConfigurationError } from './configuration.ts'; + +export class ZeroTrustUnavailableError extends Error { + readonly status = 409 as const; + constructor(message: string) { + super(message); + this.name = 'ZeroTrustUnavailableError'; + } +} + +/** Explain a deploy or rollback rejected because project routing cannot carry protection yet. */ +export function zeroTrustNotReadyMessage(error: unknown): string | null { + if (error instanceof ZeroTrustConfigurationError) { + return 'The platform Zero Trust configuration is invalid. Ask a platform administrator to repair it, then retry.'; + } + if (error instanceof ZeroTrustUnavailableError) { + return `Zero Trust protection for this project is not ready: ${error.message} Run \`void project zero-trust status\` for details. A project admin or owner can run \`void project zero-trust reconcile\` once the platform is ready, then retry.`; + } + return null; +} + +export function errorMessage(error: unknown): string { + return (error instanceof Error ? error.message : String(error)).slice(0, 4096); +} diff --git a/platform/packages/api/src/zero-trust/installation.ts b/platform/packages/api/src/zero-trust/installation.ts new file mode 100644 index 000000000..84acb4fd0 --- /dev/null +++ b/platform/packages/api/src/zero-trust/installation.ts @@ -0,0 +1,400 @@ +import { + PLATFORM_APPLICATION_KEY, + readPlatformApplication, + type PlatformApplication, +} from '@void/platform'; +import type { Db } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { getPlatformSetting, setPlatformSetting } from '../platform-settings.ts'; +import { encryptPlatformCredential } from '../project-secrets.ts'; +import { + applicationTargetEnvironment, + PLATFORM_APPLICATION_SETTING, + resolveZeroTrustApplicationEnvironment, +} from './application.ts'; +import { ZeroTrustCloudflareClient } from './cloudflare.ts'; +import { + ZERO_TRUST_CREDENTIAL_ID, + type ZeroTrustConfiguration, + type ZeroTrustConfigureInput, + parseZeroTrustApplicationTarget, + zeroTrustConfigurationStatus, + readZeroTrustConfigurationStrict, + redactedZeroTrustConfiguration, + writeZeroTrustConfiguration, + zeroTrustWildcardDestinations, +} from './configuration.ts'; +import { ZeroTrustUnavailableError } from './errors.ts'; +import { lockZeroTrustConfiguration } from './lock.ts'; +import { + claimConfiguration, + leaseActive, + leaseExpiresAt, + operationStartedAt, + runConfigureOperation, + runDisableOperation, +} from './operation.ts'; +import { applicationName, installationId } from './state.ts'; + +/** 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 lockZeroTrustConfiguration(tx, 'exclusive'); + 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.'); + } + assertWildcardExcludesControlPlane(env, zeroTrustWildcardDestinations(target)); + const now = new Date().toISOString(); + const configuration: ZeroTrustConfiguration = { + ...current, + revision: current.revision + 1, + 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 }; +} + +function wildcardDestinationMatches(pattern: string, hostname: string): boolean { + // Treat `*` as any non-empty text, including dots. Over-matching only + // rejects a configuration; under-matching could gate the control plane. + const expression = pattern + .toLowerCase() + .split('*') + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')) + .join('.+'); + return new RegExp(`^${expression}$`).test(hostname); +} + +/** + * The project wildcard must never cover the API, proxy or email gateway. CLI, + * webhooks and project Workers call them without an Access session. + */ +function assertWildcardExcludesControlPlane( + env: ApiEnv['Bindings'], + destinations: ReadonlyArray, + requestUrl?: string, +): void { + const hostnames = new Set(); + for (const url of [requestUrl, env.AUTH_REDIRECT_URI, env.PROXY_URL, env.EMAIL_GATEWAY_ORIGIN]) { + if (url && URL.canParse(url)) { + hostnames.add(new URL(url).hostname.toLowerCase()); + } + } + for (const hostname of hostnames) { + const pattern = destinations.find((destination) => + wildcardDestinationMatches(destination, hostname), + ); + if (pattern) { + throw new ZeroTrustUnavailableError( + `Project Zero Trust would put the platform control plane (${hostname}) behind the project Access application ${pattern}. Serve the API, proxy and email gateway from hostnames outside the project application domain, then configure Zero Trust again.`, + ); + } + } +} + +/** + * The existing-project choice applies only when a configure starts from + * disabled, or when it resumes such an operation with a replacement token. + */ +export function assertExistingProjectsChoice( + current: ZeroTrustConfiguration | null, + input: ZeroTrustConfigureInput, +): void { + const operation = current?.operation; + if (operation) { + if ( + input.existingProjects === undefined || + input.existingProjects === operation.existingProjects + ) { + return; + } + throw new ZeroTrustUnavailableError( + operation.existingProjects === null + ? 'The pending Zero Trust operation does not choose protection for existing projects. Configure again without an existing-projects choice.' + : `The pending Zero Trust operation keeps existing projects ${operation.existingProjects}. Configure again with that choice or without one.`, + ); + } + if (!current?.enabled && input.existingProjects === undefined) { + throw new ZeroTrustUnavailableError( + 'Enabling Zero Trust needs a choice for existing projects: protected or public.', + ); + } + if (current?.enabled && input.existingProjects !== undefined) { + throw new ZeroTrustUnavailableError( + 'Zero Trust is already enabled, so existing projects keep their protection. Omit the existing-projects choice, and change individual projects with `void platform zero-trust project-protect` or `project-public`.', + ); + } +} + +export async function configurePlatformZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + input: ZeroTrustConfigureInput, + options: Readonly<{ requestUrl?: string }> = {}, +): Promise> { + // Zero Trust always uses the Cloudflare account that owns the installation. + const accountId = env.CF_ACCOUNT_ID; + 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 previous = await readZeroTrustConfigurationStrict(db); + // Fail before any Cloudflare call; the claim below checks again under the lock. + assertExistingProjectsChoice(previous, input); + const replacingDisableCredential = previous?.operation?.kind === 'disable'; + // A disable removes the wildcard, so a replacement credential for it must + // not be blocked by hostnames that moved under the wildcard afterwards. + if (!replacingDisableCredential) { + assertWildcardExcludesControlPlane(env, destinations, options.requestUrl); + } + const client = new ZeroTrustCloudflareClient(accountId, input.apiToken); + // 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 configuration = await claimConfiguration(db, async (current, owner, startedAt) => { + if ( + replacingDisableCredential && + (current?.revision !== previous.revision || current.operation?.kind !== 'disable') + ) { + throw new ZeroTrustUnavailableError( + 'The saved Zero Trust operation changed. Retry configuration.', + ); + } + assertExistingProjectsChoice(current, input); + if (current?.operation) { + const sameIds = (left: ReadonlyArray, right: ReadonlyArray) => + left.length === right.length && left.every((id) => right.includes(id)); + if ( + current.accountId !== accountId || + current.protectNewProjects !== input.protectNewProjects || + !sameIds(current.identityProviderIds, input.identityProviderIds) || + !sameIds(current.policyIds, input.policyIds) + ) { + throw new ZeroTrustUnavailableError( + 'A saved Zero Trust operation is pending. To replace its API token, configure again with the same identity providers, policies, and project selections.', + ); + } + return { + ...current, + revision: current.revision + 1, + credential, + teamDomain: selection.teamDomain, + operation: { ...current.operation, owner, leaseExpiresAt: leaseExpiresAt() }, + lastError: null, + updatedAt: new Date().toISOString(), + }; + } + const now = new Date().toISOString(); + return { + version: 1, + revision: (current?.revision ?? 0) + 1, + enabled: current?.enabled ?? false, + protectNewProjects: input.protectNewProjects, + accountId, + teamDomain: selection.teamDomain, + identityProviderIds: [...input.identityProviderIds], + policyIds: [...input.policyIds], + credential, + // The name is kept for the installation's lifetime so a retry or a later + // re-enable can recover the same wildcard application. + wildcardApplicationName: + current?.wildcardApplicationName ?? (await applicationName(env, 'project-zero-trust')), + wildcardApplication: current?.wildcardApplication ?? null, + healthApplicationName: + current?.healthApplicationName ?? (await applicationName(env, 'platform-health')), + healthApplication: current?.healthApplication ?? null, + operation: { + id: crypto.randomUUID(), + kind: 'configure', + phase: 'seed', + owner, + leaseExpiresAt: leaseExpiresAt(), + startedAt, + existingProjects: 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, + 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, + operation: { + id: crypto.randomUUID(), + 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, + operation: + current.operation?.kind === 'disable' + ? { ...current.operation, owner, leaseExpiresAt: leaseExpiresAt() } + : { + id: crypto.randomUUID(), + kind: 'disable', + phase: 'seed', + owner, + leaseExpiresAt: leaseExpiresAt(), + startedAt, + existingProjects: null, + }, + lastError: null, + updatedAt: now, + }; + }); + return redactedZeroTrustConfiguration(await runDisableOperation(env, db, configuration)); +} + +/** + * Resume an installation operation whose lease is free. Returns true only when + * a batch ran to its checkpoint; a batch that throws is recorded on the + * operation and does not own the scheduled invocation. The fast tier leaves a + * failed operation to the hourly tier or an explicit reconcile, so a + * persistent failure is not retried every two minutes. + */ +export async function resumePlatformZeroTrustOperation( + env: ApiEnv['Bindings'], + db: Db, + options: Readonly<{ saved?: ZeroTrustConfiguration; retryFailed?: boolean }> = {}, +): Promise { + const configuration = options.saved ?? (await readZeroTrustConfigurationStrict(db)); + if ( + !configuration?.operation || + leaseActive(configuration.operation) || + (zeroTrustConfigurationStatus(configuration) === 'error' && !options.retryFailed) + ) { + return false; + } + await reconcilePlatformZeroTrust(env, db); + return true; +} diff --git a/platform/packages/api/src/zero-trust/lock.ts b/platform/packages/api/src/zero-trust/lock.ts new file mode 100644 index 000000000..84bbd58b3 --- /dev/null +++ b/platform/packages/api/src/zero-trust/lock.ts @@ -0,0 +1,87 @@ +import { sql } from 'drizzle-orm'; +import type { Db, DbOrTx } from '../db.ts'; +import { getPlatformSetting } from '../platform-settings.ts'; +import { ZeroTrustUnavailableError } from './errors.ts'; +import { markProjectError } from './state.ts'; + +/** + * Written by platform uninstall while it holds the configuration lock, after + * its final check. Mirrored in packages/void/src/cli/platform-zero-trust-uninstall.ts. + */ +export const PLATFORM_UNINSTALL_SETTING = 'platform.uninstall'; + +/** + * The installation configuration lock. Project mutations, deployments and + * route publication hold it shared; installation operations take it exclusive + * to claim, checkpoint and finish, so they never overtake project work. + * Platform uninstall holds the same key from its final check until it disables + * traffic (packages/void/src/cli/platform-zero-trust-uninstall.ts), and records + * the uninstall before it releases. A request admitted before traffic was + * disabled may still be waiting here, so every holder refuses once that record + * exists rather than create an Access application uninstall did not see. + */ +export async function lockZeroTrustConfiguration( + db: DbOrTx, + mode: 'shared' | 'exclusive', +): Promise { + await db.execute( + mode === 'shared' + ? sql`SELECT pg_advisory_xact_lock_shared(hashtext('zero-trust:configuration'))` + : sql`SELECT pg_advisory_xact_lock(hashtext('zero-trust:configuration'))`, + ); + // A separate statement takes a snapshot after the lock is granted, so it + // sees a record committed while this request waited. + if ((await getPlatformSetting(db, PLATFORM_UNINSTALL_SETTING)) !== null) { + throw new ZeroTrustUnavailableError( + 'The platform is being uninstalled, so Zero Trust changes and deployments are refused.', + ); + } +} + +/** Carries a failure out of the transaction once its project error is recorded. */ +class RecordedProjectError { + constructor(readonly error: unknown) {} +} + +/** Runs a step whose failure is recorded on the project row and committed. */ +export type RecordProjectErrors = (step: () => Promise) => Promise; + +/** + * Run `work` in one transaction holding the project row lock and the shared + * configuration lock. An error thrown by `work` rolls the transaction back. + * An error thrown inside `recordErrors` instead marks the project failed, + * commits that mark with the work done before it, and is then rethrown. + */ +export async function withProjectZeroTrustLock( + db: Db, + projectId: string, + work: (lockedDb: Db, recordErrors: RecordProjectErrors) => Promise, +): Promise { + let recorded: RecordedProjectError | undefined; + const result = await db.transaction(async (tx) => { + await tx.execute(sql`SELECT id FROM projects WHERE id = ${projectId} FOR UPDATE`); + await lockZeroTrustConfiguration(tx, 'shared'); + const lockedDb = tx as unknown as Db; + const recordErrors: RecordProjectErrors = async (step) => { + try { + return await step(); + } catch (error) { + await markProjectError(lockedDb, projectId, error); + throw new RecordedProjectError(error); + } + }; + try { + return await work(lockedDb, recordErrors); + } catch (error) { + if (!(error instanceof RecordedProjectError)) { + throw error; + } + recorded = error; + return undefined; + } + }); + if (recorded) { + throw recorded.error; + } + return result as T; +} diff --git a/platform/packages/api/src/zero-trust/maintenance.ts b/platform/packages/api/src/zero-trust/maintenance.ts new file mode 100644 index 000000000..0fb554d64 --- /dev/null +++ b/platform/packages/api/src/zero-trust/maintenance.ts @@ -0,0 +1,192 @@ +import { and, eq, isNull, or, sql } from 'drizzle-orm'; +import type { Db } from '../db.ts'; +import { getProject } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { projects, projectZeroTrust } from '../schema.ts'; +import { candidateQuery } from './candidates.ts'; +import type { ZeroTrustCloudflareClient } from './cloudflare.ts'; +import { + type ZeroTrustConfiguration, + readZeroTrustConfigurationStrict, + zeroTrustConfigurationStatus, +} from './configuration.ts'; +import { ZeroTrustUnavailableError } from './errors.ts'; +import { resumePlatformZeroTrustOperation } from './installation.ts'; +import { withProjectZeroTrustLock } from './lock.ts'; +import { reconcileProjectZeroTrust } from './project.ts'; +import { + MAX_ROUTING_KEYS_PER_RUN, + publishProjectZeroTrustRoutingLocked, + type RoutingBudget, +} from './routing.ts'; +import { + clientForConfiguration, + getProjectZeroTrustState, + initialProjectProtection, + markPendingProjectError, + MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT, + putProjectState, + updateProjectState, +} from './state.ts'; + +/** Clear protection left on a project after the installation finished disabling. */ +async function retireDisabledProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + client: ZeroTrustCloudflareClient, + routingBudget: RoutingBudget, +): Promise { + await withProjectZeroTrustLock(db, projectId, async (lockedDb, recordErrors) => { + const saved = await readZeroTrustConfigurationStrict(lockedDb); + if (!saved || saved.enabled || saved.operation) { + throw new ZeroTrustUnavailableError('Project Zero Trust configuration changed.'); + } + const project = await getProject(lockedDb, projectId); + const row = await getProjectZeroTrustState(lockedDb, projectId); + if (!project || project.status === 'deleting' || !row) { + return; + } + await recordErrors(async () => { + await putProjectState(lockedDb, projectId, { + desired_protected: false, + status: 'reconciling', + ...(row.desired_protected ? { routing_cursor: null } : {}), + }); + if ( + !(await publishProjectZeroTrustRoutingLocked(env, lockedDb, project, null, routingBudget)) + ) { + routingBudget.exhausted = true; + return; + } + // The wildcard is gone, so neither exception application still guards anything. + await client.deleteApplication(row.protected_application_id, row.protected_application_name); + await client.deleteApplication(row.bypass_application_id, row.bypass_application_name); + await updateProjectState(lockedDb, projectId, { + desired_protected: false, + effective_protected: false, + status: 'ready', + protected_application_id: null, + protected_application_name: null, + protected_application_audience: null, + bypass_application_id: null, + bypass_application_name: null, + last_error: null, + }); + }); + }); +} + +async function retirePendingDisabledProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + configuration: ZeroTrustConfiguration, + limit: number, +): Promise { + const rows = await candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + or( + sql`${projectZeroTrust.status} <> 'ready'`, + eq(projectZeroTrust.desired_protected, true), + eq(projectZeroTrust.effective_protected, true), + sql`${projectZeroTrust.protected_application_id} IS NOT NULL`, + sql`${projectZeroTrust.protected_application_name} IS NOT NULL`, + sql`${projectZeroTrust.bypass_application_id} IS NOT NULL`, + sql`${projectZeroTrust.bypass_application_name} IS NOT NULL`, + ), + ), + ) + .orderBy(projectZeroTrust.updated_at, projects.created_at) + .limit(limit); + if (rows.length === 0) { + return; + } + const client = await clientForConfiguration(env, configuration); + const routingBudget: RoutingBudget = { remaining: MAX_ROUTING_KEYS_PER_RUN }; + for (const row of rows) { + if (routingBudget.exhausted || routingBudget.remaining < 1) { + break; + } + await retireDisabledProjectZeroTrust(env, db, row.project.id, client, routingBudget).catch( + (error) => + console.error('[zero-trust] scheduled project cleanup failed', { + projectId: row.project.id, + error, + }), + ); + } +} + +export async function reconcilePendingProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + limit = 20, +): Promise { + const configuration = await readZeroTrustConfigurationStrict(db); + if (!configuration) { + return; + } + if (configuration.operation) { + // The hourly tier also retries an operation that failed; the fast tier does not. + await resumePlatformZeroTrustOperation(env, db, { saved: configuration, retryFailed: true }); + return; + } + if (!configuration.enabled) { + // Projects skipped by a completed disable operation still hold protection. + await retirePendingDisabledProjectZeroTrust(env, db, configuration, limit); + return; + } + if (zeroTrustConfigurationStatus(configuration) !== 'ready') { + return; + } + const rows = await candidateQuery(db) + .where( + and( + eq(projects.status, 'active'), + or(isNull(projectZeroTrust.project_id), sql`${projectZeroTrust.status} <> 'ready'`), + ), + ) + // A project without a row has no recorded attempt and cannot deploy, so it + // goes first. Postgres would otherwise sort its NULL timestamp last. + .orderBy(sql`${projectZeroTrust.updated_at} ASC NULLS FIRST`, projects.created_at) + .limit(limit); + if (rows.length === 0) { + return; + } + const client = await clientForConfiguration(env, configuration); + const routingBudget: RoutingBudget = { remaining: MAX_ROUTING_KEYS_PER_RUN }; + for (const row of rows) { + if ( + routingBudget.exhausted || + routingBudget.remaining < MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT + 1 + ) { + break; + } + await reconcileProjectZeroTrust(env, db, row.project.id, { + configuration, + client, + routingBudget, + }).catch(async (error) => { + console.error('[zero-trust] scheduled project reconciliation failed', { + projectId: row.project.id, + error, + }); + // A checkpoint failure rolls back without touching the row, or without + // creating it. Record it so the project moves behind other pending + // projects in the next run. + await markPendingProjectError( + db, + row.project.id, + error, + initialProjectProtection(configuration, row.project), + ).catch((markError) => + console.error('[zero-trust] could not record scheduled reconciliation failure', { + projectId: row.project.id, + error: markError, + }), + ); + }); + } +} diff --git a/platform/packages/api/src/zero-trust/operation.ts b/platform/packages/api/src/zero-trust/operation.ts new file mode 100644 index 000000000..1446781d8 --- /dev/null +++ b/platform/packages/api/src/zero-trust/operation.ts @@ -0,0 +1,765 @@ +import { + PLATFORM_APPLICATION_KEY, + platformHealthHostname, + readPlatformApplication, +} from '@void/platform'; +import { sql } from 'drizzle-orm'; +import type { Db, DbOrTx } from '../db.ts'; +import { getProject } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { setPlatformSetting } from '../platform-settings.ts'; +import { projectZeroTrust } from '../schema.ts'; +import { + PLATFORM_APPLICATION_SETTING, + resolveZeroTrustApplicationEnvironment, +} from './application.ts'; +import { + bypassCleanupCandidates, + disableCandidates, + publicBypassCandidates, + reconciliationCandidates, + retrySkippedProjects, + seedCandidates, + skippedOperationKey, + skippedProjectIds, +} from './candidates.ts'; +import { + ZeroTrustCloudflareClient, + ZeroTrustCloudflareError, + healthCheckApplication, + reusablePolicyReferences, +} from './cloudflare.ts'; +import { + type ZeroTrustConfiguration, + type ZeroTrustOperation, + readZeroTrustConfigurationStrict, + replaceZeroTrustConfiguration, + writeZeroTrustConfiguration, + zeroTrustWildcardDestinations, +} from './configuration.ts'; +import { errorMessage, ZeroTrustUnavailableError } from './errors.ts'; +import { lockZeroTrustConfiguration, withProjectZeroTrustLock } from './lock.ts'; +import { reconcileProjectZeroTrust, reconcilePublicProjectForOperation } from './project.ts'; +import { + MAX_ROUTING_KEYS_PER_RUN, + publishProjectZeroTrustRoutingLocked, + type RoutingBudget, +} from './routing.ts'; +import { + applicationName, + clientForConfiguration, + getProjectZeroTrustState, + initialProjectProtection, + putProjectState, + sameConfiguration, + updateProjectState, +} from './state.ts'; + +const OPERATION_LEASE_MS = 15 * 60 * 1000; +const MAX_PROJECTS_PER_RUN = 100; + +async function setInitialProjectProtection( + db: Db, + projectId: string, + protectedProject: boolean, + configuration: ZeroTrustConfiguration, +): Promise { + await withProjectZeroTrustLock(db, projectId, async (lockedDb) => { + 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, + status: 'reconciling', + last_error: null, + }); + }); +} + +export function leaseExpiresAt(): string { + return new Date(Date.now() + OPERATION_LEASE_MS).toISOString(); +} + +export 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(); +} + +export 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; +} + +/** + * Claim the installation operation under the exclusive lock. A resumed + * operation keeps its id; a new one gets a fresh id from the claim callback. + */ +export async function claimConfiguration( + db: Db, + claim: ( + current: ZeroTrustConfiguration | null, + owner: string, + startedAt: string, + ) => ZeroTrustConfiguration | Promise, +): Promise { + return db.transaction(async (tx) => { + await lockZeroTrustConfiguration(tx, 'exclusive'); + const current = await readZeroTrustConfigurationStrict(tx); + if (current?.operation && leaseActive(current.operation)) { + throw new ZeroTrustUnavailableError('Another Zero Trust operation is already running.'); + } + const configuration = await claim(current, crypto.randomUUID(), await operationStartedAt(tx)); + 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, { + 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.'; +} + +/** Credential, rate-limit and provider outages affect every project; the whole operation retries. */ +function isOperationWideFailure(error: unknown): boolean { + if (error instanceof ZeroTrustCloudflareError) { + return ( + error.status === 401 || error.status === 403 || error.status === 429 || error.status >= 500 + ); + } + return ( + error instanceof TypeError || + (error instanceof DOMException && + (error.name === 'TimeoutError' || error.name === 'AbortError')) + ); +} + +/** + * Record a project failure and let the operation continue. The row keeps a + * skip marker so later batches pass over it; scheduled per-project maintenance + * retries it after the operation finishes. An operation with an application + * target cannot finish while such a row remains (completeConfigureOperation). + * The skip never marks a row + * effectively public. Routes a disable already cleared stay cleared; they open + * with every other project when the wildcard is deleted. Returns false, + * writing nothing, when a newer operation has replaced this one. + */ +async function skipFailedProject( + db: Db, + projectId: string, + configuration: ZeroTrustConfiguration & { operation: ZeroTrustOperation }, + error: unknown, +): Promise { + const reconciledOperation = skippedOperationKey(configuration.operation); + const lastError = errorMessage(error); + const skipped = await withProjectZeroTrustLock(db, projectId, async (lockedDb) => { + // A claim takes the exclusive lock, so the revision cannot change while this + // shared lock is held. A newer operation may already have reconciled this + // row; recording the stale failure would overwrite its result. + if (!sameConfiguration(configuration, await readZeroTrustConfigurationStrict(lockedDb))) { + return false; + } + const project = await getProject(lockedDb, projectId); + if (!project || project.status === 'deleting') { + return true; + } + await lockedDb + .insert(projectZeroTrust) + .values({ + project_id: projectId, + desired_protected: + configuration.operation.kind !== 'disable' && + initialProjectProtection(configuration, project), + effective_protected: false, + status: 'error', + reconciled_operation: reconciledOperation, + last_error: lastError, + }) + .onConflictDoUpdate({ + target: projectZeroTrust.project_id, + set: { + status: 'error', + reconciled_operation: reconciledOperation, + last_error: lastError, + updated_at: sql`now()`, + }, + }); + return true; + }); + if (skipped) { + console.error('[zero-trust] installation operation skipped a failed project', { + projectId, + error, + }); + } + return skipped; +} + +/** Run one project's work inside an installation batch; only operation-wide failures escape. */ +async function runOperationProjectStep( + db: Db, + configuration: ZeroTrustConfiguration, + projectId: string, + step: () => Promise, +): Promise { + try { + await step(); + } catch (error) { + if (isDeletedProject(error)) { + return; + } + const operation = configuration.operation; + if ( + !operation || + isOperationWideFailure(error) || + !(await skipFailedProject(db, projectId, { ...configuration, operation }, error)) + ) { + throw error; + } + } +} + +async function withZeroTrustOperationLock( + db: Db, + configuration: ZeroTrustConfiguration, + mutate: (lockedDb: Db) => Promise, +): Promise { + return db.transaction(async (tx) => { + await lockZeroTrustConfiguration(tx, 'exclusive'); + 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); + }); +} + +function skippedProjectsError(projectIds: ReadonlyArray): string { + const shown = projectIds.slice(0, 5).join(', '); + const more = projectIds.length > 5 ? ' and others' : ''; + return `The new application domain stays unpublished until Zero Trust can update projects ${shown}${more}. See each project's last error with \`void platform zero-trust project-status \`, fix the cause, then run \`void platform zero-trust reconcile\`. Void also retries every hour.`; +} + +async function completeConfigureOperation( + env: ApiEnv['Bindings'], + db: Db, + configuration: ZeroTrustConfiguration, +): Promise { + return db.transaction(async (tx) => { + await lockZeroTrustConfiguration(tx, 'exclusive'); + 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) { + // A skipped project still lacks the new hostnames: a protected route has + // no audience for them (dispatch answers 403) and a public bypass does + // not cover them (Access asks for sign-in). Keep the domain unpublished, + // make those projects candidates for the next run, and stop this run + // failed so only the hourly tier or an operator retries it. + const skipped = await skippedProjectIds(tx as unknown as Db, saved.operation, 6); + if (skipped.length > 0) { + await retrySkippedProjects(tx as unknown as Db, saved.operation); + return patchConfiguration(tx as unknown as Db, saved, { + lastError: skippedProjectsError(skipped), + operation: { ...saved.operation, owner: null, leaseExpiresAt: null }, + }); + } + 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, { + operation: null, + lastError: null, + }); + }); +} + +export async function runConfigureOperation( + env: ApiEnv['Bindings'], + db: Db, + initial: ZeroTrustConfiguration, + suppliedClient?: ZeroTrustCloudflareClient, + suppliedSelection?: { teamDomain: string }, +): Promise { + let configuration = initial; + let projectsProcessed = 0; + const routingBudget: 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, + initialProjectProtection(configuration, candidate.project), + 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) { + await setInitialProjectProtection( + db, + candidate.project.id, + initialProjectProtection(configuration, candidate.project), + 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) { + const batch = configuration; + await runOperationProjectStep(db, batch, candidate.project.id, () => + reconcilePublicProjectForOperation( + env, + db, + candidate.project.id, + batch, + client, + routingBudget, + ), + ); + projectsProcessed += 1; + configuration = await heartbeatConfiguration(db, configuration); + if (routingBudget.exhausted || 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') { + const healthHostname = platformHealthHostname(readPlatformApplication(env)); + if (healthHostname && !configuration.healthApplicationName) { + // Configurations saved before the health exception existed lack its + // name. Commit it before any create so a lost response is recoverable. + configuration = await patchConfiguration(db, configuration, { + healthApplicationName: await applicationName(env, 'platform-health'), + }); + continue; + } + const name = configuration.wildcardApplicationName; + configuration = await withZeroTrustOperationLock(db, configuration, async (lockedDb) => { + // Health probes reach dispatch through this bypass of `/health` on + // the health hostname. It exists before the wildcard covers that + // hostname, and dispatch never routes that path to a project. + const health = + healthHostname && configuration.healthApplicationName + ? await client.ensureApplication( + healthCheckApplication(configuration.healthApplicationName, healthHostname), + { + id: configuration.healthApplication?.id, + name: configuration.healthApplicationName, + }, + ) + : configuration.healthApplication; + 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, + healthApplication: health, + 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 batch = configuration; + // The budget check runs after the project checkpoint, which may + // create the row or change its intent. + await runOperationProjectStep(db, batch, candidate.project.id, () => + reconcileProjectZeroTrust(env, db, candidate.project.id, { + configuration: batch, + client, + routingBudget, + }), + ); + if (routingBudget.exhausted) { + stoppedForRoutingBudget = true; + break; + } + 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: RoutingBudget, + configuration: ZeroTrustConfiguration, +): Promise { + await withProjectZeroTrustLock(db, projectId, async (lockedDb, recordErrors) => { + const saved = await readZeroTrustConfigurationStrict(lockedDb); + if (!sameConfiguration(configuration, saved) || saved.operation?.kind !== 'disable') { + throw new ZeroTrustUnavailableError('The Zero Trust disable operation was replaced.'); + } + const operation = saved.operation; + await recordErrors(async () => { + 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, + status: '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, + status: 'ready', + reconciled_operation: operation.id, + // 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, + }); + }); + }); +} + +async function deleteProjectZeroTrustBypass( + db: Db, + projectId: string, + client: ZeroTrustCloudflareClient, + configuration: ZeroTrustConfiguration, +): Promise { + await withProjectZeroTrustLock(db, projectId, async (lockedDb, recordErrors) => { + const saved = await readZeroTrustConfigurationStrict(lockedDb); + if (!sameConfiguration(configuration, saved) || saved.operation?.phase !== 'bypass-delete') { + throw new ZeroTrustUnavailableError('The Zero Trust disable operation was replaced.'); + } + const row = await getProjectZeroTrustState(lockedDb, projectId); + if (!row) { + return; + } + await recordErrors(async () => { + await client.deleteApplication(row.bypass_application_id, row.bypass_application_name); + await updateProjectState(lockedDb, projectId, { + bypass_application_id: null, + bypass_application_name: null, + status: 'ready', + last_error: null, + }); + }); + }); +} + +export async function runDisableOperation( + env: ApiEnv['Bindings'], + db: Db, + initial: ZeroTrustConfiguration, +): Promise { + let configuration = initial; + let projectsProcessed = 0; + const routingBudget: 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; + } + const batch = configuration; + await runOperationProjectStep(db, batch, candidate.project.id, () => + disableProjectZeroTrust(env, db, candidate.project.id, client, routingBudget, batch), + ); + 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, + ); + // The health exception outlives the wildcard, so health probes never + // meet Access. Deleting the wildcard again on a retry is a no-op. + await client.deleteApplication( + configuration.healthApplication?.id, + configuration.healthApplicationName, + ); + return advanceOperation(lockedDb, configuration, 'bypass-delete', { + enabled: false, + wildcardApplication: null, + healthApplication: null, + lastError: null, + }); + }); + continue; + } + if (operation.phase === 'bypass-delete') { + const candidates = await bypassCleanupCandidates( + db, + operation, + MAX_PROJECTS_PER_RUN - projectsProcessed, + ); + for (const candidate of candidates) { + const batch = configuration; + await runOperationProjectStep(db, batch, candidate.project.id, () => + deleteProjectZeroTrustBypass(db, candidate.project.id, client, batch), + ); + 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, operation, 1)).length > 0) { + return configuration; + } + return patchConfiguration(lockedDb, configuration, { + 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; + } +} diff --git a/platform/packages/api/src/zero-trust/project.ts b/platform/packages/api/src/zero-trust/project.ts new file mode 100644 index 000000000..77d18364b --- /dev/null +++ b/platform/packages/api/src/zero-trust/project.ts @@ -0,0 +1,501 @@ +import type { Db } from '../db.ts'; +import { getProject } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { requireProjectCapability } from '../project-access.ts'; +import type { projectZeroTrust } from '../schema.ts'; +import type { Project, ProjectZeroTrust } from '../types.ts'; +import { bypassPolicy, reusablePolicyReferences, ZeroTrustCloudflareClient } from './cloudflare.ts'; +import { + type ManagedAccessApplication, + type ZeroTrustConfiguration, + readZeroTrustConfigurationStrict, +} from './configuration.ts'; +import { ZeroTrustUnavailableError } from './errors.ts'; +import { withProjectZeroTrustLock, type RecordProjectErrors } from './lock.ts'; +import { + publishProjectZeroTrustRoutingLocked, + requiredRoutingBudget, + routingProtection, + type RoutingBudget, +} from './routing.ts'; +import { + applicationName, + clientForConfiguration, + getProjectZeroTrustState, + initialProjectProtection, + projectApplicationReference, + projectDestinations, + projectZeroTrustAvailable, + putProjectState, + sameConfiguration, + updateProjectState, +} from './state.ts'; + +/** + * The operation a reconciled row completes. A public row is complete once its + * bypass and routes are done, which the public-bypass phase already does. A + * protected row depends on the wildcard audience, so it completes only after + * the wildcard phase. + */ +function completedProjectOperation( + configuration: ZeroTrustConfiguration, + publicProject: boolean, +): string | null { + const operation = configuration.operation; + if (!operation || operation.kind === 'disable') { + return null; + } + return operation.phase === 'projects' || (publicProject && operation.phase === 'public-bypasses') + ? operation.id + : null; +} + +type ProjectReconciliationOptions = { + /** Snapshot owned by an installation operation; it must still match under the lock. */ + configuration?: ZeroTrustConfiguration; + client?: ZeroTrustCloudflareClient; + desiredProtection?: boolean; + routingBudget?: RoutingBudget; + /** Recheck this user's `settings` capability under the project lock. */ + actorUserId?: string; + /** + * Use the configuration saved under the lock even while an installation + * operation runs. Domain changes must not wait for the whole operation. + */ + duringOperation?: boolean; +}; + +function savedConfigurationAllows( + saved: ZeroTrustConfiguration | null, + options?: ProjectReconciliationOptions, +): saved is ZeroTrustConfiguration { + return options?.duringOperation + ? !!saved?.enabled && !!saved.wildcardApplication && saved.operation?.kind !== 'disable' + : projectZeroTrustAvailable(saved); +} + +async function checkpointProjectReconciliation( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + options?: ProjectReconciliationOptions, +): Promise { + return withProjectZeroTrustLock(db, projectId, async (lockedDb) => { + // The route precheck can go stale. Team and ownership changes share this + // lock, so authorize again before committing the new intent. Platform + // callers (operators, maintenance, domain hooks) have no actor here. + if (options?.actorUserId) { + await requireProjectCapability(lockedDb, projectId, options.actorUserId, 'settings'); + } + 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 && !savedConfigurationAllows(saved, options)) + ) { + 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 ?? + initialProjectProtection(configuration, project); + const destinations = await projectDestinations(env, lockedDb, project, desired); + const values: Partial = { + desired_protected: desired, + status: '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); + }); +} + +/** + * Public transition, under the project lock: ensure the bypass, publish every + * route without enforcement, mark the project effectively public, then delete + * its protected custom-domain application. It needs no wildcard application. + */ +async function reconcilePublicProjectLocked( + env: ApiEnv['Bindings'], + lockedDb: Db, + project: Project, + current: ProjectZeroTrust, + platformDestinations: ReadonlyArray, + client: ZeroTrustCloudflareClient, + configuration: ZeroTrustConfiguration, + routingBudget?: RoutingBudget, +): Promise { + if (!current.bypass_application_name) { + throw new Error('The project public application checkpoint is missing.'); + } + const bypass = await client.ensureApplication( + { + name: current.bypass_application_name, + destinations: platformDestinations, + policies: bypassPolicy('Void project is public'), + }, + projectApplicationReference(current, 'bypass'), + ); + await updateProjectState(lockedDb, project.id, { + bypass_application_id: bypass.id, + bypass_application_name: bypass.name, + }); + if (!(await publishProjectZeroTrustRoutingLocked(env, lockedDb, project, null, routingBudget))) { + return (await getProjectZeroTrustState(lockedDb, project.id))!; + } + const row = await updateProjectState(lockedDb, project.id, { effective_protected: false }); + await client.deleteApplication(row.protected_application_id, row.protected_application_name); + return updateProjectState(lockedDb, project.id, { + effective_protected: false, + status: 'ready', + reconciled_operation: completedProjectOperation(configuration, true), + protected_application_id: null, + protected_application_name: null, + protected_application_audience: null, + last_error: null, + }); +} + +async function reconcileProjectZeroTrustLocked( + env: ApiEnv['Bindings'], + lockedDb: Db, + projectId: string, + recordErrors: RecordProjectErrors, + options?: ProjectReconciliationOptions, +): Promise { + const saved = await readZeroTrustConfigurationStrict(lockedDb); + const configuration = options?.configuration ?? saved; + if ( + !saved || + !configuration || + !saved.enabled || + !saved.wildcardApplication || + saved.operation?.kind === 'disable' || + (!options?.configuration && !savedConfigurationAllows(saved, options)) || + (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.'); + } + const existing = await getProjectZeroTrustState(lockedDb, projectId); + if (!existing) { + 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, + existing.desired_protected, + ); + + return recordErrors(async () => { + let row = existing; + if (!row.desired_protected) { + return reconcilePublicProjectLocked( + env, + lockedDb, + project, + row, + destinations.platform, + client, + configuration, + options?.routingBudget, + ); + } + + 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, + }); + } + if ( + !(await publishProjectZeroTrustRoutingLocked( + env, + lockedDb, + project, + routingProtection(configuration, protectedApplication), + options?.routingBudget, + )) + ) { + // Unpublished keys still lack the audience set. Keep the public + // exception and stay reconciling until every route carries it. + return (await getProjectZeroTrustState(lockedDb, projectId))!; + } + 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, + status: 'ready', + reconciled_operation: completedProjectOperation(configuration, false), + bypass_application_id: null, + bypass_application_name: null, + last_error: null, + }); + }); +} + +export async function reconcileProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + options?: ProjectReconciliationOptions, +): Promise { + const checkpointed = await checkpointProjectReconciliation(env, db, projectId, options); + const budget = options?.routingBudget; + if (budget && budget.remaining < requiredRoutingBudget(checkpointed)) { + // The committed checkpoint keeps this project reconciling for the next batch. + budget.exhausted = true; + return checkpointed; + } + return withProjectZeroTrustLock(db, projectId, (lockedDb, recordErrors) => + reconcileProjectZeroTrustLocked(env, lockedDb, projectId, recordErrors, options), + ); +} + +export async function setProjectZeroTrustProtection( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + protectedProject: boolean, + options?: Pick, +): Promise { + return reconcileProjectZeroTrust(env, db, projectId, { + desiredProtection: protectedProject, + actorUserId: options?.actorUserId, + }); +} + +async function ensureProjectBypassLocked( + env: ApiEnv['Bindings'], + lockedDb: Db, + projectId: string, + client: ZeroTrustCloudflareClient, + status: 'ready' | 'reconciling', +): Promise { + 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, + status, + last_error: null, + }); +} + +/** + * Public-bypass phase: finish one public project before the wildcard changes. + * The row then counts as done for this operation, so the projects phase does + * not repeat its Access and routing writes. + */ +export async function reconcilePublicProjectForOperation( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, + configuration: ZeroTrustConfiguration, + client: ZeroTrustCloudflareClient, + routingBudget: RoutingBudget, +): Promise { + await checkpointProjectReconciliation(env, db, projectId, { configuration }); + return withProjectZeroTrustLock(db, projectId, async (lockedDb, recordErrors) => { + if (!sameConfiguration(configuration, await readZeroTrustConfigurationStrict(lockedDb))) { + throw new ZeroTrustUnavailableError('The Zero Trust operation was replaced.'); + } + const project = await getProject(lockedDb, projectId); + const row = await getProjectZeroTrustState(lockedDb, projectId); + if (!project || project.status === 'deleting' || !row) { + throw new ZeroTrustUnavailableError('Project is unavailable.'); + } + if (row.desired_protected) { + return row; + } + return recordErrors(async () => { + const destinations = await projectDestinations(env, lockedDb, project, false); + return reconcilePublicProjectLocked( + env, + lockedDb, + project, + row, + destinations.platform, + client, + configuration, + routingBudget, + ); + }); + }); +} + +/** A running configure operation may start before the project exists or be seeded later. */ +function initializesProjectZeroTrust(configuration: ZeroTrustConfiguration | null): boolean { + return ( + !!configuration && + configuration.operation?.kind !== 'disable' && + (configuration.enabled || configuration.operation?.kind === 'configure') + ); +} + +export async function initializeNewProjectZeroTrust( + env: ApiEnv['Bindings'], + db: Db, + projectId: string, +): Promise { + if (!initializesProjectZeroTrust(await readZeroTrustConfigurationStrict(db))) { + return null; + } + // Operation heartbeats advance the saved revision. Decide from the + // configuration read under the lock, never from the unlocked snapshot above. + const initialized = await withProjectZeroTrustLock(db, projectId, async (lockedDb) => { + const project = await getProject(lockedDb, projectId); + const saved = await readZeroTrustConfigurationStrict(lockedDb); + if (!project || project.status === 'deleting') { + throw new ZeroTrustUnavailableError('Project is unavailable.'); + } + if (!saved || !initializesProjectZeroTrust(saved)) { + return null; + } + // 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 desired = initialProjectProtection(saved, project); + // A new protected project has no route or custom domain yet, so the + // wildcard alone settles it when no operation is running. + const settled = desired && !!saved.wildcardApplication && !saved.operation; + return { + row: await putProjectState(lockedDb, projectId, { + desired_protected: desired, + effective_protected: settled, + status: settled ? 'ready' : 'reconciling', + ...(!desired + ? { bypass_application_name: await applicationName(env, `public-${project.id}`) } + : {}), + }), + created: true, + }; + }); + if (!initialized) { + return null; + } + const { row } = initialized; + if (!initialized.created || row.desired_protected) { + return row; + } + return withProjectZeroTrustLock(db, projectId, async (lockedDb, recordErrors) => { + const saved = await readZeroTrustConfigurationStrict(lockedDb); + if (!saved || !initializesProjectZeroTrust(saved)) { + throw new ZeroTrustUnavailableError('Project Zero Trust configuration changed.'); + } + // A running operation still owns this row; its batch finishes the transition. + return recordErrors(async () => + ensureProjectBypassLocked( + env, + lockedDb, + projectId, + await clientForConfiguration(env, saved), + saved.operation ? 'reconciling' : 'ready', + ), + ); + }); +} + +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; + } + // Operation heartbeats advance the saved revision, so this unlocked read + // only filters. Reconciliation uses the configuration saved under the lock. + return reconcileProjectZeroTrust(env, db, projectId, { duringOperation: true }); +} diff --git a/platform/packages/api/src/zero-trust/routing.ts b/platform/packages/api/src/zero-trust/routing.ts new file mode 100644 index 000000000..c66692744 --- /dev/null +++ b/platform/packages/api/src/zero-trust/routing.ts @@ -0,0 +1,383 @@ +import type { ProjectZeroTrustRouting } from '@void/edge'; +import { and, eq, gt } from 'drizzle-orm'; +import type { Db, DbOrTx } from '../db.ts'; +import { + getActiveDeployment, + getDeployment, + getDomainsByProjectId, + getLatestLiveDeployment, + getUser, +} from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { computeDispatchLimits } from '../routing-limits.ts'; +import { domains } from '../schema.ts'; +import { routingSuspensionReason } from '../suspension.ts'; +import { PLAN_LIMITS } from '../tiers.ts'; +import type { DeployManifest, Project, ProjectZeroTrust } from '../types.ts'; +import { + type ManagedAccessApplication, + type ZeroTrustConfiguration, + ZeroTrustConfigurationError, + readZeroTrustConfigurationStrict, +} from './configuration.ts'; +import { ZeroTrustUnavailableError } from './errors.ts'; +import { lockZeroTrustConfiguration } from './lock.ts'; +import { + getProjectZeroTrustState, + MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT, + putProjectState, +} from './state.ts'; + +export const MAX_ROUTING_KEYS_PER_RUN = 300; + +/** Routing writes left in one invocation. `exhausted` stops the batch before another project. */ +export type RoutingBudget = { remaining: number; exhausted?: boolean }; + +export function requiredRoutingBudget(row: Pick): number { + // Protected routes are published in one pass: slug plus every capped custom domain. + return row.desired_protected ? MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT + 1 : 1; +} + +export 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, 'shared'); + 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.status !== 'ready')) + ) { + 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); +} + +/** An empty audience set makes dispatch deny every request until reconciliation finishes. */ +const DENY_ALL_ROUTING: ProjectZeroTrustRouting = { teamDomain: '', audiences: [] }; + +/** + * Read current protection inside the caller's project-row transaction before + * publishing KV. When protection is not settled: + * - a desired or effectively protected project gets the deny-all marker; + * - a public project with a durable bypass stays public; + * - anything else is unknown. A writer that can retry later (`retryable`) + * gets the error; every other writer must publish now and gets deny-all. + */ +export async function resolveProjectZeroTrustRoutingForPublication( + db: DbOrTx, + projectId: string, + options: Readonly<{ retryable?: boolean }> = {}, +): 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) + ) { + throw error; + } + const row = await getProjectZeroTrustState(db, projectId); + if (row && (row.desired_protected || row.effective_protected)) { + return DENY_ALL_ROUTING; + } + if (row?.bypass_application_id) { + return null; + } + if (options.retryable) { + throw error; + } + return DENY_ALL_ROUTING; + } +} + +/** + * KV can still return the previous project's route for a reused slug or + * hostname. That payload carries its own project's Worker and protection, so + * applying this project's protection to it could strip the Access gate from + * a still-live Worker. Callers retry once KV converges. + */ +export function assertRouteBelongsToProject( + entry: Record, + projectId: string, +): void { + if (entry.projectId !== undefined && entry.projectId !== projectId) { + throw new ZeroTrustUnavailableError( + 'Live routing still belongs to another project. Retry once routing converges.', + ); + } +} + +export function withProjectZeroTrustRouting( + raw: string, + protection: ProjectZeroTrustRouting | null, + projectId: string, +): string { + const entry = JSON.parse(raw) as Record; + assertRouteBelongsToProject(entry, projectId); + if (protection) { + entry.zeroTrust = protection; + } else { + delete entry.zeroTrust; + } + return JSON.stringify(entry); +} + +/** + * Rewrite the fields of a live route that can change after deployment: the + * owner, plan limits (Worker routes only), suspension and protection. The + * deployment payload itself is immutable and left as published. + */ +export function rewriteProjectRouteMetadata( + entry: Record, + metadata: Readonly<{ + projectId: string; + userId: string; + limits: ReturnType; + suspended: string | null; + zeroTrust: ProjectZeroTrustRouting | null; + }>, +): string { + assertRouteBelongsToProject(entry, metadata.projectId); + entry.userId = metadata.userId; + if ('workerName' in entry) { + if (metadata.limits) { + entry.limits = metadata.limits; + } else { + delete entry.limits; + } + } + if (metadata.suspended) { + entry.suspended = metadata.suspended; + } else { + delete entry.suspended; + } + if (metadata.zeroTrust) { + entry.zeroTrust = metadata.zeroTrust; + } 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) + ); +} + +export 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.`, + ); + } + // A fresh project lock does not make a cached KV snapshot fresh, so every + // mutable field is rewritten from PostgreSQL. + await env.ROUTING.put( + key, + rewriteProjectRouteMetadata(entry, { + projectId: project.id, + userId: owner.id, + limits: dispatchLimits, + suspended: suspensionReason, + zeroTrust: protection, + }), + ); + } + const complete = projectDomains.length <= domainBudget; + await putProjectState(db, project.id, { + routing_cursor: complete ? null : (page.at(-1)?.hostname ?? cursor ?? ''), + ...(!complete ? { status: 'reconciling' as const } : {}), + }); + return complete; +} diff --git a/platform/packages/api/src/zero-trust/state.ts b/platform/packages/api/src/zero-trust/state.ts new file mode 100644 index 000000000..7186447aa --- /dev/null +++ b/platform/packages/api/src/zero-trust/state.ts @@ -0,0 +1,283 @@ +import { platformProjectHostnames, readPlatformApplication } from '@void/platform'; +import { and, eq, isNotNull, or, sql } from 'drizzle-orm'; +import type { Db, DbOrTx } from '../db.ts'; +import { getDomainsByProjectId } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { decryptPlatformCredential } from '../project-secrets.ts'; +import { projectZeroTrust } from '../schema.ts'; +import type { Project, ProjectZeroTrust } from '../types.ts'; +import { resolveZeroTrustApplicationEnvironment } from './application.ts'; +import { ZeroTrustCloudflareClient } from './cloudflare.ts'; +import { + ZERO_TRUST_CREDENTIAL_ID, + type ZeroTrustConfiguration, + zeroTrustConfigurationStatus, +} from './configuration.ts'; +import { errorMessage, ZeroTrustUnavailableError } from './errors.ts'; + +export const MAX_ZERO_TRUST_CUSTOM_DOMAINS_PER_PROJECT = 100; + +export function installationId(env: ApiEnv['Bindings']): string { + return env.PLATFORM_INSTALLATION_ID ?? 'installation'; +} + +export 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); +} + +export 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); +} + +/** Every configuration write increments the revision, so it alone identifies a snapshot. */ +export function sameConfiguration( + expected: ZeroTrustConfiguration, + current: ZeroTrustConfiguration | null, +): current is ZeroTrustConfiguration { + return current !== null && current.revision === expected.revision; +} + +export 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 }; +} + +export 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, + status: '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; +} + +export type ProjectZeroTrustState = 'public' | 'protected' | 'reconciling' | 'error'; + +/** The one label shown to users: a settled row reports its protection. */ +export function projectZeroTrustState( + row: Pick, +): ProjectZeroTrustState { + if (row.status !== 'ready') { + return row.status; + } + return row.desired_protected ? 'protected' : 'public'; +} + +type PublicProjectZeroTrustState = Readonly< + Pick< + ProjectZeroTrust, + 'project_id' | 'desired_protected' | 'effective_protected' | 'last_error' | 'updated_at' + > & { state: ProjectZeroTrustState } +>; + +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: projectZeroTrustState(row), + last_error: row.last_error, + updated_at: row.updated_at, + }; +} + +/** The user-facing project shape plus the handles only platform operators see. */ +export function operatorProjectZeroTrustState(row: ProjectZeroTrust | null) { + if (!row) { + return null; + } + return { + ...publicProjectZeroTrustState(row)!, + bypass_application_id: row.bypass_application_id, + bypass_application_name: row.bypass_application_name, + protected_application_id: row.protected_application_id, + protected_application_name: row.protected_application_name, + protected_application_audience: row.protected_application_audience, + routing_cursor: row.routing_cursor, + reconciled_operation: row.reconciled_operation, + created_at: row.created_at, + }; +} + +/** Project protection can change only on a ready installation with no running operation. */ +export function projectZeroTrustAvailable(configuration: ZeroTrustConfiguration | null): boolean { + return ( + !!configuration?.enabled && + zeroTrustConfigurationStatus(configuration) === 'ready' && + !!configuration.wildcardApplication + ); +} + +export 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; +} + +export 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 }; +} + +/** + * A removed custom domain stays in the project's custom domain application + * until reconciliation rewrites it. Call this in the transaction that deletes + * the domain: a settled row then reads as pending, so hourly maintenance + * repairs the application even if the follow-up reconciliation never runs. + */ +export async function markProjectCustomDomainsChanged( + db: DbOrTx, + projectId: string, +): Promise { + await db + .update(projectZeroTrust) + .set({ status: 'reconciling', updated_at: sql`now()` }) + .where( + and( + eq(projectZeroTrust.project_id, projectId), + eq(projectZeroTrust.status, 'ready'), + or( + isNotNull(projectZeroTrust.protected_application_id), + isNotNull(projectZeroTrust.protected_application_name), + ), + ), + ); +} + +export async function markProjectError( + db: DbOrTx, + projectId: string, + error: unknown, +): Promise { + await db + .update(projectZeroTrust) + .set({ status: 'error', last_error: errorMessage(error), updated_at: sql`now()` }) + .where(eq(projectZeroTrust.project_id, projectId)); +} + +/** + * Like markProjectError, but leaves rows that settled since the failure alone. + * A project whose first checkpoint failed has no row yet. Record the failure + * with its initial protection so its next retry waits behind other pending + * projects instead of being picked first again. + */ +export async function markPendingProjectError( + db: Db, + projectId: string, + error: unknown, + initialProtection: boolean, +): Promise { + const lastError = errorMessage(error); + await db + .insert(projectZeroTrust) + .values({ + project_id: projectId, + desired_protected: initialProtection, + effective_protected: false, + status: 'error', + last_error: lastError, + }) + .onConflictDoUpdate({ + target: projectZeroTrust.project_id, + set: { status: 'error', last_error: lastError, updated_at: sql`now()` }, + setWhere: sql`${projectZeroTrust.status} <> 'ready'`, + }); +} + +/** + * Initial protection for a project that has no row yet. Projects that existed + * when an enabling configure operation started follow its existing-project + * choice; every later project follows the default for new projects. + */ +export function initialProjectProtection( + configuration: ZeroTrustConfiguration, + project: Pick, +): boolean { + const operation = configuration.operation; + return operation?.kind === 'configure' && + operation.existingProjects !== null && + Date.parse(project.created_at) <= Date.parse(operation.startedAt) + ? operation.existingProjects === 'protected' + : configuration.protectNewProjects; +} diff --git a/platform/packages/api/src/zero-trust/status.ts b/platform/packages/api/src/zero-trust/status.ts new file mode 100644 index 000000000..ae0b0d1ab --- /dev/null +++ b/platform/packages/api/src/zero-trust/status.ts @@ -0,0 +1,110 @@ +import { platformHealthHostname, readPlatformApplication } from '@void/platform'; +import { sql } from 'drizzle-orm'; +import type { Db } from '../db.ts'; +import type { ApiEnv } from '../env.ts'; +import { projectZeroTrust } from '../schema.ts'; +import { resolveZeroTrustApplicationEnvironment } from './application.ts'; +import { healthCheckApplication, reusablePolicyReferences } from './cloudflare.ts'; +import { + type ZeroTrustConfiguration, + ZeroTrustConfigurationError, + readZeroTrustConfigurationStrict, + redactedZeroTrustConfiguration, + zeroTrustWildcardDestinations, +} from './configuration.ts'; +import { clientForConfiguration, projectZeroTrustState } from './state.ts'; + +/** + * Installation status from saved state. `check` also asks Cloudflare whether + * the account, selections, wildcard application, and platform health exception + * still match; it makes several Access API calls, so reads leave it off unless + * an operator asks. + */ +export async function zeroTrustStatus( + env: ApiEnv['Bindings'], + db: Db, + options: Readonly<{ check?: boolean }> = {}, +) { + const groups = await db + .select({ + desiredProtected: projectZeroTrust.desired_protected, + effectiveProtected: projectZeroTrust.effective_protected, + status: projectZeroTrust.status, + count: sql`count(*)::int`, + }) + .from(projectZeroTrust) + .groupBy( + projectZeroTrust.desired_protected, + projectZeroTrust.effective_protected, + projectZeroTrust.status, + ); + const projectCounts = groups.map(({ desiredProtected, effectiveProtected, status, count }) => ({ + desiredProtected, + effectiveProtected, + state: projectZeroTrustState({ status, desired_protected: desiredProtected }), + count, + })); + 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, + checked: false, + live: null, + projects: projectCounts, + }; + } + const checked = + !!options.check && !!configuration?.enabled && !!configuration.wildcardApplication; + let live: { present: boolean; drifted: boolean } | null = null; + if (checked && 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, + destinations: zeroTrustWildcardDestinations(readPlatformApplication(env)), + allowedIdentityProviderIds: configuration.identityProviderIds, + policies: reusablePolicyReferences(configuration.policyIds), + }); + const healthHostname = platformHealthHostname(readPlatformApplication(env)); + if (healthHostname) { + // A missing health exception sends platform health probes to Access; + // one that covers more than `/health` leaves project paths outside it. + const health = configuration.healthApplication + ? await client.inspectApplication( + configuration.healthApplication, + healthCheckApplication(configuration.healthApplication.name, healthHostname), + ) + : { present: false, drifted: true }; + live = { + present: live.present && health.present, + drifted: live.drifted || health.drifted, + }; + } + 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), + checked, + live, + projects: projectCounts, + }; +} diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 4ca78c1d3..87fa333b8 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'; @@ -448,6 +450,367 @@ describe('auth middleware', () => { ); }); +const zeroTrustCredential = 'stored-access-management-credential'; + +function zeroTrustSetting(overrides: Record = {}) { + return { + key: 'zeroTrust.configuration', + value: { + version: 1, + revision: 918273645, + 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 = { + enabled: false, + wildcardApplication: null, + operation: { + id: 'configure-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 = + /