Skip to content

Project Zero Trust accepts Access policies that let in anyone with a One-time PIN or public login #429

Closed

Description

@Brooooooklyn

Problem

Project Zero Trust accepts an Access policy whose only identity check is a One-time PIN, a public login (GitHub, consumer Google, an unrestricted Cloudflare login), or a login added with the generic OpenID Connect or SAML connector. Such a policy lets in anyone who has an email address or such an account, so the "protected" apps are public. Cloudflare names Include | Login Methods | One-time PIN next to Include | Everyone as the two settings that let anyone access an application.

installer (platform protection, app access)   ──▶ rejects OTP-only   (#424)
Admin → Zero Trust "Save and reconcile"        ──▶ accepts OTP-only
void platform zero-trust configure             ──▶ accepts OTP-only
status --check / "Check with Cloudflare"       ──▶ "Healthy", also after a policy is edited to Everyone
  • The server passes an "any login method" rule to the shared check (platform/packages/api/src/zero-trust/cloudflare.ts). This has been the behavior since project Zero Trust shipped (feat(platform): add project Zero Trust protection #335).
  • Policy rejections are plain errors. The operator API maps them to 502 Cloudflare Zero Trust reconciliation failed, and domain activation maps them to 503 Retry domain setup. The operator never sees the reason.
  • Access apps reference policies by ID, and Cloudflare evaluates the live policy. A check passes only at the moment it runs. A later edit in Cloudflare is not reported.

Where a selection is checked today:

Path Code
configure (Admin UI, CLI) zero-trust/installation.ts configurePlatformZeroTrust → validateSelection
reconcile, hourly resume, domain activation zero-trust/operation.ts runConfigureOperation (from installation.ts reconcile, configure, domain)
status --check zero-trust/status.ts
installer readiness platform-app-access.ts (needs live.present && !live.drifted)

Decision

Check strictly when someone submits settings. Stay lenient when the server re-checks saved settings. Report problems with saved settings as a warning.

submit (Admin Save, zero-trust configure, installer handoff) ─▶ STRICT  → 409 + reason
   exception: only the token changes (same IdPs, policies, defaults) ─▶ lenient
background re-check (reconcile, resume, domain activation)   ─▶ LENIENT (unchanged)
status --check / "Check with Cloudflare"                     ─▶ lenient + policyWarning (never changes live)

Rejecting during background re-checks closes nothing, because Cloudflare keeps serving the live policy. It would only stop reconcile, domain changes and new protected projects. A strict check on submit costs an affected admin one policy edit, at a time when they are already changing the settings.

Design

cloudflare.ts

  • validateSelection(input, check: 'submit' | 'saved' | 'report' = 'saved') returns { teamDomain, policyWarning? }.
    • submit: a login_method rule counts only when isPlatformAccessCompanyIdentityProvider(provider) is true (the installer's rule). Since fix: use the Void Cloudflare login in dev and unblock self-hosted platform onboarding #424 this means an IdP type whose required settings name one tenant: Entra ID, Okta, OneLogin, PingOne, Centrify, Google Workspace, or Cloudflare with Restrict to account members. Generic oidc and saml do not count.
    • saved: today's behavior.
    • report: run saved (failures throw as today), then the strict rule on the policies already fetched. This pass is best effort: a policy rejection becomes policyWarning, and any other error (429, 5xx) is logged and gives no warning. It never reaches the status catch.
  • Provider lookup: seed a map with the selected IdPs that are already fetched. Fetch each other login_method IdP once by ID. A 404 means the IdP was deleted. A policy with no login_method rule makes no extra call. Share one group cache between the two passes.
  • Every policy-shape throw becomes ZeroTrustPolicyRejectedError. "Cloudflare returned an invalid …" stays a plain Error.

errors.ts: ZeroTrustPolicyRejectedError extends ZeroTrustUnavailableError (409). It lives here to avoid an import cycle with cloudflare.ts.

installation.ts: use saved only when the token is the only change: (previous.enabled || previous.operation) and the same IdP IDs, policy IDs, protectNewProjects and publicAccess, and no existingProjects. Otherwise use submit. In the claim callback, if the exception applied but the saved operation changed meanwhile, throw ZeroTrustUnavailableError('The saved Zero Trust operation changed. Retry configuration.').

status.ts: pass report and add policyWarning: string | null. In the existing catch, set policyWarning to the message when the error is a ZeroTrustPolicyRejectedError (a saved policy that was edited to Everyone or Bypass), and keep live exactly as it is today.

Messages

  • Access policy "<name>" lets in anyone who can sign in with <IdP name>. In Cloudflare, add a Require rule such as Emails ending in your company domain, or select another policy. The text says Require, not Include: Include rules are OR, so an extra Include still lets in anyone with OTP.
  • For a group: Access group "<name>" lets in … with the same ending.
  • Generic OIDC or SAML login: Access policy "<name>" relies only on <IdP name>, a generic OIDC or SAML login, and Void cannot tell who can sign in with it. In Cloudflare, add a Require rule such as Emails ending in your company domain, or select another policy.
  • Deleted IdP: Access policy "<name>" uses a login method whose identity provider no longer exists (<id>). Fix the policy in Cloudflare, or select another policy.
  • Cloudflare IdP without "Restrict to account members": end with turn on Restrict to account members for that login method in Cloudflare, or select another policy.

Surfaces

  • Admin "Save and reconcile": the error page shows the message, and nothing is saved.
  • Admin "Check with Cloudflare": <Banner status="warning" title="Access policy is too broad" description={policyWarning} />. "Live application" stays Healthy.
  • zero-trust configure: 409 with the message. --plan stays offline.
  • zero-trust status --check: prints Policy warning: <text>.
  • zero-trust reconcile and domain activation on a policy now rejected for its shape: 409 with the reason, instead of the generic 502 or 503.
  • No new flag and no new token permission ("Identity Providers … Read" is already required).

Docs

  • docs/guide/platform/administration/zero-trust.md: list "policies that rely only on One-time PIN, a public login such as GitHub, or a generic OIDC or SAML login" among rejected policies, as setup.md does. Add a Policy warning bullet to the status section.
  • docs/reference/cli/platform-zero-trust.md: the same clause.

Effect on running installations

Serving, reconcile, resume and domain changes keep working. status --check and the Admin page show a warning until the policy is fixed. The next save of Zero Trust settings rejects the policy. Release note: "If you chose Zero Trust policies in Admin or with void platform zero-trust configure, run void platform zero-trust status --check and fix any Policy warning."

Not doing

  • Reject during background re-checks: blocks operations and closes nothing.
  • Filter pickers: the Admin UI and the CLI take IDs, and the installer pickers already check on submit.
  • Migrate or turn off Zero Trust: Void never rewrites customer policies, and turning Zero Trust off makes apps public.
  • Fail closed for protected projects: an outage caused by a policy the admin chose.

Tests

  • configure with include: [login_method OTP] → 409 with the message, nothing saved. Fails today (200).
  • Cloudflare's recipe (include email_domain, require login_method OTP) → 200. include [OTP, email_domain] (OR) → 409.
  • Cloudflare IdP: restrict_to_account_members false → 409, true → 200. Okta login method → 200.
  • Generic OIDC or SAML login method alone → 409. Generic OIDC Include + email_domain Require → 200.
  • login_method IdP outside the selected IDs → fetched by ID; a 404 → 409 with the "no longer exists" text.
  • configure with an Everyone policy → 409 with the reason. Fails today (502).
  • Saved OTP-only policy: reconcile → 200; status --check → live unchanged and policyWarning set; a new configure with other settings → 409.
  • Token-only change with a saved OTP-only policy → 200, with or without a pending operation. Changing the IDs → 409.
  • A strict-pass 429 in report → no warning, live unchanged.
  • Domain activation with a saved policy edited to Everyone → 409 with the reason (was 503).
  • Admin page shows the banner when policyWarning is set.

Related, out of scope

  • Platform protection policies (admission.access) are checked only at install. A later OTP-only edit in Cloudflare can open company admission. This needs a separate check.
  • PingOne's Cloudflare doc example shows type ping, while the API enum says pingone. Confirm the live value. duo is not on the list on either side.
  • Cloudflare's "Cloudflare Account Member" selector is not on the company rule list on either side. Its API rule key is unverified.
  • zero-trust configure --plan could run the same read-only check, since the token is in the request.

Depends on #424 (isPlatformAccessCompanyIdentityProvider in packages/platform/src/access-policy.ts).

🤖 Generated with Claude Code

https://claude.ai/code/session_0123r5HnanmW2VUrZ7VWDufH

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions