Hive Hive
Sign in

feat(server): reason-gate operator access to customer projects

GitHub issue · Closed

Metadata
Source
tuist/tuist #11212
Updated
Jun 24, 2026
Domains
Atlas
Details

Summary

Replaces blanket operator access to customer projects with a per-access, reason-gated, time-boxed grant. An operator who isn’t a member of a customer account is redirected to ops.tuist.dev to justify access, then redirected back with a signed grant the customer server/ verifies offline. Read access is self-serve (reason only); admin access (“sign in as admins”) goes through the same Slack JIT approval as kubectl writes. A valid grant also bypasses the customer’s SSO enforcement, which is what makes SSO-enforced orgs reachable.

Built on #10988, which has since merged into main, so this targets main directly.

Why

Today operator access is a static blanket: ops_access returns true whenever user.account.name in ops_user_handles(), granting read of any project’s dashboard, runs, bundles, billing, etc. There is no per-access justification, no time bound, and no audit of why an operator looked at a customer’s data. Separately, require_sso_authentication redirects operators into the customer’s own SSO, which they can’t complete, so SSO-enforced orgs are inaccessible today.

This makes operator access a deliberate, reason-logged, time-boxed, auditable act, and reuses #10988’s JIT machinery (Tailscale role as source of truth, Slack approval, fail-closed) for the admin tier.

How it works

Operator -> tuist.dev/{account}/{project} (not a member; org may enforce SSO)
server: no grant -> redirect to ops.tuist.dev/grants/new?return_to=...&account=...
ops (Pomerium / Google OIDC; X-Pomerium-Claim-Email):
read -> record grant, mint Ed25519 JWT, 302 back with ?operator_grant=<jwt>
admin -> Slack approval card; second human approves; then 302 back with the jwt
server: verify jwt offline (EdDSA-strict, iss/aud pinned, TTL capped),
pin account_id, store in session, strip the param,
skip SSO enforcement, authorize via the grant

What changed

tuist-ops (the grant issuer)

  • lib/tuist_ops/project_access/ new feature module: request.ex + grant.ex (schemas), approvals.ex (read = self-serve grant inline; admin = pending until Slack approval), policy.ex (read self-serve / admin-approver gates, reusing JIT.TailscaleClient), token.ex (mints the Ed25519 grant), slack_blocks.ex.
  • grant_controller.ex + router /grants/*: the reason form, identity from X-Pomerium-Claim-Email. The admin “pending approval” page is a plain HTML poll of /grants/:id/status, not LiveView, since tuist-ops has no asset pipeline.
  • slack_controller.ex: pa_approve / pa_deny interactive actions.
  • Migration create_project_access_tables, jose dep, new env config.

server (offline verification + enforcement)

  • lib/tuist_web/operator_grant.ex: verify/1 (JOSE.JWT.verify_strict(_, ["EdDSA"], _) only, rejecting none/HS256 confusion tokens; exp, max-TTL ceiling, iss/aud pinning), the accept_operator_grant handoff plug, load_operator_grant plug + on_mount, and the redirect_to_ops_if_operator gate.
  • Authorization: split the shared ops_access check. The internal /ops panel keeps its static behavior via the new internal_ops_access; customer-data objects move to a grant-scoped ops_access / ops_write_access (admin tier required for writes), resolving each object to its account via object_account_id/1.
  • SSO bypass in require_sso_authentication when the operator holds a valid grant for that account.
  • ops_user_handles removed. Operator eligibility is now Tuist.Accounts.tuist_operator?/1 (confirmed @tuist.dev email), and the redirect gate additionally requires a Google-authenticated session (the “google sso identity under the @tuist.dev org” signal), so password and test sessions stay on the normal path.

infra / docs

  • PROJECT_ACCESS_SIGNING_KEY added to the tuist-ops ESO.
  • infra/k8s/operator-project-access-audit.md: the three-trail audit story plus the deployment runbook.

Reasoning behind the non-obvious choices

  • Signed grant, not a server->ops call. The server verifies offline with only the public key, so operator access and the customer-facing app stay on separate failure domains (the same decoupling #10988 is built on). Asymmetric, so even a compromised server can’t mint grants. Revocation is short TTL + keypair rotation.
  • Google-auth gate on the redirect. The eligibility heuristic is a routing decision, not the security boundary (the boundaries are Pomerium/Google-OIDC at ops and the offline verification). Requiring auth_method == :google is the faithful reading of “google sso identity” and keeps the many @tuist.dev test fixtures on the normal path.

Security notes for the reviewer

  • X-Pomerium-Claim-Email is the operator identity for /grants/* and is spoofable on a raw ingress. Those routes must be Pomerium-fronted (which #10988 deferred); they are deliberately not added to the unprotected public ingress here. Called out in the runbook.
  • The ?operator_grant= token is stripped via redirect before any page renders or any observability plug logs the query string.

Deployment (manual, in the runbook)

  1. Generate an Ed25519 keypair. Private PEM -> 1P TUIST_OPS_BOT.project_access_signing_key; public PEM -> server TUIST_OPERATOR_GRANT_PUBLIC_KEY.
  2. Stand up Pomerium in front of ops.tuist.dev/grants/* (Google OIDC), carving out /webhooks/slack/*.

Validation

  • tuist-ops: 131 tests pass (new suites for policy, approvals, token); mix format + mix credo clean; compiles with --warnings-as-errors.
  • server: full suite 4923/4924 pass; mix format + mix credo clean; edited tests compile with --warnings-as-errors. The single failing test (TuistWeb.TestsLiveTest, a render_async 100ms ClickHouse-latency flake) was confirmed pre-existing on the base commit by stashing all changes and re-running.
  • New server tests cover verify/1 (valid / alg:none / HS256 / expired / over-TTL / wrong aud / wrong iss), the grant-based checks across every object shape, the redirect gate (operator vs non-operator vs grant-holder vs unauthenticated), the token handoff, and a full account-settings integration test exercising grant -> plug -> on_mount -> SSO bypass -> ops_write_access.

🤖 Generated with Claude Code

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

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

The reason-gated operator access feature implemented in this pull request is now available. Update to xcresult-processor-image@0.21.0 to use it.

TA
tuist-atlas[bot] Jun 16, 2026

Reason-gate operator access to customer projects is now available in server@1.212.0. Update to this version to use it.