Gate scoped BTCPay connect to sandbox + non-mainnet

Slices 3-4 of agent-payment-connect: a scoped key carrying the a-la-carte
payment_providers:write scope may connect a BTCPay provider, but only on a
sandbox daemon (KEYSAT_SANDBOX_MODE) and only for a non-mainnet
(regtest/testnet/signet) store. Master may connect any network; disconnect and
production/mainnet reconnect stay master-only. A credential that can repoint
settlement is a fund-redirection key, so the gate is deliberately narrow and
fails closed.

- require_provider_connect: outer gate (sandbox flag) at start_connect
- btcpay/network.rs classify_address_network + client::fetch_onchain_network:
  resolve the store network at finish_connect, fail-closed to mainnet on any
  ambiguity (no on-chain method, non-2xx, non-JSON, unknown prefix), before any
  webhook/persist side effect
- initiator carried across the OAuth round-trip via btcpay_authorize_state
  (migration 0025: scoped_initiator + initiator_actor_hash); scoped connects
  are audited
- the GET callback now returns the error's HTTP status (was a misleading 200 on
  a denied connect)
- openapi.rs documents the BTCPay connect/callback/status/disconnect paths and
  the key-creation scopes field

Validated end-to-end against a live regtest BTCPay. Full suite green; adds gate
+ network unit/integration tests.
This commit is contained in:
Grant
2026-06-17 09:31:57 -05:00
parent be8688de80
commit 8eb4a97c6f
11 changed files with 839 additions and 37 deletions
+85
View File
@@ -227,6 +227,91 @@ pub async fn require_scope(
Ok(token_hash)
}
/// Who initiated a payment-provider connect — determines the network gate at
/// callback time (`btcpay_authorize::finish_connect`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectInitiator {
/// The master `admin_api_key`. May connect any network.
Master,
/// A scoped key carrying `payment_providers:write` on a sandbox daemon.
/// Restricted to non-mainnet stores (enforced after the OAuth round-trip,
/// once the store + network are known).
Scoped,
}
/// Gate for **starting** a BTCPay provider connect — the fund-redirection-
/// sensitive operation. Stricter than `require_scope`: a scoped key reaches it
/// ONLY with the à-la-carte `payment_providers:write` scope AND only on a
/// **sandbox daemon** (the OUTER gate — on a production box scoped connect is
/// disabled entirely, even for regtest, since a scoped key re-pointing
/// settlement on a live box is denial-of-revenue). The INNER gate (target
/// network must be non-mainnet) is enforced separately at callback time, once
/// the store is known. See `plans/agent-payment-connect-scope.md` §5.
///
/// Returns `(actor_hash, initiator)`. The caller records `initiator` in the
/// authorize-state row so the callback can apply the network gate. Master keys
/// bypass both gates (still subject to BTCPay's own OAuth approval).
pub async fn require_provider_connect(
state: &AppState,
headers: &HeaderMap,
) -> AppResult<(String, ConnectInitiator)> {
let header_val = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = header_val
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
// Master admin key — full bypass, may connect any network.
if bool::from(
token
.as_bytes()
.ct_eq(state.config.admin_api_key.as_bytes()),
) {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
return Ok((hex::encode(hasher.finalize()), ConnectInitiator::Master));
}
// Scoped key — must carry `payment_providers:write` (never role-granted;
// only via à-la-carte `extra_scopes`) AND the daemon must be in sandbox mode.
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
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, extra_scopes_json) = row.ok_or(AppError::Forbidden)?;
if revoked_at.is_some() {
return Err(AppError::Forbidden);
}
let role = Role::parse(&role_str).ok_or(AppError::Forbidden)?;
let has_scope = role.grants("payment_providers:write")
|| extra_scopes_contains(extra_scopes_json.as_deref(), "payment_providers:write");
if !has_scope {
return Err(AppError::Forbidden);
}
// OUTER gate: scoped connect is permitted only on a sandbox daemon.
if !state.config.sandbox_mode {
return Err(AppError::Forbidden);
}
let now = Utc::now().to_rfc3339();
let _ = sqlx::query("UPDATE scoped_api_keys SET last_used_at = ? WHERE id = ?")
.bind(&now)
.bind(&key_id)
.execute(&state.db)
.await;
Ok((token_hash, ConnectInitiator::Scoped))
}
// ---------- CRUD endpoints (gated on master admin only) ----------
#[derive(Debug, Deserialize)]
+100 -12
View File
@@ -22,9 +22,14 @@
//! callback path uses the CSRF `state` token to tie a callback back to the
//! issuing operator session.
use crate::api::{admin::{require_admin, require_scope}, AppState};
use crate::api::{
admin::{require_admin, require_scope},
api_keys::{require_provider_connect, ConnectInitiator},
AppState,
};
use crate::btcpay::client::{self as btcpay_client, BtcpayClient};
use crate::btcpay::config as btcpay_cfg;
use crate::btcpay::network::BitcoinNetwork;
use crate::error::{AppError, AppResult};
use crate::payment::btcpay::BtcpayProvider;
use std::sync::Arc;
@@ -84,7 +89,12 @@ pub async fn start_connect(
headers: HeaderMap,
body: Option<Json<StartConnectReq>>,
) -> AppResult<Json<ConnectResp>> {
require_admin(&state, &headers)?;
// Master key → connect any network. Scoped key with `payment_providers:write`
// → permitted ONLY on a sandbox daemon (outer gate); the non-mainnet inner
// gate is enforced at callback time once the store is known. See
// `plans/agent-payment-connect-scope.md` §5.
let (actor_hash, initiator) = require_provider_connect(&state, &headers).await?;
let scoped_initiator = matches!(initiator, ConnectInitiator::Scoped);
let req = body.map(|Json(b)| b).unwrap_or_default();
// Resolve the target merchant profile (defaulting to the default).
@@ -115,9 +125,17 @@ pub async fn start_connect(
rand::thread_rng().fill_bytes(&mut raw);
let state_token = BASE32_NOPAD.encode(&raw);
btcpay_cfg::record_authorize_state(&state.db, &state_token, Some(&profile.id))
.await
.map_err(AppError::Internal)?;
btcpay_cfg::record_authorize_state(
&state.db,
&state_token,
Some(&profile.id),
scoped_initiator,
// Only stored for scoped connects (the callback's audit row). Master
// connects are covered by the StartOS action audit trail.
scoped_initiator.then_some(actor_hash.as_str()),
)
.await
.map_err(AppError::Internal)?;
// Construct the authorize URL per BTCPay's docs.
// https://docs.btcpayserver.org/API/Greenfield/v1/#api-keys
@@ -226,11 +244,18 @@ pub async fn callback_get(
Ok(()) => success_page(
"BTCPay connected successfully. You can close this tab and return to Keysat.",
),
Err(e) => Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&e.to_string())
))
.into_response(),
// Carry the error's HTTP status onto the HTML page so a denied connect
// (e.g. a scoped key targeting a mainnet store -> 400) surfaces as a
// non-2xx an agent can detect, not a misleading 200. Matches the POST
// callback, which propagates the status via `?`.
Err(e) => (
e.status_code(),
Html(format!(
"<html><body><h2>BTCPay authorization failed</h2><p>{}</p></body></html>",
html_escape::encode_text(&e.to_string())
)),
)
.into_response(),
}
}
@@ -302,10 +327,10 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
// Recovers the `merchant_profile_id` recorded when the operator
// kicked off the connect flow. NULL falls back to the default
// profile (back-compat for state tokens from pre-0022 runs).
let recorded_profile_id = btcpay_cfg::consume_authorize_state(&state.db, state_token)
let auth_state = btcpay_cfg::consume_authorize_state(&state.db, state_token)
.await
.map_err(|_| AppError::Unauthorized)?;
let profile = match recorded_profile_id.as_deref() {
let profile = match auth_state.merchant_profile_id.as_deref() {
Some(id) => crate::merchant_profiles::get(&state.db, id)
.await?
.ok_or_else(|| AppError::BadRequest(format!(
@@ -331,6 +356,43 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
"The authorized API key has access to zero stores. Re-run connect and pick a store.".into()
))?;
// INNER gate (scoped initiators only): the target store must settle on a
// non-mainnet network. This is the first point in the flow where we know
// the store, so detection happens here — BEFORE registering any webhook or
// persisting the provider. Fail closed: if the network can't be positively
// determined as non-mainnet, treat it as mainnet and refuse. Master
// initiators skip this entirely (they may connect any network).
let resolved_network = if auth_state.scoped_initiator {
let network = match btcpay_client::fetch_onchain_network(base_url, api_key, &store.id).await {
Ok(Some(net)) => net,
Ok(None) => {
tracing::warn!(
store = %store.id,
"scoped BTCPay connect: on-chain network undetermined → fail-closed to mainnet (deny)"
);
BitcoinNetwork::Mainnet
}
Err(e) => {
tracing::warn!(
store = %store.id, error = %format!("{e:#}"),
"scoped BTCPay connect: network detection errored → fail-closed to mainnet (deny)"
);
BitcoinNetwork::Mainnet
}
};
if network.is_mainnet() {
return Err(AppError::BadRequest(format!(
"Scoped payment-provider connect is restricted to non-mainnet \
(regtest/testnet/signet) BTCPay stores; the selected store resolved \
to '{}'. Use the master admin key to connect a mainnet store.",
network.as_str()
)));
}
Some(network)
} else {
None
};
// Generate a strong webhook secret, then register the webhook on BTCPay.
let mut raw_secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw_secret);
@@ -387,14 +449,40 @@ async fn finish_connect(state: &AppState, state_token: &str, api_key: &str) -> A
state.set_payment_provider(provider).await;
}
let network_str = resolved_network.map(|n| n.as_str());
tracing::info!(
provider_id = %provider_id,
merchant_profile_id = %profile.id,
store = %store.id,
store_name = %store.name,
webhook_id = %created_webhook.id,
scoped = auth_state.scoped_initiator,
network = network_str.unwrap_or("master/any"),
"BTCPay connected via authorize flow"
);
// Audit every scoped connect (spec §7) — attributes the fund-redirection-
// sensitive op to the initiating credential + the resolved network. Master
// connects are already covered by the StartOS action audit trail.
if auth_state.scoped_initiator {
let _ = crate::db::repo::insert_audit(
&state.db,
"scoped_api_key",
auth_state.initiator_actor_hash.as_deref(),
"payment_provider.connect_scoped",
Some("payment_provider"),
Some(&provider_id),
None,
None,
&json!({
"kind": "btcpay",
"store_id": store.id,
"merchant_profile_id": profile.id,
"network": network_str,
}),
)
.await;
}
Ok(())
}
+38 -2
View File
@@ -400,7 +400,8 @@ const SPEC_JSON: &str = r##"{
"type": "object",
"properties": {
"label": { "type": "string", "description": "Operator-friendly name, e.g. 'Recap support bot'" },
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "merchant-onboard", "full-admin"] }
"role": { "type": "string", "enum": ["read-only", "license-issuer", "support", "merchant-onboard", "full-admin"] },
"scopes": { "type": "array", "items": { "type": "string", "enum": ["payment_providers:write"] }, "description": "A-la-carte extra scopes granted on top of the role. Only payment_providers:write today: lets the key connect a non-mainnet BTCPay provider on a sandbox daemon. In no role by default." }
},
"required": ["label", "role"]
} } }
@@ -418,9 +419,44 @@ const SPEC_JSON: &str = r##"{
"/v1/admin/tier": {
"get": {
"summary": "Get this daemon's tier + usage + caps",
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier.",
"description": "Master admin key required. Returns current self-tier label + entitlements, current product/code usage, and the caps that apply at this tier. Includes a read-only `sandbox` boolean (true when KEYSAT_SANDBOX_MODE is set).",
"responses": { "200": { "description": "Tier info" } }
}
},
"/v1/admin/btcpay/connect": {
"post": {
"summary": "Start a BTCPay provider connect",
"description": "Returns a one-time `state` token and the BTCPay authorize URL; complete the connect at /v1/btcpay/authorize/callback. The master key may connect any network. A scoped key needs the `payment_providers:write` extra scope AND a sandbox daemon (KEYSAT_SANDBOX_MODE); the target store must resolve to a non-mainnet network or the callback refuses. Optional JSON body: { merchant_profile_id }.",
"responses": {
"200": { "description": "{ authorize_url, state, merchant_profile_id }" },
"403": { "description": "Scoped key without payment_providers:write, or not a sandbox daemon" },
"409": { "description": "Profile already has a BTCPay provider; disconnect first" }
}
}
},
"/v1/btcpay/authorize/callback": {
"get": {
"summary": "Complete a BTCPay connect",
"description": "BTCPay redirects here after the operator approves in a browser, or an agent calls it directly with a pre-issued store API key. Query params: `state` (from /connect) and `apiKey` (a BTCPay store key with the same store-settings + invoice permissions the browser flow grants). Keysat resolves the store's network and, for a scoped initiator, refuses anything not provably non-mainnet (fail-closed). No auth header; the single-use `state` token is the tie. A refusal returns a 4xx on both the GET and POST forms.",
"responses": {
"200": { "description": "Connected (HTML confirmation page)" },
"400": { "description": "Scoped connect to a mainnet/undetermined store; nothing persisted" }
}
}
},
"/v1/admin/btcpay/status": {
"get": {
"summary": "BTCPay connection status (default profile)",
"description": "Requires payment_providers:read. Returns { connected, store_id, base_url, webhook_id, ... }.",
"responses": { "200": { "description": "Connection status" } }
}
},
"/v1/admin/btcpay/disconnect": {
"post": {
"summary": "Disconnect a BTCPay provider",
"description": "Master admin key required, on any daemon. Best-effort revokes the webhook + key on BTCPay, then clears the local provider row.",
"responses": { "200": { "description": "Disconnected (or no-op)" } }
}
}
}
}"##;