Add sandbox flag + per-key à-la-carte scopes (payment-connect foundation)

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.
This commit is contained in:
Grant
2026-06-16 21:16:20 -05:00
parent 069cf1eb40
commit 3afac078d4
8 changed files with 247 additions and 22 deletions
@@ -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;
+139 -13
View File
@@ -102,7 +102,13 @@ impl Role {
/// `<resource>:<read|write>`, e.g. `licenses:write`. /// `<resource>:<read|write>`, e.g. `licenses:write`.
pub fn grants(self, scope: &str) -> bool { pub fn grants(self, scope: &str) -> bool {
match self { 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::ReadOnly => scope.ends_with(":read"),
Role::LicenseIssuer => { Role::LicenseIssuer => {
scope.ends_with(":read") 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::<Vec<String>>(s).ok())
.map(|v| v.iter().any(|s| s == scope))
.unwrap_or(false)
}
/// Verify the request carries a credential that grants the named scope. /// Verify the request carries a credential that grants the named scope.
/// Order of acceptance: /// Order of acceptance:
/// 1. Master `admin_api_key` — always passes. /// 1. Master `admin_api_key` — always passes.
@@ -171,14 +193,14 @@ pub async fn require_scope(
hasher.update(token.as_bytes()); hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize()); let token_hash = hex::encode(hasher.finalize());
let row: Option<(String, String, Option<String>)> = sqlx::query_as( let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT id, role, revoked_at FROM scoped_api_keys WHERE token_hash = ?", "SELECT id, role, revoked_at, extra_scopes FROM scoped_api_keys WHERE token_hash = ?",
) )
.bind(&token_hash) .bind(&token_hash)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await?; .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, Some(r) => r,
None => return Err(AppError::Forbidden), None => return Err(AppError::Forbidden),
}; };
@@ -186,7 +208,11 @@ pub async fn require_scope(
return Err(AppError::Forbidden); return Err(AppError::Forbidden);
} }
let role = Role::parse(&role_str).ok_or(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); return Err(AppError::Forbidden);
} }
@@ -207,6 +233,10 @@ pub async fn require_scope(
pub struct CreateApiKeyReq { pub struct CreateApiKeyReq {
pub label: String, pub label: String,
pub role: 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<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -214,6 +244,8 @@ pub struct CreateApiKeyResp {
pub id: String, pub id: String,
pub label: String, pub label: String,
pub role: String, pub role: String,
/// À-la-carte scopes granted on top of the role (echoed back).
pub scopes: Vec<String>,
pub created_at: String, pub created_at: String,
/// The raw token. Returned ONCE on create and never again — operator /// The raw token. Returned ONCE on create and never again — operator
/// must copy it now or generate a new key. /// 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<String> = 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<String> serializes"))
};
// 32 bytes of secure random, base64-url-encoded (no padding) → 43 chars. // 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. // Prefix `ks_` so it's recognizable in logs as a Keysat-style token.
use rand::RngCore; use rand::RngCore;
@@ -259,14 +316,15 @@ pub async fn create(
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
sqlx::query( sqlx::query(
"INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at) "INSERT INTO scoped_api_keys (id, label, token_hash, role, created_at, extra_scopes)
VALUES (?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?)",
) )
.bind(&id) .bind(&id)
.bind(label) .bind(label)
.bind(&token_hash) .bind(&token_hash)
.bind(role.as_str()) .bind(role.as_str())
.bind(&now) .bind(&now)
.bind(&extra_scopes_json)
.execute(&state.db) .execute(&state.db)
.await?; .await?;
@@ -279,7 +337,7 @@ pub async fn create(
Some(&id), Some(&id),
ip.as_deref(), ip.as_deref(),
ua.as_deref(), ua.as_deref(),
&json!({ "label": label, "role": role.as_str() }), &json!({ "label": label, "role": role.as_str(), "scopes": extra_scopes.clone() }),
) )
.await; .await;
@@ -287,6 +345,7 @@ pub async fn create(
id, id,
label: label.to_string(), label: label.to_string(),
role: role.as_str().to_string(), role: role.as_str().to_string(),
scopes: extra_scopes,
created_at: now, created_at: now,
token, token,
})) }))
@@ -297,6 +356,8 @@ pub struct ApiKeyListEntry {
pub id: String, pub id: String,
pub label: String, pub label: String,
pub role: String, pub role: String,
/// À-la-carte scopes granted on top of the role (empty for most keys).
pub scopes: Vec<String>,
pub created_at: String, pub created_at: String,
pub last_used_at: Option<String>, pub last_used_at: Option<String>,
pub revoked_at: Option<String>, pub revoked_at: Option<String>,
@@ -309,23 +370,35 @@ pub async fn list(
headers: HeaderMap, headers: HeaderMap,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?; require_admin(&state, &headers)?;
let rows: Vec<(String, String, String, String, Option<String>, Option<String>)> = let rows: Vec<(
sqlx::query_as( String,
"SELECT id, label, role, created_at, last_used_at, revoked_at String,
String,
Option<String>,
String,
Option<String>,
Option<String>,
)> = 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", FROM scoped_api_keys ORDER BY created_at DESC",
) )
.fetch_all(&state.db) .fetch_all(&state.db)
.await?; .await?;
let out: Vec<ApiKeyListEntry> = rows let out: Vec<ApiKeyListEntry> = rows
.into_iter() .into_iter()
.map(|(id, label, role, created_at, last_used_at, revoked_at)| ApiKeyListEntry { .map(
|(id, label, role, extra_scopes, created_at, last_used_at, revoked_at)| ApiKeyListEntry {
id, id,
label, label,
role, role,
scopes: extra_scopes
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.unwrap_or_default(),
created_at, created_at,
last_used_at, last_used_at,
revoked_at, revoked_at,
}) },
)
.collect(); .collect();
Ok(Json(json!({ "api_keys": out }))) Ok(Json(json!({ "api_keys": out })))
} }
@@ -374,3 +447,56 @@ pub async fn revoke(
.await; .await;
Ok(Json(json!({ "ok": true, "revoked_at": now }))) 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
}
}
+4
View File
@@ -162,6 +162,10 @@ pub async fn admin_status(
Ok(axum::Json(serde_json::json!({ Ok(axum::Json(serde_json::json!({
"tier": tier.label, "tier": tier.label,
"tier_name": tier.display_name, "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, "entitlements": tier.entitlements,
"usage": { "usage": {
"products": product_count, "products": product_count,
+14
View File
@@ -61,6 +61,16 @@ pub struct Config {
/// Optional human-readable operator name shown in `/` index responses. /// Optional human-readable operator name shown in `/` index responses.
pub operator_name: Option<String>, pub operator_name: Option<String>,
/// 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 { impl Config {
@@ -102,6 +112,9 @@ impl Config {
let btcpay_webhook_secret = optional_nonempty("BTCPAY_WEBHOOK_SECRET"); let btcpay_webhook_secret = optional_nonempty("BTCPAY_WEBHOOK_SECRET");
let public_base_url = required_with_fallback("KEYSAT_PUBLIC_URL", "LICENSING_PUBLIC_URL")?; 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 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 { Ok(Self {
bind, bind,
@@ -115,6 +128,7 @@ impl Config {
btcpay_webhook_secret, btcpay_webhook_secret,
public_base_url: public_base_url.trim_end_matches('/').to_string(), public_base_url: public_base_url.trim_end_matches('/').to_string(),
operator_name, operator_name,
sandbox_mode,
}) })
} }
} }
+65
View File
@@ -103,6 +103,7 @@ async fn make_test_state() -> (AppState, NamedTempFile) {
btcpay_webhook_secret: None, btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(), public_base_url: "http://keysat.test".to_string(),
operator_name: Some("Test Operator".into()), operator_name: Some("Test Operator".into()),
sandbox_mode: false,
}; };
let state = AppState { 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` /// Zaprite Connect refuses on Creator-tier (no `zaprite_payments`
/// entitlement) with 402. Switching the daemon's self-tier to a /// entitlement) with 402. Switching the daemon's self-tier to a
/// Pro-flavored Licensed tier lets the Connect-precheck pass (it then /// Pro-flavored Licensed tier lets the Connect-precheck pass (it then
+1
View File
@@ -77,6 +77,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc<MockProvider>) {
btcpay_webhook_secret: None, btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(), public_base_url: "http://keysat.test".to_string(),
operator_name: None, operator_name: None,
sandbox_mode: false,
}; };
let mock = Arc::new(MockProvider::new()); let mock = Arc::new(MockProvider::new());
let state = AppState { let state = AppState {
+1
View File
@@ -61,6 +61,7 @@ async fn make_state() -> (AppState, NamedTempFile) {
btcpay_webhook_secret: None, btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(), public_base_url: "http://keysat.test".to_string(),
operator_name: None, operator_name: None,
sandbox_mode: false,
}; };
let state = AppState { let state = AppState {
db: pool, db: pool,
+1
View File
@@ -60,6 +60,7 @@ async fn make_state() -> (AppState, NamedTempFile) {
btcpay_webhook_secret: None, btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(), public_base_url: "http://keysat.test".to_string(),
operator_name: None, operator_name: None,
sandbox_mode: false,
}; };
let state = AppState { let state = AppState {
db: pool, db: pool,