mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(capabilities): honest secret-presence downgrade + admin features view [operator §4]
Make /capabilities fail-closed and add the admin features surface. - Secret-presence downgrade (§4.2): a chain whose flag is on but whose source is unconfigured (btc/ltc no electrum URL, xmr/wow no LWS admin key, grin no owner secret) now reports enabled:false — so /capabilities never advertises a chain that would 404, and a missing-secret downgrade is indistinguishable from a deliberately-off chain. grin_relay requires serviceable Grin; nostr_identity requires PUBLIC_API_URL. effective_capabilities() is the single explicit mapper. - contract_version stamp on the public document (clients soft-notice a higher value, ignore unknown additive keys). - GET /admin/features (admin_guard): the resolved effective state PLUS admin-only downgrade reasons (the public surface hides the reason, per §4.3). - Golden test: the public capabilities JSON, built from a fully-secret-seeded config, contains no secret value (asserts no "integration-test-" substring). openapi regenerated (contract_version). Admin features view stays off the public OpenAPI. Deferred (documented): runtime mutation — PUT /admin/features + a DB feature- override table + resolve() precedence + Arc hot-swap + CLI `features set`. Flags are env/config-driven for v1; runtime tuning (and its fleet propagation, itself a deferred LB decision) is a later addition.
This commit is contained in:
@@ -1719,6 +1719,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"version",
|
"version",
|
||||||
|
"contract_version",
|
||||||
"chains",
|
"chains",
|
||||||
"features"
|
"features"
|
||||||
],
|
],
|
||||||
@@ -1726,6 +1727,12 @@
|
|||||||
"chains": {
|
"chains": {
|
||||||
"$ref": "#/components/schemas/ChainCapabilities"
|
"$ref": "#/components/schemas/ChainCapabilities"
|
||||||
},
|
},
|
||||||
|
"contract_version": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"description": "Capabilities contract version (additive changes do not bump it).",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
"features": {
|
"features": {
|
||||||
"$ref": "#/components/schemas/FeatureCapabilities"
|
"$ref": "#/components/schemas/FeatureCapabilities"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -651,6 +651,93 @@ pub async fn admin_keys_rotate(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── features (read-only view) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Downgrade {
|
||||||
|
pub feature: String,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminFeaturesResponse {
|
||||||
|
pub effective: crate::api::capabilities::CapabilitiesResponse,
|
||||||
|
/// Flags that are ON in config but serve as disabled (missing secret/URL).
|
||||||
|
/// Exposed only to the admin — the public capabilities hides the reason.
|
||||||
|
pub downgrades: Vec<Downgrade>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin-only view of the resolved feature state + why anything is downgraded.
|
||||||
|
/// (Runtime mutation — PUT /admin/features + DB override — is deferred; flags are
|
||||||
|
/// env/config-driven for now.)
|
||||||
|
#[instrument(skip(state, headers))]
|
||||||
|
pub async fn admin_features(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<AdminFeaturesResponse>, AppError> {
|
||||||
|
admin_guard(&state, &headers).await?;
|
||||||
|
let cfg = &state.config;
|
||||||
|
let effective = crate::api::capabilities::effective_capabilities(cfg);
|
||||||
|
|
||||||
|
let mut downgrades = Vec::new();
|
||||||
|
let mut note = |on: bool, eff: bool, feature: &str, reason: &str| {
|
||||||
|
if on && !eff {
|
||||||
|
downgrades.push(Downgrade {
|
||||||
|
feature: feature.into(),
|
||||||
|
reason: reason.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let c = &cfg.features.chains;
|
||||||
|
note(
|
||||||
|
c.btc,
|
||||||
|
effective.chains.btc.enabled,
|
||||||
|
"btc",
|
||||||
|
"BTC_ELECTRUM_URL/fallbacks unset",
|
||||||
|
);
|
||||||
|
note(
|
||||||
|
c.ltc,
|
||||||
|
effective.chains.ltc.enabled,
|
||||||
|
"ltc",
|
||||||
|
"LTC_ELECTRUM_URL/fallbacks unset",
|
||||||
|
);
|
||||||
|
note(
|
||||||
|
c.xmr,
|
||||||
|
effective.chains.xmr.enabled,
|
||||||
|
"xmr",
|
||||||
|
"XMR_LWS_ADMIN_KEY unset",
|
||||||
|
);
|
||||||
|
note(
|
||||||
|
c.wow,
|
||||||
|
effective.chains.wow.enabled,
|
||||||
|
"wow",
|
||||||
|
"WOW_LWS_ADMIN_KEY unset",
|
||||||
|
);
|
||||||
|
note(
|
||||||
|
c.grin,
|
||||||
|
effective.chains.grin.enabled,
|
||||||
|
"grin",
|
||||||
|
"GRIN_OWNER_API_SECRET unset",
|
||||||
|
);
|
||||||
|
note(
|
||||||
|
cfg.features.grin_relay,
|
||||||
|
effective.features.grin_relay,
|
||||||
|
"grin_relay",
|
||||||
|
"Grin chain not serviceable",
|
||||||
|
);
|
||||||
|
note(
|
||||||
|
cfg.features.nostr_identity,
|
||||||
|
effective.features.nostr_identity,
|
||||||
|
"nostr_identity",
|
||||||
|
"PUBLIC_API_URL unset",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(AdminFeaturesResponse {
|
||||||
|
effective,
|
||||||
|
downgrades,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// ── router ───────────────────────────────────────────────────────────────────
|
// ── router ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket.
|
/// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket.
|
||||||
@@ -661,6 +748,7 @@ pub fn routes() -> Router<Arc<AppState>> {
|
|||||||
.route("/admin/auth/refresh", post(admin_refresh))
|
.route("/admin/auth/refresh", post(admin_refresh))
|
||||||
.route("/admin/auth/logout", post(admin_logout))
|
.route("/admin/auth/logout", post(admin_logout))
|
||||||
.route("/admin/me", get(admin_me))
|
.route("/admin/me", get(admin_me))
|
||||||
|
.route("/admin/features", get(admin_features))
|
||||||
.route("/admin/keys", post(admin_keys_add).get(admin_keys_list))
|
.route("/admin/keys", post(admin_keys_add).get(admin_keys_list))
|
||||||
.route("/admin/keys/:id", delete(admin_keys_revoke))
|
.route("/admin/keys/:id", delete(admin_keys_revoke))
|
||||||
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
|
.route("/admin/keys/:id/rotate", post(admin_keys_rotate))
|
||||||
|
|||||||
+66
-33
@@ -12,8 +12,13 @@ use axum::{extract::State, routing::get, Json, Router};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Capabilities contract version. Bumped only on a breaking shape change; clients
|
||||||
|
/// soft-notice a higher value and ignore unknown (additive) keys.
|
||||||
|
pub const CAPABILITIES_CONTRACT_VERSION: u32 = 1;
|
||||||
|
|
||||||
/// Per-chain availability. `network` is the configured network for UTXO chains
|
/// Per-chain availability. `network` is the configured network for UTXO chains
|
||||||
/// (so the wallet derives addresses for the right one); `null` for chains whose
|
/// (so the wallet derives addresses for the right one); `null` for chains whose
|
||||||
/// network isn't a backend setting.
|
/// network isn't a backend setting.
|
||||||
@@ -48,10 +53,70 @@ pub struct FeatureCapabilities {
|
|||||||
pub struct CapabilitiesResponse {
|
pub struct CapabilitiesResponse {
|
||||||
/// Backend version (Cargo package version).
|
/// Backend version (Cargo package version).
|
||||||
pub version: String,
|
pub version: String,
|
||||||
|
/// Capabilities contract version (additive changes do not bump it).
|
||||||
|
pub contract_version: u32,
|
||||||
pub chains: ChainCapabilities,
|
pub chains: ChainCapabilities,
|
||||||
pub features: FeatureCapabilities,
|
pub features: FeatureCapabilities,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether an enabled chain can actually be served (its infra secret/URL is
|
||||||
|
/// present). A chain whose flag is on but whose source is unconfigured reports
|
||||||
|
/// `enabled:false` — so `/capabilities` never advertises a chain that 404s, and
|
||||||
|
/// a missing-secret downgrade is indistinguishable from a deliberately-off chain.
|
||||||
|
fn chain_serviceable(config: &Config, asset: &str) -> bool {
|
||||||
|
let f = &config.features.chains;
|
||||||
|
let c = &config.chains;
|
||||||
|
match asset {
|
||||||
|
"btc" => {
|
||||||
|
f.btc && (c.btc.electrum_primary.is_some() || !c.btc.electrum_fallbacks.is_empty())
|
||||||
|
}
|
||||||
|
"ltc" => {
|
||||||
|
f.ltc && (c.ltc.electrum_primary.is_some() || !c.ltc.electrum_fallbacks.is_empty())
|
||||||
|
}
|
||||||
|
"xmr" => f.xmr && !c.xmr.lws_admin_key.is_empty(),
|
||||||
|
"wow" => f.wow && !c.wow.lws_admin_key.is_empty(),
|
||||||
|
"grin" => f.grin && !c.grin.owner_api_secret.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the public capabilities projection with the secret-presence downgrade.
|
||||||
|
pub fn effective_capabilities(config: &Config) -> CapabilitiesResponse {
|
||||||
|
let utxo_net = |on: bool, net: &str| ChainCapability {
|
||||||
|
enabled: on,
|
||||||
|
network: Some(net.to_string()),
|
||||||
|
};
|
||||||
|
CapabilitiesResponse {
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
contract_version: CAPABILITIES_CONTRACT_VERSION,
|
||||||
|
chains: ChainCapabilities {
|
||||||
|
btc: utxo_net(chain_serviceable(config, "btc"), &config.chains.btc.network),
|
||||||
|
ltc: utxo_net(chain_serviceable(config, "ltc"), &config.chains.ltc.network),
|
||||||
|
xmr: ChainCapability {
|
||||||
|
enabled: chain_serviceable(config, "xmr"),
|
||||||
|
network: None,
|
||||||
|
},
|
||||||
|
wow: ChainCapability {
|
||||||
|
enabled: chain_serviceable(config, "wow"),
|
||||||
|
network: None,
|
||||||
|
},
|
||||||
|
grin: ChainCapability {
|
||||||
|
enabled: chain_serviceable(config, "grin"),
|
||||||
|
network: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features: FeatureCapabilities {
|
||||||
|
// The relay needs Grin chain access to be serviceable.
|
||||||
|
grin_relay: config.features.grin_relay && chain_serviceable(config, "grin"),
|
||||||
|
prices: config.features.prices,
|
||||||
|
// Nostr identity needs the canonical PUBLIC_API_URL.
|
||||||
|
nostr_identity: config.features.nostr_identity
|
||||||
|
&& config.identity.public_api_url.is_some(),
|
||||||
|
tips: config.features.tips,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Describe this instance's enabled chains and features.
|
/// Describe this instance's enabled chains and features.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -61,39 +126,7 @@ pub struct CapabilitiesResponse {
|
|||||||
)]
|
)]
|
||||||
#[instrument(skip(state))]
|
#[instrument(skip(state))]
|
||||||
pub async fn capabilities(State(state): State<Arc<AppState>>) -> Json<CapabilitiesResponse> {
|
pub async fn capabilities(State(state): State<Arc<AppState>>) -> Json<CapabilitiesResponse> {
|
||||||
let cfg = &state.config;
|
Json(effective_capabilities(&state.config))
|
||||||
let chains = &cfg.features.chains;
|
|
||||||
Json(CapabilitiesResponse {
|
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
chains: ChainCapabilities {
|
|
||||||
btc: ChainCapability {
|
|
||||||
enabled: chains.btc,
|
|
||||||
network: Some(cfg.chains.btc.network.clone()),
|
|
||||||
},
|
|
||||||
ltc: ChainCapability {
|
|
||||||
enabled: chains.ltc,
|
|
||||||
network: Some(cfg.chains.ltc.network.clone()),
|
|
||||||
},
|
|
||||||
xmr: ChainCapability {
|
|
||||||
enabled: chains.xmr,
|
|
||||||
network: None,
|
|
||||||
},
|
|
||||||
wow: ChainCapability {
|
|
||||||
enabled: chains.wow,
|
|
||||||
network: None,
|
|
||||||
},
|
|
||||||
grin: ChainCapability {
|
|
||||||
enabled: chains.grin,
|
|
||||||
network: None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
features: FeatureCapabilities {
|
|
||||||
grin_relay: cfg.features.grin_relay,
|
|
||||||
prices: cfg.features.prices,
|
|
||||||
nostr_identity: cfg.features.nostr_identity,
|
|
||||||
tips: cfg.features.tips,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth).
|
/// Capability route, RELATIVE to the `/api/v1` mount point. Public (no auth).
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ async fn capabilities_is_public_and_well_shaped() {
|
|||||||
assert_eq!(status, StatusCode::OK);
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|
||||||
assert!(body["version"].as_str().is_some(), "version present");
|
assert!(body["version"].as_str().is_some(), "version present");
|
||||||
|
assert!(
|
||||||
|
body["contract_version"].as_u64().is_some(),
|
||||||
|
"contract_version present"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Golden: no server secret may appear in the public capabilities JSON. The
|
||||||
|
// harness seeds every secret with the "integration-test-" prefix.
|
||||||
|
let raw = serde_json::to_string(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
!raw.contains("integration-test"),
|
||||||
|
"no secret leaks into public capabilities: {raw}"
|
||||||
|
);
|
||||||
|
|
||||||
// Every chain reports an `enabled` boolean.
|
// Every chain reports an `enabled` boolean.
|
||||||
for chain in ["btc", "ltc", "xmr", "wow", "grin"] {
|
for chain in ["btc", "ltc", "xmr", "wow", "grin"] {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Reference in New Issue
Block a user