From 762f17b2dfccbd1a9332ec0820dc7b02bd90d61c Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 17:00:48 +0000 Subject: [PATCH 1/3] control-plane-api: require_unmasked guards credential and admin surfaces Task 6 of #3376: close the remaining surfaces a masked bearer must not reach. Forbidden::require_unmasked is the one definition of the masked-bearer refusal - keyed on the capability_mask claim's presence, never its value - consumed by requirement evaluation at extraction, by the capability_token mint, and directly by GraphQL resolvers, which an axum extractor cannot reach. createRefreshToken refuses masked bearers ahead of its service-account lookup: a refresh token exchanges for a full-authority access token, which would escape the mask. Revocation never widens authority and stays open to masked bearers, with a test pinning both. /admin/create-data-plane and /admin/update-l2-reporting take Authority: their SQL internal.user_roles authorization cannot bind the capability ceiling, so they fail closed for masked bearers, byte-identical for unmasked callers. Each gains its first tests: the structured 403 for a masked bearer, and the handler's own ops/-admin refusal for an unmasked one. The RequireUnmasked doc carries the audited inventory of unmasked-only surfaces and why everything else deliberately stays open; the SQL functions reachable through PostgREST remain the documented mask-bypass boundary tracked under #3376 task 8. The GraphQL schema regen also picks up the userCapability description from the legacy-decision migration, which had not been regenerated. --- crates/control-plane-api/src/authority.rs | 41 ++++++++- .../src/server/create_data_plane.rs | 67 ++++++++++++++- .../server/public/graphql/refresh_tokens.rs | 84 +++++++++++++++++++ .../src/server/public/token_exchange.rs | 6 +- .../src/server/update_l2_reporting.rs | 67 ++++++++++++++- crates/flow-client/control-plane-api.graphql | 8 ++ 6 files changed, 258 insertions(+), 15 deletions(-) diff --git a/crates/control-plane-api/src/authority.rs b/crates/control-plane-api/src/authority.rs index a0b86631532..cb51f6774ee 100644 --- a/crates/control-plane-api/src/authority.rs +++ b/crates/control-plane-api/src/authority.rs @@ -58,6 +58,29 @@ impl Requirement for NoRequirement { /// `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. +/// +/// The audited inventory of unmasked-only surfaces (#3376): +/// - The `capability_token` grant of `POST /api/v1/auth/token`: a reduced +/// token must not widen or re-mint itself. +/// - GraphQL `createRefreshToken`: a refresh token exchanges for a +/// full-authority access token, so a masked bearer minting one escapes +/// its mask. The resolver enforces this through +/// [`Forbidden::require_unmasked`], since an axum extractor cannot reach +/// a resolver. +/// - `/admin/create-data-plane` and `/admin/update-l2-reporting`: their +/// SQL authorization cannot bind the mask, so they fail closed instead. +/// +/// Every other identity-gated operation deliberately stays open to masked +/// bearers: revocations (`revokeRefreshToken`, `revokeApiKey`, and kin) +/// never widen the bearer's authority; credential-adjacent operations like +/// `createApiKey` and `createServiceAccount` authorize through the grant +/// walk, which the mask already filters; and invite redemption widens the +/// *user's* grants while the bearer still exercises them only through its +/// mask. SQL functions reachable through PostgREST +/// (`public.create_refresh_token`, `public.gateway_auth_token`) are outside +/// this crate's enforcement entirely — they are part of the documented +/// PostgREST mask bypass whose resolution is the #2877 migration, tracked +/// under #3376. pub struct RequireUnmasked; impl Requirement for RequireUnmasked { @@ -133,6 +156,20 @@ impl Forbidden { } } + /// The masked-bearer refusal shared by every surface which demands a + /// full-authority credential: a definitive denial keyed on the + /// *presence* of the `capability_mask` claim, never its value — a mask + /// which happens to enable everything is still a deliberately-reduced + /// credential. Requirement evaluation consumes this during extraction; + /// GraphQL resolvers consume it directly, where an axum extractor + /// cannot reach. + pub fn require_unmasked(claims: &crate::ControlClaims) -> Result<(), Self> { + if claims.capability_mask.is_some() { + return Err(Self::unmasked_token_required()); + } + Ok(()) + } + pub fn unmasked_token_required() -> Self { Self { error: "unmasked_token_required", @@ -304,8 +341,8 @@ fn evaluate_requirement( }; 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())); + if R::REQUIRE_UNMASKED { + Forbidden::require_unmasked(claims).map_err(Rejection::Forbidden)?; } let required = R::required(); diff --git a/crates/control-plane-api/src/server/create_data_plane.rs b/crates/control-plane-api/src/server/create_data_plane.rs index eec36bde7b8..30fcbf3ce77 100644 --- a/crates/control-plane-api/src/server/create_data_plane.rs +++ b/crates/control-plane-api/src/server/create_data_plane.rs @@ -53,14 +53,14 @@ pub struct Request { pub struct Response {} /// Authorization is SQL `internal.user_roles` rather than the snapshot walk, -/// so the bearer's capability mask has no effect — a masked bearer is -/// treated as unmasked — until this endpoint's authorization is refactored -/// onto the snapshot. See #3376. +/// so the capability ceiling cannot bind here; `RequireUnmasked` fail-closes +/// masked bearers instead, until the wider refactor retires this endpoint's +/// SQL authorization (#3376, decision 12). #[axum::debug_handler(state=std::sync::Arc)] #[tracing::instrument(skip(app, env), ret, err(Debug, level = tracing::Level::WARN))] pub async fn create_data_plane( axum::extract::State(app): axum::extract::State>, - crate::Authority { envelope: env, .. }: crate::Authority, + crate::Authority { envelope: env, .. }: crate::Authority, super::Request(Request { name, private, @@ -330,3 +330,62 @@ impl Validate for Category { } } } + +#[cfg(test)] +mod test { + use crate::test_server; + + const ALICE: uuid::Uuid = uuid::Uuid::from_bytes([0x11; 16]); + + /// A masked bearer is refused at extraction with the structured `403`, + /// while an unmasked bearer reaches the handler's own `ops/` admin gate + /// unchanged. + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../fixtures", scripts("data_planes", "alice")) + )] + async fn test_create_data_plane_requires_unmasked(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + let server = test_server::TestServer::start( + pool.clone(), + test_server::snapshot(pool.clone(), true).await, + ) + .await; + + let body = serde_json::json!({"name": "test-plane", "category": "managed"}); + + // The mask enables Admin, and is still refused: masked-ness is the + // claim's presence, never its value. + let masked_token = + server.make_masked_access_token(ALICE, Some("alice@example.com"), Some(vec!["Admin"])); + let response = server + .rest_client() + .post("/admin/create-data-plane", &body, Some(&masked_token)) + .send() + .await + .unwrap(); + let (status, text) = (response.status(), response.text().await.unwrap()); + assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{text}"); + insta::assert_snapshot!( + text, + @r###"{"error":"unmasked_token_required","message":"this operation requires a full-authority token, but the bearer token carries a capability mask","missing_capabilities":[]}"### + ); + + // An unmasked bearer passes extraction and lands on the handler's + // SQL authorization, which refuses alice: she is no `ops/` admin. + let unmasked_token = server.make_access_token(ALICE, Some("alice@example.com")); + let response = server + .rest_client() + .post("/admin/create-data-plane", &body, Some(&unmasked_token)) + .send() + .await + .unwrap(); + let (status, text) = (response.status(), response.text().await.unwrap()); + assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{text}"); + assert!( + text.contains("not an admin of the 'ops/' tenant"), + "the unmasked refusal is the handler's own gate: {text}" + ); + } +} diff --git a/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs b/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs index 420009cab6e..1d71c3128d0 100644 --- a/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs +++ b/crates/control-plane-api/src/server/public/graphql/refresh_tokens.rs @@ -117,6 +117,8 @@ pub struct RefreshTokensMutation; impl RefreshTokensMutation { /// Create a refresh token for the authenticated user. /// + /// Capability-masked callers are rejected: a refresh token exchanges for + /// a full-authority access token, which would escape the bearer's mask. /// Service-account callers are rejected: their API keys are administered /// via createApiKey and revokeApiKey. async fn create_refresh_token( @@ -133,6 +135,11 @@ impl RefreshTokensMutation { let env = ctx.data::()?; let claims = env.claims()?; + // The mask refusal precedes the service-account lookup: it's a pure + // function of the verified claims, so a masked caller is refused + // without a database round-trip, in the same order as the + // capability_token mint. + crate::Forbidden::require_unmasked(claims).map_err(crate::ApiError::Forbidden)?; super::service_accounts::verify_not_service_account(&env.pg_pool, claims.sub).await?; // ISO 8601 durations begin with 'P'; considering this cheap and good enough validation for now. @@ -483,4 +490,81 @@ mod test { .await; assert!(revoke_again["errors"].is_array()); } + + /// A masked bearer cannot mint a refresh credential: a refresh token + /// exchanges for a full-authority access token, which would escape the + /// mask. Revocation never widens authority, so it deliberately stays + /// open to masked bearers. + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../../../fixtures", scripts("data_planes", "alice")) + )] + async fn test_create_refresh_token_requires_unmasked(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + let server = test_server::TestServer::start( + pool.clone(), + test_server::snapshot(pool.clone(), true).await, + ) + .await; + + let alice = uuid::Uuid::from_bytes([0x11; 16]); + let unmasked_token = server.make_access_token(alice, Some("alice@example.com")); + // The mask enables Admin, and is still refused: masked-ness is the + // claim's presence, never its value. + let masked_token = + server.make_masked_access_token(alice, Some("alice@example.com"), Some(vec!["Admin"])); + + let refused: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { createRefreshToken(validFor: "P30D") { id } }"# + }), + Some(&masked_token), + ) + .await; + assert_eq!( + refused["errors"][0]["extensions"]["error"], "unmasked_token_required", + "a masked caller is refused with the structured code: {refused}" + ); + assert_eq!( + refused["errors"][0]["extensions"]["missing_capabilities"], + serde_json::json!([]), + "no re-mint can remedy this refusal: {refused}" + ); + + // An unmasked caller mints one... + let create: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation { createRefreshToken(validFor: "P30D") { id } }"# + }), + Some(&unmasked_token), + ) + .await; + assert!( + create["errors"].is_null(), + "create should succeed: {create}" + ); + let token_id = create["data"]["createRefreshToken"]["id"] + .as_str() + .unwrap() + .to_string(); + + // ...and a masked bearer may revoke it. + let revoke: serde_json::Value = server + .graphql( + &serde_json::json!({ + "query": r#"mutation($id: Id!) { revokeRefreshToken(id: $id) }"#, + "variables": { "id": token_id } + }), + Some(&masked_token), + ) + .await; + assert!( + revoke["errors"].is_null(), + "masked revocation should succeed: {revoke}" + ); + assert_eq!(revoke["data"]["revokeRefreshToken"], true); + } } diff --git a/crates/control-plane-api/src/server/public/token_exchange.rs b/crates/control-plane-api/src/server/public/token_exchange.rs index 238cf88eb37..dd969a308a7 100644 --- a/crates/control-plane-api/src/server/public/token_exchange.rs +++ b/crates/control-plane-api/src/server/public/token_exchange.rs @@ -91,11 +91,7 @@ async fn mint_capability_token( ) -> Result { let claims = envelope.claims()?; - if claims.capability_mask.is_some() { - return Err(crate::ApiError::Forbidden( - crate::Forbidden::unmasked_token_required(), - )); - } + crate::Forbidden::require_unmasked(claims).map_err(crate::ApiError::Forbidden)?; if crate::grants::is_service_account(&envelope.pg_pool, claims.sub).await? { return Err(crate::ApiError::Forbidden( crate::Forbidden::service_account_forbidden(), diff --git a/crates/control-plane-api/src/server/update_l2_reporting.rs b/crates/control-plane-api/src/server/update_l2_reporting.rs index ad0d5f132b0..85e37cee631 100644 --- a/crates/control-plane-api/src/server/update_l2_reporting.rs +++ b/crates/control-plane-api/src/server/update_l2_reporting.rs @@ -20,9 +20,9 @@ pub struct Response { } /// Authorization is SQL `internal.user_roles` rather than the snapshot walk, -/// so the bearer's capability mask has no effect — a masked bearer is -/// treated as unmasked — until this endpoint's authorization is refactored -/// onto the snapshot. See #3376. +/// so the capability ceiling cannot bind here; `RequireUnmasked` fail-closes +/// masked bearers instead, until the wider refactor retires this endpoint's +/// SQL authorization (#3376, decision 12). #[axum::debug_handler] #[tracing::instrument( skip(app), @@ -30,7 +30,7 @@ pub struct Response { )] pub async fn update_l2_reporting( axum::extract::State(app): axum::extract::State>, - crate::Authority { envelope: env, .. }: crate::Authority, + crate::Authority { envelope: env, .. }: crate::Authority, super::Request(Request { default_data_plane, dry_run, @@ -366,3 +366,62 @@ fn camel_case(name: &str, mut upper: bool) -> String { } w } + +#[cfg(test)] +mod test { + use crate::test_server; + + const ALICE: uuid::Uuid = uuid::Uuid::from_bytes([0x11; 16]); + + /// A masked bearer is refused at extraction with the structured `403`, + /// while an unmasked bearer reaches the handler's own `ops/` admin gate + /// unchanged. + #[sqlx::test( + migrations = "../../supabase/migrations", + fixtures(path = "../fixtures", scripts("data_planes", "alice")) + )] + async fn test_update_l2_reporting_requires_unmasked(pool: sqlx::PgPool) { + let _guard = test_server::init(); + + let server = test_server::TestServer::start( + pool.clone(), + test_server::snapshot(pool.clone(), true).await, + ) + .await; + + let body = serde_json::json!({"dryRun": true}); + + // The mask enables Admin, and is still refused: masked-ness is the + // claim's presence, never its value. + let masked_token = + server.make_masked_access_token(ALICE, Some("alice@example.com"), Some(vec!["Admin"])); + let response = server + .rest_client() + .post("/admin/update-l2-reporting", &body, Some(&masked_token)) + .send() + .await + .unwrap(); + let (status, text) = (response.status(), response.text().await.unwrap()); + assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{text}"); + insta::assert_snapshot!( + text, + @r###"{"error":"unmasked_token_required","message":"this operation requires a full-authority token, but the bearer token carries a capability mask","missing_capabilities":[]}"### + ); + + // An unmasked bearer passes extraction and lands on the handler's + // SQL authorization, which refuses alice: she is no `ops/` admin. + let unmasked_token = server.make_access_token(ALICE, Some("alice@example.com")); + let response = server + .rest_client() + .post("/admin/update-l2-reporting", &body, Some(&unmasked_token)) + .send() + .await + .unwrap(); + let (status, text) = (response.status(), response.text().await.unwrap()); + assert_eq!(status, reqwest::StatusCode::FORBIDDEN, "{text}"); + assert!( + text.contains("not an admin of the 'ops/' tenant"), + "the unmasked refusal is the handler's own gate: {text}" + ); + } +} diff --git a/crates/flow-client/control-plane-api.graphql b/crates/flow-client/control-plane-api.graphql index 9e237f659ca..7a289da92f0 100644 --- a/crates/flow-client/control-plane-api.graphql +++ b/crates/flow-client/control-plane-api.graphql @@ -1155,6 +1155,12 @@ type LiveSpecRef { name, and passing a name that the user cannot access. In either case, the result would be `userCapability: null`, and all other fields on the LiveSpecRef would also be null. + + Access is decided by the user's effective capability bits under the + bearer's mask, and a non-null value is the literal legacy `capability` + column of the covering grant(s): informational compatibility metadata, + reported as `none` when access comes entirely from the `bundles` + column. """ userCapability: Capability """ @@ -1373,6 +1379,8 @@ type MutationRoot { """ Create a refresh token for the authenticated user. + Capability-masked callers are rejected: a refresh token exchanges for + a full-authority access token, which would escape the bearer's mask. Service-account callers are rejected: their API keys are administered via createApiKey and revokeApiKey. """ From 062c7841904395d93e0a8f033af45aa353f38d85 Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Thu, 27 Aug 2026 17:05:09 +0000 Subject: [PATCH 2/3] control-plane-api: audit inventory distinguishes gate kinds precisely revokeApiKey authorizes RevokeApiKey through the mask-filtered grant walk; it is not an identity-gated revocation, and grouping it with revokeRefreshToken misstated why it needs no unmasked guard. --- crates/control-plane-api/src/authority.rs | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/control-plane-api/src/authority.rs b/crates/control-plane-api/src/authority.rs index cb51f6774ee..3c4b5b4cc63 100644 --- a/crates/control-plane-api/src/authority.rs +++ b/crates/control-plane-api/src/authority.rs @@ -70,17 +70,17 @@ impl Requirement for NoRequirement { /// - `/admin/create-data-plane` and `/admin/update-l2-reporting`: their /// SQL authorization cannot bind the mask, so they fail closed instead. /// -/// Every other identity-gated operation deliberately stays open to masked -/// bearers: revocations (`revokeRefreshToken`, `revokeApiKey`, and kin) -/// never widen the bearer's authority; credential-adjacent operations like -/// `createApiKey` and `createServiceAccount` authorize through the grant -/// walk, which the mask already filters; and invite redemption widens the -/// *user's* grants while the bearer still exercises them only through its -/// mask. SQL functions reachable through PostgREST -/// (`public.create_refresh_token`, `public.gateway_auth_token`) are outside -/// this crate's enforcement entirely — they are part of the documented -/// PostgREST mask bypass whose resolution is the #2877 migration, tracked -/// under #3376. +/// Everything else needs no unmasked guard. Identity-gated revocation +/// (`revokeRefreshToken`) deliberately stays open to masked bearers, +/// because revocation never widens the bearer's authority. +/// Capability-gated operations — `createApiKey`, `createServiceAccount`, +/// `revokeApiKey`, and kin — authorize through the grant walk, which the +/// mask already filters. Invite redemption widens the *user's* grants +/// while the bearer still exercises them only through its mask. SQL +/// functions reachable through PostgREST (`public.create_refresh_token`, +/// `public.gateway_auth_token`) are outside this crate's enforcement +/// entirely — they are part of the documented PostgREST mask bypass whose +/// resolution is the #2877 migration, tracked under #3376. pub struct RequireUnmasked; impl Requirement for RequireUnmasked { From af9640907213bbf3dac0eb801ba74d5521193e8a Mon Sep 17 00:00:00 2001 From: Brian Bartman Date: Fri, 28 Aug 2026 11:41:28 +0000 Subject: [PATCH 3/3] control-plane-api, agent: capability-token validity is configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validity window of capability_token mints is an App field fed by the agent's --capability-token-validity / CAPABILITY_TOKEN_VALIDITY setting (humantime syntax), following the pattern of the controller duration settings. Its 1h default matches the SQL generate_access_token mint, so an unconfigured deployment mints exactly the window it always has; an override diverges from the SQL mint's fixed hour deliberately, and a longer window widens the exposure of a leaked masked token — the flag docs say so. A unit test pins a non-default window at the signing seam; the HTTP test exercises the harness-configured default. --- crates/agent/src/integration_tests/harness.rs | 2 + crates/agent/src/main.rs | 12 ++++ crates/control-plane-api/src/server/mod.rs | 7 +++ .../src/server/public/token_exchange.rs | 63 ++++++++++++++----- crates/control-plane-api/src/test_server.rs | 2 + 5 files changed, 69 insertions(+), 17 deletions(-) diff --git a/crates/agent/src/integration_tests/harness.rs b/crates/agent/src/integration_tests/harness.rs index fb6c08abae0..dfef6feae8a 100644 --- a/crates/agent/src/integration_tests/harness.rs +++ b/crates/agent/src/integration_tests/harness.rs @@ -1790,6 +1790,8 @@ impl TestHarness { self.publisher.clone(), snapshot_watch.clone(), None, + // The production default of the --capability-token-validity setting. + std::time::Duration::from_secs(3600), )); self.control_plane_app = Some(app); diff --git a/crates/agent/src/main.rs b/crates/agent/src/main.rs index 1c6a6b4fda2..e51eaaa9f19 100644 --- a/crates/agent/src/main.rs +++ b/crates/agent/src/main.rs @@ -95,6 +95,17 @@ struct Args { )] #[arg(value_parser = humantime::parse_duration)] heartbeat_timeout: std::time::Duration, + /// Validity window of access tokens minted by the `capability_token` + /// grant. The default matches the one-hour access tokens of the SQL + /// `generate_access_token` mint; raising it widens the exposure of a + /// leaked masked token, so overrides warrant care. + #[clap( + long = "capability-token-validity", + env = "CAPABILITY_TOKEN_VALIDITY", + default_value = "1h" + )] + #[arg(value_parser = humantime::parse_duration)] + capability_token_validity: std::time::Duration, #[clap(long = "log-format", env = "LOG_FORMAT", default_value = "json")] log_format: LogFormat, @@ -392,6 +403,7 @@ async fn async_main(args: Args) -> Result<(), anyhow::Error> { publisher.clone(), snapshot_watch.clone(), args.stripe_webhook_secret, + args.capability_token_validity, )); let api_router = control_plane_api::build_router( api_app.clone(), diff --git a/crates/control-plane-api/src/server/mod.rs b/crates/control-plane-api/src/server/mod.rs index 4f3d40c5cd6..f5412315427 100644 --- a/crates/control-plane-api/src/server/mod.rs +++ b/crates/control-plane-api/src/server/mod.rs @@ -35,6 +35,11 @@ pub enum Rejection { pub struct App { pub _id_generator: std::sync::Mutex, pub billing_provider: Option>, + /// Validity window of access tokens minted by the `capability_token` + /// grant. The agent binary configures this via + /// `--capability-token-validity` / `CAPABILITY_TOKEN_VALIDITY`, whose + /// default of one hour matches the SQL `generate_access_token` mint. + pub capability_token_validity: std::time::Duration, pub control_plane_jwt_decode_keys: Vec, pub control_plane_jwt_encode_key: tokens::jwt::EncodingKey, pub pg_pool: sqlx::PgPool, @@ -55,10 +60,12 @@ impl App { publisher: crate::publications::Publisher, snapshot: Arc>, stripe_webhook_secret: Option, + capability_token_validity: std::time::Duration, ) -> Self { Self { _id_generator: std::sync::Mutex::new(id_generator), billing_provider, + capability_token_validity, control_plane_jwt_decode_keys: vec![tokens::jwt::DecodingKey::from_secret(jwt_secret)], control_plane_jwt_encode_key: tokens::jwt::EncodingKey::from_secret(jwt_secret), pg_pool, diff --git a/crates/control-plane-api/src/server/public/token_exchange.rs b/crates/control-plane-api/src/server/public/token_exchange.rs index dd969a308a7..1aa3a3c6790 100644 --- a/crates/control-plane-api/src/server/public/token_exchange.rs +++ b/crates/control-plane-api/src/server/public/token_exchange.rs @@ -1,9 +1,5 @@ use std::sync::Arc; -/// Validity of a minted capability token, matching the one-hour access -/// tokens of the SQL `generate_access_token` mint. -const CAPABILITY_TOKEN_VALIDITY_SECONDS: u64 = 3600; - #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[serde(tag = "grant_type")] pub enum TokenRequest { @@ -61,12 +57,7 @@ pub async fn handle_post_token( Ok(axum::Json(response)) } TokenRequest::CapabilityToken { capability_mask } => { - let response = mint_capability_token( - &envelope, - capability_mask, - &app.control_plane_jwt_encode_key, - ) - .await?; + let response = mint_capability_token(&envelope, capability_mask, &app).await?; Ok(axum::Json(response)) } } @@ -87,7 +78,7 @@ pub async fn handle_post_token( async fn mint_capability_token( envelope: &crate::Envelope, capability_mask: Vec, - encoding_key: &tokens::jwt::EncodingKey, + app: &crate::App, ) -> Result { let claims = envelope.claims()?; @@ -98,15 +89,20 @@ async fn mint_capability_token( )); } - let access_token = sign_capability_token(claims, capability_mask, encoding_key)?; + let access_token = sign_capability_token( + claims, + capability_mask, + app.capability_token_validity, + &app.control_plane_jwt_encode_key, + )?; Ok(TokenResponse { access_token, refresh_token: None, }) } -/// Sign a one-hour access token which copies the caller's verified identity -/// claims and carries `capability_mask` verbatim. +/// Sign an access token, valid for `validity`, which copies the caller's +/// verified identity claims and carries `capability_mask` verbatim. /// /// The identity claims (`sub`, `role`, `aud`, and `email` when the caller's /// token has it) are a pure copy-through, so the minted token is a fully @@ -125,6 +121,7 @@ async fn mint_capability_token( fn sign_capability_token( caller: &models::authorizations::ControlClaims, capability_mask: Vec, + validity: std::time::Duration, encoding_key: &tokens::jwt::EncodingKey, ) -> tonic::Result { let iat = tokens::now().timestamp() as u64; @@ -132,7 +129,7 @@ fn sign_capability_token( let claims = models::authorizations::ControlClaims { aud: caller.aud.clone(), iat, - exp: iat + CAPABILITY_TOKEN_VALIDITY_SECONDS, + exp: iat + validity.as_secs(), sub: caller.sub, role: caller.role.clone(), email: caller.email.clone(), @@ -242,6 +239,37 @@ mod test { (unverified.claims().clone(), access_token) } + /// The configured validity drives the minted expiry: `exp` sits exactly + /// `--capability-token-validity` past `iat`, whatever that setting is. + /// The HTTP test below exercises only the harness-configured one-hour + /// default, so a non-default window is pinned here at the signing seam. + #[test] + fn test_validity_is_configurable() { + let caller = models::authorizations::ControlClaims { + aud: "authenticated".to_string(), + iat: 0, + exp: 0, + sub: ALICE, + role: "authenticated".to_string(), + email: None, + capability_mask: None, + }; + let token = super::sign_capability_token( + &caller, + vec!["CatalogRead".to_string()], + std::time::Duration::from_secs(90), + &tokens::jwt::EncodingKey::from_secret(b"unit-test-secret"), + ) + .unwrap(); + + let unverified = tokens::jwt::parse_unverified::( + token.as_bytes(), + ) + .unwrap(); + let claims = unverified.claims(); + assert_eq!(claims.exp, claims.iat + 90); + } + /// Covers the capability_token grant end-to-end: the copy-through claim /// set of a successful mint (with a scoped-role, email-less bearer /// distinguishing copy-through from hardcoded values), verbatim mask @@ -284,8 +312,9 @@ mod test { assert_eq!(status, reqwest::StatusCode::OK, "mint failed: {body}"); let (claims, minted_token) = claims_of(&body); - // The validity window matches the SQL mint's one hour and `iat` is - // fresh; both are volatile, so the snapshot below redacts them. + // The validity window is the harness-configured hour — the + // production default, matching the SQL mint — and `iat` is fresh; + // both are volatile, so the snapshot below redacts them. assert_eq!(claims.exp, claims.iat + 3600); let now = tokens::now().timestamp() as u64; assert!(now - claims.iat < 60, "iat {} is fresh", claims.iat); diff --git a/crates/control-plane-api/src/test_server.rs b/crates/control-plane-api/src/test_server.rs index f6cffe0c2be..c120b3088c9 100644 --- a/crates/control-plane-api/src/test_server.rs +++ b/crates/control-plane-api/src/test_server.rs @@ -108,6 +108,8 @@ pub fn build_app( publisher, snapshot, Some(crate::server::public::stripe_webhooks::tests::DEV_WEBHOOK_SECRET.to_string()), + // The production default of the --capability-token-validity setting. + std::time::Duration::from_secs(3600), )) }