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;
+148 -22
View File
@@ -102,7 +102,13 @@ impl Role {
/// `<resource>:<read|write>`, 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::<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.
/// 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<String>)> = sqlx::query_as(
"SELECT id, role, revoked_at FROM scoped_api_keys WHERE token_hash = ?",
let row: Option<(String, String, Option<String>, Option<String>)> = 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<String>,
}
#[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<String>,
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<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.
// 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<String>,
pub created_at: String,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
@@ -309,23 +370,35 @@ pub async fn list(
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&state, &headers)?;
let rows: Vec<(String, String, String, String, Option<String>, Option<String>)> =
sqlx::query_as(
"SELECT id, label, role, created_at, last_used_at, revoked_at
let rows: Vec<(
String,
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",
)
.fetch_all(&state.db)
.await?;
)
.fetch_all(&state.db)
.await?;
let out: Vec<ApiKeyListEntry> = 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::<Vec<String>>(&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
}
}
+4
View File
@@ -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,
+14
View File
@@ -61,6 +61,16 @@ pub struct Config {
/// Optional human-readable operator name shown in `/` index responses.
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 {
@@ -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,
})
}
}
+65
View File
@@ -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
+1
View File
@@ -77,6 +77,7 @@ async fn make_state() -> (AppState, NamedTempFile, Arc<MockProvider>) {
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 {
+1
View File
@@ -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,
+1
View File
@@ -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,