Internal authentication¶
The gateway checks webhook signatures, Git permissions and policy. The orchestrator sits behind it, but it has listeners of its own: an HTTP API, a gRPC port and an admin port. This page is about how those are protected, so that the checks the gateway makes cannot be walked around by calling the orchestrator directly.
The rule is simple: the orchestrator does not trust where a call comes from, only what it carries. Every call to it carries a short-lived signed grant. The gateway mints the grants for webhooks and CLI calls after it has made its own checks; the watcher mints its own; an operator mints one for the metrics scrape.
What is protected¶
| Listener | Needs | Open without it |
|---|---|---|
HTTP API :8000 — webhooks, dry runs, schema warm-up, API docs |
a sync grant |
/v1/health*, /v1/auth/* (the sign-in flow) |
HTTP API /v1/ui/* |
the web UI's signed-in session | — |
HTTP API GET /v1/tasks/{id}, GET /v1/tenants/{id}/schemas |
a sync grant or a signed-in session |
— |
gRPC :50051 — Orchestrator, CLIService |
a sync grant on every call |
— |
gRPC :50051 — Watcher |
a watch grant on every call |
— |
Admin :9093 — /metrics, /circuit-breaker, /debug/pprof |
an admin grant |
/healthz, /health/* |
A route added later is closed until someone opens it on purpose: the HTTP gate
(app/api/middlewares/auth.py) lists the few routes that answer for themselves and treats
everything else as needing a grant.
What a grant says¶
A grant is a compact HS256-signed token (header.payload.signature, the usual JWT layout):
| Claim | Meaning |
|---|---|
aud |
the API it is for: sync, watch, admin or authz |
sub |
who is asking — login@platform for a CLI user, a service name otherwise |
tenant |
the tenant the call is scoped to (optional) |
platform |
the Git platform the call was authorised on (optional) |
datasource |
the datasource the call was authorised for (optional) |
iat, nbf, exp |
issue, not-before and expiry — all required |
The orchestrator checks the signature, the audience and the lifetime on every call, and the claims then bound what the call may do:
- The grant decides the tenant and platform, never a header or the payload. A call for a
different datasource, tenant or platform than the grant names is refused. For a CLI call the
tenant in the grant is the one the gateway's permission engine checked: a declared tenant,
allowed by the rule, that the caller's Git access reaches (an admin may act for any declared
tenant). With internal authentication on, a CLI grant without a tenant is refused, and the
tenant_ida request body carries is never copied into a grant. - A push's tenant is the one whose repositories it came from, and a schema push's the one
whose
schema_repoit is; a tenant named by a grant or header is checked against that, never used in its place. A push no declared tenant owns is refused (403). - A tenant writes only its
destinations. Once any tenant lists destinations for a datasource, a sync to a place outside the tenant's own is refused before a sink is opened, and the sync lock is keyed by the destination so two tenants writing one place wait for each other. - The caller recorded with a task is the grant's
sub. Thex-cli-userandx-tenant-idheaders are ignored once authentication is on. - The pushed repository must be one the rule covers. Independently of the grant, a
webhook payload that names a repository outside the rule's
repositories(or inside itsexclude_repositories) is refused, because the payload is whatever its sender wrote. A rule that names no repositories covers the one itsrepo_urlpoints at.
Lifetimes are short — sync and watch grants live at most 10 minutes (the gateway and
watcher mint one per call, valid for 60 seconds); an admin grant is a standing credential
for a scraper and may live up to 30 days.
Turning it on¶
Give every service the same secret, at least 32 characters:
| Service | Variable | Notes |
|---|---|---|
| orchestrator (API and workers) | INTERNAL_AUTH_SECRET or INTERNAL_AUTH_SECRET_FILE |
verifies grants |
| gateway | INTERNAL_AUTH_SECRET or INTERNAL_AUTH_SECRET_FILE |
signs sync grants |
| watcher | INTERNAL_AUTH_SECRET or INTERNAL_AUTH_SECRET_FILE |
signs watch grants |
| gateway and orchestrator | INTERNAL_AUTH_SECRET or INTERNAL_AUTH_SECRET_FILE |
the orchestrator signs authz grants, the gateway verifies them (the permission engine) |
Prefer *_FILE pointing at a mounted secret over putting the value in the environment.
The watcher and the admin listener may have separate keys, so that holding one does not
let you mint the others: INTERNAL_AUTH_WATCH_SECRET (orchestrator and watcher) and
INTERNAL_AUTH_ADMIN_SECRET (orchestrator, and whoever mints the scrape token). Each falls
back to INTERNAL_AUTH_SECRET when unset.
Authentication is enforced on the orchestrator whenever INTERNAL_AUTH_SECRET is set there.
Turn it on everywhere at once: a gateway without the secret would send unsigned calls that an
orchestrator with it refuses.
Production refuses to start without it¶
With APP_ENV=production the orchestrator, the gateway and the watcher all refuse to start
unless a secret is set. The gateway and the watcher also refuse a secret shorter than 32
characters, and a *_FILE that cannot be read, rather than running unsigned.
The development switch¶
Without a secret the orchestrator runs open: every call is accepted, as before, and it
logs internal authentication is OFF at startup. This is for a laptop. The repository's
end-to-end demo (e2e/docker-compose.yml) runs with it on: one demo INTERNAL_AUTH_SECRET
(overridable with VARTRACK_DEMO_INTERNAL_AUTH_SECRET) on the gateway, the orchestrator, the
watcher and the test runner, which signs its own calls to the orchestrator; its Suite 18 sends the
calls that must be refused and checks each refusal. (The live UI stack, e2e/docker-compose.live.yml,
still runs open.) To run a production-mode service open on purpose, set INTERNAL_AUTH_INSECURE=true;
there is no other way to get a production orchestrator without authentication.
Scraping the admin port¶
/metrics needs an admin grant once authentication is on. Mint one and give it to the
scraper:
docker compose exec orchestrator-api \
python -m app.api.tokens mint --audience admin --subject prometheus --ttl 2592000 > admin.token
# prometheus.yml
scrape_configs:
- job_name: vartrack-orchestrator
authorization:
credentials_file: /etc/prometheus/admin.token
static_configs:
- targets: ["orchestrator-api:9093"]
Renew the token before it expires (--ttl is in seconds, at most 30 days). The orchestrator's
own /v1/health/liveness and /v1/health/readiness on the API port stay open and are the
right place for Kubernetes probes: the admin listener binds to loopback by default
(ADMIN_HOST=127.0.0.1), so a probe that targets the pod's address cannot reach it. Publish
the admin port deliberately with ADMIN_HOST=0.0.0.0 only together with
INTERNAL_AUTH_SECRET.
The permission engine¶
Who may do what is decided in one place: the permission engine in the gateway (Authentication & Authorization). The CLI API asks it in-process. The orchestrator's web UI asks it over gRPC, so the two never disagree. This is the one place a Git token has to reach, which is why the call is protected like the others.
| Served by | the gateway, on AUTHZ_ADDR (e.g. :5658); unset serves nothing |
| Asked by | the orchestrator API, with GATEWAY_AUTHZ_ADDR=gateway:5658 |
| Needs | an authz grant on every call, signed with INTERNAL_AUTH_AUTHZ_SECRET (else INTERNAL_AUTH_SECRET), minted by the orchestrator |
| Carries | the user's Git tokens as request fields, never in the grant (a grant is signed, not encrypted) and never in a log |
| Without it | the web UI with sign-in on answers 503 permissions are unavailable: there is no local fallback |
In production the listener is TLS with the gateway's own certificate (GATEWAY_TLS_CERT/KEY) and,
when GRPC_TLS_CA is set, requires a client certificate that CA signed (mTLS); the orchestrator
presents TLS_CERT_FILE/TLS_KEY_FILE and verifies the gateway with GATEWAY_AUTHZ_CA_FILE (else
TLS_CA_FILE). The gateway refuses to serve AUTHZ_ADDR in production without a signing secret.
Without a secret (development) it serves open and says so at startup. Only the orchestrator's API
needs to reach it: the NetworkPolicy in deploy/networkpolicy/gateway.yaml
opens 5658 to it alone.
What the engine learns from the Git platforms is remembered for AUTHZ_CACHE_TTL (default 60 s) per
token, platform and question, keyed by a hash of the token. Signing out drops what it knows of the
session's tokens; a revoked permission keeps working for up to the TTL.
The optional Prometheus listener¶
When PROM_ENABLED=true the orchestrator starts a second metrics server on PROM_PORT. It is
a separate listener from the admin port, listens on every interface, and is not covered by
grants: its only protection is its own basic auth (PROM_BASIC_AUTH_USER /
PROM_BASIC_AUTH_PASS, optionally with PROM_ENABLE_TLS). If you turn it on, set those, or
keep the port off the network (the NetworkPolicy manifests
do not open it); the admin port's /metrics is the one that takes a grant.
CORS¶
CORS_ORIGINS=* is honoured only while the orchestrator runs open for development. With
authentication on, or in production, a wildcard is ignored and logged as an error: list the
web UI's origin instead.
Keep the network closed as well¶
Authentication is the control; the network is the second one. Only the gateway, the watcher
and the web UI's proxy need to reach the orchestrator, and only Prometheus needs the admin
port. deploy/networkpolicy/
has Kubernetes NetworkPolicy manifests that say exactly that: label your pods as the
manifests expect, apply them, and everything else is denied by default.
Troubleshooting¶
| You see | Cause |
|---|---|
401 a signed grant is required on a webhook or schema call |
the caller sent no Authorization: Bearer <grant> |
401 invalid grant: invalid signature |
the caller and the orchestrator hold different secrets |
401 invalid grant: token has expired / not valid yet |
the clocks of the two hosts differ by more than 30 seconds, or the grant is stale |
401 invalid grant: wrong audience |
a watch or admin grant was sent where a sync grant is needed (or the reverse) |
403 the grant was issued for another tenant / datasource |
the call asks for more than its grant allows |
403 no declared tenant owns … |
a push came from a repository no tenant's repositories covers (and the rule names no single tenant to take it) |
permission denied: tenant … is not one of the tenants of the … rule / … has no access to the repositories tenant … covers |
the caller named a tenant the rule or their Git access does not allow |
destination '…' is not one of tenant …'s on … |
the sync would write outside the tenant's destinations |
403 repository '…' is not covered by the rule |
the payload names a repository the rule's repositories do not include |
gRPC UNAUTHENTICATED from the gateway |
the gateway has no INTERNAL_AUTH_SECRET, or a different one |
web UI answers 503 permissions are unavailable; the orchestrator logs the permission engine refused this service's credentials |
the gateway and the orchestrator hold different INTERNAL_AUTH_SECRET / INTERNAL_AUTH_AUTHZ_SECRET, or their clocks differ by more than 30 s |
web UI answers 503 permissions are unavailable; the orchestrator logs GATEWAY_AUTHZ_ADDR is not set or unreachable |
set GATEWAY_AUTHZ_ADDR on the orchestrator and AUTHZ_ADDR on the gateway, and let the orchestrator reach it |
orchestrator exits at startup with production mode requires INTERNAL_AUTH_SECRET |
set it, or INTERNAL_AUTH_INSECURE=true to run open on purpose |