crates/gunnar-server/tests/setting_change_audited_rpc.rs
//! ★ RA2-9 / RA4-8 **through the real entry point**: `Admin.SetSetting` as a box serves it,
//! the real gRPC router with its gate in front, over the real `CoreControlPlane`, with the
//! ledger `serve.rs` installs (`grpc_gate::activity_ledger` over the hash-chained trail).
//! `setting_change_audited.rs` calls `AdminService` with a `Caller` put in by hand; here every
//! call carries a signed key the gate must verify.
//!
//! * the server admin's change writes its change record (`SETTING <name>: old -> new`) into
//! the hash-chained trail, and the chain verifies;
//! * a person without the server's `Admin` and a call with no credential are refused, and no
//! change record is written for them, nor any change applied;
//! * a trail that can no longer be appended to refuses the change by name
//! (`audit.unavailable`) and applies nothing.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use gunnar_core::ssh_key::PrivateKey;
use gunnar_core::{AuditChain, Principal, PrincipalKind};
use gunnar_grpc::client_signer::{authed, plain_channel, ControlChannel};
use gunnar_grpc::{ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::admin_client::AdminClient;
use gunnar_proto::{GetSettingRequest, SetSettingRequest};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use tonic::{Code, Request};
fn free_addr() -> SocketAddr {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
l.local_addr().expect("addr")
}
fn mint() -> (PrivateKey, String) {
let pem = gunnar_core::mint_ed25519().expect("mint");
let k = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
let line = k.public_key().to_openssh().expect("line");
(k, line)
}
/// One box: the real router, the server's own ledger over the chain at `audit/chain.jsonl`,
/// its first admin (`admin`) and a person with no server grant (`bob`).
struct Rig {
dir: tempfile::TempDir,
rt: tokio::runtime::Runtime,
plane: Arc<CoreControlPlane>,
chain: Arc<AuditChain>,
at: String,
admin: PrivateKey,
bob: PrivateKey,
stop: Option<tokio::sync::oneshot::Sender<()>>,
}
impl Drop for Rig {
fn drop(&mut self) {
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
}
}
impl Rig {
fn start() -> Rig {
let dir = tempfile::tempdir().unwrap();
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let (admin, admin_line) = mint();
stores
.auth()
.bootstrap_admin(&admin_line)
.expect("the first admin");
stores
.auth()
.add_principal(Principal::new("bob", "bob", PrincipalKind::User).expect("legal id"))
.expect("principal");
let (bob, bob_line) = mint();
stores
.auth()
.register_key("bob", &bob_line, None)
.expect("bob's SSH key");
let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
let control: Arc<dyn ControlPlane> = plane.clone();
let chain = Arc::new(
AuditChain::open(dir.path().join("audit").join("chain.jsonl")).expect("the chain"),
);
// What serve.rs installs: the activity stamp over the hash-chained trail.
let ledger = gunnar_server::grpc_gate::activity_ledger(
Some(gunnar_server::grpc_gate::control_audit_ledger(Arc::clone(
&chain,
))),
Arc::clone(stores.activity()),
);
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
)
.with_audit_ledger(ledger);
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("rt");
let addr = free_addr();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
rt.spawn(async move {
let _ = gunnar_grpc::router(ctx, || 0)
.serve_with_shutdown(addr, async {
let _ = rx.await;
})
.await;
});
let t0 = Instant::now();
while std::net::TcpStream::connect(addr).is_err() {
assert!(t0.elapsed() < Duration::from_secs(10), "the box listens");
std::thread::sleep(Duration::from_millis(25));
}
Rig {
dir,
rt,
plane,
chain,
at: addr.to_string(),
admin,
bob,
stop: Some(tx),
}
}
fn admin_client(&self) -> AdminClient<ControlChannel> {
AdminClient::new(plain_channel(&self.at).expect("a channel"))
}
/// The change records in the trail: (principal, reason).
fn changes(&self) -> Vec<(String, String)> {
self.chain
.entries()
.expect("the chain reads")
.into_iter()
.filter(|e| e.method == "Admin.SetSetting" && e.reason.starts_with("SETTING "))
.map(|e| (e.principal, e.reason))
.collect()
}
}
/// ★ **A setting change through the router writes its audit row**; refusals write none and
/// change nothing.
#[test]
fn a_setting_change_through_the_router_writes_its_audit_row() {
let r = Rig::start();
r.rt.block_on(async {
let mut admin = r.admin_client();
let set = |value: &[u8]| SetSettingRequest {
name: "console.a-switch".into(),
value: value.to_vec(),
};
// No credential, and a person without the server's Admin: refused, nothing written.
let anon = admin
.set_setting(Request::new(set(b"on")))
.await
.unwrap_err();
assert_eq!(anon.code(), Code::Unauthenticated, "{anon:?}");
let refused = admin
.set_setting(authed(&r.bob, set(b"on")))
.await
.unwrap_err();
assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
assert_eq!(
r.plane.setting("console.a-switch").unwrap(),
None,
"a refusal applied a change"
);
assert!(
r.changes().is_empty(),
"a refused caller wrote a change record: {:?}",
r.changes()
);
// The admin's two changes: each writes its record, old -> new.
admin
.set_setting(authed(&r.admin, set(b"on")))
.await
.expect("Admin.SetSetting through the gate");
let previous = admin
.set_setting(authed(&r.admin, set(b"off")))
.await
.expect("the second change")
.into_inner();
assert!(previous.had && previous.previous == b"on", "{previous:?}");
let read = admin
.get_setting(authed(
&r.admin,
GetSettingRequest {
name: "console.a-switch".into(),
},
))
.await
.expect("Admin.GetSetting")
.into_inner();
assert!(read.present && read.value == b"off", "{read:?}");
});
let changes = r.changes();
let admin_id = changes
.first()
.map(|(who, _)| who.clone())
.unwrap_or_default();
assert!(!admin_id.is_empty() && admin_id != "bob", "{changes:?}");
assert_eq!(
changes,
[
(
admin_id.clone(),
"SETTING console.a-switch: (unset) -> \"on\"".to_owned()
),
(
admin_id,
"SETTING console.a-switch: \"on\" -> \"off\"".to_owned()
),
],
"a change record per change, in the hash-chained trail"
);
let entries = r.chain.entries().unwrap();
assert!(
entries.iter().any(|e| e.method == "Admin.SetSetting"
&& e.principal == "bob"
&& e.decision == "deny"),
"the gate's refusal of bob is in the trail: {entries:#?}"
);
assert_eq!(r.chain.verify().expect("the chain verifies"), entries.len());
}
/// ★ **RA4-8 through the router: a change the trail cannot hold is refused by name and not
/// applied.**
#[test]
fn a_setting_change_the_trail_cannot_hold_is_refused_through_the_router() {
let r = Rig::start();
// The chain's file goes; a directory takes its name, so no append can land.
let path = r.dir.path().join("audit").join("chain.jsonl");
let _ = std::fs::remove_file(&path);
std::fs::create_dir_all(&path).unwrap();
r.rt.block_on(async {
let refused = r
.admin_client()
.set_setting(authed(
&r.admin,
SetSettingRequest {
name: "console.a-switch".into(),
value: b"on".to_vec(),
},
))
.await
.expect_err("a change the trail cannot hold is refused");
assert_eq!(refused.code(), Code::Unavailable, "{refused:?}");
assert!(
refused.message().starts_with("audit.unavailable"),
"refused by name: {refused:?}"
);
});
assert_eq!(
r.plane.setting("console.a-switch").unwrap(),
None,
"and not applied"
);
}