Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions docs/shared-tier-admission.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,43 @@ The five legacy single-scope quota headers (`X-Saturn-Service-Tier`,
`X-Saturn-Rate-Limit-Uncached-Prompt-Tokens`,
`X-Saturn-Rate-Limit-Generated-Tokens`) were removed in one cutover release
(ruling R8, a hard cut). In that release Saturn stops stamping them, Traefik
stops allowlisting them, the phoebe chart drops them from `trustedHeaders`
(so they leave the trust set and the strip middleware together), and Phoebe
stops reading them. There is no dual-contract window after the cutover and no
stops allowlisting them, the phoebe chart drops them from `trustedHeaders`,
and Phoebe stops reading them. The strip outlives the trust: the edge keeps
blanking client-supplied copies of the five names until every Phoebe replica
runs the R8 image (see below). There is no dual-contract window after the cutover and no
fallback: a request that carries only the legacy headers and no
`X-Saturn-Owner-Id` has no policy and, with admission enabled, fails closed
with 503. The four components of that release can roll in any order, because
the scoped envelope was already stamped on every route where admission runs
before the cutover, and every Phoebe that still read the legacy headers
preferred the scoped envelope whenever the owner id was present.
with 503.

The four components of that release are safe to roll in any order only
because admission stays disabled (`admission.enabled: false`, ruling R12) for the
whole cutover, so no replica enforces a policy envelope during the rollout.
No current or pre-cutover Phoebe reads the legacy headers on dedicated
routes. The scoped envelope is stamped only by the gateway ForwardAuth, on
gateway routes; every Phoebe that still read the legacy headers preferred the
scoped envelope whenever the owner id was present. Historical per-resource
shared routes never carried the policy contract, and R8 does not change that:
they must be drained or removed before admission is enabled (see the
requirement later in this section).

The five legacy names leave the trust set but must not leave the edge strip
lists in the same release. A Phoebe replica older than the R8 image still
parses the legacy envelope, and during a mixed rollout it would accept a
client-forged `X-Saturn-Service-Tier` / `X-Saturn-Rate-Limit-*` set on any
admission-enabled request that has no `X-Saturn-Owner-Id`. The edge strip has
always prevented that, and the R8 image cannot protect older replicas by
stripping in its own code. So the names stay stripped at the edge, without
being trusted: the traefik chart's `tf-gateway-headers` middleware blanks
them on the gateway route, and the phoebe chart's
`phoebe-inference-headers` middleware must blank them on the standard route
through a strip-only list that is not rendered into `PHOEBE_TRUSTED_HEADERS`.
On the standard route this breaks the usual equality of the strip set and the
trusted set (ruling R3) on purpose: the strip set is the trusted set plus the
five legacy names. Remove the strip-only names only in a later release, after
every Phoebe replica runs the R8 image. The recommended order is phoebe #55
first (or in the same window), then Atlas #6715, with saturn-k8s #1073
alongside either. Nothing reads these headers after the cutover, so dropping
the strip once no pre-R8 replica remains is harmless.

While admission is disabled, Phoebe does not require a policy envelope. Once
admission is enabled, a missing or structurally broken policy fails closed.
Expand Down
2 changes: 2 additions & 0 deletions internal/identity/trusted_headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ var requiredTrustedHeaders = []struct {
}{
{HeaderGateway, "gateway requests will not be recognized"},
{HeaderServingMode, "every header-routed (non-gateway) inference request will be refused with 404 because the serving mode reads as absent (ruling #19)"},
{HeaderOwnerID, "with admission enabled, every shared gateway inference request will be refused with 503 because the quota policy has no owner-id anchor (R7/R8: the legacy service-tier envelope is no longer read)"},
{HeaderOrgID, "with admission enabled, every shared gateway inference request will be refused with 503 because the trusted organization identity reads as absent"},
}

// trustedHeaderSet is the active trusted-header set. Keys are canonical
Expand Down
46 changes: 42 additions & 4 deletions internal/identity/trusted_headers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,14 @@ func TestFromRequestIgnoresHeadersOutsideActiveSet(t *testing.T) {
}
}

// TestFromRequestFallbackTrustsPinnedHeaders is the no-regression pin: with
// the fallback engaged (unset env), all 13 remain trusted on the real parse
// path — request handling is unchanged from before the gate existed.
// TestFromRequestFallbackTrustsPinnedHeaders is the no-regression pin for the
// gate: with the fallback engaged (unset env), all 13 pinned envelope headers
// are trusted on the real parse path, so a missing or misrendered ConfigMap
// does not change request handling relative to the chart's intended set, and
// the gate's fallback reads exactly what the ungated parser reads. Note:
// ruling R8 removed the five legacy single-scope quota headers
// (X-Saturn-Service-Tier, X-Saturn-Rate-Limit-*) from the parser entirely,
// so pre-R8 phoebe read 18 headers; see TestPinnedFallbackIsExactlyThe13.
func TestFromRequestFallbackTrustsPinnedHeaders(t *testing.T) {
withTrustedHeadersEnv(t, "", false)

Expand Down Expand Up @@ -274,12 +279,45 @@ func TestLoadTrustedHeadersErrorsWhenGatewayMissing(t *testing.T) {
// TestLoadTrustedHeadersNoErrorWhenRequiredHeadersPresent: a configured list
// containing every required header (in any case) logs nothing at ERROR.
func TestLoadTrustedHeadersNoErrorWhenRequiredHeadersPresent(t *testing.T) {
out := loadTrustedHeadersCapturingErrors(t, "x-saturn-gateway, X-SATURN-SERVING-MODE, X-Saturn-Org-Id")
out := loadTrustedHeadersCapturingErrors(t, "x-saturn-gateway, X-SATURN-SERVING-MODE, X-Saturn-Org-Id, x-saturn-owner-id")
if out != "" {
t.Fatalf("unexpected error log for a list with every required header: %q", out)
}
}

// TestLoadTrustedHeadersErrorsWhenOwnerOrOrgAnchorMissing: after R8,
// X-Saturn-Owner-Id is the only anchor for the quota policy and admission
// also requires X-Saturn-Org-Id, so a configured list that omits either
// would 503 every admitted shared request. Each omission logs an ERROR line
// naming the header and the 503 consequence, and the list is still installed
// exactly as written (neither header becomes trusted).
func TestLoadTrustedHeadersErrorsWhenOwnerOrOrgAnchorMissing(t *testing.T) {
out := loadTrustedHeadersCapturingErrors(t, "X-Saturn-Gateway,X-Saturn-Serving-Mode")
for _, missing := range []string{HeaderOwnerID, HeaderOrgID} {
if !strings.Contains(out, "omits "+missing) {
t.Fatalf("error log %q does not flag the missing %s", out, missing)
}
if _, ok := ActiveTrustedHeaders()[missing]; ok {
t.Fatalf("active set trusts %s although the configured list omits it", missing)
}
}
if !strings.Contains(out, "503") || !strings.Contains(out, "owner-id anchor") {
t.Fatalf("error log %q does not state the 503 owner-anchor consequence", out)
}
if got := len(ActiveTrustedHeaders()); got != 2 {
t.Fatalf("active set has %d headers, want exactly the 2 configured", got)
}
}

// TestLoadTrustedHeadersNoErrorForPinnedList: the full pinned 13, configured
// explicitly, satisfies every required header and logs nothing at ERROR.
func TestLoadTrustedHeadersNoErrorForPinnedList(t *testing.T) {
out := loadTrustedHeadersCapturingErrors(t, strings.Join(pinnedTrustedHeaders, ","))
if out != "" {
t.Fatalf("unexpected error log for the pinned list: %q", out)
}
}

// TestStripUntrustedSaturnHeaders (ruling Q-R8STRIP option b): with the pinned
// set active, every X-Saturn-* header outside the trusted set and the
// edge-contract identity headers is removed, in any case spelling and with all
Expand Down
63 changes: 43 additions & 20 deletions internal/proxy/admission.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,16 +384,16 @@ func parseTrustedRateLimits(id identity.Identity) (admission.RateLimits, admissi
}
return out, nil
}
scopedValues := [8]string{
id.OrgRateLimitRequests, id.OrgRateLimitTotalPromptTokens,
id.OrgRateLimitUncachedPromptTokens, id.OrgRateLimitGeneratedTokens,
id.OwnerRateLimitRequests, id.OwnerRateLimitTotalPromptTokens,
id.OwnerRateLimitUncachedPromptTokens, id.OwnerRateLimitGeneratedTokens,
}
anyPresent := func(values []string) bool {
for _, value := range values {
if value != "" {
return true
orgNames := [4]string{identity.HeaderOrgRateLimitRequests, identity.HeaderOrgRateLimitTotalPromptTokens, identity.HeaderOrgRateLimitUncachedPromptTokens, identity.HeaderOrgRateLimitGeneratedTokens}
orgValues := [4]string{id.OrgRateLimitRequests, id.OrgRateLimitTotalPromptTokens, id.OrgRateLimitUncachedPromptTokens, id.OrgRateLimitGeneratedTokens}
ownerNames := [4]string{identity.HeaderOwnerRateLimitRequests, identity.HeaderOwnerRateLimitTotalPromptTokens, identity.HeaderOwnerRateLimitUncachedPromptTokens, identity.HeaderOwnerRateLimitGeneratedTokens}
ownerValues := [4]string{id.OwnerRateLimitRequests, id.OwnerRateLimitTotalPromptTokens, id.OwnerRateLimitUncachedPromptTokens, id.OwnerRateLimitGeneratedTokens}
anyScopedPresent := func() bool {
for _, values := range [][4]string{orgValues, ownerValues} {
for _, value := range values {
if value != "" {
return true
}
}
}
return false
Expand All @@ -403,25 +403,48 @@ func parseTrustedRateLimits(id identity.Identity) (admission.RateLimits, admissi
case id.OwnerID != "":
// Scoped envelope: the owner id is the structural anchor (R7). The 8
// scoped headers are per-field R4 — absent is unlimited.
organization, err := parseScope(
[4]string{identity.HeaderOrgRateLimitRequests, identity.HeaderOrgRateLimitTotalPromptTokens, identity.HeaderOrgRateLimitUncachedPromptTokens, identity.HeaderOrgRateLimitGeneratedTokens},
[4]string{id.OrgRateLimitRequests, id.OrgRateLimitTotalPromptTokens, id.OrgRateLimitUncachedPromptTokens, id.OrgRateLimitGeneratedTokens},
)
organization, err := parseScope(orgNames, orgValues)
if err != nil {
return organization, admission.RateLimits{}, err
}
owner, err := parseScope(
[4]string{identity.HeaderOwnerRateLimitRequests, identity.HeaderOwnerRateLimitTotalPromptTokens, identity.HeaderOwnerRateLimitUncachedPromptTokens, identity.HeaderOwnerRateLimitGeneratedTokens},
[4]string{id.OwnerRateLimitRequests, id.OwnerRateLimitTotalPromptTokens, id.OwnerRateLimitUncachedPromptTokens, id.OwnerRateLimitGeneratedTokens},
)
owner, err := parseScope(ownerNames, ownerValues)
return organization, owner, err
case anyPresent(scopedValues[:]):
case anyScopedPresent():
// Scoped limit headers without the owner-id anchor are a structural
// violation (R7), not unlimited fields.
return admission.RateLimits{}, admission.RateLimits{}, fmt.Errorf("incomplete trusted shared-inference rate-limit policy: scoped headers without %s", identity.HeaderOwnerID)
default:
// No identity anchor and no policy at all: a structural violation
// (R7) that fails closed.
return admission.RateLimits{}, admission.RateLimits{}, fmt.Errorf("incomplete trusted shared-inference rate-limit policy")
return admission.RateLimits{}, admission.RateLimits{}, errNoTrustedRateLimitPolicy
}
}

// errNoTrustedRateLimitPolicy is the parser's "no anchor and no scoped policy
// at all" result. The proxy call site matches it to add a log-only diagnostic
// for pre-R8 producers (see legacyQuotaHeadersPresent).
var errNoTrustedRateLimitPolicy = errors.New("incomplete trusted shared-inference rate-limit policy")

// legacyQuotaHeaderNames are the five single-scope quota headers that R8
// removed from the trusted envelope. Phoebe never reads them for a trust or
// limit decision; they are listed here ONLY so the proxy can log that a
// not-yet-upgraded producer (Atlas or Traefik) is still stamping them. Remove
// this diagnostic one release after the R8 cutover.
var legacyQuotaHeaderNames = []string{
"X-Saturn-Service-Tier",
"X-Saturn-Rate-Limit-Requests",
"X-Saturn-Rate-Limit-Total-Prompt-Tokens",
"X-Saturn-Rate-Limit-Uncached-Prompt-Tokens",
"X-Saturn-Rate-Limit-Generated-Tokens",
}

// legacyQuotaHeadersPresent reports whether the raw request carries any of the
// removed legacy quota headers. It is a logging diagnostic only.
func legacyQuotaHeadersPresent(h http.Header) bool {
for _, name := range legacyQuotaHeaderNames {
if h.Get(name) != "" {
return true
}
}
return false
}
Loading
Loading