From 3afac078d446d2ca1d0ddc8ee06fc646130c8621 Mon Sep 17 00:00:00 2001 From: Grant Date: Tue, 16 Jun 2026 21:16:20 -0500 Subject: [PATCH] =?UTF-8?q?Add=20sandbox=20flag=20+=20per-key=20=C3=A0-la-?= =?UTF-8?q?carte=20scopes=20(payment-connect=20foundation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for agent-delegable payment-provider connect (plans/agent-payment-connect-scope.md, slices 1-2 of 5). Not yet wired to any connect endpoint — the gate (require_provider_connect + BTCPay non-mainnet network check) is a follow-up. - Config.sandbox_mode from KEYSAT_SANDBOX_MODE (daemon-level, never settable via any API); surfaced read-only in /v1/admin/tier as "sandbox". - Migration 0024: additive scoped_api_keys.extra_scopes column (JSON array). - Per-key à-la-carte scopes: require_scope grants via role OR a key's extra_scopes; GRANTABLE_EXTRA_SCOPES allowlist (payment_providers:write only), validated on create and echoed in create/list responses. - payment_providers:write is in NO role: grants() carves the à-la-carte set out of full-admin's wildcard, so even a scoped full-admin key can't reach it through its role — only a per-key grant does. extra_scopes parsing fails closed (NULL/malformed -> no grant). - Tests: invariant (no role grants the à-la-carte set), fail-closed parsing, create/list round-trip, reject ungrantable scope. Suite green: lib 13, api 59. --- .../0024_scoped_key_extra_scopes.sql | 13 ++ licensing-service/src/api/api_keys.rs | 170 +++++++++++++++--- licensing-service/src/api/tier.rs | 4 + licensing-service/src/config.rs | 14 ++ licensing-service/tests/api.rs | 65 +++++++ licensing-service/tests/subscriptions.rs | 1 + licensing-service/tests/upgrades.rs | 1 + licensing-service/tests/worker.rs | 1 + 8 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 licensing-service/migrations/0024_scoped_key_extra_scopes.sql diff --git a/licensing-service/migrations/0024_scoped_key_extra_scopes.sql b/licensing-service/migrations/0024_scoped_key_extra_scopes.sql new file mode 100644 index 0000000..f078304 --- /dev/null +++ b/licensing-service/migrations/0024_scoped_key_extra_scopes.sql @@ -0,0 +1,13 @@ +-- Migration 0024: per-key à-la-carte scopes on scoped API keys. +-- +-- Roles (read-only | license-issuer | support | merchant-onboard | full-admin) +-- expand to a fixed scope set. Some capabilities are too sensitive to bake into +-- any role but still need to be grantable to a SPECIFIC key. The first is +-- `payment_providers:write` — agent-delegated payment-provider connect, itself +-- gated further by the daemon sandbox flag + a non-mainnet network check (see +-- plans/agent-payment-connect-scope.md). +-- +-- `extra_scopes` holds a JSON array of additional scope strings granted to THIS +-- key on top of its role. NULL / absent = role scopes only (every existing key), +-- so this is a pure additive column — no table rebuild. +ALTER TABLE scoped_api_keys ADD COLUMN extra_scopes TEXT; diff --git a/licensing-service/src/api/api_keys.rs b/licensing-service/src/api/api_keys.rs index ef7f7f8..6eefd50 100644 --- a/licensing-service/src/api/api_keys.rs +++ b/licensing-service/src/api/api_keys.rs @@ -102,7 +102,13 @@ impl Role { /// `:`, e.g. `licenses:write`. pub fn grants(self, scope: &str) -> bool { match self { - Role::FullAdmin => true, + // Every scope EXCEPT the à-la-carte-only ones (e.g. + // `payment_providers:write`). Those are never role-grantable — only + // a per-key `extra_scopes` entry grants them — so even a full-admin + // *scoped* key can't reach payment-connect through its role. (The + // master key still passes `require_scope` ahead of this, via the + // early constant-time compare, and may do anything.) + Role::FullAdmin => !GRANTABLE_EXTRA_SCOPES.contains(&scope), Role::ReadOnly => scope.ends_with(":read"), Role::LicenseIssuer => { scope.ends_with(":read") @@ -133,6 +139,22 @@ impl Role { } } +/// Scopes an operator may grant à-la-carte on a key (on top of its role), via +/// the `scopes` field on create. Deliberately tiny: only sensitive +/// capabilities that don't belong in any role. `payment_providers:write` is the +/// first — it is further gated at the endpoint (daemon sandbox mode + a +/// non-mainnet network check). See `plans/agent-payment-connect-scope.md`. +pub const GRANTABLE_EXTRA_SCOPES: &[&str] = &["payment_providers:write"]; + +/// Parse a key's `extra_scopes` JSON array and test membership. Tolerant of +/// NULL / malformed JSON (treated as "no extra scopes") so a bad row can never +/// widen access — it only ever fails closed. +fn extra_scopes_contains(json: Option<&str>, scope: &str) -> bool { + json.and_then(|s| serde_json::from_str::>(s).ok()) + .map(|v| v.iter().any(|s| s == scope)) + .unwrap_or(false) +} + /// Verify the request carries a credential that grants the named scope. /// Order of acceptance: /// 1. Master `admin_api_key` — always passes. @@ -171,14 +193,14 @@ pub async fn require_scope( hasher.update(token.as_bytes()); let token_hash = hex::encode(hasher.finalize()); - let row: Option<(String, String, Option)> = sqlx::query_as( - "SELECT id, role, revoked_at FROM scoped_api_keys WHERE token_hash = ?", + let row: Option<(String, String, Option, Option)> = sqlx::query_as( + "SELECT id, role, revoked_at, extra_scopes FROM scoped_api_keys WHERE token_hash = ?", ) .bind(&token_hash) .fetch_optional(&state.db) .await?; - let (key_id, role_str, revoked_at) = match row { + let (key_id, role_str, revoked_at, extra_scopes_json) = match row { Some(r) => r, None => return Err(AppError::Forbidden), }; @@ -186,7 +208,11 @@ pub async fn require_scope( return Err(AppError::Forbidden); } let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?; - if !role.grants(scope) { + // A key grants a scope via its role OR via an à-la-carte `extra_scopes` + // entry (e.g. `payment_providers:write`, which is in no role). + let granted = + role.grants(scope) || extra_scopes_contains(extra_scopes_json.as_deref(), scope); + if !granted { return Err(AppError::Forbidden); } @@ -207,6 +233,10 @@ pub async fn require_scope( pub struct CreateApiKeyReq { pub label: String, pub role: String, + /// Optional à-la-carte scopes granted on top of the role. Each must be in + /// `GRANTABLE_EXTRA_SCOPES`. Omitted / empty = role scopes only. + #[serde(default)] + pub scopes: Vec, } #[derive(Debug, Serialize)] @@ -214,6 +244,8 @@ pub struct CreateApiKeyResp { pub id: String, pub label: String, pub role: String, + /// À-la-carte scopes granted on top of the role (echoed back). + pub scopes: Vec, pub created_at: String, /// The raw token. Returned ONCE on create and never again — operator /// must copy it now or generate a new key. @@ -242,6 +274,31 @@ pub async fn create( ) })?; + // Validate à-la-carte extra scopes (granted on top of the role). Only the + // capabilities in GRANTABLE_EXTRA_SCOPES may be granted this way; anything + // else is rejected so a typo can't silently grant nothing (or something). + let mut extra_scopes: Vec = req + .scopes + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + extra_scopes.sort(); + extra_scopes.dedup(); + for s in &extra_scopes { + if !GRANTABLE_EXTRA_SCOPES.contains(&s.as_str()) { + return Err(AppError::BadRequest(format!( + "scope '{s}' is not grantable on a key; allowed à-la-carte scopes: {}", + GRANTABLE_EXTRA_SCOPES.join(", ") + ))); + } + } + let extra_scopes_json = if extra_scopes.is_empty() { + None + } else { + Some(serde_json::to_string(&extra_scopes).expect("Vec serializes")) + }; + // 32 bytes of secure random, base64-url-encoded (no padding) → 43 chars. // Prefix `ks_` so it's recognizable in logs as a Keysat-style token. use rand::RngCore; @@ -259,14 +316,15 @@ pub async fn create( let id = Uuid::new_v4().to_string(); let now = Utc::now().to_rfc3339(); sqlx::query( - "INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at) - VALUES (?, ?, ?, ?, ?)", + "INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at, extra_scopes) + VALUES (?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(label) .bind(&token_hash) .bind(role.as_str()) .bind(&now) + .bind(&extra_scopes_json) .execute(&state.db) .await?; @@ -279,7 +337,7 @@ pub async fn create( Some(&id), ip.as_deref(), ua.as_deref(), - &json!({ "label": label, "role": role.as_str() }), + &json!({ "label": label, "role": role.as_str(), "scopes": extra_scopes.clone() }), ) .await; @@ -287,6 +345,7 @@ pub async fn create( id, label: label.to_string(), role: role.as_str().to_string(), + scopes: extra_scopes, created_at: now, token, })) @@ -297,6 +356,8 @@ pub struct ApiKeyListEntry { pub id: String, pub label: String, pub role: String, + /// À-la-carte scopes granted on top of the role (empty for most keys). + pub scopes: Vec, pub created_at: String, pub last_used_at: Option, pub revoked_at: Option, @@ -309,23 +370,35 @@ pub async fn list( headers: HeaderMap, ) -> AppResult> { require_admin(&state, &headers)?; - let rows: Vec<(String, String, String, String, Option, Option)> = - sqlx::query_as( - "SELECT id, label, role, created_at, last_used_at, revoked_at + let rows: Vec<( + String, + String, + String, + Option, + String, + Option, + Option, + )> = sqlx::query_as( + "SELECT id, label, role, extra_scopes, created_at, last_used_at, revoked_at FROM scoped_api_keys ORDER BY created_at DESC", - ) - .fetch_all(&state.db) - .await?; + ) + .fetch_all(&state.db) + .await?; let out: Vec = rows .into_iter() - .map(|(id, label, role, created_at, last_used_at, revoked_at)| ApiKeyListEntry { - id, - label, - role, - created_at, - last_used_at, - revoked_at, - }) + .map( + |(id, label, role, extra_scopes, created_at, last_used_at, revoked_at)| ApiKeyListEntry { + id, + label, + role, + scopes: extra_scopes + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default(), + created_at, + last_used_at, + revoked_at, + }, + ) .collect(); Ok(Json(json!({ "api_keys": out }))) } @@ -374,3 +447,56 @@ pub async fn revoke( .await; Ok(Json(json!({ "ok": true, "revoked_at": now }))) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The invariant: à-la-carte-only scopes (e.g. `payment_providers:write`) + /// are NEVER grantable by any role — not even `full-admin`. Only a per-key + /// `extra_scopes` entry grants them. Guards the P1 regression where + /// `FullAdmin => true` would let a scoped full-admin key reach + /// payment-connect through its role. + #[test] + fn no_role_grants_alacarte_only_scopes() { + let roles = [ + Role::ReadOnly, + Role::LicenseIssuer, + Role::Support, + Role::MerchantOnboard, + Role::FullAdmin, + ]; + for role in roles { + for scope in GRANTABLE_EXTRA_SCOPES { + assert!( + !role.grants(scope), + "role {} must NOT grant à-la-carte-only scope {scope}", + role.as_str() + ); + } + } + } + + /// Full-admin still grants every *role* scope — the fix only carves out the + /// à-la-carte-only set, nothing else. + #[test] + fn full_admin_still_grants_ordinary_scopes() { + assert!(Role::FullAdmin.grants("products:write")); + assert!(Role::FullAdmin.grants("policies:write")); + assert!(Role::FullAdmin.grants("settings:read")); + assert!(Role::FullAdmin.grants("payment_providers:read")); + } + + /// `extra_scopes` parsing fails closed: NULL / malformed / wrong-shape JSON + /// grants nothing and never errors open. + #[test] + fn extra_scopes_contains_fails_closed() { + let json = r#"["payment_providers:write"]"#; + assert!(extra_scopes_contains(Some(json), "payment_providers:write")); + assert!(!extra_scopes_contains(Some(json), "products:write")); + assert!(!extra_scopes_contains(None, "payment_providers:write")); // NULL + assert!(!extra_scopes_contains(Some("not json"), "payment_providers:write")); // malformed + assert!(!extra_scopes_contains(Some("{}"), "payment_providers:write")); // wrong shape + assert!(!extra_scopes_contains(Some("[]"), "payment_providers:write")); // empty + } +} diff --git a/licensing-service/src/api/tier.rs b/licensing-service/src/api/tier.rs index b09c47e..fb281e0 100644 --- a/licensing-service/src/api/tier.rs +++ b/licensing-service/src/api/tier.rs @@ -162,6 +162,10 @@ pub async fn admin_status( Ok(axum::Json(serde_json::json!({ "tier": tier.label, "tier_name": tier.display_name, + // Daemon-level sandbox flag (env KEYSAT_SANDBOX_MODE, read-only here — + // never settable via any API). The admin SPA renders a "SANDBOX" + // banner on it; it also gates scoped payment-provider connect. + "sandbox": state.config.sandbox_mode, "entitlements": tier.entitlements, "usage": { "products": product_count, diff --git a/licensing-service/src/config.rs b/licensing-service/src/config.rs index e5a1d97..5b35aff 100644 --- a/licensing-service/src/config.rs +++ b/licensing-service/src/config.rs @@ -61,6 +61,16 @@ pub struct Config { /// Optional human-readable operator name shown in `/` index responses. pub operator_name: Option, + + /// When true, this daemon is a disposable dev / sandbox instance. It is + /// the OUTER gate for agent-delegated payment-provider connect: only on a + /// sandbox daemon may a scoped `payment_providers:write` key connect a + /// provider (and then only a non-mainnet one — see the network gate). On a + /// production daemon (false) scoped payment-connect is refused outright, so + /// a scoped key can never disrupt a live store's payments. Daemon-level + /// only (env `KEYSAT_SANDBOX_MODE`) and **never settable via any API** — + /// otherwise a scoped key could flip it on, then connect. + pub sandbox_mode: bool, } impl Config { @@ -102,6 +112,9 @@ impl Config { let btcpay_webhook_secret = optional_nonempty("BTCPAY_WEBHOOK_SECRET"); let public_base_url = required_with_fallback("KEYSAT_PUBLIC_URL", "LICENSING_PUBLIC_URL")?; let operator_name = env_with_fallback("KEYSAT_OPERATOR_NAME", "LICENSING_OPERATOR_NAME"); + let sandbox_mode = optional_nonempty("KEYSAT_SANDBOX_MODE") + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) + .unwrap_or(false); Ok(Self { bind, @@ -115,6 +128,7 @@ impl Config { btcpay_webhook_secret, public_base_url: public_base_url.trim_end_matches('/').to_string(), operator_name, + sandbox_mode, }) } } diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index 3da1bf0..b4a9afa 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -103,6 +103,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) { btcpay_webhook_secret: None, public_base_url: "http://keysat.test".to_string(), operator_name: Some("Test Operator".into()), + sandbox_mode: false, }; let state = AppState { @@ -3542,6 +3543,70 @@ async fn scoped_merchant_onboard_key_onboards_but_not_master() { ); } +/// À-la-carte `payment_providers:write` can be granted on a key via the `scopes` +/// field (it's in no role), and round-trips through create + list. This is the +/// per-key grant mechanism the agent-payment-connect gate (slices 3+) builds on. +#[tokio::test] +async fn scoped_key_extra_scopes_round_trip() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + + // Create a key with the à-la-carte scope on top of a read-only role. + let req = build_request( + "POST", + "/v1/admin/api-keys", + &[("authorization", &auth)], + Some(json!({ + "label": "Sandbox connect bot", + "role": "merchant-onboard", + "scopes": ["payment_providers:write"] + })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + let body = body_json(resp).await; + let scopes = body["scopes"].as_array().expect("scopes echoed on create"); + assert!( + scopes.iter().any(|s| s == "payment_providers:write"), + "create echoes the granted à-la-carte scope; got {scopes:?}" + ); + let key_id = body["id"].as_str().unwrap().to_string(); + + // List shows the same scope on the key entry. + let req = build_request("GET", "/v1/admin/api-keys", &[("authorization", &auth)], None); + let body = body_json(send(&state, req).await).await; + let entry = body["api_keys"] + .as_array() + .unwrap() + .iter() + .find(|k| k["id"] == key_id.as_str()) + .expect("created key appears in list"); + let scopes = entry["scopes"].as_array().expect("list entry carries scopes"); + assert!( + scopes.iter().any(|s| s == "payment_providers:write"), + "list echoes the granted à-la-carte scope; got {scopes:?}" + ); +} + +/// Create rejects any scope that isn't in the à-la-carte allowlist — a typo'd +/// or arbitrary scope string is a 400, never silently granted or dropped. +#[tokio::test] +async fn scoped_key_create_rejects_ungrantable_scope() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + let req = build_request( + "POST", + "/v1/admin/api-keys", + &[("authorization", &auth)], + Some(json!({ + "label": "Overreach", + "role": "read-only", + "scopes": ["billing:nuke"] + })), + ); + assert_eq!(send(&state, req).await.status(), StatusCode::BAD_REQUEST); +} + /// Zaprite Connect refuses on Creator-tier (no `zaprite_payments` /// entitlement) with 402. Switching the daemon's self-tier to a /// Pro-flavored Licensed tier lets the Connect-precheck pass (it then diff --git a/licensing-service/tests/subscriptions.rs b/licensing-service/tests/subscriptions.rs index 55e94cf..3d0b027 100644 --- a/licensing-service/tests/subscriptions.rs +++ b/licensing-service/tests/subscriptions.rs @@ -77,6 +77,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc) { btcpay_webhook_secret: None, public_base_url: "http://keysat.test".to_string(), operator_name: None, + sandbox_mode: false, }; let mock = Arc::new(MockProvider::new()); let state = AppState { diff --git a/licensing-service/tests/upgrades.rs b/licensing-service/tests/upgrades.rs index 1224eb5..53a9e67 100644 --- a/licensing-service/tests/upgrades.rs +++ b/licensing-service/tests/upgrades.rs @@ -61,6 +61,7 @@ async fn make_state() -> (AppState, NamedTempFile) { btcpay_webhook_secret: None, public_base_url: "http://keysat.test".to_string(), operator_name: None, + sandbox_mode: false, }; let state = AppState { db: pool, diff --git a/licensing-service/tests/worker.rs b/licensing-service/tests/worker.rs index 328b09c..c005287 100644 --- a/licensing-service/tests/worker.rs +++ b/licensing-service/tests/worker.rs @@ -60,6 +60,7 @@ async fn make_state() -> (AppState, NamedTempFile) { btcpay_webhook_secret: None, public_base_url: "http://keysat.test".to_string(), operator_name: None, + sandbox_mode: false, }; let state = AppState { db: pool,