Hive Hive
Sign in

fix(server): verify operators against the Google Workspace hosted domain

GitHub issue · Closed

Metadata
Source
tuist/tuist #11298
Updated
Jun 24, 2026
Details

What changed

tuist_operator?/1 decides operator eligibility as: in dev, or an account whose email matches the operator domain (@tuist.dev). On tuist-hosted, the domain match isn’t enough — the user must also be a member of the operator Google Workspace, verified the same way org SSO binds an org to its Google tenant (the :google OAuth identity’s provider_organization_id == the operator domain). On a self-hosted instance — which has no Tuist Google Workspace — the operator-domain email match alone qualifies; the Google check is skipped, not the operator concept. It no longer requires confirmed_at.

String.ends_with?(String.downcase(email), "@" <> domain) and
(not Environment.tuist_hosted?() or google_workspace_member?(user, domain))

Why

#11212 replaced the old TUIST_OPS_USER_HANDLES allowlist with tuist_operator?/1, gating on a confirmed @tuist.dev email. But it required confirmed_at to be non-nil, and OAuth sign-ins never set it.

On hosted (multi-tenant) tuist.dev, find_or_create_user_from_oauth2 logs the user in with confirmed_at: nil because default_confirmed_at() only returns a timestamp when skip_email_confirmation? is true. The address is verified by Google Workspace, not by our email-confirmation flow.

Result: every operator who signs in with Google (which is all of us) silently lost access to the /ops panel and the reason-gated operator redirect to ops.tuist.dev. Visiting a customer URL returned a 404 instead of the reason form. The old handle allowlist never looked at confirmed_at, so this was a regression from the cutover.

Why match the hosted domain rather than the email suffix

Google records the Workspace it authenticated against as the hosted-domain (hd) claim. We already capture it at sign-in and store it as the identity’s provider_organization_id — the exact same code path org SSO uses to bind an org (extract_provider_organization_id/1). For a tuist.dev Workspace account that value is tuist.dev.

Matching on it directly asserts “member of the tuist.dev Google Workspace” rather than inferring it from the email string, and mirrors how require_sso_authentication enforces org SSO (compare a captured-at-sign-in binding, not a live Google call). tuist_operator?/1 runs at authorization time with only a %User{}, so a live Directory API check is not possible — both org SSO and this gate rely on what was captured at sign-in.

Why the Google check is hosted-only

The Google Workspace hd match is meaningful only on tuist.dev, where operators are the Tuist team signing into our org-restricted Workspace. A self-hosted instance has no Tuist Google Workspace and configures its own operator email domain, so requiring our Google hd there would lock out every operator on that instance. The gate (not tuist_hosted?() or google_workspace_member?(...)) wraps only the Google check — self-hosted operators still work via the operator-domain email match.

Migration note

The hosted check depends on existing :google identity rows for @tuist.dev users having provider_organization_id populated. Verified against production: both existing operators (marek, eduardo.ext) already have provider_organization_id = tuist.dev (and confirmed_at null, which is exactly why the old gate locked them out), so no backfill is needed and no migration ships with this PR. Every Google sign-in path captures hd at creation, so new operators get it too.

Impact

Restores /ops access and the operator → ops.tuist.dev/grants/new redirect for hosted operators who are members of the tuist.dev Google Workspace. Email-confirmation-only accounts, non-Google providers, no-hosted-domain identities, and other Workspaces are not operators on hosted. Self-hosted instances are unaffected — operators there are whoever matches the configured operator email domain.

Validation

mix test test/tuist/accounts_test.exstuist_operator?/1 block (green):

  • hosted + Google identity, hd tuist.dev → operator
  • hosted + Google identity, hd null → not operator (the historical-row case)
  • hosted + Google identity, hd evil.example → not operator
  • hosted + confirmed via email flow only (no Google identity) → not operator
  • hosted + non-Google (GitHub) identity → not operator
  • hosted + non-@tuist.dev Google identity → not operator
  • self-hosted + operator-domain email (no Google identity) → operator
  • self-hosted + non-operator-domain email → not operator

Plus the gate’s other call sites, all green (490 tests across checks_test, authorization_test, account_settings_live_test, operator_grant_plugs_test, operator_grant_test, ops_account(s)_live_test) — operator describes stub Environment.tuist_hosted? true to exercise the hosted Google path, with self-hosted cases asserting an operator-domain user is an operator (and internal_ops_access grants them) with no Google identity. mix credo lib/tuist/accounts.ex clean.

🤖 Generated with Claude Code

Comments
TA
tuist-atlas[bot] Jun 17, 2026

The fix verifying operators against the Google Workspace hosted domain is now available. Update to xcresult-processor-image@0.23.1 to get this change.

TA
tuist-atlas[bot] Jun 17, 2026

This fix is now available in server@1.212.1. Update to server@1.212.1 (Docker image: ghcr.io/tuist/tuist:1.212.1) to restore /ops access and the operator redirect to ops.tuist.dev for hosted operators who are members of the tuist.dev Google Workspace.