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
110 changes: 110 additions & 0 deletions docs/adr/0043-internal-serviceaccount-token-auth-on-dashboard-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# 0043. Accept an internal Kubernetes ServiceAccount-token on the dashboard read surface

- Status: Accepted
- Date: 2026-11-07
- Relates to: [0030](0030-app-level-oidc-verification-supersedes-edge-trust.md) (the app-level
OIDC verifier + fail-closed enforcement this extends), [0031](0031-read-only-mcp-server-tiered-redaction.md)
(the same read-only, tiered-redaction surface — the internal lane is read-only too),
[0016](0016-severity-vs-urgency.md) (presentation is a view, never a decision gate — this gates
*viewing*, never the verdict/actuation path)

## Context

[0030](0030-app-level-oidc-verification-supersedes-edge-trust.md) made protector a **resource
server**: the dashboard read surface (the shell + the `/api/*.json` snapshots) verifies the
Cloudflare Access JWT the edge issues (the `Cf-Access-Jwt-Assertion` header / `CF_Authorization`
cookie) and fails closed. That is the right gate for a *browser* reaching protector through the
Cloudflare tunnel. It is the wrong gate for an *in-cluster* caller.

The cluster repo's **`fixer`** runs in the same Kubernetes cluster as protector and needs to read
`/api/*.json` (its new `protector_read` tool). Routing it out to the Cloudflare edge and back to
obtain an Access assertion is absurd for a Service-to-Service call on the pod network — slower,
coupling an internal control path to the public edge, and `fixer` has no browser to carry the
Access cookie and cannot mint an edge Access JWT in-cluster. What `fixer` *does* already have is a
projected Kubernetes ServiceAccount token.

Those tokens are OIDC JWTs the cluster itself issues and can verify:

- issuer `https://kubernetes.default.svc.cluster.local`, signed **RS256**;
- JWKS published in-pod at `https://kubernetes.default.svc.cluster.local/openid/v1/jwks`;
- a projected token carries `aud=["protector"]` (the audience we require), `exp`, `nbf`, and
`sub="system:serviceaccount:<namespace>:<name>"`.

Reading that JWKS is itself authenticated: the `system:service-account-issuer-discovery`
ClusterRole gates it, so the fetch must present protector's own projected token as
`Authorization: Bearer` and trust the cluster CA (the apiserver's serving cert chains to it, not a
public root).

## Decision

Accept an internal ServiceAccount-token JWT on the dashboard read surface **in addition to** the
Cloudflare Access assertion. This is **pure addition** — the edge/Access path is unchanged, and the
lane is **read-only** (it mounts on the same read gate, never on a mutating or admission path).

- **Reuse the existing verifier.** `dashboard::auth::Verifier` already checks RS256 signature +
`iss` + `aud` + `exp` + `nbf` + `sub`, pins the algorithm from config (never the token's own
header — the alg-confusion close of 0030 §1), and is issuer/audience-agnostic. A new
`jwks::ClusterJwksFetcher` points it at the cluster issuer/JWKS, trusts the cluster CA
(`reqwest::Certificate::from_pem`), and reads protector's SA token **fresh on every JWKS fetch**
(projected tokens rotate) to authenticate the discovery request. The same bounded-body /
single-flight / rate-limited `JwksStore` (0030 §5) protects this fetch too.
- **Require a `sub` allowlist.** A verified internal token is admitted only when its `sub` is in
`PROTECTOR_DASHBOARD_INTERNAL_ALLOWED_SUBS` (e.g. `system:serviceaccount:fixer:fixer`). A valid
token with a non-allowlisted `sub` is a hard `401` — a presented-but-unauthorized identity is a
reject, never a fall-through, and never a browser `302` (a machine identity is not re-challenged
at a login page). If the lane is configured (`PROTECTOR_DASHBOARD_INTERNAL_AUD` set) but the
allowlist is empty, the lane is treated as **off** (logged at ERROR) — fail closed, and the edge
path is left untouched.
- **One gate, two lanes, tried in order.** The dashboard enforce gate first tries the internal lane
when an `Authorization: Bearer` token is present; on a verify failure it **falls through** to the
edge path (it never fails *open* on the internal lane). An admitted internal identity is inserted
and subjected to the **same** minimum-tier gate as the edge path — it authorizes read exactly as
the edge path authorizes read today.
- **Floor tier, read-only.** A cluster SA token carries no `tier` claim, so an admitted internal
identity resolves to the most-restricted `Redacted` tier (0030 §6 — a missing tier is never
permissive). `fixer` reads the redacted view, which is the appropriate disclosure for an
automated reader.

### Posture: fail-closed internal, fail-open-nowhere

The edge path remains defense-in-depth exactly as 0030 left it. The internal lane fails **closed**:
an internal caller sends no `Cf-Access-Jwt-Assertion`, so when its token can't be verified it falls
through to the edge path, hits "no token," and gets `401`/`302` per the route class. A cluster-JWKS
outage (`AuthError::JwksUnreachable`) therefore admits no unverified internal caller — unlike the
Cloudflare path, there is no warm-cache fail-open here, because the internal lane is the *only*
thing that could admit a tokenless-at-the-edge caller.

### Coupling to the edge gate

The internal lane rides on `dashboard::auth::enforce::Enforcer`, which `run_loop` mounts only when
the edge OIDC issuer is configured (the dashboard's `Oidc` mode). An edge-trust-only deployment (no
issuer) mounts no gate at all, so the `PROTECTOR_DASHBOARD_INTERNAL_*` vars are inert there — but
that mode already serves the dashboard unauthenticated in-cluster, so there is nothing for the
internal lane to add. The cluster deploys protector with the edge issuer set, so the lane is active.

### Configuration (env vars)

| Variable | Default | Meaning |
| --- | --- | --- |
| `PROTECTOR_DASHBOARD_INTERNAL_AUD` | *(unset ⇒ lane off)* | Required audience; enables the lane when set and non-empty. |
| `PROTECTOR_DASHBOARD_INTERNAL_ALLOWED_SUBS` | *(empty ⇒ lane off)* | Comma-separated exact `sub` allowlist. |
| `PROTECTOR_DASHBOARD_INTERNAL_ISSUER` | `https://kubernetes.default.svc.cluster.local` | Expected `iss`. |
| `PROTECTOR_DASHBOARD_INTERNAL_JWKS_URL` | `<issuer>/openid/v1/jwks` | JWKS endpoint (direct, no discovery). |
| `PROTECTOR_DASHBOARD_INTERNAL_TOKEN_PATH` | `/var/run/secrets/kubernetes.io/serviceaccount/token` | protector's own SA token, read fresh per fetch. |
| `PROTECTOR_DASHBOARD_INTERNAL_CA_PATH` | `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` | Cluster CA trusted for the JWKS fetch. |

## Consequences

- `fixer` (and any future in-cluster `/api` reader) authenticates with its own SA token — no
Cloudflare round-trip, scoped to a named identity, read-only at the redacted tier. The cluster
repo's protector chart sets `PROTECTOR_DASHBOARD_INTERNAL_AUD=protector` and the allowlist to
`system:serviceaccount:fixer:fixer`; the `fixer` chart mounts a projected `aud=protector` SA token
and presents it as the bearer.
- The attack surface stays narrow: RS256 only (no alg-confusion regression), the exact cluster `iss`
and the required `aud`, a non-expired/active token, and an explicit `sub` allowlist. Token
contents are never logged — only the `sub` on a reject.
- The edge path is untouched; existing Access/enforce tests and behavior stand. The cost is a second
verifier and a slightly more involved gate (two ordered lanes instead of one), plus the operational
duty to keep the allowlist correct — an empty or wrong allowlist fails closed, the safe direction.
- Unset `PROTECTOR_DASHBOARD_INTERNAL_AUD` leaves the lane entirely out of the gate, so local dev and
non-cluster deploys are unchanged.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ Copy [`0000-template.md`](0000-template.md) to start one.
| [0040](0040-node-scoped-containment-mechanism-escalation.md) | Node-scoped containment is a **deterministic mechanism escalation** of a model-decided target: when typed evidence proves the adversary broke the *pod* boundary (host-cred read; root + escape edge; ptrace/module-load; or ≥2 model-confirmed actively-exploited pods co-resident), the resolver escalates the model-named workload's cut from a pod NetworkPolicy to `ContainNode` (cordon + co-resident default-deny) — the model keeps *what*, determinism owns *how* (rejects a model-selectable node menu line: highest blast on the weakest measured axis). Own `node` action class at ladder rung 3 (above quarantine), propose-first by construction (a node always has alive collateral), reversible + ownership-marked + one-node-cap + control-plane-excluded + worker-floor; posture-derived `nodes` RBAC; ships shadow-first (trigger/proposal, then actuator). Also records: adversary-reach is a **presentation annotation, never judge context** (context-not-evidence enforced by absence from the prompt), and off-path auto-containment is a **won't-build** (reaffirms 0032 §6, strengthened by 0038). Refines 0032/0034; reuses 0010/0017/0021/0035/0036 rails | Proposed |
| [0041](0041-narrow-blanket-notable-exec-corroboration.md) | Narrow the blanket notable-exec corroboration arm to shapes: a bare interactive-shell/pkg-mgr `ProcessExec` stops blanket-corroborating every objective (`Behavior::ProcessExec → false` in the flat arm — symmetric with PrivilegeChange/PtraceAttach/ModuleLoad, restoring the 0011 on-call-engineer FP guard), and exec-based corroboration re-homes in an entry-scoped `reverse_shell_on_foothold` shape (interactive-shell exec + internet NetworkConnection within a symmetric 60s window, foothold-gated) — unmasking the deferred reverse-shell shape per 0024. Corrects the framing: the model never sees `corroborated` (removed from the prompt by 0034); narrowing buys deterministic auto-apply-gate precision + honest latent/live presentation + 0024 unmasking, NOT model-input precision. Safe **now** because Falco's `Alert` arm still backstops (`falco.enabled: true`), pre-arming in `audit`. One real behavior change (inert under audit): a shell-only + decisive-attack incident becomes a proposal not an auto-cut in enforce (0011-correct). Package managers excluded (always egress); `is_alarming_now`/menu seeding untouched; a narrowing-delta counter feeds the retire-Falco parity bake (0037). Amends 0009's action-bar input | Proposed |
| [0042](0042-flag-remaining-consumer-count-on-consumer-removal.md) | Flag a shared layer's remaining live-consumer count when a PR removes a consumer: a consumer-removal PR states the layer's remaining live-consumer count in one line, and a drop-to-one is flagged in review as a refactor smell rather than merged silently — the discipline PR #189's shared model-completion cache and PR #190's hypothesis-stage deletion needed (their conjunction, invisible to either single-PR review, left a redundant, `Uncertain`-pinning cache that PR #191 had to revert). Extends 0024's single-PR redundant-by-construction rule across a two-PR conjunction | Accepted |
| [0043](0043-internal-serviceaccount-token-auth-on-dashboard-read.md) | Accept an internal Kubernetes ServiceAccount-token on the dashboard read surface, IN ADDITION TO the edge Cloudflare Access path (0030): an in-cluster caller (the cluster repo's `fixer`), authorized by its SA identity, reads `/api/*.json` with its projected SA-token JWT (cluster issuer, RS256, required `aud`, `sub` allowlist) instead of an edge Access JWT it cannot mint in-cluster. Pure addition + read-only: reuses the 0030 `Verifier` (alg-pinned, iss/aud/exp/nbf/sub) pointed at the cluster JWKS via a `ClusterJwksFetcher` (trusts the cluster CA, presents protector's own SA token read fresh per fetch); one gate, two lanes tried in order — a verified+allowlisted token admits read-only at the floor `Redacted` tier, a verified-but-non-allowlisted one is a hard 401 (never a 302 or fall-through), an unverifiable one falls through to the unchanged edge path. FAILS CLOSED (empty allowlist / unreadable CA / JWKS-unreachable ⇒ the lane admits no one; edge untouched). Rides the Oidc-mode Enforcer, so it needs the edge issuer set. Extends 0030; read-only sibling of 0031 | Accepted |

See also [`../VISION.md`](../VISION.md) for the longer-form narrative this ADR realizes.
72 changes: 71 additions & 1 deletion engine/src/engine/dashboard/auth/enforce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use axum::middleware::Next;
use axum::response::{IntoResponse, Response};

use super::claims::Tier;
use super::internal::{InternalAuth, InternalOutcome};
use super::{AuthError, ConfigError, Identity, OidcConfig, Verifier, authenticate, non_empty_env};

/// The tiny JSON body for a `401` — no graph data, no stack, no which-check-failed detail (ADR-0030
Expand Down Expand Up @@ -60,32 +61,49 @@ pub struct Enforcer {
login_redirect: String,
/// The minimum authorization tier a verified identity must hold to view (default `Redacted`).
min_tier: Tier,
/// The additive internal Kubernetes SA-token lane (ADR-0043), or `None` when it is not
/// configured. When set, it is tried BEFORE the edge path and ONLY for an
/// `Authorization: Bearer` token: a verified, allowlisted SA token is admitted read-only; a
/// non-allowlisted one is a hard `401`; an unverifiable one falls through to the edge path. It
/// never weakens the edge path and never fails open.
internal: Option<InternalAuth>,
}

impl Enforcer {
/// Build the production gate from a configured [`OidcConfig`]: a verifier that fetches the
/// issuer's keys over HTTPS, the login redirect (configured URL, else the issuer), and the
/// minimum tier (env escape hatch, else the most-restricted default). A MISTYPED minimum tier is
/// a loud [`ConfigError`] — fail-closed exactly like a bad audience/algorithm, so `run_loop`
/// refuses to serve rather than silently degrade the gate to allow-all.
/// refuses to serve rather than silently degrade the gate to allow-all. The internal lane is
/// NOT attached here — the caller adds it with [`with_internal`](Self::with_internal).
pub fn new(config: OidcConfig) -> Result<Self, ConfigError> {
let login_redirect = login_redirect(&config);
let min_tier = configured_min_tier()?;
Ok(Self {
verifier: Verifier::from_config(config),
login_redirect,
min_tier,
internal: None,
})
}

/// Attach (or clear) the additive internal SA-token lane (ADR-0043). Pure addition and
/// reversible: `None` leaves the enforcer the edge-only gate it was.
pub fn with_internal(mut self, internal: Option<InternalAuth>) -> Self {
self.internal = internal;
self
}

/// Assemble a gate from explicit parts — the seam tests use to inject a [`Verifier`] built over
/// an in-memory JWKS (no egress) and pin the login redirect / minimum tier deterministically.
/// The internal lane starts unattached; a test adds it with [`with_internal`](Self::with_internal).
#[cfg(test)]
pub(crate) fn from_parts(verifier: Verifier, login_redirect: String, min_tier: Tier) -> Self {
Self {
verifier,
login_redirect,
min_tier,
internal: None,
}
}

Expand All @@ -107,6 +125,46 @@ pub async fn enforce(
next: Next,
) -> Response {
let class = route_class(request.uri(), request.headers());

// 1. Internal Kubernetes SA-token lane (ADR-0043) — additive, read-only, tried FIRST. It engages
// only when configured AND an `Authorization: Bearer` token is present, and it NEVER fails
// open: an unverifiable internal token falls through to the unchanged edge path below.
if let Some(internal) = &enforcer.internal
&& let Some(token) = super::bearer_from_headers(request.headers())
{
match internal.authorize(&token).await {
InternalOutcome::Admit(identity) => {
// Authorize READ-ONLY exactly as the edge path does: insert the verified
// Identity and apply the SAME minimum-tier gate.
if enforcer.identity_permitted(&identity) {
request.extensions_mut().insert(identity);
return next.run(request).await;
}
tracing::warn!(
subject = %identity.subject,
"dashboard: internal SA identity is below the minimum tier (forbidden)"
);
return deny(Denial::Forbidden, class, &enforcer.login_redirect);
}
InternalOutcome::NotAllowed(subject) => {
// A validly-signed cluster token whose `sub` is NOT allowlisted is a hard reject
// — never a fall-through, and never a browser redirect (a machine identity is
// not re-challenged with a login page).
tracing::warn!(%subject, "dashboard: internal SA-token sub not in allowlist");
return deny_internal_unauthorized(class);
}
InternalOutcome::Unverifiable(error) => {
// Do NOT fail open: fall through to the edge path. An in-cluster caller carries
// no Cloudflare Access assertion, so the edge path then rejects it fail-closed.
tracing::debug!(
%error,
"dashboard: internal bearer did not verify; falling through to edge auth"
);
}
}
}

// 2. Edge Cloudflare Access lane (ADR-0030) — UNCHANGED.
match authenticate(&enforcer.verifier, request.headers()).await {
Ok(identity) if enforcer.identity_permitted(&identity) => {
request.extensions_mut().insert(identity);
Expand Down Expand Up @@ -187,6 +245,18 @@ fn deny(denial: Denial, class: RouteClass, login_redirect: &str) -> Response {
no_store(response)
}

/// A hard `401` for a validly-signed internal SA token whose `sub` is NOT allowlisted (ADR-0043).
/// NEVER a `302` — a machine identity is not a browser to re-challenge at a login page — and never
/// a fall-through. It carries the same tiny JSON body (for the `/api` class) + `no-store` as the
/// edge `401`, so a rejected internal caller leaks no more than an edge one.
fn deny_internal_unauthorized(class: RouteClass) -> Response {
let response = match class {
RouteClass::Api => json(StatusCode::UNAUTHORIZED, BODY_UNAUTHENTICATED),
RouteClass::Document => StatusCode::UNAUTHORIZED.into_response(),
};
no_store(response)
}

/// A tiny JSON error response with the given status — no graph data, no error detail.
fn json(status: StatusCode, body: &'static str) -> Response {
(
Expand Down
Loading
Loading