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
--listenhost, unless it is0.0.0.0or::(an address to bind, not one clients reach); - a name given with
--allowed-host(repeatable or comma separated, or$UPGRADESCOPE_ALLOWED_HOSTSwhen 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. Runserve --require-read-credential(see above) to make the closed state independent of the data. --read-tokenkeeps 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'supgradescope.basit.engineer/ignoreannotation, 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,suppressedCountand 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(a413), 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-Verdictand the status?fail-ongives 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-tokenor 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:
- 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.
- The proxy is the gate. Run without any read credential
(
--allow-anonymous-read, chartserver.allowAnonymousRead=trueandserver.ingress.allowAnonymousRead=truewhen the chart's Ingress carries the auth annotations; the proxy's Host must be an--allowed-host, which the chart does forserver.ingress.host, and it must not pass anAuthorizationbearer, which an open server answers with401) and make sure nothing but the proxy can reach the read API: a ClusterIP Service, a NetworkPolicy (networkPolicy.enabled,serverIngressFromnaming 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. - 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:
- 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-cidraddress: no Service, NodePort or Ingress that reaches the server's port, and a--trusted-proxy-cidrthat covers the proxy's addresses and nothing else. A pod CIDR is not "the proxy": every pod in it could send the header.serverefuses only the extreme: a set of ranges that together hold every address of IPv4 or of IPv6 (0.0.0.0/0,::/0, or0.0.0.0/1with128.0.0.0/1, however they are split across flags) would trust the header from any client. A partial cover such as0.0.0.0/1or10.0.0.0/8starts, and keeping the ranges to the proxy remains your job. - 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-Groupson (or appends to it) lets that client read any team it names. - 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
(
flattenHeadersinpkg/middleware/headers.gojoins the values it injects). So a single group namedinterns,paymentssends exactly what membership ofinternsandpaymentssends, and the server cannot tell them apart; a group namedpay%6Dentsdecodes topayments. Only a space or tab around an entry is trimmed, sopaymentsfollowed 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 inorgs[].teams). oauth2-proxy's--allowed-groupdoes not filter the claim: it only decides who may sign in (Authorizein itsproviders/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-forwardto the server's pod delivers its connections from127.0.0.1, so anyone allowedpods/portforwardin 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 setssidecar.istio.io/inject: "false"andlinkerd.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):
servelistens on127.0.0.1:8080, so it is reachable only from inside the pod, and trusts the header from127.0.0.1/32only. 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=trueand--skip-auth-strip-headers=true, and leavesAuthorizationalone (--pass-basic-auth=false). - The proxy sends
serveits upstream's Host,127.0.0.1:8080(--pass-host-header=false; v7.15.5's default,true, forwards the client's,setProxyUpstreamHostHeaderin itspkg/upstream/http.go), so every client reachesserveas loopback, which the Host check answers, and no--allowed-hostis 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.