From 0508690d5ac300b0b49b3c52eecc20f9a9e17624 Mon Sep 17 00:00:00 2001 From: Keysat Date: Sat, 13 Jun 2026 00:10:45 -0500 Subject: [PATCH] Wire scoped API keys and add advisory settle-amount tripwire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped API keys (P1): migrate 58 admin endpoints from require_admin to require_scope so ks_ keys with Read-only/License-issuer/Support/Full-admin roles work as intended. 12 sensitive endpoints stay master-key-only (issuer key, provider connect/disconnect, web password, api-key CRUD, db-info, operator-name, per-license tier change). require_scope is re-exported from api::admin so both auth gates import from one place. Adds role-boundary tests. Settle-amount tripwire (P1): get_invoice_status now returns ProviderInvoiceSnapshot { status, amount }. On a confirmed settle, audit_settle_amount (shared by the webhook and reconcile issue paths) compares the provider-reported sat amount against the invoice's amount_sats and, on drift, logs a warning + writes an invoice.amount_mismatch audit row, then issues anyway. Advisory by design: a hard gate would fight an operator's BTCPay payment tolerance, and Settled already implies paid-in-full. SAT-only — skips non-SAT settles (fiat subscription renewals) and unparseable amounts. --- licensing-service/src/api/admin.rs | 37 +- licensing-service/src/api/api_keys.rs | 33 +- licensing-service/src/api/btcpay_authorize.rs | 6 +- licensing-service/src/api/community.rs | 8 +- licensing-service/src/api/discount_codes.rs | 14 +- licensing-service/src/api/machines.rs | 6 +- .../src/api/merchant_profiles.rs | 18 +- licensing-service/src/api/payment_provider.rs | 4 +- licensing-service/src/api/policies.rs | 20 +- licensing-service/src/api/rates_admin.rs | 6 +- licensing-service/src/api/subscriptions.rs | 6 +- licensing-service/src/api/tier.rs | 2 +- licensing-service/src/api/webhook.rs | 84 +++- .../src/api/webhook_deliveries.rs | 6 +- .../src/api/webhook_endpoints.rs | 10 +- .../src/api/zaprite_authorize.rs | 4 +- licensing-service/src/payment/btcpay.rs | 41 +- licensing-service/src/payment/mod.rs | 22 +- .../src/payment/zaprite/provider.rs | 31 +- licensing-service/src/reconcile.rs | 14 +- licensing-service/tests/api.rs | 373 +++++++++++++++++- licensing-service/tests/subscriptions.rs | 11 +- licensing-service/tests/upgrades.rs | 11 +- 23 files changed, 652 insertions(+), 115 deletions(-) diff --git a/licensing-service/src/api/admin.rs b/licensing-service/src/api/admin.rs index 79a80c4..62e110f 100644 --- a/licensing-service/src/api/admin.rs +++ b/licensing-service/src/api/admin.rs @@ -16,6 +16,13 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; +// The scoped-API-key gate lives in `api_keys` (next to the Role/scope logic), +// but endpoint modules import both auth gates from here so there's one obvious +// place to reach for when wiring an admin route. `require_admin` = master key +// only; `require_scope` = master key OR a scoped key whose role grants the +// named scope. +pub use crate::api::api_keys::require_scope; + /// Guards every admin handler: pulls the bearer token out of the header and /// compares constant-time against the configured admin key. Returns the /// SHA-256 hex of the token on success so handlers can write an audit row @@ -169,7 +176,7 @@ pub async fn create_product( headers: HeaderMap, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "products:write").await?; let (ip, ua) = request_context(&headers); // Tier-cap gate: Creator caps at 5 products. 402 if over. crate::api::tier::enforce_product_cap(&state).await?; @@ -260,7 +267,7 @@ pub async fn delete_product( Path(id): Path, Query(opts): Query, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "products:write").await?; let (ip, ua) = request_context(&headers); let product = repo::get_product_by_id(&state.db, &id) @@ -451,7 +458,7 @@ pub async fn update_product( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "products:write").await?; let (ip, ua) = request_context(&headers); // Resolve the pricing patch into (currency, value, sats) tuple @@ -551,7 +558,7 @@ pub async fn set_product_active( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "products:write").await?; let (ip, ua) = request_context(&headers); repo::set_product_active(&state.db, &id, req.active).await?; let _ = repo::insert_audit( @@ -581,7 +588,7 @@ pub async fn list_licenses( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "licenses:read").await?; let licenses = repo::list_licenses_by_product(&state.db, &q.product_id).await?; Ok(Json(json!({ "licenses": licenses }))) } @@ -605,7 +612,7 @@ pub async fn search_licenses( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "licenses:read").await?; let licenses = repo::search_licenses( &state.db, q.buyer_email.as_deref(), @@ -685,7 +692,7 @@ pub async fn revenue_summary( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "licenses:read").await?; let total: i64 = sqlx::query_scalar( "SELECT COALESCE(SUM(amount_sats), 0) FROM invoices WHERE status = 'settled'", ) @@ -730,7 +737,7 @@ pub async fn license_counts( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "licenses:read").await?; let by_product: Vec<(String, i64)> = sqlx::query_as( "SELECT product_id, COUNT(*) FROM licenses GROUP BY product_id", ) @@ -762,7 +769,7 @@ pub async fn licenses_summary( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "licenses:read").await?; let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") .fetch_one(&state.db) .await?; @@ -845,7 +852,7 @@ pub async fn issue_license( headers: HeaderMap, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "licenses:write").await?; let (ip, ua) = request_context(&headers); let product = repo::get_product_by_slug(&state.db, &req.product_slug) @@ -997,7 +1004,7 @@ pub async fn revoke_license( Path(license_id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "licenses:write").await?; let (ip, ua) = request_context(&headers); let reason = if req.reason.is_empty() { "admin revoke".to_string() @@ -1040,7 +1047,7 @@ pub async fn suspend_license( Path(license_id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "licenses:write").await?; let (ip, ua) = request_context(&headers); let reason = if req.reason.is_empty() { "admin suspend".to_string() @@ -1074,7 +1081,7 @@ pub async fn unsuspend_license( headers: HeaderMap, Path(license_id): Path, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "licenses:write").await?; let (ip, ua) = request_context(&headers); repo::unsuspend_license(&state.db, &license_id).await?; let _ = repo::insert_audit( @@ -1116,7 +1123,7 @@ pub async fn list_audit( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "audit:read").await?; let rows = repo::list_audit(&state.db, q.limit.min(1000).max(1), q.action.as_deref()).await?; Ok(Json(json!({ "entries": rows }))) } @@ -1164,7 +1171,7 @@ pub async fn get_operator_name( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "settings:read").await?; let stored = repo::settings_get(&state.db, SETTING_OPERATOR_NAME).await?; let effective = stored .clone() diff --git a/licensing-service/src/api/api_keys.rs b/licensing-service/src/api/api_keys.rs index b2e7b46..3694cef 100644 --- a/licensing-service/src/api/api_keys.rs +++ b/licensing-service/src/api/api_keys.rs @@ -5,20 +5,29 @@ //! script a credential that does only what it needs to. Operator-friendly //! flow: //! -//! 1. Operator generates a new key in Settings → API keys, picks a role +//! 1. Operator mints a new key via `POST /v1/admin/api-keys`, picking a role //! from a fixed list (Read-only / License issuer / Support / Full admin). -//! 2. UI returns the raw token ONCE. The token never appears in any -//! response afterward — only its sha256 hash is stored. -//! 3. Agent uses `Authorization: Bearer ` like the master key. -//! Endpoints that have been scope-wired check the agent's role -//! grants the required scope; if not, 403. -//! 4. Operator can revoke any key from the same UI; revoked tokens -//! stop working immediately. +//! (A clickable Settings → API keys panel in the admin SPA is planned; +//! until then keys are minted through the API.) +//! 2. The create response returns the raw token ONCE. The token never +//! appears in any response afterward — only its sha256 hash is stored. +//! 3. Agent uses `Authorization: Bearer ` like the master key. Each +//! scope-gated endpoint checks the agent's role grants the required +//! scope; if not, 403. +//! 4. Operator can revoke any key (`DELETE /v1/admin/api-keys/:id`); revoked +//! tokens stop working immediately. //! -//! The master `admin_api_key` always works on every endpoint. Scoped keys -//! work only on endpoints that have been migrated to call `require_scope` -//! instead of `require_admin`. Endpoints not yet migrated reject scoped -//! keys with 403 — secure-by-default. +//! The master `admin_api_key` always works on every endpoint. Scoped keys are +//! honored across the catalog/license/support surface: every read endpoint +//! (`:read`), license writes (`licenses:write`), and the support +//! writes (`subscriptions:write`, `machines:write`). A deliberate set of +//! sensitive endpoints stays master-key-only — even a `full-admin` scoped key +//! gets 403 on them: rotating the issuer signing key, connecting/disconnecting +//! payment providers, setting the web-admin password, managing API keys +//! themselves, changing server settings or license tiers, and DB +//! introspection. When adding a new admin route, gate it with +//! `require_scope(state, headers, ":")` unless it belongs +//! in that master-only set, in which case use `require_admin`. use crate::api::admin::{request_context, require_admin}; use crate::api::AppState; diff --git a/licensing-service/src/api/btcpay_authorize.rs b/licensing-service/src/api/btcpay_authorize.rs index 8c63697..d323f99 100644 --- a/licensing-service/src/api/btcpay_authorize.rs +++ b/licensing-service/src/api/btcpay_authorize.rs @@ -22,7 +22,7 @@ //! callback path uses the CSRF `state` token to tie a callback back to the //! issuing operator session. -use crate::api::{admin::require_admin, AppState}; +use crate::api::{admin::{require_admin, require_scope}, AppState}; use crate::btcpay::client::{self as btcpay_client, BtcpayClient}; use crate::btcpay::config as btcpay_cfg; use crate::error::{AppError, AppResult}; @@ -243,7 +243,7 @@ pub async fn payment_methods( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "payment_providers:read").await?; let default = crate::merchant_profiles::require_default(&state.db).await?; let rows = crate::db::repo::list_payment_providers_for_profile(&state.db, &default.id) .await?; @@ -271,7 +271,7 @@ pub async fn status( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "payment_providers:read").await?; let default = crate::merchant_profiles::get_default(&state.db).await?; let row = match &default { Some(profile) => { diff --git a/licensing-service/src/api/community.rs b/licensing-service/src/api/community.rs index c303dc0..8a5670b 100644 --- a/licensing-service/src/api/community.rs +++ b/licensing-service/src/api/community.rs @@ -19,7 +19,7 @@ use crate::analytics::{ self, SETTING_COLLECTOR_URL, SETTING_ENABLED, SETTING_INSTALL_UUID, }; -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::db::repo; use crate::error::{AppError, AppResult}; @@ -31,7 +31,7 @@ pub async fn get( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "community:read").await?; let enabled = analytics::is_enabled(&state).await; let collector_url = repo::settings_get(&state.db, SETTING_COLLECTOR_URL).await?; let install_uuid = repo::settings_get(&state.db, SETTING_INSTALL_UUID).await?; @@ -84,7 +84,7 @@ pub async fn set( headers: HeaderMap, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "community:write").await?; let (ip, ua) = request_context(&headers); // Validate URL shape if one was supplied. We don't try to reach @@ -154,7 +154,7 @@ pub async fn reset( State(state): State, headers: HeaderMap, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "community:write").await?; let (ip, ua) = request_context(&headers); repo::settings_set(&state.db, SETTING_INSTALL_UUID, None).await?; let _ = repo::insert_audit( diff --git a/licensing-service/src/api/discount_codes.rs b/licensing-service/src/api/discount_codes.rs index 0340220..38b0fb6 100644 --- a/licensing-service/src/api/discount_codes.rs +++ b/licensing-service/src/api/discount_codes.rs @@ -4,7 +4,7 @@ //! The public purchase flow consumes codes via the `code` field on //! `POST /v1/purchase`; that path is handled in `crate::api::purchase`. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::db::repo; use crate::error::{AppError, AppResult}; @@ -67,7 +67,7 @@ pub async fn create( headers: HeaderMap, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?; let (ip, ua) = request_context(&headers); // Tier-cap gate: Creator caps at 5 active discount codes. @@ -200,7 +200,7 @@ pub async fn list( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "discount_codes:read").await?; let codes = repo::list_discount_codes(&state.db, !q.include_inactive).await?; Ok(Json(json!({ "codes": codes }))) } @@ -210,7 +210,7 @@ pub async fn get_one( headers: HeaderMap, Path(id): Path, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "discount_codes:read").await?; let code = repo::get_discount_code_by_id(&state.db, &id) .await? .ok_or_else(|| AppError::NotFound(format!("discount code {id}")))?; @@ -271,7 +271,7 @@ pub async fn update( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?; let (ip, ua) = request_context(&headers); // Resolve policy_slugs → policy ids using the code's EXISTING product @@ -360,7 +360,7 @@ pub async fn set_active( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?; let (ip, ua) = request_context(&headers); repo::set_discount_code_active(&state.db, &id, req.active).await?; let action = if req.active { @@ -392,7 +392,7 @@ pub async fn delete( headers: HeaderMap, Path(id): Path, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "discount_codes:write").await?; let (ip, ua) = request_context(&headers); // Look up the code so we can audit-log meaningful detail. diff --git a/licensing-service/src/api/machines.rs b/licensing-service/src/api/machines.rs index ab30112..7079cb1 100644 --- a/licensing-service/src/api/machines.rs +++ b/licensing-service/src/api/machines.rs @@ -19,7 +19,7 @@ //! Admin endpoints let operators look at who's using what and force-kick a //! machine off a license. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::crypto; use crate::db::repo; @@ -261,7 +261,7 @@ pub async fn admin_list( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "machines:read").await?; // Resolve product_slug → product_id if the caller passed the slug // form. Either works; product_id takes precedence on conflict. @@ -299,7 +299,7 @@ pub async fn admin_deactivate( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "machines:write").await?; let (ip, ua) = request_context(&headers); let reason = if req.reason.is_empty() { "admin deactivate".to_string() diff --git a/licensing-service/src/api/merchant_profiles.rs b/licensing-service/src/api/merchant_profiles.rs index 042d0ee..9ff35bd 100644 --- a/licensing-service/src/api/merchant_profiles.rs +++ b/licensing-service/src/api/merchant_profiles.rs @@ -4,7 +4,7 @@ //! `crate::merchant_profiles` and the rail-preference repo helpers. //! Consumed by the new Merchant Profiles section of the admin UI. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::error::{AppError, AppResult}; use crate::merchant_profiles::{ @@ -77,7 +77,7 @@ pub async fn list( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "merchant_profiles:read").await?; let profiles = merchant_profiles::list(&state.db).await?; let mut out: Vec = Vec::with_capacity(profiles.len()); for p in &profiles { @@ -94,7 +94,7 @@ pub async fn get( headers: HeaderMap, Path(id): Path, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "merchant_profiles:read").await?; let profile = merchant_profiles::get(&state.db, &id) .await? .ok_or_else(|| AppError::NotFound(format!("merchant profile {id}")))?; @@ -125,7 +125,7 @@ pub async fn create( headers: HeaderMap, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?; let (ip, ua) = request_context(&headers); let created = merchant_profiles::create(&state, req).await?; let _ = crate::db::repo::insert_audit( @@ -150,7 +150,7 @@ pub async fn update( Path(id): Path, Json(patch): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?; let (ip, ua) = request_context(&headers); let updated = merchant_profiles::update(&state.db, &id, patch).await?; let _ = crate::db::repo::insert_audit( @@ -175,7 +175,7 @@ pub async fn delete( headers: HeaderMap, Path(id): Path, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?; let (ip, ua) = request_context(&headers); merchant_profiles::delete(&state.db, &id).await?; let _ = crate::db::repo::insert_audit( @@ -200,7 +200,7 @@ pub async fn set_default( headers: HeaderMap, Path(id): Path, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?; let (ip, ua) = request_context(&headers); merchant_profiles::set_default(&state.db, &id).await?; let _ = crate::db::repo::insert_audit( @@ -237,7 +237,7 @@ pub async fn set_rail_preference( Path((profile_id, rail)): Path<(String, String)>, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?; let (ip, ua) = request_context(&headers); // Validate the rail name. @@ -311,7 +311,7 @@ pub async fn clear_rail_preference( headers: HeaderMap, Path((profile_id, rail)): Path<(String, String)>, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "merchant_profiles:write").await?; let (ip, ua) = request_context(&headers); let parsed_rail = crate::payment::Rail::parse(&rail).ok_or_else(|| { diff --git a/licensing-service/src/api/payment_provider.rs b/licensing-service/src/api/payment_provider.rs index f36ae73..3d9b300 100644 --- a/licensing-service/src/api/payment_provider.rs +++ b/licensing-service/src/api/payment_provider.rs @@ -16,7 +16,7 @@ //! should use the new `/v1/admin/merchant-profiles` endpoints to see //! all providers across all profiles. -use crate::api::admin::require_admin; +use crate::api::admin::require_scope; use crate::api::AppState; use crate::error::AppResult; use axum::{extract::State, http::HeaderMap, Json}; @@ -31,7 +31,7 @@ pub async fn status( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "payment_providers:read").await?; let default = crate::merchant_profiles::get_default(&state.db).await?; let providers = match &default { Some(p) => crate::db::repo::list_payment_providers_for_profile(&state.db, &p.id).await?, diff --git a/licensing-service/src/api/policies.rs b/licensing-service/src/api/policies.rs index a389781..f4c3121 100644 --- a/licensing-service/src/api/policies.rs +++ b/licensing-service/src/api/policies.rs @@ -9,7 +9,7 @@ //! product when a customer buys it through the normal purchase flow — so most //! products should have at least one policy slugged `default`. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::db::repo; use crate::error::{AppError, AppResult}; @@ -158,7 +158,7 @@ pub async fn create( headers: HeaderMap, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "policies:write").await?; let (ip, ua) = request_context(&headers); let product = repo::get_product_by_slug(&state.db, &req.product_slug) .await? @@ -289,7 +289,7 @@ pub async fn list( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "policies:read").await?; let product = repo::get_product_by_slug(&state.db, &q.product_slug) .await? .ok_or_else(|| AppError::NotFound(format!("product '{}'", q.product_slug)))?; @@ -314,7 +314,7 @@ pub async fn set_active( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "policies:write").await?; let (ip, ua) = request_context(&headers); repo::set_policy_active(&state.db, &id, req.active).await?; let _ = repo::insert_audit( @@ -351,7 +351,7 @@ pub async fn set_archived( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "policies:write").await?; let (ip, ua) = request_context(&headers); repo::set_policy_archived(&state.db, &id, req.archived).await?; let _ = repo::insert_audit( @@ -389,7 +389,7 @@ pub async fn delete( Path(id): Path, Query(opts): Query, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "policies:write").await?; let (ip, ua) = request_context(&headers); let policy = repo::get_policy_by_id(&state.db, &id) @@ -606,7 +606,7 @@ pub async fn update( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "policies:write").await?; let (ip, ua) = request_context(&headers); if let Some(d) = req.duration_seconds { @@ -739,7 +739,7 @@ pub async fn set_public( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "policies:write").await?; let (ip, ua) = request_context(&headers); repo::set_policy_public(&state.db, &id, req.public).await?; let _ = repo::insert_audit( @@ -933,7 +933,7 @@ pub async fn set_tip( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "policies:write").await?; let (ip, ua) = request_context(&headers); if req.tip_pct_bps < 0 || req.tip_pct_bps > 10_000 { return Err(AppError::BadRequest( @@ -992,7 +992,7 @@ pub async fn list_tips( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "policies:read").await?; let entries = repo::list_tip_attempts( &state.db, q.license_id.as_deref(), diff --git a/licensing-service/src/api/rates_admin.rs b/licensing-service/src/api/rates_admin.rs index 7d6ce33..ef58b7c 100644 --- a/licensing-service/src/api/rates_admin.rs +++ b/licensing-service/src/api/rates_admin.rs @@ -12,7 +12,7 @@ //! outage to confirm the //! chain works end-to-end. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::error::{AppError, AppResult}; use crate::rates; @@ -24,7 +24,7 @@ pub async fn get( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "rates:read").await?; let snapshot = state.rates.snapshot().await; let rates_json: Vec = snapshot .into_iter() @@ -52,7 +52,7 @@ pub async fn refresh( headers: HeaderMap, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "rates:write").await?; let (ip, ua) = request_context(&headers); let currency = req.currency.to_uppercase(); diff --git a/licensing-service/src/api/subscriptions.rs b/licensing-service/src/api/subscriptions.rs index 08d8ba0..ba1ee52 100644 --- a/licensing-service/src/api/subscriptions.rs +++ b/licensing-service/src/api/subscriptions.rs @@ -26,7 +26,7 @@ //! convention (Stripe, Zaprite, etc.) and avoids a UX where the //! buyer cancels mid-month and immediately loses what they paid for. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::error::{AppError, AppResult}; use axum::{ @@ -58,7 +58,7 @@ pub async fn admin_list( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "subscriptions:read").await?; if let Some(s) = q.status.as_deref() { if !["active", "past_due", "cancelled", "lapsed"].contains(&s) { return Err(AppError::BadRequest(format!( @@ -115,7 +115,7 @@ pub async fn admin_cancel( Path(id): Path, body: Option>, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "subscriptions:write").await?; let (ip, ua) = request_context(&headers); let reason = body.and_then(|Json(b)| b.reason).filter(|s| !s.trim().is_empty()); diff --git a/licensing-service/src/api/tier.rs b/licensing-service/src/api/tier.rs index ce45ee7..b09c47e 100644 --- a/licensing-service/src/api/tier.rs +++ b/licensing-service/src/api/tier.rs @@ -124,7 +124,7 @@ pub async fn admin_status( axum::extract::State(state): axum::extract::State, headers: axum::http::HeaderMap, ) -> AppResult> { - crate::api::admin::require_admin(&state, &headers)?; + crate::api::admin::require_scope(&state, &headers, "tier:read").await?; let tier = current(&state).await; let product_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM products") .fetch_one(&state.db) diff --git a/licensing-service/src/api/webhook.rs b/licensing-service/src/api/webhook.rs index 3799c38..f111f7b 100644 --- a/licensing-service/src/api/webhook.rs +++ b/licensing-service/src/api/webhook.rs @@ -131,14 +131,21 @@ async fn handle_inner( // not the replayed one). BTCPay is HMAC-verified upstream and is settled // already, so this is cheap belt-and-suspenders there. On a provider // error we fail closed — the reconcile loop re-confirms on its next tick. - if new_status == "settled" { + // `Some` once a settle is confirmed: the provider-reported amount, fed to + // the advisory tripwire below (after the local invoice is loaded). `None` + // for non-settle events and when the provider reports no parseable amount. + let confirmed_amount = if new_status == "settled" { match provider.get_invoice_status(&provider_invoice_id).await { - Ok(crate::payment::ProviderInvoiceStatus::Settled) => {} - Ok(other) => { + Ok(snapshot) + if snapshot.status == crate::payment::ProviderInvoiceStatus::Settled => + { + snapshot.amount + } + Ok(snapshot) => { tracing::warn!( provider = provider.kind().as_str(), provider_invoice_id = %provider_invoice_id, - provider_status = ?other, + provider_status = ?snapshot.status, "settle webhook NOT confirmed by provider API; refusing to settle/issue" ); return Ok(StatusCode::OK); @@ -159,7 +166,9 @@ async fn handle_inner( return Ok(StatusCode::OK); } } - } + } else { + None + }; // Persist status. repo::update_invoice_status(&state.db, &provider_invoice_id, new_status).await?; @@ -199,6 +208,12 @@ async fn handle_inner( return Ok(StatusCode::OK); }; + // Advisory settle-amount tripwire. The Settled gate above already ensures + // the provider considers this paid in full, so this never blocks issuance + // — it logs + audits if the provider's recorded amount/currency ever + // drifts from what we charged. See docs/guides/payments.md. + audit_settle_amount(&state, &invoice, confirmed_amount.as_ref()).await; + // Tier-change branch: this settled invoice may be a tier upgrade // (recorded by POST /v1/upgrade or the future admin-change-tier // endpoint) rather than a fresh purchase or a subscription @@ -240,6 +255,65 @@ async fn handle_inner( Ok(StatusCode::OK) } +/// Advisory settle-amount tripwire, shared by the webhook handler and the +/// reconcile loop. The Settled gate at both call sites already guarantees the +/// provider considers the invoice paid in full (BTCPay won't settle an unpaid +/// invoice; Zaprite maps `UNDERPAID` → `Pending`), so this NEVER blocks +/// issuance. It exists to surface drift: if the provider's recorded amount or +/// currency ever differs from what we charged — a charge-vs-record bug on our +/// side, or a currency-confusion bug — we log a warning and write an +/// `invoice.amount_mismatch` audit row, then let issuance proceed. +/// +/// `confirmed` is `None` ("no opinion") when the provider response carried no +/// parseable amount; in that case the tripwire is skipped. Every invoice we +/// create is SAT-denominated (`purchase.rs` passes `Money::sats`), so the +/// expected value is `invoice.amount_sats` in `SAT`. +pub(crate) async fn audit_settle_amount( + state: &AppState, + invoice: &crate::models::Invoice, + confirmed: Option<&crate::payment::Money>, +) { + let Some(paid) = confirmed else { return }; + // The comparison basis is `invoice.amount_sats` (SAT), which equals what we + // told the provider to charge ONLY for SAT-denominated orders — one-shot + // purchases and SAT subscriptions (`purchase.rs` / `upgrades` pass + // `Money::sats`). Fiat-priced subscription RENEWALS (`subscriptions.rs`) + // create the order in the listed fiat currency, where `amount_sats` is not + // the charged amount, so there's no clean SAT comparison — skip those (the + // `Settled` gate already guarantees paid-in-full). A non-SAT provider + // amount therefore means "no comparable basis", not a mismatch. + if paid.currency != "SAT" { + return; + } + if paid.amount == invoice.amount_sats { + return; + } + tracing::warn!( + invoice_id = %invoice.id, + provider_invoice_id = %invoice.btcpay_invoice_id, + expected_amount_sats = invoice.amount_sats, + provider_amount_sats = paid.amount, + "settled invoice amount does NOT match the recorded charge; issuing \ + anyway (advisory) — investigate provider config or a charge-vs-record bug" + ); + let _ = repo::insert_audit( + &state.db, + "system", + None, + "invoice.amount_mismatch", + Some("invoice"), + Some(&invoice.id), + None, + None, + &serde_json::json!({ + "provider_invoice_id": invoice.btcpay_invoice_id, + "expected_amount_sats": invoice.amount_sats, + "provider_amount_sats": paid.amount, + }), + ) + .await; +} + /// Shared issuance path — used by both the webhook handler and the reconcile /// loop. Pulls the invoice's associated policy (if the product has a default /// one) and materializes a license row with the right expiry / entitlements. diff --git a/licensing-service/src/api/webhook_deliveries.rs b/licensing-service/src/api/webhook_deliveries.rs index 5c318b8..ed5bf07 100644 --- a/licensing-service/src/api/webhook_deliveries.rs +++ b/licensing-service/src/api/webhook_deliveries.rs @@ -14,7 +14,7 @@ //! that was down for >6h during a license-issuance burst would //! silently lose those events forever. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::db::repo::{self, DeliveryStatusFilter}; use crate::error::{AppError, AppResult}; @@ -46,7 +46,7 @@ pub async fn list( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "webhooks:read").await?; let status = match q.status.as_deref() { Some(s) => DeliveryStatusFilter::parse(s).ok_or_else(|| { AppError::BadRequest(format!( @@ -80,7 +80,7 @@ pub async fn retry( headers: HeaderMap, Path(id): Path, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "webhooks:write").await?; let (ip, ua) = request_context(&headers); let delivery = repo::requeue_delivery(&state.db, &id) .await? diff --git a/licensing-service/src/api/webhook_endpoints.rs b/licensing-service/src/api/webhook_endpoints.rs index 7ddb8ca..44ce017 100644 --- a/licensing-service/src/api/webhook_endpoints.rs +++ b/licensing-service/src/api/webhook_endpoints.rs @@ -9,7 +9,7 @@ //! they've stored it somewhere safe, later reads return the secret masked. //! (If they lose it, they can rotate by deleting + recreating the endpoint.) -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_scope}; use crate::api::AppState; use crate::db::repo; use crate::error::AppResult; @@ -48,7 +48,7 @@ pub async fn create( headers: HeaderMap, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "webhooks:write").await?; let (ip, ua) = request_context(&headers); let secret = req.secret.unwrap_or_else(generate_secret); let ep = repo::create_webhook_endpoint( @@ -96,7 +96,7 @@ pub async fn list( headers: HeaderMap, Query(q): Query, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "webhooks:read").await?; let rows = repo::list_webhook_endpoints(&state.db, q.include_secret).await?; Ok(Json(json!({ "endpoints": rows }))) } @@ -112,7 +112,7 @@ pub async fn set_active( Path(id): Path, Json(req): Json, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "webhooks:write").await?; let (ip, ua) = request_context(&headers); repo::set_webhook_active(&state.db, &id, req.active).await?; let _ = repo::insert_audit( @@ -135,7 +135,7 @@ pub async fn delete( headers: HeaderMap, Path(id): Path, ) -> AppResult> { - let actor_hash = require_admin(&state, &headers)?; + let actor_hash = require_scope(&state, &headers, "webhooks:write").await?; let (ip, ua) = request_context(&headers); repo::delete_webhook_endpoint(&state.db, &id).await?; let _ = repo::insert_audit( diff --git a/licensing-service/src/api/zaprite_authorize.rs b/licensing-service/src/api/zaprite_authorize.rs index 6e833a1..562154e 100644 --- a/licensing-service/src/api/zaprite_authorize.rs +++ b/licensing-service/src/api/zaprite_authorize.rs @@ -14,7 +14,7 @@ //! Old "active provider" semantics are gone — profiles attach to //! products explicitly. -use crate::api::admin::{request_context, require_admin}; +use crate::api::admin::{request_context, require_admin, require_scope}; use crate::api::AppState; use crate::error::{AppError, AppResult}; use crate::payment::zaprite::{ZapriteClient, ZapriteProvider}; @@ -276,7 +276,7 @@ pub async fn status( State(state): State, headers: HeaderMap, ) -> AppResult> { - require_admin(&state, &headers)?; + require_scope(&state, &headers, "payment_providers:read").await?; let default = crate::merchant_profiles::get_default(&state.db).await?; let connected_row = match &default { Some(profile) => { diff --git a/licensing-service/src/payment/btcpay.rs b/licensing-service/src/payment/btcpay.rs index 0f1b9fc..3d959c1 100644 --- a/licensing-service/src/payment/btcpay.rs +++ b/licensing-service/src/payment/btcpay.rs @@ -8,7 +8,7 @@ use super::{ CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt, - ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent, + ProviderInvoiceSnapshot, ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent, }; use crate::btcpay::client::BtcpayClient; use crate::btcpay::webhook::{verify_signature, WebhookEvent as BtcpayWebhookEvent}; @@ -155,17 +155,13 @@ impl PaymentProvider for BtcpayProvider { async fn get_invoice_status( &self, provider_invoice_id: &str, - ) -> Result { + ) -> Result { let raw = self .client .get_invoice(provider_invoice_id) .await .context("BTCPay get-invoice")?; - let status = raw - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or("Pending"); - Ok(match status { + let status = match raw.get("status").and_then(|v| v.as_str()).unwrap_or("Pending") { "Settled" | "Complete" => ProviderInvoiceStatus::Settled, "Expired" => ProviderInvoiceStatus::Expired, "Invalid" => ProviderInvoiceStatus::Invalid, @@ -173,7 +169,36 @@ impl PaymentProvider for BtcpayProvider { // reports it via metadata we'd handle here. For now it falls // through to Pending. _ => ProviderInvoiceStatus::Pending, - }) + }; + // The amount the invoice is denominated for, for the advisory + // settle-amount tripwire (see docs/guides/payments.md). We price + // BTCPay invoices in "BTC" with a decimal amount = sats / 1e8 (see + // btcpay/client.rs::create_invoice), so convert that back to sats — + // f64 is exact for sat-magnitude integers and mirrors the inverse + // conversion already used in the client. Any other currency + // shouldn't occur in our flow; pass it through verbatim so the + // tripwire downstream flags the unexpected currency. Absent or + // unparseable amount → None ("no opinion"; tripwire skips it). + let amount = match ( + raw.get("currency").and_then(|v| v.as_str()), + raw.get("amount").and_then(|v| v.as_str()), + ) { + (Some("BTC"), Some(amt)) => amt + .parse::() + .ok() + .map(|btc| (btc * 100_000_000.0).round() as i64) + // Guard against garbage from the provider (negative/zero/NaN + // → 0): a real invoice amount is positive. Non-positive → None + // ("no opinion"), so the advisory tripwire skips it. + .filter(|&sats| sats > 0) + .map(Money::sats), + (Some(cur), Some(amt)) => amt.parse::().ok().map(|v| Money { + currency: cur.to_string(), + amount: v, + }), + _ => None, + }; + Ok(ProviderInvoiceSnapshot { status, amount }) } fn validate_webhook( diff --git a/licensing-service/src/payment/mod.rs b/licensing-service/src/payment/mod.rs index 0e83810..ab3d5c6 100644 --- a/licensing-service/src/payment/mod.rs +++ b/licensing-service/src/payment/mod.rs @@ -18,7 +18,8 @@ //! //! - `kind()` — provider identity, for logs / audit / admin UI //! - `create_invoice` — make a hosted-checkout session, return a URL -//! - `get_invoice_status` — for the reconcile loop (webhook misses) +//! - `get_invoice_status` — authoritative status + amount, for the reconcile +//! loop (webhook misses) and the webhook settle-confirmation gate //! - `validate_webhook` — provider-specific signature scheme + parse //! - `pay_lightning_invoice` — for the tip-recipient flow; default impl //! returns a "not supported" error so providers without a Lightning @@ -280,6 +281,23 @@ pub enum ProviderInvoiceStatus { Invalid, } +/// The provider's current view of an invoice: its `status` plus the amount +/// the provider has the invoice denominated for. Returned by +/// `PaymentProvider::get_invoice_status`. +/// +/// `amount` is the price the provider has on record for the invoice (what we +/// asked it to charge), normalized to `SAT` when the provider used a Bitcoin +/// unit. It is `None` when the response carried no parseable amount/currency. +/// `status` is the load-bearing settle gate; `amount` feeds only the +/// **advisory** settle-amount tripwire in `api::webhook` / `reconcile` — +/// callers treat `None` as "no opinion" and MUST NOT gate issuance on it. +/// See docs/guides/payments.md. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderInvoiceSnapshot { + pub status: ProviderInvoiceStatus, + pub amount: Option, +} + /// Parsed webhook event. Only the kinds Keysat actually acts on are /// modeled; everything else falls into `Other` and is ignored. #[derive(Debug, Clone)] @@ -355,7 +373,7 @@ pub trait PaymentProvider: Send + Sync + Any { async fn get_invoice_status( &self, provider_invoice_id: &str, - ) -> Result; + ) -> Result; /// Verify and parse a webhook delivery. Implementations are /// responsible for reading whatever signature header their provider diff --git a/licensing-service/src/payment/zaprite/provider.rs b/licensing-service/src/payment/zaprite/provider.rs index c6a6f51..a3d1eee 100644 --- a/licensing-service/src/payment/zaprite/provider.rs +++ b/licensing-service/src/payment/zaprite/provider.rs @@ -7,7 +7,7 @@ use crate::payment::{ CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, PaymentReceipt, - ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent, + ProviderInvoiceSnapshot, ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent, }; use anyhow::{anyhow, Context, Result}; use axum::http::HeaderMap; @@ -175,7 +175,7 @@ impl PaymentProvider for ZapriteProvider { async fn get_invoice_status( &self, provider_invoice_id: &str, - ) -> Result { + ) -> Result { let order = self .client .get_order(provider_invoice_id) @@ -198,7 +198,7 @@ impl PaymentProvider for ZapriteProvider { .get("status") .and_then(|v| v.as_str()) .unwrap_or(""); - Ok(match status_str { + let status = match status_str { "PAID" | "COMPLETE" | "OVERPAID" => ProviderInvoiceStatus::Settled, "PENDING" | "PROCESSING" | "UNDERPAID" => ProviderInvoiceStatus::Pending, // Zaprite doesn't have explicit Expired/Refunded states @@ -207,7 +207,30 @@ impl PaymentProvider for ZapriteProvider { // doesn't change. Fall-through covers any future // additions defensively. _ => ProviderInvoiceStatus::Invalid, - }) + }; + // The amount the order is denominated for, for the advisory + // settle-amount tripwire (see docs/guides/payments.md). We create + // Zaprite orders priced in "BTC" with the amount already in sats + // (see create_invoice above), so a Bitcoin currency maps straight + // to sats. Zaprite's order schema isn't fully documented, so this + // is best-effort: an absent/unparseable amount yields None and the + // tripwire is skipped. A non-Bitcoin currency is passed through so + // the tripwire can flag the unexpected currency. + let amount = match ( + order.get("currency").and_then(|v| v.as_str()), + order.get("amount").and_then(|v| v.as_i64()), + ) { + // Zaprite spells Bitcoin as "BTC" with the amount already in sats + // (see create_invoice above); "SAT" is accepted defensively. Both + // map to our canonical sat unit. Non-positive → None (skip). + (Some("BTC") | Some("SAT"), Some(sats)) if sats > 0 => Some(Money::sats(sats)), + (Some(cur), Some(v)) if v > 0 => Some(Money { + currency: cur.to_string(), + amount: v, + }), + _ => None, + }; + Ok(ProviderInvoiceSnapshot { status, amount }) } /// Validate an incoming webhook delivery from Zaprite. diff --git a/licensing-service/src/reconcile.rs b/licensing-service/src/reconcile.rs index e691643..51ee558 100644 --- a/licensing-service/src/reconcile.rs +++ b/licensing-service/src/reconcile.rs @@ -83,9 +83,9 @@ async fn tick(state: &AppState) -> anyhow::Result<()> { }, }; match provider.get_invoice_status(&inv.btcpay_invoice_id).await { - Ok(status) => { + Ok(snapshot) => { use crate::payment::ProviderInvoiceStatus::*; - let new_status = match status { + let new_status = match snapshot.status { Settled => "settled", Expired => "expired", Invalid => "invalid", @@ -124,6 +124,16 @@ async fn tick(state: &AppState) -> anyhow::Result<()> { } if new_status == "settled" { + // Same advisory amount tripwire the webhook path applies + // (see crate::api::webhook::audit_settle_amount). Never + // blocks issuance — logs + audits any amount/currency + // drift from what we charged. + crate::api::webhook::audit_settle_amount( + state, + &inv, + snapshot.amount.as_ref(), + ) + .await; if let Err(e) = ensure_license(state, &inv).await { tracing::warn!( error = %e, diff --git a/licensing-service/tests/api.rs b/licensing-service/tests/api.rs index bc4607e..e7b725b 100644 --- a/licensing-service/tests/api.rs +++ b/licensing-service/tests/api.rs @@ -22,8 +22,8 @@ use keysat::crypto::{self, LicensePayload}; use keysat::db::repo; use keysat::license_self::Tier; use keysat::payment::{ - CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus, - ProviderKind, ProviderWebhookEvent, + CreateInvoiceParams, CreatedInvoiceHandle, Money, PaymentProvider, ProviderInvoiceSnapshot, + ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent, }; use serde_json::{json, Value}; use sqlx::sqlite::{ @@ -393,6 +393,10 @@ enum StatusReport { struct MockPaymentProvider { next_invoice_id: AtomicU64, status_report: StatusReport, + /// Amount `get_invoice_status` reports the invoice is denominated for. + /// `None` (the default) = "no opinion", which disables the advisory + /// settle-amount tripwire; `Some` lets a test drive an amount mismatch. + settled_amount: Option, } impl MockPaymentProvider { @@ -401,6 +405,7 @@ impl MockPaymentProvider { Self { next_invoice_id: AtomicU64::new(1), status_report: StatusReport::Reports(ProviderInvoiceStatus::Settled), + settled_amount: None, } } @@ -410,6 +415,7 @@ impl MockPaymentProvider { Self { next_invoice_id: AtomicU64::new(1), status_report: StatusReport::Reports(ProviderInvoiceStatus::Pending), + settled_amount: None, } } @@ -419,6 +425,18 @@ impl MockPaymentProvider { Self { next_invoice_id: AtomicU64::new(1), status_report: StatusReport::Unavailable, + settled_amount: None, + } + } + + /// Confirms `Settled` but reports a specific denominated amount, so a test + /// can exercise the advisory settle-amount tripwire (mismatch → still + /// issues, but audits). + fn new_settled_with_amount(amount: Money) -> Self { + Self { + next_invoice_id: AtomicU64::new(1), + status_report: StatusReport::Reports(ProviderInvoiceStatus::Settled), + settled_amount: Some(amount), } } } @@ -443,12 +461,15 @@ impl PaymentProvider for MockPaymentProvider { async fn get_invoice_status( &self, _provider_invoice_id: &str, - ) -> Result { + ) -> Result { // The webhook handler re-fetches this to confirm a settle claim // before issuing. Configurable per-mock so a test can simulate the // provider disagreeing with a forged "settled" body, or being down. match self.status_report { - StatusReport::Reports(s) => Ok(s), + StatusReport::Reports(s) => Ok(ProviderInvoiceSnapshot { + status: s, + amount: self.settled_amount.clone(), + }), StatusReport::Unavailable => { anyhow::bail!("mock: provider status API unavailable") } @@ -838,6 +859,196 @@ async fn settle_webhook_acks_without_issuing_when_provider_unreachable() { ); } +/// Advisory settle-amount tripwire (P1): when the provider confirms `Settled` +/// but reports a different amount than we charged, the handler STILL issues +/// the license — the amount check is advisory, NOT a gate — and records an +/// `invoice.amount_mismatch` audit row so the drift is observable. This pins +/// the deliberate non-blocking behavior: a hard gate would false-reject +/// operators running a BTCPay payment tolerance. See docs/guides/payments.md. +#[tokio::test] +async fn settled_amount_mismatch_issues_license_but_audits() { + let (state, _tmp) = + install_mock_provider(MockPaymentProvider::new_settled_with_amount(Money::sats(1))).await; + + let product = repo::create_product( + &state.db, + "amount-mismatch-test", + "Amount Mismatch Test", + "", + 7_000, + &json!({}), + ) + .await + .expect("create_product"); + + let internal_invoice_id = Uuid::new_v4().to_string(); + let provider_invoice_id = "mock-inv-mismatch".to_string(); + repo::create_invoice( + &state.db, + &internal_invoice_id, + &provider_invoice_id, + &product.id, + 7_000, + "http://mock-checkout.test/i/mismatch", + None, // buyer_email + None, // buyer_note + None, // policy_id + None, // payment_provider_id + ) + .await + .expect("create_invoice"); + + let req = build_request( + "POST", + "/v1/btcpay/webhook", + &[("content-type", "application/json")], + Some(json!({ "kind": "settled", "provider_invoice_id": provider_invoice_id })), + ); + let resp = send(&state, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + // The settle is confirmed (status Settled), so issuance proceeds despite + // the amount mismatch — the tripwire is advisory. + let status_after: String = + sqlx::query_scalar("SELECT status FROM invoices WHERE btcpay_invoice_id = ?") + .bind(&provider_invoice_id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(status_after, "settled"); + + let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(licenses, 1, "advisory amount mismatch must NOT block issuance"); + + // ...but the drift is recorded for the operator to investigate. + let mismatches: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = 'invoice.amount_mismatch'", + ) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!( + mismatches, 1, + "amount/currency drift must be recorded in the audit log" + ); +} + +/// Fiat-denominated settles have no clean SAT comparison basis, so the advisory +/// tripwire SKIPS them — issues, no audit row. This is the case of a USD +/// subscription renewal, where the provider charges in the listed fiat currency +/// (not sats) and `amount_sats` is not the charged amount. Regression guard for +/// the false-positive a naive SAT comparison would emit on every fiat renewal. +#[tokio::test] +async fn settled_non_sat_settle_skips_amount_tripwire() { + let (state, _tmp) = install_mock_provider(MockPaymentProvider::new_settled_with_amount( + Money { + currency: "USD".to_string(), + amount: 999, + }, + )) + .await; + + let product = + repo::create_product(&state.db, "non-sat-test", "Non-SAT Test", "", 7_000, &json!({})) + .await + .expect("create_product"); + let internal_invoice_id = Uuid::new_v4().to_string(); + let provider_invoice_id = "mock-inv-nonsat".to_string(); + repo::create_invoice( + &state.db, + &internal_invoice_id, + &provider_invoice_id, + &product.id, + 7_000, + "http://mock-checkout.test/i/nonsat", + None, + None, + None, + None, + ) + .await + .expect("create_invoice"); + + let req = build_request( + "POST", + "/v1/btcpay/webhook", + &[("content-type", "application/json")], + Some(json!({ "kind": "settled", "provider_invoice_id": provider_invoice_id })), + ); + assert_eq!(send(&state, req).await.status(), StatusCode::OK); + + let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(licenses, 1, "non-SAT settle must still issue the license"); + let mismatches: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = 'invoice.amount_mismatch'", + ) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!( + mismatches, 0, + "non-SAT settle has no SAT comparison basis — skip, do NOT audit as a mismatch" + ); +} + +/// When the provider reports no parseable amount (`None`), the tripwire has no +/// opinion and is skipped: the license issues and no `invoice.amount_mismatch` +/// row is written. Pins the "None = skip, not mismatch" contract. +#[tokio::test] +async fn settled_without_provider_amount_skips_tripwire() { + // make_test_state_with_mock_provider uses MockPaymentProvider::new() — + // confirms Settled but reports no amount (settled_amount = None). + let (state, _tmp) = make_test_state_with_mock_provider().await; + + let product = + repo::create_product(&state.db, "none-amt-test", "None Amt", "", 5_000, &json!({})) + .await + .expect("create_product"); + let internal_invoice_id = Uuid::new_v4().to_string(); + let provider_invoice_id = "mock-inv-noneamt".to_string(); + repo::create_invoice( + &state.db, + &internal_invoice_id, + &provider_invoice_id, + &product.id, + 5_000, + "http://mock-checkout.test/i/noneamt", + None, + None, + None, + None, + ) + .await + .expect("create_invoice"); + + let req = build_request( + "POST", + "/v1/btcpay/webhook", + &[("content-type", "application/json")], + Some(json!({ "kind": "settled", "provider_invoice_id": provider_invoice_id })), + ); + assert_eq!(send(&state, req).await.status(), StatusCode::OK); + + let licenses: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM licenses") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(licenses, 1); + let mismatches: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log WHERE action = 'invoice.amount_mismatch'", + ) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(mismatches, 0, "no provider amount → tripwire skipped, no audit row"); +} + /// The settle webhook: provider POSTs an InvoiceSettled event, daemon /// flips the invoice status and issues a license. Re-POSTing the same /// webhook (which providers DO retry, sometimes aggressively) must not @@ -3084,6 +3295,160 @@ async fn scoped_api_key_management_rejects_scoped_full_admin() { ); } +/// Mint a scoped API key of `role` via the master-authed create endpoint and +/// return its raw bearer token. Exercises the real issue path the same way an +/// operator would. +async fn mint_scoped_key(state: &AppState, role: &str) -> String { + let auth = format!("Bearer {}", TEST_ADMIN_KEY); + let req = build_request( + "POST", + "/v1/admin/api-keys", + &[("authorization", &auth)], + Some(json!({ "label": format!("{role} key"), "role": role })), + ); + let resp = send(state, req).await; + assert_eq!(resp.status(), StatusCode::OK, "minting a {role} key should succeed"); + body_json(resp) + .await + .get("token") + .and_then(|t| t.as_str()) + .expect("create returns the raw token once") + .to_string() +} + +/// Read-only scoped keys can hit read endpoints but are 403 on writes, and are +/// still denied the endpoints we deliberately keep master-only (db-info). +#[tokio::test] +async fn scoped_read_only_key_reads_but_cannot_write() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", mint_scoped_key(&state, "read-only").await); + + // Read endpoint — allowed (every role grants `:read`). Use a param-free + // getter so the only gate exercised is the scope check (GET + // /v1/admin/licenses requires a product_id query param that 400s at the + // extractor before auth even runs). + let req = build_request( + "GET", + "/v1/admin/settings/operator-name", + &[("authorization", &auth)], + None, + ); + assert_eq!(send(&state, req).await.status(), StatusCode::OK); + + // db-info stays master-only even for reads. + let req = build_request("GET", "/v1/admin/db-info", &[("authorization", &auth)], None); + assert_eq!( + send(&state, req).await.status(), + StatusCode::FORBIDDEN, + "db-info is master-only; a read-only scoped key must be denied" + ); + + // Write endpoint — denied (products:write is full-admin only). + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ "slug": "ro-denied", "name": "Nope", "price_sats": 1000 })), + ); + assert_eq!(send(&state, req).await.status(), StatusCode::FORBIDDEN); +} + +/// License-issuer scoped keys can issue licenses (licenses:write) but cannot +/// manage the catalog (products:write is full-admin only). +#[tokio::test] +async fn scoped_license_issuer_key_issues_but_cannot_manage_catalog() { + let (state, _tmp) = make_test_state().await; + let master = format!("Bearer {}", TEST_ADMIN_KEY); + + // Master seeds a product to issue against. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &master)], + Some(json!({ "slug": "issuer-prod", "name": "Issuer Prod", "price_sats": 1000 })), + ); + assert_eq!(send(&state, req).await.status(), StatusCode::OK); + + let auth = format!("Bearer {}", mint_scoped_key(&state, "license-issuer").await); + + // Issue a license — allowed. + let req = build_request( + "POST", + "/v1/admin/licenses", + &[("authorization", &auth)], + Some(json!({ "product_slug": "issuer-prod" })), + ); + assert_eq!( + send(&state, req).await.status(), + StatusCode::OK, + "license-issuer must be able to issue licenses" + ); + + // Create a product — denied. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ "slug": "issuer-cant", "name": "Nope", "price_sats": 1000 })), + ); + assert_eq!( + send(&state, req).await.status(), + StatusCode::FORBIDDEN, + "license-issuer must NOT manage the catalog" + ); +} + +/// Support scoped keys are granted subscription/machine writes but not catalog +/// writes. The cancel of a nonexistent subscription is expected to fail +/// downstream (not found) — what matters is that authorization PASSED (not +/// 401/403), which isolates the scope grant from the business logic. +#[tokio::test] +async fn scoped_support_key_allowed_support_writes_not_catalog() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", mint_scoped_key(&state, "support").await); + + // subscriptions:write — auth passes; missing sub yields a non-403/401 status. + let req = build_request( + "POST", + "/v1/admin/subscriptions/does-not-exist/cancel", + &[("authorization", &auth)], + None, + ); + let status = send(&state, req).await.status(); + assert_ne!(status, StatusCode::FORBIDDEN, "support is granted subscriptions:write"); + assert_ne!(status, StatusCode::UNAUTHORIZED); + + // Catalog write — denied. + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ "slug": "sup-cant", "name": "Nope", "price_sats": 1000 })), + ); + assert_eq!(send(&state, req).await.status(), StatusCode::FORBIDDEN); +} + +/// Full-admin scoped keys CAN manage the catalog (products:write). The +/// master-only denial (minting other keys, etc.) is covered by +/// `scoped_api_key_management_rejects_scoped_full_admin`. +#[tokio::test] +async fn scoped_full_admin_key_manages_catalog() { + let (state, _tmp) = make_test_state().await; + let auth = format!("Bearer {}", mint_scoped_key(&state, "full-admin").await); + + let req = build_request( + "POST", + "/v1/admin/products", + &[("authorization", &auth)], + Some(json!({ "slug": "fa-prod", "name": "FA Prod", "price_sats": 1000 })), + ); + assert_eq!( + send(&state, req).await.status(), + StatusCode::OK, + "full-admin must be able to manage the catalog" + ); +} + /// 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 diff --git a/licensing-service/tests/subscriptions.rs b/licensing-service/tests/subscriptions.rs index 378f188..55e94cf 100644 --- a/licensing-service/tests/subscriptions.rs +++ b/licensing-service/tests/subscriptions.rs @@ -20,8 +20,8 @@ use keysat::api::AppState; use keysat::config::Config; use keysat::license_self::Tier; use keysat::payment::{ - CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus, - ProviderKind, ProviderWebhookEvent, + CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceSnapshot, + ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent, }; use keysat::subscriptions; use serde_json::{json, Value}; @@ -133,8 +133,11 @@ impl PaymentProvider for MockProvider { checkout_url: format!("http://mock.test/checkout/{n}"), }) } - async fn get_invoice_status(&self, _id: &str) -> Result { - Ok(ProviderInvoiceStatus::Pending) + async fn get_invoice_status(&self, _id: &str) -> Result { + Ok(ProviderInvoiceSnapshot { + status: ProviderInvoiceStatus::Pending, + amount: None, + }) } fn validate_webhook(&self, _h: &HeaderMap, _b: &[u8]) -> Result { anyhow::bail!("not exercised by renewal-worker tests") diff --git a/licensing-service/tests/upgrades.rs b/licensing-service/tests/upgrades.rs index 498ce53..1224eb5 100644 --- a/licensing-service/tests/upgrades.rs +++ b/licensing-service/tests/upgrades.rs @@ -734,8 +734,8 @@ async fn apply_tier_change_mutates_license_and_subscription() { #[tokio::test] async fn renewal_worker_applies_pending_tier_change_before_billing() { use keysat::payment::{ - CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus, - ProviderKind, ProviderWebhookEvent, + CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceSnapshot, + ProviderInvoiceStatus, ProviderKind, ProviderWebhookEvent, }; use std::any::Any; use std::sync::atomic::{AtomicU64, Ordering}; @@ -765,8 +765,11 @@ async fn renewal_worker_applies_pending_tier_change_before_billing() { checkout_url: format!("http://cap/{n}"), }) } - async fn get_invoice_status(&self, _id: &str) -> anyhow::Result { - Ok(ProviderInvoiceStatus::Pending) + async fn get_invoice_status(&self, _id: &str) -> anyhow::Result { + Ok(ProviderInvoiceSnapshot { + status: ProviderInvoiceStatus::Pending, + amount: None, + }) } fn validate_webhook( &self,