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
7 changes: 7 additions & 0 deletions charts/protector/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,13 @@ spec:
value: {{ .audience | quote }}
- name: PROTECTOR_DASHBOARD_OIDC_TIER_CLAIM
value: {{ .tierClaim | quote }}
{{- if .tierGrants }}
# Identity→tier grants (JEF-501): resolves the forensic/raw ceiling from the VERIFIED
# sub/email when the IdP mints no `tier` claim (e.g. Cloudflare Access over GitHub).
# An explicit `tier` claim, when present, still takes precedence over a grant.
- name: PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS
value: {{ .tierGrants | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
Expand Down
10 changes: 10 additions & 0 deletions charts/protector/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,16 @@ engine:
# already-read-only view an identity sees; JEF-489). Default `tier`; supports a flat namespaced
# key or a dotted path (e.g. `authz.tier`).
tierClaim: "tier"
# Operator identity→tier grants (JEF-501): resolves the forensic/raw ceiling from the
# VERIFIED token identity (`sub`/`email`) when the IdP mints no `tier` claim at all — e.g.
# Cloudflare Access relaying GitHub, which emits neither. Format `tier=id1,id2;tier=id3`,
# e.g. `raw=alice@example.com;forensic=bob@example.com`. An explicit `tier` claim (when the
# IdP does mint one) always takes precedence over a grant. EMPTY = no grants (every identity
# floors to `redacted`, today's behavior) — the actual grant for this cluster is wired in the
# separate cluster repo, not here. A malformed value or an unrecognized tier name is a loud
# misconfiguration: the dashboard/MCP server is NOT served rather than silently dropping the
# grant.
tierGrants: ""
# Two exploitation-intel feeds drive the engine's exploit intel + model reasoning
# (ADR-0015): the CISA KEV catalogue (actively-exploited CVEs, known NOW) and the FIRST.org
# EPSS scores (the PREDICTIVE per-CVE exploitation probability — JEF-243). The engine NEVER
Expand Down
149 changes: 144 additions & 5 deletions engine/src/engine/dashboard/auth/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,157 @@ impl Tier {
_ => Tier::Redacted,
}
}

/// Resolve the CEILING tier for a verified identity (JEF-501), with this precedence:
///
/// 1. An explicit, **recognized** `tier` claim wins — the IdP's own statement is authoritative,
/// even over a configured grant (e.g. a claim of `forensic` beats a `raw` grant for the same
/// identity: "the IdP's explicit statement wins").
/// 2. Else, the highest tier the operator's [`TierGrants`] awards this identity, matched by the
/// verified `sub` (exact) or a **verified** `email` (case-insensitive) — this is what lets an
/// operator grant forensic/raw access when the IdP (e.g. Cloudflare Access over GitHub)
/// mints no `tier` claim at all.
/// 3. Else, the most-restricted [`Tier::Redacted`] floor (ADR-0030 §6 — never a permissive
/// default).
///
/// Step 1 uses the STRICT [`Tier::parse_config`], not the lenient [`Tier::from_claim_str`]: an
/// unrecognized claim value (garbage, not merely absent) is deliberately NOT treated as an
/// explicit "redacted" statement — it falls through to the grant lookup instead, so a malformed
/// IdP claim can never shadow a legitimate operator grant.
///
/// Step 2 passes `claims.email_verified` through to [`TierGrants::resolve`]: a signature only
/// proves the IdP MINTED the token, not that the subject OWNS the `email` claim it carries (a
/// provider that self-asserts `email` — social login, a self-service directory — lets an
/// attacker set their account email to a granted operator's address). An email-typed grant is
/// therefore never a match candidate unless the token asserts `email_verified: true`; `sub`
/// grants are unaffected (a `sub` is the IdP-assigned principal, not self-asserted).
pub fn from_claims_with_grants(claims: &Claims, path: &str, grants: &TierGrants) -> Tier {
if let Some(tier) = Tier::recognized_claim(claims, path) {
return tier;
}
grants.resolve(&claims.sub, claims.email.as_deref(), claims.email_verified)
}

/// The claim at `path`, if present, a string, and a RECOGNIZED tier label — `None` for
/// absent/empty/non-string/unrecognized (see [`Tier::from_claims_with_grants`] for why
/// "unrecognized" must be `None`, not `Some(Redacted)`, here).
fn recognized_claim(claims: &Claims, path: &str) -> Option<Tier> {
let value = claims.lookup(path).and_then(Value::as_str)?;
Tier::parse_config(value)
}
}

/// A single grant identifier, TYPED at parse time by how the operator wrote it, so it can only
/// ever match its own field: an identifier containing `@` is an **email** (matched only against a
/// **verified** `email` claim, case-insensitively); one without is a **sub** (matched only against
/// `sub`, exactly). This closes a cross-field collision a single untyped OR would otherwise allow
/// (JEF-501 HIGH fix): without typing, an operator's `raw=alice@example.com` (meant as an email)
/// would ALSO match a token whose opaque `sub` happened to equal that exact string, silently
/// widening the granted set beyond what was configured — and symmetrically for a bare `sub`
/// identifier that happens to collide with someone's `email`. `@`-presence is an unambiguous split
/// (an email always contains `@`; a `sub` — a GitHub id, a UUID, a service-account name — never
/// does in practice), so no identifier is ever ambiguous between the two.
#[derive(Debug, Clone, PartialEq, Eq)]
enum GrantId {
/// Matched against a **verified** `email` claim only, case-insensitively.
Email(String),
/// Matched against `sub` only, exactly.
Sub(String),
}

impl GrantId {
fn parse(id: String) -> Self {
if id.contains('@') {
GrantId::Email(id)
} else {
GrantId::Sub(id)
}
}

/// Whether this identifier matches the verified identity. An [`GrantId::Email`] NEVER matches
/// unless `email_verified` is `true` (JEF-501 HIGH fix: a self-asserted, unverified `email`
/// claim proves nothing about ownership — only that the IdP minted *a* token, not that the
/// subject controls that address).
fn matches(&self, sub: &str, email: Option<&str>, email_verified: bool) -> bool {
match self {
GrantId::Sub(id) => id == sub,
GrantId::Email(id) => {
email_verified && email.is_some_and(|email| email.eq_ignore_ascii_case(id))
}
}
}
}

/// Operator-configured identity→tier grants (`PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS`, JEF-501):
/// resolves the tier ceiling from the VERIFIED token identity (`sub`/verified-`email`) when the
/// IdP mints no `tier` claim at all — e.g. Cloudflare Access relaying GitHub, which emits neither.
/// A grant is a CEILING like the claim it stands in for: it can only be READ here, never combined
/// additively, and [`TierGrants::default`] (no entries) reproduces today's behavior (every
/// identity floors to `Redacted` absent a `tier` claim).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TierGrants {
/// Identifiers granted [`Tier::Raw`].
raw: Vec<GrantId>,
/// Identifiers granted [`Tier::Forensic`].
forensic: Vec<GrantId>,
}

impl TierGrants {
/// Grant `tier` to every identifier in `ids`, each classified as email/sub by
/// [`GrantId::parse`]. A [`Tier::Redacted`] grant is accepted (the config syntax names a real
/// tier) but is a documented no-op: `Redacted` is already the floor every identity gets absent
/// any grant, so there is nothing to record.
pub fn grant(&mut self, tier: Tier, ids: impl IntoIterator<Item = String>) {
let ids = ids.into_iter().map(GrantId::parse);
match tier {
Tier::Raw => self.raw.extend(ids),
Tier::Forensic => self.forensic.extend(ids),
Tier::Redacted => {}
}
}

/// The highest tier granted to a verified identity. `sub` is matched EXACTLY against a
/// sub-typed grant only; `email` is matched CASE-INSENSITIVELY against an email-typed grant
/// only, and only when `email_verified` is `true` (JEF-501 — an unverified `email` claim is
/// never a match candidate). Neither matching ⇒ [`Tier::Redacted`] (an unlisted/absent identity
/// stays at the floor — a grant never widens beyond what's configured).
pub fn resolve(&self, sub: &str, email: Option<&str>, email_verified: bool) -> Tier {
if Self::matches(&self.raw, sub, email, email_verified) {
Tier::Raw
} else if Self::matches(&self.forensic, sub, email, email_verified) {
Tier::Forensic
} else {
Tier::Redacted
}
}

fn matches(ids: &[GrantId], sub: &str, email: Option<&str>, email_verified: bool) -> bool {
ids.iter().any(|id| id.matches(sub, email, email_verified))
}
}

/// The decoded token claims the verifier reads: the required `sub`, plus every other claim
/// captured flat in `extra` so the operator-configured tier claim can be looked up from it
/// without this struct having to name the IdP's claim schema (ADR-0030 §1: protector reads the
/// tier claim, it does not define it).
/// The decoded token claims the verifier reads: the required `sub`, the optional `email` +
/// `email_verified` (JEF-501 — used, together with `sub`, to match an operator-configured tier
/// grant), plus every other claim captured flat in `extra` so the operator-configured tier claim
/// can be looked up from it without this struct having to name the IdP's claim schema (ADR-0030
/// §1: protector reads the tier claim, it does not define it).
#[derive(Debug, Deserialize)]
pub struct Claims {
/// The subject — the normalized identity's principal. Required (enforced by the verifier's
/// `set_required_spec_claims`).
pub sub: String,
/// Every non-`sub` claim, flattened, so a configurable tier path resolves against it.
/// The verified token's `email` claim, if the IdP includes one. Optional — many machine
/// tokens (ID-JAG, service principals) carry no email at all.
#[serde(default)]
pub email: Option<String>,
/// The verified token's `email_verified` claim. **Absent ⇒ `false`** — the safe default
/// (JEF-501 HIGH fix): a signature only proves the IdP minted the token, never that the
/// subject owns the `email` it carries, unless the IdP itself asserts it verified that
/// ownership. An email-typed [`TierGrants`] entry never matches without this being `true`.
#[serde(default)]
pub email_verified: bool,
/// Every non-`sub`/`email`/`email_verified` claim, flattened, so a configurable tier path
/// resolves against it.
#[serde(flatten)]
pub extra: Map<String, Value>,
}
Expand Down
77 changes: 74 additions & 3 deletions engine/src/engine/dashboard/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ mod enforce_tests;
pub(crate) mod test_support;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tier_grants_tests;

use std::sync::Arc;

Expand All @@ -43,7 +45,7 @@ use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use jsonwebtoken::{Algorithm, Validation, decode, decode_header};

use claims::{Claims, Tier};
use claims::{Claims, Tier, TierGrants};
use jwks::{HttpJwksFetcher, JwksFetcher, JwksStore};

/// `PROTECTOR_DASHBOARD_OIDC_ISSUER` — the configured issuer. Its ABSENCE is what makes the
Expand All @@ -57,6 +59,11 @@ const ENV_AUDIENCE: &str = "PROTECTOR_DASHBOARD_OIDC_AUDIENCE";
const ENV_TIER_CLAIM: &str = "PROTECTOR_DASHBOARD_OIDC_TIER_CLAIM";
/// `PROTECTOR_DASHBOARD_OIDC_ALGORITHM` — the pinned asymmetric algorithm (`RS256` | `ES256`).
const ENV_ALGORITHM: &str = "PROTECTOR_DASHBOARD_OIDC_ALGORITHM";
/// `PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS` — operator identity→tier grants (JEF-501): resolves the
/// ceiling from the VERIFIED `sub`/`email` when the IdP mints no `tier` claim at all (e.g.
/// Cloudflare Access relaying GitHub). Format `tier=id1,id2;tier=id3`, e.g.
/// `raw=alice@example.com;forensic=bob@example.com`. Unset/empty = no grants (unchanged behavior).
const ENV_TIER_GRANTS: &str = "PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS";

/// The default tier claim path when `PROTECTOR_DASHBOARD_OIDC_TIER_CLAIM` is unset.
const DEFAULT_TIER_CLAIM: &str = "tier";
Expand Down Expand Up @@ -111,6 +118,9 @@ pub struct OidcConfig {
pub tier_claim: String,
/// The pinned asymmetric algorithm.
pub algorithm: SigningAlgorithm,
/// Operator identity→tier grants (JEF-501): resolves the ceiling from the verified `sub`/
/// `email` when no `tier` claim is present. Empty (default) = no grants — current behavior.
pub tier_grants: TierGrants,
}

/// Why [`OidcConfig::from_env`] could not build a config even though an issuer was present. This
Expand All @@ -129,6 +139,19 @@ pub enum ConfigError {
"PROTECTOR_DASHBOARD_OIDC_MIN_TIER `{0}` is not a recognized tier (redacted, forensic, raw)"
)]
UnsupportedTier(String),
/// A `PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS` entry names a tier that is not one of the
/// recognized tiers — a loud misconfiguration (JEF-501), never a silently-dropped grant.
#[error(
"PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS names an unrecognized tier `{0}` \
(redacted, forensic, raw)"
)]
UnsupportedTierGrant(String),
/// `PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS` is not well-formed: every `;`-separated entry must be
/// `tier=id1,id2,...` with at least one non-empty identifier.
#[error(
"PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS entry `{0}` is malformed (expected `tier=id1,id2`)"
)]
MalformedTierGrants(String),
}

impl OidcConfig {
Expand All @@ -148,15 +171,53 @@ impl OidcConfig {
}
None => SigningAlgorithm::Rs256,
};
let tier_grants = match non_empty_env(ENV_TIER_GRANTS) {
Some(value) => parse_tier_grants(&value)?,
None => TierGrants::default(),
};
Ok(Some(OidcConfig {
issuer,
audience,
tier_claim,
algorithm,
tier_grants,
}))
}
}

/// Strictly parse `PROTECTOR_DASHBOARD_OIDC_TIER_GRANTS`: `;`-separated entries of
/// `tier=id1,id2,...`. Every entry must name a RECOGNIZED tier ([`Tier::parse_config`] — the same
/// strict parser `PROTECTOR_DASHBOARD_OIDC_MIN_TIER` uses, see [`enforce`]) and carry at least one
/// non-empty identifier, or this fails loud with a [`ConfigError`] — a typo here must never
/// silently drop a grant or, worse, silently admit an unintended identifier (fail loud, exactly
/// like a mistyped `MIN_TIER`).
fn parse_tier_grants(value: &str) -> Result<TierGrants, ConfigError> {
let mut grants = TierGrants::default();
for entry in value.split(';') {
let entry = entry.trim();
if entry.is_empty() {
return Err(ConfigError::MalformedTierGrants(value.to_string()));
}
let (tier_name, ids) = entry
.split_once('=')
.ok_or_else(|| ConfigError::MalformedTierGrants(entry.to_string()))?;
let tier_name = tier_name.trim();
let tier = Tier::parse_config(tier_name)
.ok_or_else(|| ConfigError::UnsupportedTierGrant(tier_name.to_string()))?;
let ids: Vec<String> = ids
.split(',')
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_string)
.collect();
if ids.is_empty() {
return Err(ConfigError::MalformedTierGrants(entry.to_string()));
}
grants.grant(tier, ids);
}
Ok(grants)
}

/// A trimmed, non-empty environment value, or `None` if unset/blank.
pub(super) fn non_empty_env(key: &str) -> Option<String> {
std::env::var(key)
Expand All @@ -172,8 +233,13 @@ pub(super) fn non_empty_env(key: &str) -> Option<String> {
pub struct Identity {
/// The token subject (`sub`) — the principal (a human for a browser/ID-JAG token).
pub subject: String,
/// The resolved authorization tier (most-restricted when the claim is absent/empty/unknown).
/// The resolved authorization tier — see [`Tier::from_claims_with_grants`] for the precedence
/// (an explicit recognized `tier` claim, else an identity→tier grant, else the floor).
pub tier: Tier,
/// The verified token's `email` claim, if present (JEF-501). Not an identity in its own
/// right — `subject` remains the principal — but threaded through so callers/logs that want it
/// (and the tier-grant resolution above) can read it without re-decoding the token.
pub email: Option<String>,
}

/// Every distinct way verification can fail. Each acceptance-critical failure — tampered
Expand Down Expand Up @@ -323,10 +389,15 @@ impl Verifier {
let key = self.jwks.decoding_key(header.kid.as_deref()).await?;
let data =
decode::<Claims>(token, &key, &self.validation).map_err(|e| AuthError::from_jwt(&e))?;
let tier = Tier::from_claims(&data.claims, &self.config.tier_claim);
let tier = Tier::from_claims_with_grants(
&data.claims,
&self.config.tier_claim,
&self.config.tier_grants,
);
Ok(Identity {
subject: data.claims.sub,
tier,
email: data.claims.email,
})
}
}
Expand Down
1 change: 1 addition & 0 deletions engine/src/engine/dashboard/auth/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ pub(crate) fn test_config() -> OidcConfig {
audience: AUDIENCE.into(),
tier_claim: "tier".into(),
algorithm: SigningAlgorithm::Rs256,
tier_grants: super::claims::TierGrants::default(),
}
}

Expand Down
8 changes: 7 additions & 1 deletion engine/src/engine/dashboard/auth/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde_json::json;

use super::claims::{Claims, Tier};
use super::claims::{Claims, Tier, TierGrants};
use super::jwks::{HttpJwksFetcher, JwksFetcher, JwksStore};
use super::test_support::{
AUDIENCE, E, ISSUER, KEY_A_N, KEY_A_PEM, KEY_B_N, KEY_B_PEM, KID_A, KID_B, TestFetcher,
Expand Down Expand Up @@ -512,6 +512,7 @@ fn from_env_models_unconfigured_configured_and_errors() {
super::ENV_AUDIENCE,
super::ENV_TIER_CLAIM,
super::ENV_ALGORITHM,
super::ENV_TIER_GRANTS,
];
let clear = || unsafe {
for key in keys {
Expand All @@ -534,6 +535,11 @@ fn from_env_models_unconfigured_configured_and_errors() {
assert_eq!(config.audience, AUDIENCE);
assert_eq!(config.tier_claim, "tier");
assert_eq!(config.algorithm, SigningAlgorithm::Rs256);
assert_eq!(
config.tier_grants,
TierGrants::default(),
"TIER_GRANTS unset ⇒ no grants (unchanged behavior)"
);

// Configurable tier claim + ES256.
unsafe {
Expand Down
Loading