Skip to main content

boatramp_node/
auth.rs

1//! Control-plane auth assembly: build the [`Auth`](boatramp_server::Auth) from
2//! the resolved root-key settings, and the fail-closed bind guard that refuses
3//! to expose an unauthenticated control plane on a public listener.
4//!
5//! Moved out of the `boatramp` binary's `serve` path so an in-process embedder —
6//! or a fidelity test — assembles auth exactly as `boatramp serve` does.
7
8use std::net::SocketAddr;
9use std::sync::Arc;
10
11use boatramp_core::kv::KvStore;
12
13use crate::error::{Error, Result};
14
15/// Build the control-plane [`Auth`](boatramp_server::Auth) from the resolved
16/// root-key settings (flag/env > `serve` config). For an issuing node (a
17/// private key or an external signer) it also sets `options.issuer` so the
18/// token-create and OIDC-exchange routes can mint. No key ⇒ auth disabled (dev).
19pub async fn configure_auth(
20    signer: Option<&crate::config::AuthSignerConfig>,
21    private_key: Option<String>,
22    public_key: Option<String>,
23    options: &mut boatramp_server::ServerOptions,
24    kv: Arc<dyn KvStore>,
25    shared_authz: Option<SharedAuthz>,
26) -> Result<boatramp_server::Auth> {
27    use boatramp_core::cose::{LocalSigner, Signer, TokenPublicKey};
28    // Apply the multi-writer `shared`-mode authz guard (MF-3 fence + MF-4 fail-closed) when present.
29    // A no-op on a disabled auth, and NEVER passed for a single-writer backend (whose authz path is
30    // unchanged) — the node bootstrap builds `shared_authz` only for a multi-writer KV backend.
31    let with_shared = |auth: boatramp_server::Auth| match &shared_authz {
32        Some(s) => auth.with_shared_authz(s.backing.clone(), s.fence.clone()),
33        None => auth,
34    };
35    // An external signer (KMS/HSM/Vault) issues *and* provides the trust anchor:
36    // it resolves its own public key at connect.
37    if let Some(cfg) = signer {
38        let issuer = boatramp_server::signer::build_signer(&cfg.to_signer_config())
39            .await
40            .map_err(|e| Error::AuthPrivKey(e.to_string()))?;
41        let public = issuer.public_key();
42        options.issuer = Some(issuer);
43        return Ok(with_shared(boatramp_server::Auth::with_key(public, kv)));
44    }
45    if let Some(hex) = private_key {
46        let signer =
47            LocalSigner::from_private_hex(&hex).map_err(|e| Error::AuthPrivKey(e.to_string()))?;
48        let public = signer.public_key();
49        options.issuer = Some(Arc::new(signer) as Arc<dyn Signer>);
50        return Ok(with_shared(boatramp_server::Auth::with_key(public, kv)));
51    }
52    if let Some(hex) = public_key {
53        let public =
54            TokenPublicKey::from_hex(&hex).map_err(|e| Error::AuthPubKey(e.to_string()))?;
55        return Ok(with_shared(boatramp_server::Auth::with_key(public, kv)));
56    }
57    Ok(boatramp_server::Auth::disabled())
58}
59
60/// The multi-writer `shared`-mode authz guard the node bootstrap hands [`configure_auth`] (MF-3
61/// stale-authz fence + MF-4 fail-closed). Built ONLY when the control-plane KV backend declares
62/// [`WriterModel::MultiWriter`](boatramp_core::kv::WriterModel); a single-writer backend passes
63/// `None` and keeps the unchanged authz path.
64pub struct SharedAuthz {
65    /// The UNCACHED control-plane store (the `CachedKv`'s inner backend) the fence reads the authz
66    /// keyspace THROUGH when it cannot trust the cache.
67    pub backing: Arc<dyn KvStore>,
68    /// The MF-3 currency fence, shared (by `Arc`) with the cache poller.
69    pub fence: Arc<boatramp_core::cache_coherence::AuthzFence>,
70}
71
72/// Fail-closed bind guard: refuse to expose an unauthenticated control plane on a
73/// non-loopback listener unless the posture explicitly allows it, and warn loudly
74/// for any auth-disabled listener.
75pub fn enforce_auth_bind(
76    addr: SocketAddr,
77    auth: &boatramp_server::Auth,
78    posture: &boatramp_core::security::SecurityPosture,
79) -> Result<()> {
80    if auth.is_disabled() {
81        if !addr.ip().is_loopback() && !posture.allow_unauthenticated_public_bind {
82            return Err(Error::UnauthenticatedPublicBind { addr });
83        }
84        tracing::warn!(
85            %addr,
86            "control-plane auth is DISABLED — do not expose this listener to an untrusted network"
87        );
88    }
89    Ok(())
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use boatramp_core::security::SecurityProfile;
96
97    /// An auth-disabled non-loopback bind is refused under the strict
98    /// posture, allowed on loopback, and allowed when the posture opts in.
99    #[test]
100    fn fail_closed_refuses_unauthenticated_public_bind() {
101        let disabled = boatramp_server::Auth::disabled();
102        let strict = SecurityProfile::MultiTenant.preset();
103        let dev = SecurityProfile::Dev.preset();
104        let public: SocketAddr = "0.0.0.0:8080".parse().unwrap();
105        let loopback: SocketAddr = "127.0.0.1:8080".parse().unwrap();
106
107        // Auth disabled + public + strict → refused.
108        assert!(matches!(
109            enforce_auth_bind(public, &disabled, &strict),
110            Err(Error::UnauthenticatedPublicBind { .. })
111        ));
112        // Loopback is always permitted (local-dev convenience).
113        assert!(enforce_auth_bind(loopback, &disabled, &strict).is_ok());
114        // The `dev` posture opts into an unauthenticated public bind.
115        assert!(enforce_auth_bind(public, &disabled, &dev).is_ok());
116    }
117}