Skip to content

Read access: tokens, teams and SSO

Who may read what on upgradescope serve: the read API, the dashboard's data, POST /api/v1/gate and /metrics. Pushes and cluster administration have credentials of their own (Tenancy and access control).

Every read is answered for a scope: the whole fleet, or a set of teams. Teams are the attribution findings already carry (a namespace's team label, or the server's --team-map rule that overrides it), so a team can be handed a view of its own clusters and findings without the rest of the fleet. A team name is free text: a --team-map team can be Platform Team or Équipe, Paris. A read token names one team per --teams flag, taken as written, and the trusted header and the X-Upgradescope-Teams answer header use the team list encoding. The one name a scope cannot hold is *, a token's scope of the whole fleet: a --team-map team called * is read as the team (*) (serve logs a warning at startup and keeps serving), so mint its token with --teams '(*)'. A map that also names a team (*) makes the two one team, and the warning says so: rename one to keep them apart. A namespace label value cannot be *. A --team-map team cannot be (unattributed) either: that is the name of the findings no team owns, and serve refuses a map that uses it.

Read credentials

The server checks, in this order:

Credential Scope Configured with
--read-token the whole fleet serve --read-token (or $UPGRADESCOPE_READ_TOKEN, --read-token-file)
the admin token the whole fleet serve --admin-token
a stored read token its teams, or the whole fleet for * upgradescope tokens create --read --teams payments --teams checkout
a trusted proxy's team header the teams it lists, never the whole fleet serve --trust-team-header X-Forwarded-Groups --trusted-proxy-cidr 127.0.0.1/32
nothing the whole fleet, only while the read API is open no --read-token, no read token in the database, no trusted header, and not --require-read-credential

All tokens are sent as Authorization: Bearer <token>. Once --read-token is set, a read token has been minted or the trusted header is configured, a request that presents no valid credential gets 401. serve refuses an open read API on an address that is not loopback unless --allow-anonymous-read is set.

An unknown bearer is never the open read. On an open read API, a request with no Authorization header reads the whole fleet, but one that presents a bearer matching no credential the server knows (not --read-token, not the admin token, not a stored read token, and no trusted team mapping) gets 401. A team-scoped token whose database was lost would otherwise read every team's data instead of failing. A client that sends a placeholder token to a server it believes is open (a CI gate's READ_TOKEN set to anything) now gets 401: send no Authorization header, or a real token.

Keeping the read API closed (--require-read-credential)

Whether a read token has been minted is a fact in the database. A lost PVC, a pod restart with persistence.enabled=false (an emptyDir), an older backup restored, or --db-url pointed at a fresh database has no such rows, so a server that was closed by its minted tokens, and was started with --allow-anonymous-read or on loopback, is open again: the fleet, the dashboard's data and /api/v1/gate answer anyone who can reach it, and the only signal is a WARN log line at startup.

serve --require-read-credential (chart: the default when server.readToken is empty and no allowAnonymousRead opts in) makes the closed state something that cannot be lost along with the data: the read API is never open, so a read with no credential, or with a bearer nothing knows, is 401 whatever the database holds, and serve starts on any address (an open read API is what it would refuse). While no credential exists yet (no --read-token, no active read token in the store, no trusted header) it logs a WARN saying reads return 401 until one is minted. It excludes --allow-anonymous-read. Mint the first token with the same --db or --db-url as serve; the running server sees it on the next request, SQLite included (the command and the server are two processes on one WAL-mode file):

$ upgradescope tokens create --read --teams '*' --db upgradescope.db

After the database is lost, mint again. /healthz and /readyz need no credential.

In the chart the flags are the chart's to choose: server.extraArgs may not contradict them. With no read token the chart passes --require-read-credential (or --allow-anonymous-read when server.allowAnonymousRead or server.ingress.allowAnonymousRead opts in), and serve refuses the two flags together, so the render fails on an extraArgs entry that sets the other one true (--allow-anonymous-read, --allow-anonymous-read=true), that sets the one the chart passes false (--require-read-credential=false would undo it), or that sets both. The other flag set false (--allow-anonymous-read=false) is its default and is accepted.

--read-token must differ from --ingest-token (and both from --admin-token): serve refuses to start otherwise, since one bearer would let every agent read the whole fleet and every reader push as any cluster.

The Host check (DNS rebinding)

A web page can point its own name at your loopback address: a page on attacker.example makes attacker.example resolve to 127.0.0.1 (DNS rebinding), and its requests then reach a server on the browser's machine as same-origin requests that carry Host: attacker.example. Two kinds of request need no credential to read: an anonymous read of an open read API, and the trusted team header, which is trusted from every connection out of a --trusted-proxy-cidr address, kubectl port-forward's included (it delivers the browser's connections to the pod from 127.0.0.1, Host unchanged). A server that listens on every interface (--listen :8080, the chart's) is reached on loopback too, by the same port-forward, so the address it bound says nothing about who can send such a request. So serve answers a request only when its Host names one of the names below, when it listens on loopback, when --trust-team-header is set, or when the read API is open and the request presents no bearer (an anonymous read; decided on each request, so minting a read token ends it without a restart, within a second of the mint: see below; and a store that cannot say whether the API is open is treated as open). The names:

  • localhost, or a loopback address (127.0.0.0/8, ::1);
  • the address the request arrived on, as an IP literal: the kubelet's probes and Prometheus' scrapes name the pod's IP, so no endpoint needs an exemption;
  • the --listen host, unless it is 0.0.0.0 or :: (an address to bind, not one clients reach);
  • a name given with --allowed-host (repeatable or comma separated, or $UPGRADESCOPE_ALLOWED_HOSTS when the flag is not given): the Service, Ingress or proxy name clients use.

Any port matches: a rebinding page needs a name its owner controls, and the port differs from the listen port wherever a tunnel or a Service maps it (kubectl port-forward 9000:8080 sends Host: localhost:9000). On an open read API the answer to "is a read token minted yet?" is a store query, so serve keeps "no" for one second (a monotonic clock, concurrent requests sharing one query) instead of asking on every anonymous request whose Host it does not answer for, /, assets and probes included. A read token minted by another process (tokens create --read) therefore closes the API to anonymous reads, and ends the Host check for them, within that second, not on the very next request; the token itself is looked up on every request and works at once. "Yes" is kept for good (the rows are only ever revoked), an error from the store is never kept, and --require-read-credential or --read-token never ask.

Every other request gets 421 Misdirected Request before any route, token, team header or scope is looked at, /healthz, ingest and the dashboard included. Each refusal is counted in /metrics (upgradescope_http_requests_total{route="host-refused",code="421"}) and logged at most once a minute, with the Host quoted and escaped, so a name clients use but serve was not given shows up in the log. The exceptions are the requests the check cannot help: with a read credential (--read-token, a minted read token or --require-read-credential) on a routable address and no trusted header, any Host is answered, since every read needs a token a page does not have; and on an open read API a request that presents a bearer is left to its credential (an unknown one is 401, whatever its Host), so an agent pushing to an open hub under a name nobody listed is not turned away by the check, only by its token. Probes (/healthz, /readyz) and Prometheus scrapes of the pod's IP name the address they arrive on and are answered; a scrape through a Service name needs that name in --allowed-host (the chart passes its Service names).

The chart passes --allowed-host with the server Service's DNS names (<release>-server, .<namespace>, .<namespace>.svc and .<namespace>.svc.<clusterDomain>, cluster.local unless you set the clusterDomain value; for a long release, the Service name as cut to 63 characters, the name the Service has), server.ingress.host and server.allowedHosts, so turning the check on (--trust-team-header in server.extraArgs) breaks no push, Ingress or Service client. A proxy in front sends either its upstream's Host (oauth2-proxy --pass-host-header=false, as the example does) or the client's, which must then be an --allowed-host. Cross-origin protection (http.NewCrossOriginProtection) is no defence here: a rebound request is same-origin, and it exempts GET.

Team-scoped read tokens

$ upgradescope tokens create --read --teams payments --teams checkout
3f9a1c0e...  (the token, on stdout alone)
read token id 1 (prefix 3f9a1c0e) for teams checkout,payments created — shown once: ...
$ upgradescope tokens create --read --teams '*'
$ upgradescope tokens list --read
ID  TEAMS              PREFIX    CREATED               REVOKED
1   checkout,payments  3f9a1c0e  2026-10-04T09:12:44Z  -
2   *                  b71d02aa  2026-10-04T09:13:02Z  -
$ upgradescope tokens revoke --read --id 1

Read tokens are 64 random hex characters, printed once. The database keeps each one's sha256 hash and its first 8 characters (which tokens list --read prints), never the token, exactly as for ingest tokens. They live in a table of their own: a read token never pushes, and an ingest token never reads. The commands take the same --db or --db-url as serve, and the server looks a token up on every request, so one minted or revoked takes effect at once, without a restart (the first one closes the API to anonymous reads within a second: see the Host check).

  • Minting the first read token closes the read API, while this database lasts. A server that ran open (on loopback, or with --allow-anonymous-read) needs a credential from then on, within a second of the mint (the server keeps its "no read token yet" answer that long). Rows are only ever revoked, never deleted, so revoking the last one does not open the read API again. The rows are in the database, though: a lost, emptied or restored one has none, and the API is open again after the next start. Run serve --require-read-credential (see above) to make the closed state independent of the data.
  • --read-token keeps working as a fleet-wide token, answering exactly what it answered before scoped tokens existed (a regression test compares the bytes of every read endpoint and the gate with the answers the server gave before read scopes were added).
  • A revoked token is refused (401) from the next request on.

What a team-scoped read sees

Which clusters. A cluster is in the scope of teams T when one of its current evaluations (each target's newest evaluation of the cluster's latest snapshot) names a team of T. An evaluation names the teams its inventory attributes a namespace to, after --team-map: the same namespace→team mapping the engine uses to give each finding its teams. So a cluster is in scope as soon as one of the team's namespaces is in it: every workload in that namespace, and every finding about them, is that team's. A team's cluster with no finding at all is in scope too. A cluster with no evaluation yet (no snapshot pushed) is in no team's scope. Only evaluations written with the server's current --team-map count: after the map changes, a cluster is in no team's scope until the startup pass (or its next push) re-evaluates it, and the evaluation of a target since removed from --targets, which is never rewritten, never puts it back in the old team's.

Which findings and teams. Of an in-scope cluster's report, a scoped read keeps the findings (and suppressed findings: those the objects' upgradescope.basit.engineer/ignore annotations accepted, which the server's evaluations apply; never the agent's spec.ignore, see Where annotations apply) attributed to one of its teams, and the team scores of its teams. A suppressed finding whose accepted objects are all another team's is left out. Findings no team owns, such as a cluster-wide add-on in a namespace without a team, are left out: they are not the scope's to see. A team's score is still computed from the whole report, so the team's verdict is the one the fleet-wide view shows.

A finding that spans teams is cut to the scope. The engine reports one finding per API, add-on or release line, over every namespace it is used in: one extensions/v1beta1 Ingress finding covers payments' and web's Ingresses alike. A payments-scoped read gets that finding with payments' teams, payments' namespaces (by the same namespace→team mapping, after --team-map) and the objects in them only. A namespace no team is attributed counts as another team's and is cut too. Its title counts payments' objects ("(1 object in scope)") when the finding listed every object, and otherwise drops the count. Its detail, which the engine writes about everything the finding covers (each namespace's count, the managers writing the objects, the add-on installs), is replaced by a sentence that names the namespaces kept and says the rest is not shown, and its callers (the apiserver caller rows folded into it, which the metric attributes to no namespace or team) are dropped with that detail. The engine caps a finding's lists (100 namespaces, 100 objects) before anyone reads it, and what the cap dropped cannot be divided by team, so a cut finding counts none omitted and says more of the scope's may be affected than it lists. A finding whose namespace list was capped is always cut, even when every namespace it lists is the scope's: the ones it does not list may be another team's or no team's, and its count and "and N more" would say how much. Its key, severity, remediation and citations are unchanged. A suppressed finding is cut the same way, and one whose accepted objects are all another team's is left out. A finding wholly the scope's is served exactly as the fleet-wide view serves it.

Cluster-scoped objects in a finding of the scope's. A cut keeps the objects in the scope's namespaces, and an object in no namespace only when no namespace of the finding was cut. So a finding whose namespaces are all the scope's keeps the cluster-scoped objects it names, as an API-usage finding over cluster-scoped API objects does. For an in-tree volume plugin these are the cluster's unbound PersistentVolumes and its StorageClasses: a team that is the only one with rbd pods reads the names of the cluster's rbd StorageClasses and unbound PersistentVolumes in GET /api/v1/clusters/{id}/report, and in the findings and export reads. This is what a scoped reader can learn of the cluster's own storage. The gate's share drops these names (in-tree volume plugins in the gate).

What the caps still decide. The caps are applied before any scope, and they do not divide by team. A collector lists at most 100 objects per API across the whole cluster, in the order a live List returns them (namespace, then name), and counts the rest in objectsOmitted; the engine lists at most 100 namespaces per finding, in name order. So where a cap left some of the scope's objects or namespaces at an API unlisted, which of them are listed depends on how many objects (or namespaces) at that API sort before them, in any team's namespaces. This is the one way the scope's own findings depend on other teams' evidence, and a scoped caller can read it. From how many of its own objects at an API are listed, it learns how many objects at that API other namespaces hold that sort before the first of its own left unlisted (exactly, when some of its own are listed; "100 or more", when none is), and from a read's cut finding whether the API's objects across the cluster number more than 100. Where the scope's namespaces holding objects at that API are not contiguous in that order, that number is a sum over every stretch of other namespaces before that object, the gaps between the scope's namespaces included, so the caller learns the sum, not how many sort before any one of its namespaces. It learns nothing else of them: not their names, namespaces or teams. Concretely:

  • In reads, a cut finding lists the scope's objects and namespaces that made the cut, drops its title's count and says its lists were capped whenever the finding's lists were, whoever's objects filled them.
  • In the gate, the share holds the scope's objects the collector listed. A finding lists those and the PR's, counts the scope's others in objectsOmitted, and its detail follows what is listed: "3 object(s) use this API" where only the PR's are, the managers that wrote the listed ones. So do the CRD-version remediation's note on managers, and whether a Helm release's stored-manifest object is matched to its live object, which it is only when the collector listed that object; when it is not, the release gets a finding of its own, with and without the PR alike, so the cluster's. So does suppression: an object's upgradescope.basit.engineer/ignore annotation, and a ?config= rule that selects objects by namespace, name or file, accept listed objects only (a namespace rule also takes a finding that lists no object whole, when it matches every namespace the finding names), and a finding they do not take whole stays open while an object it counts is unlisted. So where the collector left some of the scope's objects at an API unlisted, the scope's own annotations cannot accept that API's finding: it stays open, the cluster's, where on a cluster with fewer objects sorting before them it would be suppressed. The Helm finding and the open finding count toward the score and the cluster verdict, and with all of these the findings, the suppressed findings, suppressedCount and the answer's size change. None of this tells the caller more than the listing does: which findings stay open follows from which of the scope's objects are listed.
  • The PR's room. The PR's own objects take room in a listing too: the gate evaluates and suppresses with every ref of the share's and the PR's, then cuts each finding's listing to 100 objects, cutting the share's refs first, so the PR's stay listed. Where the PR posts many objects at an API, that changes which of the share's objects a finding lists, objectsOmitted, and with them the answer's size and so whether it is over --max-gate-bytes (a 413), and nothing else: a ref the PR pushes out of the listing is still matched to a release's stored copy, still accepted by its annotation or a rule selecting it, and still counted in the detail, as without the PR, so the room the PR takes cannot give a release a finding, or leave a finding open, that the baseline does not. This holds fleet-wide too.
  • The verdict. The gate's verdict, X-Upgradescope-Verdict and the status ?fail-on gives judge only what the PR introduces, and do not depend on the caps.

In-tree volume plugins in the gate. The agent counts a volume plugin's pods per namespace, a bound PersistentVolume under its claim's namespace, and an unbound PersistentVolume or a StorageClass under no namespace, and names the PersistentVolumes and StorageClasses without a namespace. The share keeps the counts in the scope's namespaces only, and none of the names: a finding the PR's objects join (source: manifest) counts, titles and details the scope's pods and the PersistentVolumes bound to its claims, and the PR's objects, which are the only ones it lists, the scope's counted in objectsOmitted. Another team's pods and PersistentVolumes, and the cluster's unbound PersistentVolumes and StorageClasses, never reach it, nor the cluster verdict or the score: a cluster where only another team uses a plugin answers the scope as one where nobody does. The share cannot tell the scope's pods from the PersistentVolumes bound to its claims, so a finding of the share's own that the PR does not join counts them together as PersistentVolumes or StorageClasses.

The share also drops the scope's PersistentVolumes bound to its claims from every listing: a live PersistentVolume ref carries no namespace, so the share cannot tell the scope's from another team's, and keeps only their counts. Their upgradescope.basit.engineer/ignore annotations therefore cannot accept a volume-plugin finding in a team-scoped ?cluster= gate: the finding stays open there, where the fleet-wide gate, which lists them, can accept it. To accept it in a team-scoped gate, send a ?config= rule without object selectors, which takes the finding whole, or gate with a fleet-wide token.

Tests pin this, with payments' two Ingresses unlisted behind web's hundred in a-web against a cluster of payments' two alone: the finding lists only the PR's Ingress there; where payments' Helm release stores the two, the release gets a blocker of its own there and the share's score drops; where payments' two accept their finding by annotation, the finding is suppressed on the cluster of payments' two alone, and open there, the cluster's, with a blocked cluster verdict and a lower score; in all three the verdict and status stay the same. A PR of 99 Ingresses that accept their own finding, which leaves room for one of payments' two, is ready and 200 on both, payments-scoped and fleet-wide, with the release's blocker, where the answer has it, the cluster's; and where payments' two accept their finding too, a PR of 98, 99 or 100 such Ingresses leaves nothing open, with the same cluster verdict and score.

What stays. What describes the cluster as a whole: its name, version, score, verdict, blocker and warning counts, capability gaps, and score history. These are the whole cluster's, other teams' findings included: subtracting the scope's own blockers and warnings from the cluster's counts gives how many the other teams (and no team) have, per target (reads at ?target= included). The gate does not show them: its score, counts and cluster verdict are the share's. Two parts of the cluster's description name workloads and are not shown:

  • Unrecognized images (unrecognizedImages): image repositories no add-on matcher claims, collected cluster-wide and attributed to no namespace, so a scoped read gets none.
  • The helm capability's own words: when the Helm collector could not read some releases, its reason and skipped list name them as namespace/name. A scoped read gets the gap (capability, required, partial) with the reason replaced and the skipped list withheld, in reports, exports, cluster summaries and the cluster's capability map. The other collectors' gaps name APIs, components and resources, and are shown whole.

Everything else is hidden. A cluster outside the scope answers 404 with the same body as a cluster id that does not exist, on every per-cluster endpoint and for /api/v1/gate?cluster=, so its name cannot be probed by its answer: ?cluster= matches a name among the scope's clusters only, so a cluster outside it named like an in-scope cluster's id does not shadow that id. It is absent from /clusters, /fleet and /fleet/teams. The server looks the scope up first and never looks up a cluster outside it, so an id that exists but is out of scope costs the same store query as an unknown one. Cluster ids are sequential and say nothing but how many clusters were registered; what the 404 keeps from a scoped caller is the cluster's name and data.

Endpoint A team-scoped read
GET /api/v1/clusters in-scope clusters only, the helm gap's reason and skipped list withheld
GET /api/v1/clusters/{id} 404 outside the scope; the helm capability's reason and skipped list withheld
GET /api/v1/clusters/{id}/report (stored or what-if) 404 outside the scope; the scope's findings, cut to it, and team scores; no unrecognized images
GET /api/v1/clusters/{id}/findings 404 outside the scope; the scope's findings, cut to it
GET /api/v1/clusters/{id}/teams 404 outside the scope; the scope's teams
GET /api/v1/clusters/{id}/history 404 outside the scope; the cluster's scores
GET /api/v1/clusters/{id}/export (CSV, HTML) 404 outside the scope; the scope's findings, cut to it, and teams
GET /api/v1/fleet in-scope clusters only
GET /api/v1/fleet/teams in-scope clusters, the scope's teams
POST /api/v1/gate without ?cluster=: exactly as fleet-wide, it reads no stored data and every finding is the manifests'. With ?cluster=: 404 outside the scope. Otherwise the gate runs on the scope's share of the cluster, never on the whole cluster: the API usage, custom resources, Helm releases, GitOps charts, add-ons and in-tree volume plugin counts in the namespaces of the scope's teams, and what describes the cluster as a whole (version, nodes, control plane, CRD definitions, capabilities); never another team's evidence, a namespace no team owns, cluster-scoped usage (unbound PersistentVolumes and StorageClasses on a volume plugin included), the cluster's PersistentVolume and StorageClass names or the apiserver's deprecated-call rows. Everything in the answer is decided from that share and the manifests: the findings (a finding the PR's objects are in counts, titles and details only the PR's objects and the scope's, even when the PR names another team's namespace, where its objects are attributed to no team), the verdict and X-Upgradescope-Verdict, the status ?fail-on gives, the cluster verdict, the score and the scope's team scores. Of the share's own findings, the scope's; the manifests' unrecognized images only; suppression warnings of the share only. The exception is which of the scope's objects the share lists where the collector's 100-objects-per-API cap left some unlisted, and what follows from that (a Helm release's own finding, whether the scope's annotations and rules can accept a finding, and so the findings, score and cluster verdict), never the verdict, its header or the status (what the caps still decide). So a CI status depends on who asks (the gate with a team-scoped token)
GET /api/v1/registry as fleet-wide: the public knowledge base
GET /metrics 403: its per-cluster series name every cluster, so it takes a fleet-wide credential (Prometheus gets --read-token or a * token)

Every scoped answer carries X-Upgradescope-Teams: <team,...>, and the dashboard says "Showing teams ... only" while it is set. A fleet-wide answer carries no such header.

The gate with a team-scoped token

POST /api/v1/gate?cluster= with a team-scoped credential judges the PR against the scope's share of the cluster (the table above), and nothing else: two clusters that differ only outside the scope give the same answer, status, verdict and score included, byte for byte, in every format, except where the collector's 100-objects-per-API cap, which lists objects across the cluster in namespace order, left some of the scope's objects unlisted. Then which of them are listed, objectsOmitted, the finding's detail, and what is derived from the objects the collector listed (Helm releases' findings, and whether the scope's annotations and object-selecting ?config= rules accept a finding, and with them the findings, the suppressed findings, the score, the cluster verdict and the answer's size) depend on how many objects at that API sort before them (what the caps still decide); the verdict, X-Upgradescope-Verdict and the status never do. Where the PR's own objects take the listing's room, only which refs a finding lists, objectsOmitted and with them the answer's size (and so a 413 over --max-gate-bytes) change. The whole cluster is never evaluated for that request, so a 413 from evaluating it depends on another team's workloads only through that size.

The price is that a PR that breaks only another team's workloads passes a team-scoped gate. Say a PR changes a CRD that several teams use so that it stops serving v1alpha1, and only web's custom resources are still at v1alpha1: the payments-scoped gate answers ready and 200, the fleet-wide gate blocked and 422. The answer does not hint that something outside the scope would fail either. Any such marker, even a single bit with no count, team, namespace or title, would let a team probe the rest of the cluster one request per API: post one object at a removed API, or a CRD that stops serving a version, and read whether someone else uses it. The score is the share's for the same reason: were it the whole cluster's, a PR that adds one object at an API where another team already has a finding would leave it unchanged.

So pick the gate's credential by what the repository can break:

  • only a team's own workloads (its namespaces): the team's scoped token is enough, and its CI reads nothing of other teams;
  • anything shared (CRDs, cluster-scoped objects, add-ons, another team's namespaces): a fleet-wide token (--read-token or a * token), which judges the PR against the whole cluster, as the gate did before read scopes.

Every read that presents a bearer other than --read-token or the admin token looks it up in the store (an indexed query on its hash), and while no read token has been minted an open read API lists them once per anonymous read, and once per anonymous request whose Host is not in the allow-list on any path (/, /healthz and the dashboard's static files included), before the read and fleet concurrency limits apply. Requests with random bearers therefore each cost a store query; put the server behind a proxy that rate-limits unauthenticated clients if that matters to you.

With the Helm chart

The chart has no values for the trusted header yet: add --trust-team-header and --trusted-proxy-cidr with your own manifest (the example) or a post-renderer. Read tokens minted with tokens create --read work with the chart as it is (kubectl exec deploy/<fullname>-server -- /upgradescope tokens create --read --teams payments --db /data/upgradescope.sqlite, where <fullname> is the release name plus -upgradescope, cut to 63 characters, or the release name alone when it contains upgradescope).

With no server.readToken the chart passes --require-read-credential: the read API is closed, reads return 401, and the install notes print the command above for your database (SQLite or Postgres). A deployment that relies on minted tokens alone sets nothing else; the Ingress renders without server.ingress.allowAnonymousRead. A lost or restored database, or persistence.enabled=false on every pod restart, leaves it closed, and you mint again. metrics.serviceMonitor sends only server.readToken, so with the read API closed it needs one (the render fails otherwise).

To leave the read API open (an authenticating layer in front is the access control), set server.allowAnonymousRead=true (or the older server.ingress.allowAnonymousRead=true, the same opt-in; with an Ingress the render requires the latter too, as the statement that a proxy fronts it): the chart then passes --allow-anonymous-read, and the API is open whenever no read credential exists, including after the database is lost. Do not set either to rely on minted tokens: that was the documented advice before this flag existed, and it leaves the API open after a database loss. Open or not, a read-token server.readToken takes precedence and the chart passes neither flag.

Putting the dashboard behind SSO

(The dashboard's screens, token entry and serving it under a path prefix are on The dashboard.)

For people, put an authenticating proxy in front of the server: oauth2-proxy, an identity-aware proxy, or your ingress controller's external-auth support. Three patterns, from simplest to most integrated:

  1. Proxy in front, tokens behind. The proxy decides who may open the dashboard; the dashboard still asks for a read token once (stored in that browser), fleet-wide or scoped to the person's teams. Machine clients (the CI gate, Prometheus, scripts) call the server with their tokens, directly or through a proxy route that skips authentication. No server flag changes.
  2. The proxy is the gate. Run without any read credential (--allow-anonymous-read, chart server.allowAnonymousRead=true and server.ingress.allowAnonymousRead=true when the chart's Ingress carries the auth annotations; the proxy's Host must be an --allowed-host, which the chart does for server.ingress.host, and it must not pass an Authorization bearer, which an open server answers with 401) and make sure nothing but the proxy can reach the read API: a ClusterIP Service, a NetworkPolicy (networkPolicy.enabled, serverIngressFrom naming the proxy, Prometheus and the agents' sources). Everyone the proxy lets in reads the whole fleet, and anyone who reaches the Service directly reads it too, so this rests on the network policy.
  3. The proxy names the teams (below): the proxy's group header sets each person's scope, and the server trusts it only from the proxy.

With any of them, do not let the proxy overwrite the Authorization header: the server reads tokens from it. oauth2-proxy sets it on the upstream request only when told to: --pass-authorization-header (the ID token as a bearer), or --pass-basic-auth (on by default) together with a --basic-auth-password; in auth_request setups, --set-authorization-header and --set-basic-auth put it on the auth response, which the ingress may copy upstream. The example sets --pass-basic-auth=false and none of the others, and its test fails if one appears. To check yours, send a fleet-wide token through the proxy after signing in (curl -H 'Authorization: Bearer <token>' --cookie ... https://<host>/metrics must answer 200, which no team scope does). Keep cluster administration off the proxied path: the admin token belongs to operators and their CLI, not to the dashboard.

Trusted team header (--trust-team-header)

upgradescope serve --listen 127.0.0.1:8080 \
  --trust-team-header X-Forwarded-Groups --trusted-proxy-cidr 127.0.0.1/32

A read whose TCP peer is in a --trusted-proxy-cidr range and that carries the header reads as the teams it lists, in the team list encoding, every copy of the header counted (oauth2-proxy sends one header, the session's groups joined by commas; other proxies may send one per group). A bearer token, when one is presented and valid, takes precedence. The peer is the connection's source address, never X-Forwarded-For. From any other address the header is ignored: a client that reaches the server directly cannot spoof it, and needs a token. The header always names teams: * in it is a team called *, never the whole fleet, so whoever can name a group in the identity provider cannot grant fleet-wide reads with it. People who need the fleet use a fleet-wide token.

The proxy's requests carry no Authorization header, so a shared cache in front of the proxy would key a team's answer by its URL alone. In this mode every read answer is sent with Vary: <header>, Authorization, and every scoped answer, in any mode, with Cache-Control: private, no-store.

The two flags go together, and the mode is off by default.

When header mode is safe

The server trusts whatever arrives from a trusted address. Header mode is safe only when all three hold:

  1. The server is reachable only through the proxy. Listen on loopback (--listen 127.0.0.1:8080) with the proxy in the same pod, or otherwise make sure nothing but the proxy can open a connection from a --trusted-proxy-cidr address: no Service, NodePort or Ingress that reaches the server's port, and a --trusted-proxy-cidr that covers the proxy's addresses and nothing else. A pod CIDR is not "the proxy": every pod in it could send the header. serve refuses only the extreme: a set of ranges that together hold every address of IPv4 or of IPv6 (0.0.0.0/0, ::/0, or 0.0.0.0/1 with 128.0.0.0/1, however they are split across flags) would trust the header from any client. A partial cover such as 0.0.0.0/1 or 10.0.0.0/8 starts, and keeping the ranges to the proxy remains your job.
  2. The proxy removes every client-supplied copy of the header before it forwards a request, on every route it forwards, authenticated or not, and then sets it from the signed-in session only. A proxy that passes a client's X-Forwarded-Groups on (or appends to it) lets that client read any team it names.
  3. Nobody who may not read a team can name a group that reads as it. The header is a comma-separated list, and oauth2-proxy v7.15.5 joins a session's groups with commas without encoding them (flattenHeaders in pkg/middleware/headers.go joins the values it injects). So a single group named interns,payments sends exactly what membership of interns and payments sends, and the server cannot tell them apart; a group named pay%6Dents decodes to payments. Only a space or tab around an entry is trimmed, so payments followed by a no-break space stays another team. Group naming in the identity provider is therefore part of the trust boundary: where users can create or rename groups (Keycloak groups, GitHub teams through Dex, Okta self-service groups), anyone who can name a group can read any team whose name they know (never the whole fleet: * is a team). Filter the claim so that only groups operators control reach the header: at the identity provider (a mapper or client scope that emits the chosen groups only) or at the connector (Dex's GitHub connector lists the teams it passes in orgs[].teams). oauth2-proxy's --allowed-group does not filter the claim: it only decides who may sign in (Authorize in its providers/provider_default.go), and the header still carries every group of the session.

oauth2-proxy v7.15.5, which the example pins, does both halves of (2) with --pass-user-headers=true --skip-auth-strip-headers=true: it deletes the request's copies of each header it injects (X-Forwarded-Groups among them), whatever their case and whether they are written with - or _, then sets it from the session only: one header, the session's groups joined by commas. On routes it does not authenticate (--skip-auth-route) it strips them too, and sets them only from a valid session cookie the request carries (flag reference; in its source, getRequestHeaders in pkg/apis/options/legacy_options.go sets PreserveRequestValue to !SkipAuthStripHeaders; newStripHeaders and stripNormalizedHeader in pkg/middleware/headers.go delete those headers before the injector runs, and injectRequestHeaders there joins what it injects with flattenHeaders; getAuthenticatedSession in oauthproxy.go returns the request's session on a skip-auth route). With the alpha configuration, the same holds only for headers whose preserveRequestValue is false. Any other proxy must be checked for the same: it strips the client's copies, whatever their case, before it sets the header. (X_Forwarded_Groups is not the same header to the server: Go reads header names as written, with -, so a client cannot slip one past a proxy that strips X-Forwarded-Groups.) Check yours after signing in: curl -H 'X-Forwarded-Groups: other-team' https://<host>/api/v1/clusters must not list other-team's clusters, and neither may the same request to a route the proxy does not authenticate (POST /api/v1/gate?cluster=<other-team's cluster> must answer 401 without a token).

Everything that arrives from a trusted address is the proxy. With --trusted-proxy-cidr=127.0.0.1/32 that is every connection made over the pod's loopback, not only the sidecar's:

  • kubectl port-forward to the server's pod delivers its connections from 127.0.0.1, so anyone allowed pods/portforward in the server's namespace can send the header to port 8080 and read any team they can name (never the whole fleet: * is a team). Treat that permission as read access to every team, or keep it to the people who already have it.
  • A web page cannot ride that port-forward: the Host check answers a request whose Host is not localhost, a loopback or the pod's own address (or an --allowed-host) 421, before the header is read.
  • A service-mesh sidecar (Istio, Linkerd and others) that delivers inbound traffic to the application over localhost makes every client, inside or outside the cluster, 127.0.0.1: the header would then be trusted from anyone. Keep the server's pod out of the mesh (the example sets sidecar.istio.io/inject: "false" and linkerd.io/inject: disabled).

Map identity-provider groups to team names: the header's values are compared with the teams namespaces are attributed to, exactly, after decoding. With oauth2-proxy, --oidc-groups-claim picks the claim and --allowed-group limits who may sign in at all; it does not filter the groups the header carries (condition 3 above).

The team list encoding

Team names are free text, so the trusted header and the X-Upgradescope-Teams answer header carry them in one defined form: a comma-separated list in which each team is percent-encoded as a URL path segment (Go's url.PathEscape, JavaScript's encodeURIComponent reads it): a comma is %2C, a percent sign %25, a space %20, and a non-ASCII character its UTF-8 bytes (é is %C3%A9). The server trims the whitespace around each entry, decodes it, and drops an entry that is not valid percent-encoding (50%off): it cannot know which team that is. Only space and tab count as the whitespace around an entry; any other character, a no-break space included, is part of the name. A name sent as is reads as itself when it holds no comma and no percent sign: Platform Team, or raw UTF-8 équipe, both work, which is what oauth2-proxy sends for such groups. A group whose name holds a comma or a percent sign is read as other teams: oauth2-proxy sends it as is, so a,b reads as the teams a and b, and pay%6Dents as payments. That is a security condition of header mode, not a naming rule (condition 3 of When header mode is safe).

Team In the header
payments payments
Platform Team Platform Team or Platform%20Team
Équipe, Paris %C3%89quipe%2C%20Paris
two teams, payments and web payments,web, or two header lines

X-Upgradescope-Teams on a scoped answer is always fully encoded (Platform%20Team,payments), and the dashboard decodes it.

Example: oauth2-proxy as a sidecar

deploy/examples/oauth2-proxy/upgradescope-oauth2-proxy.yaml runs oauth2-proxy v7.15.5 in the server's pod and holds the first two conditions above (its test, deploy/examples/examples_test.go, fails if one goes):

  • serve listens on 127.0.0.1:8080, so it is reachable only from inside the pod, and trusts the header from 127.0.0.1/32 only. The one Service, upgradescope (port 80), exposes the proxy's port and never the server's. Point your Ingress and the OIDC client's redirect URL (https://<host>/oauth2/callback) at it.
  • The proxy pins --pass-user-headers=true and --skip-auth-strip-headers=true, and leaves Authorization alone (--pass-basic-auth=false).
  • The proxy sends serve its upstream's Host, 127.0.0.1:8080 (--pass-host-header=false; v7.15.5's default, true, forwards the client's, setProxyUpstreamHostHeader in its pkg/upstream/http.go), so every client reaches serve as loopback, which the Host check answers, and no --allowed-host is needed.

Machines use the same Service, on the routes the proxy does not authenticate: agents push to POST /api/v1/snapshots with their ingest tokens, CI posts to POST /api/v1/gate?target=... and Prometheus scrapes GET /metrics with read tokens, and the kubelet probes /readyz and /healthz through it (serve does not listen on the pod's address). v7.15.5 matches a --skip-auth-route against the request's decoded path without its query (isAllowedRoute in oauthproxy.go, GetRequestPath in pkg/requests/util/util.go), so POST=^/api/v1/gate$ matches every gate call, whatever its query. Versions before v7.11.0 (the CVE-2025-54576 fix) match the request URI, query included: there the same route never matches a gate call, and CI gets the proxy's sign-in answer instead of the server's. The example's test checks the routes against the requests machines make. The proxy strips the group header on those routes too, so each request reads as the token it presents and nothing else: without one, 401.

The proxy's sign-in is not what guards the data; the server's token check is. With --reverse-proxy, oauth2-proxy matches the skip-auth routes against the path in the client's X-Forwarded-Uri, from any client unless --trusted-proxy-ip names the proxies allowed to send X-Forwarded-* headers. So GET /api/v1/clusters with X-Forwarded-Uri: /metrics gets past the proxy without signing in. It reaches the server with the group header stripped and reads as the token it presents: without one, 401. Set --trusted-proxy-ip to your ingress controller's addresses, and have the ingress overwrite or drop a client's X-Forwarded-Uri, if signing in must be the only way past the proxy. Operators reach the server itself with kubectl port-forward to port 8080 and a token (the danger box above says what that permission allows). What reaches the server over loopback is trusted with the header, which is why the pod opts out of Istio and Linkerd sidecar injection.

Adapt the provider flags to your identity provider, and create the oauth2-proxy Secret (client-id, client-secret, cookie-secret) first; the manifest's header lists what it expects.