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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user