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:
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}"##;
|
||||
|
||||
Reference in New Issue
Block a user