Last updated 2026-06-29

Security & Service Auth

This page is the authoritative reference for how requests to this service are authenticated and where the trust boundary sits. Read it before deploying. The short version: workspaces is an internal service. Callers are trusted product backends that authenticate service-to-service with a shared credential. The end user is never the caller — they are data in the request, authenticated at the product edge before this service is ever called.

Service-to-service authentication

A calling backend presents a shared service credential on every RPC:

Authorization: Bearer <service-token>

The accepted credentials come from GATEWAY_SERVICE_AUTH_TOKENS (comma-separated, so you can rotate). The middleware compares the presented token against each configured token with a constant-time comparison (crypto/subtle), so a timing side-channel cannot leak the secret. A missing or wrong credential returns HTTP 401, which Connect surfaces to the client as code Unauthenticated.

ConditionResult
Valid Bearer token (matches a configured token)Request proceeds
Missing / malformed / wrong tokenHTTP 401 → Connect Unauthenticated
GATEWAY_SERVICE_AUTH_TOKENS emptyAuth disabled; every caller trusted; a warning is logged at startup
Health (/healthz, /readyz), metrics, and CORS preflight (OPTIONS)Always bypass the check

Leaving the tokens empty is intentional for deployments where a service mesh or mTLS already authenticates callers — the requirement is disabled and a loud warning is logged at construction so it is never silently off. If you are not behind a mesh, set the tokens.

There is no end-user JWT in this service. No JWKS, no HS256 secret, no issuer check, no token-verification step. The Authorization header carries the service credential, not a user's access token.

The end user is data, not the caller

Like Zanzibar, the user whose access is being managed or tested is an explicit request field, independent of who is calling:

  • acting_user_id (management RPCs) — the user the action is authorized as, e.g. "must be an admin to add a member". Required; omitting it returns InvalidArgument.
  • subject_user_id (Check) — the user whose access is being tested. Pure data, completely independent of the caller.
curl -X POST http://localhost:8080/workspace.v1.AuthzService/Check \
-H "Authorization: Bearer $WS_SERVICE_TOKEN" -H "Content-Type: application/json" \
-d '{"namespace":"workspace","object_id":"W","relation":"admin","subject_user_id":"bob"}'
# The service auth says WHO IS CALLING (a trusted backend);
# subject_user_id says WHOSE ACCESS to evaluate (data).

Why service-auth, not forwarding the end-user token

A tempting alternative is to forward the end user's access token from the product into this service and have it re-verify the JWT. This service does not do that, deliberately:

  • Separation of concerns. Authentication belongs to identity and the product edge; authorization belongs here. Re-verifying a user token would duplicate identity's job and couple this service to identity's key material and token format.
  • Zanzibar correctness. An authorization service answers "does user X have relation R on object O?" — X is an argument. The caller is a service. Binding the answer to whoever's token happens to be on the wire conflates the questioner with the subject and makes server-to-server queries (background jobs, "can user X see this?" from an admin tool) impossible to express.
  • Decoupling. Many product surfaces (web, mobile, cron, internal tools) call one backend, which calls here as itself. The user is passed as data, so none of those surfaces need this service to understand their particular token.

Trust boundary

End user Product backend workspaces (this service)
(browser/mobile) (trusted caller)
─────────── ──────────────── ──────────────────────────
identity access ─────▶ verifies the user token ─────▶ Authorization: Bearer
token (verified at its OWN edge, resolves <service-token>
at the edge) the user + tenant + acting_user_id / subject_user_id
+ project_id (all DATA)
└─ authN happens here ──┘ └─ authZ happens here, never authN ─┘

Everything left of the backend is the product's responsibility. This service trusts the backend's service credential and the user/project it passes; it performs authorization only, never authentication.

Callers are trusted — a deployment consideration

Because the trust model is service-to-service, an authenticated caller is trusted within its project. In particular, WriteRelationTuples is open to any authenticated service: there is no per-tuple authorization on the write path — a valid service credential can write or delete any tuple in any project it names. This is correct for a single trusted backend (it is the system of record for its own grants), but call it out explicitly:

  • Do not hand the service token to an untrusted or multi-tenant-shared component. A leaked token is full write access to the tuple store.
  • If multiple backends share the deployment, they share trust — there is no mutual isolation between callers beyond the project_id each one chooses to send.
  • Route end-user-facing mutations through your backend's own authorization first; do not expose WriteRelationTuples to clients.

Hardening

  • Rotate tokens with the CSV. GATEWAY_SERVICE_AUTH_TOKENS is comma-separated: add the new token, roll callers over, then drop the old one — zero-downtime rotation with no window where both are required.
  • Terminate mTLS at the mesh. When a service mesh or mTLS already authenticates callers, you may leave the tokens empty (and accept the startup warning) or keep them as defense-in-depth. Never expose the service on a public network with auth disabled.
  • Restrict CORS. Set GATEWAY_ALLOWED_ORIGINS to the exact origins that may call the service from a browser. Since this is an internal service, that list is usually empty.
  • Keep it internal. Bind it to the private network; only your trusted backends should be able to reach GATEWAY_CONNECT_PORT. Health and metrics ports need not be public either.
  • Cap request size. GATEWAY_HTTP_MAX_BODY_BYTES (default 1 MiB) bounds request bodies.

Where to next