diff --git a/API_CHANGELOG.md b/API_CHANGELOG.md index 71ecab85..ad5fb269 100644 --- a/API_CHANGELOG.md +++ b/API_CHANGELOG.md @@ -833,6 +833,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `DELETE /api/v1/payment-methods/{id}` — remove a saved method. - **Breaking:** the `nwc_connection_string` field on `GET`/`PATCH /api/v1/account` has been removed. Existing NWC connections are migrated into `user_payment_method` (provider `nwc`); manage NWC via the new payment-methods endpoints instead. +- **Demo auto-admin requires an explicit runtime opt-in**: in builds with the `demo` feature, `GET /api/admin/v1/me/roles` no longer self-assigns the admin role unconditionally — it now also requires `LNVPS_DEMO_OPEN_ADMIN=1` in the worker's environment, and the binary warns at startup when the feature is compiled in. Demo deployments set the variable; any other deployment (including one that accidentally shipped with `--features demo`) refuses self-promotion. Set the variable in the demo environment to restore the old behavior. + ## [0.4.0] - 2026-07-13 ### Added diff --git a/lnvps_api_admin/src/admin/roles.rs b/lnvps_api_admin/src/admin/roles.rs index 5d54d2ff..46b424aa 100644 --- a/lnvps_api_admin/src/admin/roles.rs +++ b/lnvps_api_admin/src/admin/roles.rs @@ -537,8 +537,16 @@ async fn admin_get_my_roles( #[cfg(feature = "demo")] { - // assign admin role when no roles are found - if role_assignments.len() == 0 { + // Demo builds auto-assign the admin role to any authenticated key + // that lands here — deliberately, so a demo deployment needs no + // bootstrap step. Two conditions must BOTH hold: the `demo` feature + // is compiled in AND the operator exported LNVPS_DEMO_OPEN_ADMIN=1. + // A release build with `--features demo` that forgot the variable + // (or an attacker hitting a demo deployment that did not set it) is + // refused instead of self-promoting. + if role_assignments.len() == 0 + && std::env::var("LNVPS_DEMO_OPEN_ADMIN").as_deref() == Ok("1") + { let roles = this.db.list_roles().await?; if let Some(admin_role) = roles.iter().find(|r| r.name == "admin") { this.db @@ -764,4 +772,48 @@ mod tests { // Ordinary roles are unaffected. assert!(check_role_revocation(1, 1, false, "read_only", 1).is_ok()); } + /// Regression (audit LNVM-006): the demo auto-admin must require the + /// explicit `LNVPS_DEMO_OPEN_ADMIN=1` opt-in. Without the variable — + /// feature compiled in or not — an authenticated key with no roles is + /// reported as having none, never promoted. (The grant path itself needs + /// a database: MockDb's role functions are stubs, so only the refusal is + /// unit-testable here.) + #[tokio::test] + async fn demo_auto_admin_requires_the_explicit_opt_in() { + use lnvps_api_common::{ + ChannelWorkCommander, MockDb, MockExchangeRate, VatClient, VmStateCache, + }; + use lnvps_db::LNVpsDb; + use std::sync::Arc; + + fn state(db: &Arc) -> crate::admin::RouterState { + crate::admin::RouterState { + node_control: None, + db: db.clone(), + work_commander: Arc::new(ChannelWorkCommander::new()), + feedback: None, + vm_state_cache: VmStateCache::new(), + exchange: Arc::new(MockExchangeRate::default()), + vat: VatClient::new(), + } + } + + let db: Arc = Arc::new(MockDb::default()); + let pubkey = [30u8; 32]; + let user_id = db.upsert_user(&pubkey).await.unwrap(); + + let auth = AdminAuth { + user_id, + pubkey: pubkey.to_vec(), + permissions: Default::default(), + nip98_auth: None, + }; + // single-threaded test body: env mutation is fine here + unsafe { std::env::remove_var("LNVPS_DEMO_OPEN_ADMIN") }; + let res = admin_get_my_roles(auth, State(state(&db))).await.unwrap(); + assert!( + res.0.data.is_empty(), + "a key with no roles must not be promoted without the opt-in" + ); + } } diff --git a/lnvps_api_admin/src/bin/admin_api.rs b/lnvps_api_admin/src/bin/admin_api.rs index 38177281..7a5a48b3 100644 --- a/lnvps_api_admin/src/bin/admin_api.rs +++ b/lnvps_api_admin/src/bin/admin_api.rs @@ -138,6 +138,10 @@ async fn main() -> Result<(), Error> { Some(i) => i.parse()?, None => SocketAddr::new(IpAddr::from([0, 0, 0, 0]), 8001), }; + #[cfg(feature = "demo")] + warn!( + "DEMO BUILD: /api/admin/v1/me/roles auto-assigns the admin role to any key when LNVPS_DEMO_OPEN_ADMIN=1 - never expose a demo build to the public internet" + ); let listener = bind_address(ip).await?; info!("Listening on {}", ip); let router = admin_router(