From f2d90d0847bff31c4304ac60c430a2b18cd361bf Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Wed, 26 Aug 2026 11:59:19 +0000 Subject: [PATCH 1/6] control-plane-api: Authority extractor with route Requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authority composes over Envelope extraction and carries the capability mask computed from the bearer's verified capability_mask claim, with the route's Requirement evaluated at extraction: - Requirement declares REQUIRED capabilities (a fast-fail necessary condition on the mask only — never a substitute for walk enforcement) and REQUIRE_UNMASKED, which keys on the claim's presence, never the mask's value. - A capability shortfall rejects with a structured 403 (Forbidden) naming the missing PascalCase capabilities, the stable contract a client parses to drive an upgrade_token re-mint. - NoRequirement stays Maybe-shaped: unauthenticated requests extract and Envelope::claims() remains the lazy per-callsite identity gate. - The PhantomData field is private, so a requirement-bearing Authority is unforgeable proof that R was evaluated; from_envelope is the assembly path for non-HTTP callers (GraphQL test harnesses). Handlers still extract Envelope; the mechanical migration to Authority follows separately. --- crates/control-plane-api/Cargo.toml | 1 + crates/control-plane-api/src/authority.rs | 622 ++++++++++++++++++++ crates/control-plane-api/src/envelope.rs | 20 +- crates/control-plane-api/src/lib.rs | 6 + crates/control-plane-api/src/test_server.rs | 69 ++- 5 files changed, 692 insertions(+), 26 deletions(-) create mode 100644 crates/control-plane-api/src/authority.rs diff --git a/crates/control-plane-api/Cargo.toml b/crates/control-plane-api/Cargo.toml index fc4c6f13a71..6d52370fa15 100644 --- a/crates/control-plane-api/Cargo.toml +++ b/crates/control-plane-api/Cargo.toml @@ -87,6 +87,7 @@ insta = { workspace = true } md5 = { workspace = true } sha2 = { workspace = true } tokio = { workspace = true, features = ["test-util"] } +tower = { workspace = true } tracing-subscriber = { workspace = true } # We need to define a feature with this name in order to allow us to use the diff --git a/crates/control-plane-api/src/authority.rs b/crates/control-plane-api/src/authority.rs new file mode 100644 index 00000000000..fae9a44964a --- /dev/null +++ b/crates/control-plane-api/src/authority.rs @@ -0,0 +1,622 @@ +use models::authz::{CapabilityMask, CapabilitySet}; +use std::sync::Arc; + +/// Requirement is a route's compile-time authorization precondition, +/// declared through its choice of [`Authority`] extractor and evaluated +/// against the bearer's `capability_mask` claim during extraction. +/// +/// A requirement is a fast-fail necessary condition on the *mask only*, +/// never a substitute for walk enforcement: the mask says nothing about +/// which grants the user actually holds, and an unmasked bearer passes any +/// requirement by definition. Full authorization is still evaluated against +/// the user grant walk under the extracted mask. +pub trait Requirement: Send + Sync + 'static { + /// Capabilities which the bearer's mask must enable. A masked bearer + /// whose mask does not cover this set is rejected at extraction with a + /// structured `403` naming the missing capabilities, so that a client + /// can drive an `upgrade_token` re-mint. + const REQUIRED: CapabilitySet; + /// Whether the route refuses masked bearers outright, regardless of what + /// their mask enables. This keys on the *presence* of the + /// `capability_mask` claim — a mask which happens to enable everything + /// is still a deliberately-reduced credential. + const REQUIRE_UNMASKED: bool; +} + +/// The default, vacuous [`Requirement`]: extraction is Maybe-shaped, so an +/// unauthenticated request extracts successfully and +/// [`crate::Envelope::claims`] remains the lazy per-callsite identity gate +/// (which GraphQL and the token endpoint depend on). +pub struct NoRequirement; + +impl Requirement for NoRequirement { + const REQUIRED: CapabilitySet = CapabilitySet::empty(); + const REQUIRE_UNMASKED: bool = false; +} + +/// A [`Requirement`] which refuses masked bearers outright. +/// +/// This is the fail-closed guard for surfaces whose authorization never +/// touches the capability mask — the `/admin` endpoints authorize via SQL +/// `internal.user_roles` rather than the snapshot walk — and for operations +/// which would let a masked bearer escape its mask, such as minting a +/// full-authority refresh credential. +pub struct RequireUnmasked; + +impl Requirement for RequireUnmasked { + const REQUIRED: CapabilitySet = CapabilitySet::empty(); + const REQUIRE_UNMASKED: bool = true; +} + +/// Forbidden is the structured body of a capability-shortfall `403`. +/// +/// Its shape is stable and machine-readable across the REST and GraphQL +/// surfaces: an MCP agent parses `missing_capabilities` and drives the +/// `upgrade_token` re-mint, so "you need capability X" must read identically +/// wherever it's said. +#[derive(Debug, serde::Serialize)] +pub struct Forbidden { + /// Stable machine-readable code: `missing_capabilities` when the + /// bearer's mask does not enable required capabilities, or + /// `unmasked_token_required` when the operation refuses masked bearers + /// outright (which no re-mint can remedy). + pub error: &'static str, + /// Human-readable description of the refusal. + pub message: String, + /// PascalCase names of capabilities which are required but not enabled + /// by the bearer's mask. Empty for `unmasked_token_required`. + pub missing_capabilities: Vec<&'static str>, +} + +impl Forbidden { + pub fn missing_capabilities(missing: CapabilitySet) -> Self { + let missing: Vec<&'static str> = missing.iter().map(|c| c.name()).collect(); + Self { + error: "missing_capabilities", + message: format!( + "the bearer token's capability mask does not enable required capabilities: {}", + missing.join(", "), + ), + missing_capabilities: missing, + } + } + + pub fn unmasked_token_required() -> Self { + Self { + error: "unmasked_token_required", + message: "this operation requires a full-authority token, but the bearer token carries a capability mask".to_string(), + missing_capabilities: Vec::new(), + } + } +} + +impl axum::response::IntoResponse for Forbidden { + fn into_response(self) -> axum::response::Response { + (axum::http::StatusCode::FORBIDDEN, axum::Json(self)).into_response() + } +} + +/// Rejection is an error of Authority extraction. +#[derive(Debug, thiserror::Error)] +pub enum Rejection { + #[error(transparent)] + Envelope(#[from] crate::envelope::Rejection), + #[error("{}", .0.message)] + Forbidden(Forbidden), +} + +impl From for Rejection { + fn from(status: tonic::Status) -> Self { + Self::Envelope(status.into()) + } +} + +impl axum::response::IntoResponse for Rejection { + fn into_response(self) -> axum::response::Response { + match self { + Rejection::Envelope(rej) => rej.into_response(), + Rejection::Forbidden(forbidden) => forbidden.into_response(), + } + } +} + +/// Authority is the authenticated context of an API request: the extracted +/// [`crate::Envelope`] plus the capability mask computed from the bearer's +/// verified `capability_mask` claim, with the route's [`Requirement`] `R` +/// already evaluated against that mask. +/// +/// Authority composes over `Envelope` extraction — JWT verification, the +/// `aud` check, refresh-token exchange, and the snapshot machinery are all +/// inherited unchanged. Call sites decompose it structurally — +/// `Authority { envelope: env, mask, .. }` — in the manner of axum's +/// `State`, and act on the parts directly. +/// +/// Handlers still extract `Envelope` while the migration to Authority is +/// pending; once it completes, `Envelope` loses its `FromRequestParts` impl +/// so that no unmasked context type remains for a handler to accidentally +/// choose. +pub struct Authority { + /// The extracted request Envelope. + pub envelope: crate::Envelope, + /// The bearer's capability mask: a ceiling on the capabilities this + /// request may exercise, applied wherever authority is derived from + /// grants. An unmasked bearer carries + /// [`CapabilityMask::ALL_CAPABILITIES`], which intersects as the + /// identity. + /// + /// Whether the bearer *is* masked is a property of the claim's presence + /// (`capability_mask.is_some()`), never of this value. + pub mask: CapabilityMask, + /// Private, so an Authority cannot be struct-literal constructed outside + /// this module: a requirement-bearing `Authority` is proof that `R` + /// was evaluated, and [`Self::from_envelope`] is the only way to mint + /// that proof. + _requirement: std::marker::PhantomData, +} + +// Manual impl because a derived Debug would demand `R: Debug` of marker +// types which are never constructed. +impl std::fmt::Debug for Authority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Authority") + .field("envelope", &self.envelope) + .field("mask", &self.mask) + .finish() + } +} + +impl Authority { + /// Assemble an Authority over an already-extracted Envelope, evaluating + /// `R` against its verified claims exactly as HTTP extraction does. + /// + /// This is the assembly path for callers which execute requests without + /// HTTP extraction, such as test harnesses driving the GraphQL schema + /// directly. + pub fn from_envelope(envelope: crate::Envelope) -> Result { + let mask = evaluate_requirement::(envelope.maybe_claims.maybe())?; + Ok(Self { + envelope, + mask, + _requirement: std::marker::PhantomData, + }) + } +} + +/// Evaluate `R` against a request's verified claims, if any, and compute the +/// bearer's capability mask. +/// +/// A vacuous requirement (nothing required, masked bearers welcome) is +/// Maybe-shaped: an unauthenticated request passes, and its mask is the +/// identity because no grant-derived authority exists without claims. A +/// non-vacuous requirement is authenticated by definition, so a missing +/// bearer is rejected here rather than at a later `claims()` call. +fn evaluate_requirement( + maybe_claims: Option<&crate::ControlClaims>, +) -> Result { + let vacuous = R::REQUIRED.is_empty() && !R::REQUIRE_UNMASKED; + + let Some(claims) = maybe_claims else { + if vacuous { + return Ok(CapabilityMask::ALL_CAPABILITIES); + } + return Err(crate::envelope::MaybeControlClaims::unauthenticated().into()); + }; + let mask = CapabilityMask::from_claim(claims.capability_mask.as_deref()); + + if R::REQUIRE_UNMASKED && claims.capability_mask.is_some() { + return Err(Rejection::Forbidden(Forbidden::unmasked_token_required())); + } + + let missing = R::REQUIRED - mask.apply(R::REQUIRED); + if !missing.is_empty() { + return Err(Rejection::Forbidden(Forbidden::missing_capabilities( + missing, + ))); + } + + Ok(mask) +} + +impl axum::extract::FromRequestParts> for Authority { + type Rejection = Rejection; + + fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &Arc, + ) -> impl std::future::Future> + Send { + async move { + let envelope = + >>::from_request_parts( + parts, state, + ) + .await?; + Ok(Self::from_envelope(envelope)?) + } + } +} + +// Empty impl allows aide to generate OpenAPI specs for handlers using this extractor. +// The extractor is an internal detail and doesn't appear in the API documentation. +impl aide::operation::OperationInput for Authority {} + +#[cfg(test)] +mod test { + use super::{Authority, NoRequirement, Rejection, RequireUnmasked, Requirement}; + use super::evaluate_requirement; + use models::authz::{Capability, CapabilityMask, CapabilitySet}; + + /// A representative capability-bearing requirement, as routes will + /// declare once per-route requirements are put to use. + struct RequireEdit; + + impl Requirement for RequireEdit { + const REQUIRED: CapabilitySet = + enumset::enum_set!(Capability::CatalogRead | Capability::SpecEdit); + const REQUIRE_UNMASKED: bool = false; + } + + fn claims(capability_mask: Option>) -> models::authorizations::ControlClaims { + models::authorizations::ControlClaims { + aud: "authenticated".to_string(), + iat: 0, + exp: u64::MAX, + sub: uuid::Uuid::nil(), + role: "authenticated".to_string(), + email: None, + capability_mask: capability_mask + .map(|names| names.into_iter().map(String::from).collect()), + } + } + + #[test] + fn test_no_requirement_is_maybe_shaped() { + // An unauthenticated request extracts successfully; its mask is the + // identity because no grant-derived authority exists without claims. + assert_eq!( + evaluate_requirement::(None).unwrap(), + CapabilityMask::ALL_CAPABILITIES, + ); + + // An unmasked bearer carries the identity mask. + assert_eq!( + evaluate_requirement::(Some(&claims(None))).unwrap(), + CapabilityMask::ALL_CAPABILITIES, + ); + + // A masked bearer extracts successfully — NoRequirement asserts + // nothing — and carries the mask of its recognized names. + assert_eq!( + evaluate_requirement::(Some(&claims(Some(vec![ + "CatalogRead", + "FutureCapability" + ])))) + .unwrap(), + CapabilityMask::bounded(Capability::CatalogRead.into()), + ); + + // The empty mask is valid: an identity-only token extracts. + assert_eq!( + evaluate_requirement::(Some(&claims(Some(vec![])))).unwrap(), + CapabilityMask::bounded(CapabilitySet::empty()), + ); + } + + #[test] + fn test_non_vacuous_requirements_are_authenticated() { + // A requirement-bearing Authority is authenticated by definition: + // a missing bearer is rejected at extraction, not deferred to a + // later claims() call. + for rejection in [ + evaluate_requirement::(None).unwrap_err(), + evaluate_requirement::(None).unwrap_err(), + ] { + assert!(matches!( + rejection, + Rejection::Envelope(crate::envelope::Rejection::Status(ref status)) + if status.code() == tonic::Code::Unauthenticated + )); + } + } + + #[test] + fn test_require_unmasked_keys_on_claim_presence() { + // An unmasked bearer passes. + assert_eq!( + evaluate_requirement::(Some(&claims(None))).unwrap(), + CapabilityMask::ALL_CAPABILITIES, + ); + + // Every masked bearer is refused — even one whose mask names every + // capability this binary knows, because "is this bearer masked" is a + // property of the claim's presence and never of the mask's value. + let all_names: Vec<&str> = CapabilitySet::all().iter().map(|c| c.name()).collect(); + + for mask in [vec![], all_names] { + let rejection = + evaluate_requirement::(Some(&claims(Some(mask)))).unwrap_err(); + + let Rejection::Forbidden(forbidden) = rejection else { + panic!("expected Forbidden, got {rejection:?}"); + }; + assert_eq!(forbidden.error, "unmasked_token_required"); + assert!(forbidden.missing_capabilities.is_empty()); + } + } + + #[test] + fn test_required_capabilities_check_the_mask() { + // An unmasked bearer passes any requirement by definition. + assert_eq!( + evaluate_requirement::(Some(&claims(None))).unwrap(), + CapabilityMask::ALL_CAPABILITIES, + ); + + // A mask covering the requirement passes, and the extracted mask is + // the bearer's mask — not the requirement. + assert_eq!( + evaluate_requirement::(Some(&claims(Some(vec![ + "CatalogRead", + "SpecEdit", + "Delegate" + ])))) + .unwrap(), + CapabilityMask::bounded( + Capability::CatalogRead | Capability::SpecEdit | Capability::Delegate + ), + ); + + // A partial mask is refused, naming exactly what's missing — + // and unrecognized names are inert, so a mask of only unknown names + // is missing the entire requirement, never treated as unmasked. + for (mask, expect_missing) in [ + (vec!["CatalogRead", "Delegate"], vec!["SpecEdit"]), + (vec!["FutureCapability"], vec!["CatalogRead", "SpecEdit"]), + ] { + let rejection = + evaluate_requirement::(Some(&claims(Some(mask)))).unwrap_err(); + + let Rejection::Forbidden(forbidden) = rejection else { + panic!("expected Forbidden, got {rejection:?}"); + }; + assert_eq!(forbidden.error, "missing_capabilities"); + assert_eq!(forbidden.missing_capabilities, expect_missing); + } + } + + // === HTTP-level extraction tests === + // + // These drive Authority as a real axum extractor over a real router, + // pinning the exact status and body of every refusal (the contract an + // MCP agent parses) and the configuration an accepted request carries. + // Behavior inherited from Envelope extraction — JWT verification, the + // `aud` check — is exercised through the composed path. + + /// Report the extracted configuration: the identity, the wire claim, + /// and the capabilities the mask enables. + async fn probe( + Authority { envelope, mask, .. }: Authority, + ) -> String { + let enabled = if mask.has_all_capabilities() { + "all".to_string() + } else { + let names: Vec<&'static str> = mask + .apply(CapabilitySet::all()) + .iter() + .map(|c| c.name()) + .collect(); + format!("{names:?}") + }; + let claims = envelope.maybe_claims.maybe(); + + format!( + "sub: {:?}, capability_mask claim: {:?}, enabled: {enabled}", + claims.map(|c| c.sub), + claims.and_then(|c| c.capability_mask.as_deref()), + ) + } + + async fn test_router() -> (axum::Router, tokens::jwt::EncodingKey) { + let snapshot = crate::test_server::empty_snapshot().await; + // The pool is never used: these tests present no dot-less + // (refresh-token) bearers, which are the one extraction path that + // reaches the database. + let pg_pool = sqlx::PgPool::connect_lazy("postgres://unused-by-extraction").unwrap(); + let app = crate::test_server::build_app(pg_pool, snapshot, None); + let encoding_key = app.control_plane_jwt_encode_key.clone(); + + let router = axum::Router::new() + .route("/none", axum::routing::get(probe::)) + .route("/unmasked", axum::routing::get(probe::)) + .route("/edit", axum::routing::get(probe::)) + .with_state(app); + + (router, encoding_key) + } + + fn sign_token( + encoding_key: &tokens::jwt::EncodingKey, + aud: &str, + expired: bool, + capability_mask: Option>, + ) -> String { + let now = tokens::now(); + let exp = if expired { + now - chrono::Duration::hours(1) + } else { + now + chrono::Duration::hours(1) + }; + let claims = models::authorizations::ControlClaims { + iat: (now - chrono::Duration::hours(2)).timestamp() as u64, + exp: exp.timestamp() as u64, + sub: uuid::Uuid::nil(), + role: "authenticated".to_string(), + aud: aud.to_string(), + email: None, + capability_mask: capability_mask + .map(|names| names.into_iter().map(String::from).collect()), + }; + jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, encoding_key).unwrap() + } + + async fn fetch(router: &axum::Router, path: &str, bearer: Option<&str>) -> String { + use tower::ServiceExt; + + let mut request = axum::http::Request::builder().uri(path); + if let Some(bearer) = bearer { + request = request.header("authorization", format!("Bearer {bearer}")); + } + let request = request.body(axum::body::Body::empty()).unwrap(); + + let (parts, body) = router.clone().oneshot(request).await.unwrap().into_parts(); + let body = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + format!("{}\n{}", parts.status, String::from_utf8_lossy(&body)) + } + + #[tokio::test] + async fn test_http_no_requirement_configurations() { + let (router, key) = test_router().await; + + // Unauthenticated requests extract; identity gating stays lazy. + insta::assert_snapshot!(fetch(&router, "/none", None).await, @r" + 200 OK + sub: None, capability_mask claim: None, enabled: all + "); + + // An unmasked bearer: identity present, identity mask. + let token = sign_token(&key, "authenticated", false, None); + insta::assert_snapshot!(fetch(&router, "/none", Some(&token)).await, @r" + 200 OK + sub: Some(00000000-0000-0000-0000-000000000000), capability_mask claim: None, enabled: all + "); + + // A masked bearer: the claim carries through verbatim while the mask + // enables only recognized names. + let token = sign_token( + &key, + "authenticated", + false, + Some(vec!["CatalogRead", "FutureCapability"]), + ); + insta::assert_snapshot!(fetch(&router, "/none", Some(&token)).await, @r#" + 200 OK + sub: Some(00000000-0000-0000-0000-000000000000), capability_mask claim: Some(["CatalogRead", "FutureCapability"]), enabled: ["CatalogRead"] + "#); + + // An empty mask mints an identity-only configuration. + let token = sign_token(&key, "authenticated", false, Some(vec![])); + insta::assert_snapshot!(fetch(&router, "/none", Some(&token)).await, @r#" + 200 OK + sub: Some(00000000-0000-0000-0000-000000000000), capability_mask claim: Some([]), enabled: [] + "#); + } + + #[tokio::test] + async fn test_http_require_unmasked_responses() { + let (router, key) = test_router().await; + + // An unmasked bearer passes. + let token = sign_token(&key, "authenticated", false, None); + insta::assert_snapshot!(fetch(&router, "/unmasked", Some(&token)).await, @r" + 200 OK + sub: Some(00000000-0000-0000-0000-000000000000), capability_mask claim: None, enabled: all + "); + + // A missing bearer is refused at extraction. + insta::assert_snapshot!(fetch(&router, "/unmasked", None).await, @r" + 401 Unauthorized + This is an authenticated API but the request is missing a required Authorization: Bearer token + "); + + // Any masked bearer is refused, keyed on the claim's presence — even + // a mask naming every capability this binary knows. + let all_names: Vec<&str> = CapabilitySet::all().iter().map(|c| c.name()).collect(); + for mask in [vec![], all_names] { + let token = sign_token(&key, "authenticated", false, Some(mask)); + let fixture = fetch(&router, "/unmasked", Some(&token)).await; + insta::allow_duplicates! { + insta::assert_snapshot!(fixture, @r#" + 403 Forbidden + {"error":"unmasked_token_required","message":"this operation requires a full-authority token, but the bearer token carries a capability mask","missing_capabilities":[]} + "#); + } + } + } + + #[tokio::test] + async fn test_http_required_capabilities_responses() { + let (router, key) = test_router().await; + + // An unmasked bearer passes any requirement by definition. + let token = sign_token(&key, "authenticated", false, None); + insta::assert_snapshot!(fetch(&router, "/edit", Some(&token)).await, @r" + 200 OK + sub: Some(00000000-0000-0000-0000-000000000000), capability_mask claim: None, enabled: all + "); + + // A covering mask passes, carrying its own mask — not the requirement. + let token = sign_token( + &key, + "authenticated", + false, + Some(vec!["CatalogRead", "SpecEdit", "Delegate"]), + ); + insta::assert_snapshot!(fetch(&router, "/edit", Some(&token)).await, @r#" + 200 OK + sub: Some(00000000-0000-0000-0000-000000000000), capability_mask claim: Some(["CatalogRead", "SpecEdit", "Delegate"]), enabled: ["CatalogRead", "SpecEdit", "Delegate"] + "#); + + // A missing bearer is refused at extraction. + insta::assert_snapshot!(fetch(&router, "/edit", None).await, @r" + 401 Unauthorized + This is an authenticated API but the request is missing a required Authorization: Bearer token + "); + + // A partial mask is refused with a body naming exactly what's + // missing: the stable contract an MCP agent parses to drive the + // `upgrade_token` re-mint. + let token = sign_token( + &key, + "authenticated", + false, + Some(vec!["CatalogRead", "Delegate"]), + ); + insta::assert_snapshot!(fetch(&router, "/edit", Some(&token)).await, @r#" + 403 Forbidden + {"error":"missing_capabilities","message":"the bearer token's capability mask does not enable required capabilities: SpecEdit","missing_capabilities":["SpecEdit"]} + "#); + + // Unrecognized names are inert: a mask of only unknown names is + // missing the entire requirement, never treated as unmasked. + let token = sign_token(&key, "authenticated", false, Some(vec!["FutureCapability"])); + insta::assert_snapshot!(fetch(&router, "/edit", Some(&token)).await, @r#" + 403 Forbidden + {"error":"missing_capabilities","message":"the bearer token's capability mask does not enable required capabilities: CatalogRead, SpecEdit","missing_capabilities":["CatalogRead","SpecEdit"]} + "#); + } + + #[tokio::test] + async fn test_http_inherits_envelope_authentication() { + let (router, key) = test_router().await; + + // The `aud` check is inherited from Envelope extraction, and refuses + // the bearer before any Requirement is considered. + let token = sign_token(&key, "wrong-audience", false, None); + insta::assert_snapshot!(fetch(&router, "/none", Some(&token)).await, @r" + 401 Unauthorized + authorization bearer claims missing required `aud` of 'authenticated' + "); + + // So is expiry... + let token = sign_token(&key, "authenticated", true, None); + insta::assert_snapshot!(fetch(&router, "/none", Some(&token)).await, @r" + 401 Unauthorized + ExpiredSignature + "); + + // ...and signature verification of a malformed bearer. + insta::assert_snapshot!(fetch(&router, "/none", Some("not.a.jwt")).await, @r" + 401 Unauthorized + InvalidToken + "); + } +} diff --git a/crates/control-plane-api/src/envelope.rs b/crates/control-plane-api/src/envelope.rs index 3ca7d720771..35c60db2b6a 100644 --- a/crates/control-plane-api/src/envelope.rs +++ b/crates/control-plane-api/src/envelope.rs @@ -17,11 +17,25 @@ impl MaybeControlClaims { pub fn result(&self) -> tonic::Result<&crate::ControlClaims> { match &self.0 { Some(verified) => Ok(verified.claims()), - None => Err(tonic::Status::unauthenticated( - "This is an authenticated API but the request is missing a required Authorization: Bearer token", - )), + None => Err(Self::unauthenticated()), } } + + /// Peek at verified claims without requiring their presence. + /// + /// This exists for `Authority` extraction, which must evaluate a route's + /// `Requirement` against the claims of an authenticated bearer while + /// leaving an unauthenticated request to the lazy per-callsite identity + /// gate of [`Self::result`]. + pub fn maybe(&self) -> Option<&crate::ControlClaims> { + self.0.as_ref().map(|verified| verified.claims()) + } + + pub(crate) fn unauthenticated() -> tonic::Status { + tonic::Status::unauthenticated( + "This is an authenticated API but the request is missing a required Authorization: Bearer token", + ) + } } /// Locale is a placeholder, since we only support a single locale today. Once diff --git a/crates/control-plane-api/src/lib.rs b/crates/control-plane-api/src/lib.rs index 856766a693e..e3392fde43c 100644 --- a/crates/control-plane-api/src/lib.rs +++ b/crates/control-plane-api/src/lib.rs @@ -4,6 +4,7 @@ use sqlx::types::Uuid; pub mod alert_subscriptions; pub mod alerts; +mod authority; pub mod billing; pub mod connector_tags; pub mod controllers; @@ -48,6 +49,11 @@ pub type AuthZResult = tonic::Result<(Option, Ok)>; /// Envelope is common fields and parameters of every API request. pub use envelope::{Envelope, Locale, MaybeControlClaims}; +/// Authority is the authenticated context of an API request: an Envelope +/// plus the capability mask of the bearer's `capability_mask` claim, with +/// the route's Requirement evaluated at extraction. +pub use authority::{Authority, Forbidden, NoRequirement, RequireUnmasked, Requirement}; + // TODO(johnny): These types are all fundamental to this crate, and should be // hoisted from the `server` module. For now, just re-export to minimize churn. pub(crate) use server::evaluate_names_authorization; diff --git a/crates/control-plane-api/src/test_server.rs b/crates/control-plane-api/src/test_server.rs index e5cd21475a7..ec74004644d 100644 --- a/crates/control-plane-api/src/test_server.rs +++ b/crates/control-plane-api/src/test_server.rs @@ -67,6 +67,50 @@ pub async fn snapshot(pg_pool: sqlx::PgPool, gate: bool) -> Arc Arc> { + let source = GatedSnapshot { + gate: true, + actual: None, + }; + tokens::watch(source).ready_owned().await +} + +/// Build a wired App over the given pool and snapshot, with a Publisher that +/// panics if used. This is the assembly path for tests which drive a router +/// or the GraphQL schema directly, without a listening TestServer. +pub fn build_app( + pg_pool: sqlx::PgPool, + snapshot: Arc>, + billing_provider: Option>, +) -> Arc { + // TODO(johnny): Aggregate into a sink? + let (logs_tx, _logs_rx) = tokio::sync::mpsc::channel(1); + + // Build an invalid Publisher that will blow up if used. + let publisher = crate::publications::Publisher::new( + std::path::PathBuf::from("/invalid"), + &url::Url::parse("file:///invalid").unwrap(), + &"invalid", + &logs_tx, + pg_pool.clone(), + models::IdGenerator::new(0), + Box::new(NoopBuilder), + ); + + Arc::new(crate::App::new( + models::IdGenerator::new(0), + billing_provider, + b"test-jwt-secret-for-integration-tests", + pg_pool, + publisher, + snapshot, + Some(crate::server::public::stripe_webhooks::tests::DEV_WEBHOOK_SECRET.to_string()), + )) +} + pub struct TestServer { pub addr: std::net::SocketAddr, pub encoding_key: tokens::jwt::EncodingKey, @@ -105,29 +149,8 @@ impl TestServer { alert_config_defaults: models::AlertConfig, ) -> Self { let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - // TODO(johnny): Aggregate into a sink? - let (logs_tx, _logs_rx) = tokio::sync::mpsc::channel(1); - - // Build an invalid Publisher that will blow up if used. - let publisher = crate::publications::Publisher::new( - std::path::PathBuf::from("/invalid"), - &url::Url::parse("file:///invalid").unwrap(), - &"invalid", - &logs_tx, - pg_pool.clone(), - models::IdGenerator::new(0), - Box::new(NoopBuilder), - ); - - let app = Arc::new(crate::App::new( - models::IdGenerator::new(0), - billing_provider, - b"test-jwt-secret-for-integration-tests", - pg_pool.clone(), - publisher, - snapshot, - Some(crate::server::public::stripe_webhooks::tests::DEV_WEBHOOK_SECRET.to_string()), - )); + + let app = build_app(pg_pool, snapshot, billing_provider); let encoding_key = app.control_plane_jwt_encode_key.clone(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") From b52e8bb5e6ba7a76ce487f6d84d10a00d41d18f5 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Wed, 26 Aug 2026 12:04:45 +0000 Subject: [PATCH 2/6] control-plane-api: pin actual envelope verification error bodies The extractor-inheritance snapshots expected bare jsonwebtoken error names, but Envelope's verify path wraps them with context: an expired bearer reports "failed to verify token: ExpiredSignature", and a malformed bearer fails base64 header decoding before any structural InvalidToken check. --- crates/control-plane-api/src/authority.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/control-plane-api/src/authority.rs b/crates/control-plane-api/src/authority.rs index fae9a44964a..c831d8ecabc 100644 --- a/crates/control-plane-api/src/authority.rs +++ b/crates/control-plane-api/src/authority.rs @@ -610,13 +610,13 @@ mod test { let token = sign_token(&key, "authenticated", true, None); insta::assert_snapshot!(fetch(&router, "/none", Some(&token)).await, @r" 401 Unauthorized - ExpiredSignature + failed to verify token: ExpiredSignature "); // ...and signature verification of a malformed bearer. insta::assert_snapshot!(fetch(&router, "/none", Some("not.a.jwt")).await, @r" 401 Unauthorized - InvalidToken + failed to verify token: Base64 error: Invalid last symbol 116, offset 2. "); } } From 7a92e57bffadbc3a91ee73634efc4c7d9036c94c Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Wed, 26 Aug 2026 12:07:05 +0000 Subject: [PATCH 3/6] control-plane-api: cargo fmt --- crates/control-plane-api/src/authority.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/control-plane-api/src/authority.rs b/crates/control-plane-api/src/authority.rs index c831d8ecabc..d51d5ffa771 100644 --- a/crates/control-plane-api/src/authority.rs +++ b/crates/control-plane-api/src/authority.rs @@ -241,8 +241,8 @@ impl aide::operation::OperationInput for Authority {} #[cfg(test)] mod test { - use super::{Authority, NoRequirement, Rejection, RequireUnmasked, Requirement}; use super::evaluate_requirement; + use super::{Authority, NoRequirement, Rejection, RequireUnmasked, Requirement}; use models::authz::{Capability, CapabilityMask, CapabilitySet}; /// A representative capability-bearing requirement, as routes will @@ -393,9 +393,7 @@ mod test { /// Report the extracted configuration: the identity, the wire claim, /// and the capabilities the mask enables. - async fn probe( - Authority { envelope, mask, .. }: Authority, - ) -> String { + async fn probe(Authority { envelope, mask, .. }: Authority) -> String { let enabled = if mask.has_all_capabilities() { "all".to_string() } else { From c67f09e557dce80729c32cbab045ef99fb5542bd Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Wed, 26 Aug 2026 12:41:54 +0000 Subject: [PATCH 4/6] control-plane-api: pin Envelope-before-Requirement refusal ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for the Authority extractor: - New test: every bearer fails BOTH Envelope authentication and its route's Requirement, pinning that the Envelope's 401 wins over the Requirement's 403 — expiry and aud against RequireUnmasked, expiry against uncovered REQUIRED capabilities, a wrong-key signature whose mask fully covers the requirement, and a malformed bearer returning the Envelope rejection verbatim on requirement-bearing routes. The existing /none cases can't discriminate this ordering because a vacuous Requirement passes any bearer. - Docs: drop the stale upgrade_token wording (the revised design has clients request a fresh capability token naming what they need), and explain why the aide::OperationInput impl exists ahead of any handler extracting Authority. --- crates/control-plane-api/src/authority.rs | 79 ++++++++++++++++++++--- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/crates/control-plane-api/src/authority.rs b/crates/control-plane-api/src/authority.rs index d51d5ffa771..82527b8bf1b 100644 --- a/crates/control-plane-api/src/authority.rs +++ b/crates/control-plane-api/src/authority.rs @@ -14,7 +14,7 @@ pub trait Requirement: Send + Sync + 'static { /// Capabilities which the bearer's mask must enable. A masked bearer /// whose mask does not cover this set is rejected at extraction with a /// structured `403` naming the missing capabilities, so that a client - /// can drive an `upgrade_token` re-mint. + /// can request a fresh capability token which enables them. const REQUIRED: CapabilitySet; /// Whether the route refuses masked bearers outright, regardless of what /// their mask enables. This keys on the *presence* of the @@ -51,9 +51,9 @@ impl Requirement for RequireUnmasked { /// Forbidden is the structured body of a capability-shortfall `403`. /// /// Its shape is stable and machine-readable across the REST and GraphQL -/// surfaces: an MCP agent parses `missing_capabilities` and drives the -/// `upgrade_token` re-mint, so "you need capability X" must read identically -/// wherever it's said. +/// surfaces: an MCP agent parses `missing_capabilities` and requests a fresh +/// capability token which enables them, so "you need capability X" must read +/// identically wherever it's said. #[derive(Debug, serde::Serialize)] pub struct Forbidden { /// Stable machine-readable code: `missing_capabilities` when the @@ -235,8 +235,11 @@ impl axum::extract::FromRequestParts> for Author } } -// Empty impl allows aide to generate OpenAPI specs for handlers using this extractor. -// The extractor is an internal detail and doesn't appear in the API documentation. +// Empty impl allows aide to generate OpenAPI specs for handlers using this +// extractor. No shipped handler extracts Authority yet — the migration off +// Envelope is pending — but the impl must exist for those handlers to compile, +// and the extractor is an internal detail which doesn't appear in the API +// documentation either way. impl aide::operation::OperationInput for Authority {} #[cfg(test)] @@ -570,8 +573,8 @@ mod test { "); // A partial mask is refused with a body naming exactly what's - // missing: the stable contract an MCP agent parses to drive the - // `upgrade_token` re-mint. + // missing: the stable contract an MCP agent parses to request a + // fresh capability token which enables them. let token = sign_token( &key, "authenticated", @@ -617,4 +620,64 @@ mod test { failed to verify token: Base64 error: Invalid last symbol 116, offset 2. "); } + + #[tokio::test] + async fn test_http_envelope_refusal_precedes_requirements() { + let (router, key) = test_router().await; + + // Each bearer below fails BOTH Envelope authentication and its + // route's Requirement, pinning that the Envelope's 401 wins: were + // Requirements evaluated first, these would be 403s. (The vacuous + // `/none` cases in test_http_inherits_envelope_authentication can't + // distinguish this ordering, because their Requirement passes any + // bearer.) + + // An expired, masked bearer against RequireUnmasked... + let token = sign_token(&key, "authenticated", true, Some(vec![])); + insta::assert_snapshot!(fetch(&router, "/unmasked", Some(&token)).await, @r" + 401 Unauthorized + failed to verify token: ExpiredSignature + "); + + // ...and against required capabilities its mask doesn't cover, which + // is the separate REQUIRED evaluation path. + let token = sign_token(&key, "authenticated", true, Some(vec!["CatalogRead"])); + insta::assert_snapshot!(fetch(&router, "/edit", Some(&token)).await, @r" + 401 Unauthorized + failed to verify token: ExpiredSignature + "); + + // A masked bearer whose signature verifies but whose `aud` doesn't: + // the aud check refuses at a later point within Envelope extraction + // than signature verification, and still precedes the Requirement. + let token = sign_token(&key, "wrong-audience", false, Some(vec![])); + insta::assert_snapshot!(fetch(&router, "/unmasked", Some(&token)).await, @r" + 401 Unauthorized + authorization bearer claims missing required `aud` of 'authenticated' + "); + + // The inverse direction: a mask which fully covers the requirement + // cannot rescue a bearer signed with the wrong key. + let wrong_key = jsonwebtoken::EncodingKey::from_secret(b"not-the-server-secret"); + let token = sign_token( + &wrong_key, + "authenticated", + false, + Some(vec!["CatalogRead", "SpecEdit"]), + ); + insta::assert_snapshot!(fetch(&router, "/edit", Some(&token)).await, @r" + 401 Unauthorized + failed to verify token: InvalidSignature + "); + + // A malformed bearer offers no claims to evaluate a Requirement + // against at all: requirement-bearing routes return the Envelope's + // rejection verbatim. + let malformed = fetch(&router, "/unmasked", Some("not.a.jwt")).await; + assert_eq!(malformed, fetch(&router, "/edit", Some("not.a.jwt")).await); + insta::assert_snapshot!(malformed, @r" + 401 Unauthorized + failed to verify token: Base64 error: Invalid last symbol 116, offset 2. + "); + } } From ee62ba9aa6f21cc931f12e1c6426d613dc896f27 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Wed, 26 Aug 2026 12:50:03 +0000 Subject: [PATCH 5/6] control-plane-api: correct NoRequirement doc's lazy-gate dependents The token endpoint (POST /api/v1/auth/token) never extracts Envelope, so it doesn't depend on Maybe-shaped extraction. GraphQL does: its one route serves every operation, so identity errors must surface per-resolver rather than at extraction. --- crates/control-plane-api/src/authority.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/control-plane-api/src/authority.rs b/crates/control-plane-api/src/authority.rs index 82527b8bf1b..53dfa46ef62 100644 --- a/crates/control-plane-api/src/authority.rs +++ b/crates/control-plane-api/src/authority.rs @@ -25,8 +25,9 @@ pub trait Requirement: Send + Sync + 'static { /// The default, vacuous [`Requirement`]: extraction is Maybe-shaped, so an /// unauthenticated request extracts successfully and -/// [`crate::Envelope::claims`] remains the lazy per-callsite identity gate -/// (which GraphQL and the token endpoint depend on). +/// [`crate::Envelope::claims`] remains the lazy per-callsite identity gate. +/// GraphQL depends on this: its one route serves every operation, so +/// identity errors must surface per-resolver rather than at extraction. pub struct NoRequirement; impl Requirement for NoRequirement { From 68c2eb9085e1c8a5da8e4bebcb254f8c32aa5574 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 12:06:24 +0000 Subject: [PATCH 6/6] control-plane-api: route Requirements speak the bundle vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requirement::REQUIRED is now a slice of CapabilityBundles — the same vocabulary the capability_mask claim names — converted to capability bits at evaluation time via capabilities(), since it is not a const fn. This drops the enum_set! literal spelling and keeps route declarations in the vocabulary a client would echo back in a mask request. --- crates/control-plane-api/src/authority.rs | 41 ++++++++++++++++------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/crates/control-plane-api/src/authority.rs b/crates/control-plane-api/src/authority.rs index 53dfa46ef62..96d18f2750b 100644 --- a/crates/control-plane-api/src/authority.rs +++ b/crates/control-plane-api/src/authority.rs @@ -1,4 +1,4 @@ -use models::authz::{CapabilityMask, CapabilitySet}; +use models::authz::{CapabilityBundle, CapabilityMask, CapabilitySet}; use std::sync::Arc; /// Requirement is a route's compile-time authorization precondition, @@ -11,16 +11,32 @@ use std::sync::Arc; /// requirement by definition. Full authorization is still evaluated against /// the user grant walk under the extracted mask. pub trait Requirement: Send + Sync + 'static { - /// Capabilities which the bearer's mask must enable. A masked bearer - /// whose mask does not cover this set is rejected at extraction with a - /// structured `403` naming the missing capabilities, so that a client - /// can request a fresh capability token which enables them. - const REQUIRED: CapabilitySet; + /// Capability bundles which the bearer's mask must enable, spelled in + /// the same [`CapabilityBundle`] vocabulary the `capability_mask` claim + /// speaks — a composite bundle or an individual capability's same-named + /// bundle alike. A masked bearer whose mask does not cover the union of + /// their capability bits is rejected at extraction with a structured + /// `403` naming the missing capabilities, so that a client can request + /// a fresh capability token which enables them. + /// + /// [`Self::required`] computes the union of the declared bundles' + /// capability bits at evaluation time, because + /// `CapabilityBundle::capabilities` is not a `const fn` and so cannot + /// feed an associated const. + const REQUIRED: &'static [CapabilityBundle]; /// Whether the route refuses masked bearers outright, regardless of what /// their mask enables. This keys on the *presence* of the /// `capability_mask` claim — a mask which happens to enable everything /// is still a deliberately-reduced credential. const REQUIRE_UNMASKED: bool; + + /// The capability bits which [`Self::REQUIRED`] demands of the mask. + fn required() -> CapabilitySet { + Self::REQUIRED + .iter() + .map(|bundle| bundle.capabilities()) + .fold(CapabilitySet::empty(), |set, bits| set | bits) + } } /// The default, vacuous [`Requirement`]: extraction is Maybe-shaped, so an @@ -31,7 +47,7 @@ pub trait Requirement: Send + Sync + 'static { pub struct NoRequirement; impl Requirement for NoRequirement { - const REQUIRED: CapabilitySet = CapabilitySet::empty(); + const REQUIRED: &'static [CapabilityBundle] = &[]; const REQUIRE_UNMASKED: bool = false; } @@ -45,7 +61,7 @@ impl Requirement for NoRequirement { pub struct RequireUnmasked; impl Requirement for RequireUnmasked { - const REQUIRED: CapabilitySet = CapabilitySet::empty(); + const REQUIRED: &'static [CapabilityBundle] = &[]; const REQUIRE_UNMASKED: bool = true; } @@ -208,7 +224,8 @@ fn evaluate_requirement( return Err(Rejection::Forbidden(Forbidden::unmasked_token_required())); } - let missing = R::REQUIRED - mask.apply(R::REQUIRED); + let required = R::required(); + let missing = required - mask.apply(required); if !missing.is_empty() { return Err(Rejection::Forbidden(Forbidden::missing_capabilities( missing, @@ -247,15 +264,15 @@ impl aide::operation::OperationInput for Authority {} mod test { use super::evaluate_requirement; use super::{Authority, NoRequirement, Rejection, RequireUnmasked, Requirement}; - use models::authz::{Capability, CapabilityMask, CapabilitySet}; + use models::authz::{Capability, CapabilityBundle, CapabilityMask, CapabilitySet}; /// A representative capability-bearing requirement, as routes will /// declare once per-route requirements are put to use. struct RequireEdit; impl Requirement for RequireEdit { - const REQUIRED: CapabilitySet = - enumset::enum_set!(Capability::CatalogRead | Capability::SpecEdit); + const REQUIRED: &'static [CapabilityBundle] = + &[CapabilityBundle::CatalogRead, CapabilityBundle::SpecEdit]; const REQUIRE_UNMASKED: bool = false; }