Wire scoped API keys and add advisory settle-amount tripwire
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.
This commit is contained in:
@@ -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<CreateProductReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Query(opts): Query<DeleteOpts>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<UpdateProductReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<ListLicensesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<SearchLicensesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<IssueLicenseReq>,
|
||||
) -> AppResult<Json<IssueLicenseResp>> {
|
||||
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<String>,
|
||||
Json(req): Json<RevokeReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<SuspendReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<ListAuditQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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()
|
||||
|
||||
@@ -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 <token>` 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 <token>` 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
|
||||
//! (`<resource>: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, "<resource>:<read|write>")` 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;
|
||||
|
||||
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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) => {
|
||||
|
||||
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<SetReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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(
|
||||
|
||||
@@ -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<CreateDiscountCodeReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<ListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<UpdateDiscountCodeReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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.
|
||||
|
||||
@@ -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<AdminListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<AdminDeactivateReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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()
|
||||
|
||||
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "merchant_profiles:read").await?;
|
||||
let profiles = merchant_profiles::list(&state.db).await?;
|
||||
let mut out: Vec<Value> = Vec::with_capacity(profiles.len());
|
||||
for p in &profiles {
|
||||
@@ -94,7 +94,7 @@ pub async fn get(
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<NewMerchantProfile>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(patch): Json<MerchantProfileUpdate>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<SetRailPreferenceReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<Json<Value>> {
|
||||
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(|| {
|
||||
|
||||
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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?,
|
||||
|
||||
@@ -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<CreatePolicyReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<ListPoliciesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<SetArchivedReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Query(opts): Query<PolicyDeleteOpts>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<UpdatePolicyReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<SetPublicReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<SetTipReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<ListTipsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "policies:read").await?;
|
||||
let entries = repo::list_tip_attempts(
|
||||
&state.db,
|
||||
q.license_id.as_deref(),
|
||||
|
||||
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
require_admin(&state, &headers)?;
|
||||
require_scope(&state, &headers, "rates:read").await?;
|
||||
let snapshot = state.rates.snapshot().await;
|
||||
let rates_json: Vec<Value> = snapshot
|
||||
.into_iter()
|
||||
@@ -52,7 +52,7 @@ pub async fn refresh(
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<RefreshReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<ListQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
body: Option<Json<CancelReq>>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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());
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn admin_status(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> AppResult<axum::Json<serde_json::Value>> {
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ListDeliveriesQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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?
|
||||
|
||||
@@ -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<CreateEndpointReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<ListEndpointsQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
Json(req): Json<SetActiveReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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<String>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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(
|
||||
|
||||
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Json<Value>> {
|
||||
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) => {
|
||||
|
||||
@@ -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<ProviderInvoiceStatus> {
|
||||
) -> Result<ProviderInvoiceSnapshot> {
|
||||
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::<f64>()
|
||||
.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::<i64>().ok().map(|v| Money {
|
||||
currency: cur.to_string(),
|
||||
amount: v,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(ProviderInvoiceSnapshot { status, amount })
|
||||
}
|
||||
|
||||
fn validate_webhook(
|
||||
|
||||
@@ -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<Money>,
|
||||
}
|
||||
|
||||
/// 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<ProviderInvoiceStatus>;
|
||||
) -> Result<ProviderInvoiceSnapshot>;
|
||||
|
||||
/// Verify and parse a webhook delivery. Implementations are
|
||||
/// responsible for reading whatever signature header their provider
|
||||
|
||||
@@ -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<ProviderInvoiceStatus> {
|
||||
) -> Result<ProviderInvoiceSnapshot> {
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Money>,
|
||||
}
|
||||
|
||||
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<ProviderInvoiceStatus> {
|
||||
) -> Result<ProviderInvoiceSnapshot> {
|
||||
// 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
|
||||
|
||||
@@ -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<ProviderInvoiceStatus> {
|
||||
Ok(ProviderInvoiceStatus::Pending)
|
||||
async fn get_invoice_status(&self, _id: &str) -> Result<ProviderInvoiceSnapshot> {
|
||||
Ok(ProviderInvoiceSnapshot {
|
||||
status: ProviderInvoiceStatus::Pending,
|
||||
amount: None,
|
||||
})
|
||||
}
|
||||
fn validate_webhook(&self, _h: &HeaderMap, _b: &[u8]) -> Result<ProviderWebhookEvent> {
|
||||
anyhow::bail!("not exercised by renewal-worker tests")
|
||||
|
||||
@@ -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<ProviderInvoiceStatus> {
|
||||
Ok(ProviderInvoiceStatus::Pending)
|
||||
async fn get_invoice_status(&self, _id: &str) -> anyhow::Result<ProviderInvoiceSnapshot> {
|
||||
Ok(ProviderInvoiceSnapshot {
|
||||
status: ProviderInvoiceStatus::Pending,
|
||||
amount: None,
|
||||
})
|
||||
}
|
||||
fn validate_webhook(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user