Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 54 additions & 2 deletions lnvps_api_admin/src/admin/roles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<dyn LNVpsDb>) -> 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<dyn LNVpsDb> = 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"
);
}
}
4 changes: 4 additions & 0 deletions lnvps_api_admin/src/bin/admin_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading