crates/gunnar-server/tests/sso_idp_ca.rs
//! **An account's own IdP CA, through the `Sso` service** (owner 2026-10-06:
//! "the account admin may add a PEM CA (or a chain) to their identity
//! provider config; gunnar uses it ONLY for that account's IdP TLS —
//! discovery, JWKS, token; validate the PEM, cap its size, audit the change,
//! show the CA fingerprint").
//!
//! Over the REAL plane and stores; the IdP is edda's fake OpenID provider over
//! loopback http (the test door), and the egress RECORDS which CA each call
//! was asked to trust before handing it on — so what is proved here is what
//! gunnar asks for; that the egress honours it is `sso_egress_private.rs`.
#![cfg(feature = "sso-egress")] // A32-18: the airgap default carries no HTTP client; the appliance rows and CI name `sso-egress`.
use std::sync::{Arc, Mutex};
use gunnar_grpc::auth::{AuditLedger, AuditRecord};
use gunnar_grpc::sso::Egress;
use gunnar_grpc::{Caller, ControlPlane, EventBus, ServerConfig, ServiceContext, SsoService};
use gunnar_proto::sso_server::Sso as _;
use gunnar_proto::*;
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use nornir_auth_oidc::fake_op::{self, FakeOp};
use tonic::{Code, Request};
const ORIGIN: &str = "http://localhost:7000";
const SECRET: &str = "oidc-client-secret-NEVER-LOGGED";
#[derive(Default)]
struct Ledger(Mutex<Vec<String>>);
impl AuditLedger for Ledger {
fn record(&self, r: AuditRecord<'_>) {
self.0.lock().unwrap().push(format!(
"{} principal={} scope={} {} {}",
r.method, r.principal, r.scope, r.decision, r.reason
));
}
}
/// gunnar's egress, recording `(url, how many CA certificates it was asked to trust)`.
struct Recording {
inner: gunnar_server::sso_egress::UreqEgress,
seen: Mutex<Vec<(String, usize)>>,
}
impl Egress for Recording {
fn get(&self, url: &str) -> Result<(u16, String), String> {
self.seen.lock().unwrap().push((url.to_owned(), 0));
self.inner.get(url)
}
fn post_form(
&self,
url: &str,
authorization: &str,
body: &str,
) -> Result<(u16, String), String> {
self.seen.lock().unwrap().push((url.to_owned(), 0));
self.inner.post_form(url, authorization, body)
}
fn get_trusting(&self, url: &str, ca: &[Vec<u8>]) -> Result<(u16, String), String> {
self.seen.lock().unwrap().push((url.to_owned(), ca.len()));
self.inner.get(url)
}
fn post_form_trusting(
&self,
url: &str,
authorization: &str,
body: &str,
ca: &[Vec<u8>],
) -> Result<(u16, String), String> {
self.seen.lock().unwrap().push((url.to_owned(), ca.len()));
self.inner.post_form(url, authorization, body)
}
}
struct Harness {
_dir: tempfile::TempDir,
ctx: ServiceContext,
ledger: Arc<Ledger>,
egress: Arc<Recording>,
op: FakeOp,
}
fn caller(id: &str, machine: bool) -> Caller {
Caller {
principal_id: id.to_owned(),
fingerprint: String::new(),
machine,
authenticated: true,
}
}
/// A call from `caller`'s session, stepped up a moment ago (the identity
/// core's fresh user-verified touch).
fn authed<T>(caller: &Caller, body: T) -> Request<T> {
let mut r = Request::new(body);
r.extensions_mut().insert(caller.clone());
r.extensions_mut().insert(gunnar_grpc::auth::PersonSession {
stepped_up: true,
stepped_up_at_unix_ms: None,
});
r
}
/// A CA (or, `ca = false`, a leaf): PEM and the SHA-256 fingerprint gunnar shows.
fn minted(ca: bool) -> (String, String) {
let key = rcgen::KeyPair::generate().expect("a key");
let mut params =
rcgen::CertificateParams::new(vec!["idp.acme.example".to_owned()]).expect("params");
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
if ca {
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
}
let cert = params.self_signed(&key).expect("signed");
(cert.pem(), gunnar_grpc::sso::saml::fingerprint(cert.der()))
}
impl Harness {
fn new() -> Harness {
let dir = tempfile::tempdir().expect("tempdir");
let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
let control: Arc<dyn ControlPlane> = plane.clone();
let ledger = Arc::new(Ledger::default());
let identity = gunnar_server::identity::build(
dir.path(),
Arc::clone(&control),
&ServerConfig::default(),
false,
);
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
)
.with_audit_ledger(ledger.clone())
.with_identity(identity);
ctx.sso.set_allow_loopback_http(true);
let egress = Arc::new(Recording {
inner: gunnar_server::sso_egress::UreqEgress::new(true),
seen: Mutex::default(),
});
ctx.sso.set_egress(egress.clone());
ctx.sso.set_egress_on(true);
plane
.add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
.expect("console");
plane
.grant("gunnar-ui", "", "", GrantLevel::Admin)
.expect("the relay is a server admin");
for account in ["acme", "globex"] {
plane
.add_principal(account, account, PrincipalKind::User)
.expect("account");
}
let op = FakeOp::start("gunnar-acme", SECRET).expect("fake OP");
Harness {
_dir: dir,
ctx,
ledger,
egress,
op,
}
}
fn sso(&self) -> SsoService {
SsoService::new(self.ctx.clone())
}
fn provider(&self, account: &str, ca_pem: &str) -> SsoProvider {
SsoProvider {
account: account.into(),
protocol: SsoProtocol::Oidc as i32,
display_name: format!("{account} corp"),
domains: vec![format!("{account}.example")],
oidc: Some(OidcSettings {
issuer: self.op.issuer().to_owned(),
client_id: "gunnar-acme".into(),
idp_ca_pem: ca_pem.into(),
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
}
}
async fn configure(
&self,
account: &str,
ca_pem: &str,
clear: bool,
) -> Result<ProviderView, tonic::Status> {
self.sso()
.set_provider(authed(
&caller(account, false),
SetProviderRequest {
provider: Some(self.provider(account, ca_pem)),
client_secret: SECRET.into(),
clear_client_secret: false,
clear_idp_ca: clear,
},
))
.await
.map(|r| r.into_inner().view.expect("a view"))
}
fn fingerprints(view: &ProviderView) -> Vec<String> {
view.provider
.as_ref()
.and_then(|p| p.oidc.as_ref())
.map(|o| o.idp_ca_fingerprints.clone())
.unwrap_or_default()
}
fn pem_in(view: &ProviderView) -> String {
view.provider
.as_ref()
.and_then(|p| p.oidc.as_ref())
.map(|o| o.idp_ca_pem.clone())
.unwrap_or_default()
}
fn calls(&self) -> Vec<(String, usize)> {
std::mem::take(&mut *self.egress.seen.lock().unwrap())
}
}
/// ★ **Stored, shown as its fingerprint, kept, cleared — and audited.**
#[tokio::test(flavor = "multi_thread")]
async fn an_idp_ca_is_stored_shown_by_fingerprint_kept_cleared_and_audited() {
let h = Harness::new();
let (ca, fp) = minted(true);
let view = h
.configure("acme", &ca, false)
.await
.expect("the account admin's CA");
assert_eq!(
Harness::fingerprints(&view),
vec![fp.clone()],
"the answer names the CA by its fingerprint"
);
assert!(
Harness::pem_in(&view).is_empty(),
"the answer never carries the CA back"
);
let got = h
.sso()
.get_provider(authed(
&caller("acme", false),
GetProviderRequest {
account: "acme".into(),
..Default::default()
},
))
.await
.expect("read")
.into_inner()
.view
.expect("a view");
assert_eq!(Harness::fingerprints(&got), vec![fp.clone()]);
assert!(
h.ledger
.0
.lock()
.unwrap()
.iter()
.any(|l| l.starts_with("Sso.SetProvider") && l.contains(&format!("idp_ca={fp} (set)"))),
"the change is audited with the CA's fingerprint: {:?}",
h.ledger.0.lock().unwrap()
);
// Not resent: kept.
let kept = h.configure("acme", "", false).await.expect("kept");
assert_eq!(Harness::fingerprints(&kept), vec![fp.clone()]);
// Cleared: gone, and audited.
let cleared = h.configure("acme", "", true).await.expect("cleared");
assert!(Harness::fingerprints(&cleared).is_empty());
assert!(
h.ledger
.0
.lock()
.unwrap()
.iter()
.any(|l| l.starts_with("Sso.SetProvider") && l.contains("idp_ca=none (cleared)")),
"{:?}",
h.ledger.0.lock().unwrap()
);
}
/// ★ **What is not a CA is refused, and nothing is stored.**
#[tokio::test(flavor = "multi_thread")]
async fn a_pem_that_is_not_a_ca_is_refused_and_nothing_is_stored() {
let h = Harness::new();
let (leaf, _) = minted(false);
for (bad, words) in [
(leaf.as_str(), "not a CA"),
(
"-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n",
"certificate",
),
("hello", "no certificate"),
] {
let err = h
.configure("globex", bad, false)
.await
.expect_err("refused");
assert_eq!(err.code(), Code::InvalidArgument, "{}", err.message());
assert!(err.message().contains(words), "{words}: {}", err.message());
}
let none = h
.sso()
.get_provider(authed(
&caller("globex", false),
GetProviderRequest {
account: "globex".into(),
..Default::default()
},
))
.await
.expect("read")
.into_inner()
.view;
assert!(none.is_none(), "a refused provider was stored");
}
/// ★ **The account's CA for ITS IdP's calls — discovery, keys, token — and
/// never for another account's**, though both name the same issuer.
#[tokio::test(flavor = "multi_thread")]
async fn the_ca_is_asked_for_its_accounts_idp_calls_only() {
let h = Harness::new();
let (ca, _) = minted(true);
h.configure("acme", &ca, false)
.await
.expect("acme, with its CA");
let configured = h.calls();
assert!(
configured.len() >= 2 && configured.iter().all(|(_, n)| *n == 1),
"acme's discovery and keys trust its CA: {configured:?}"
);
h.configure("globex", "", false)
.await
.expect("globex, no CA");
let theirs = h.calls();
assert!(
!theirs.is_empty() && theirs.iter().all(|(_, n)| *n == 0),
"globex's calls trust no CA of acme's: {theirs:?}"
);
// A whole sign-in for acme: the token POST trusts its CA too.
h.op.set_person(fake_op::Person::new("00u-bob", "bob@acme.example", "Bob"));
let begun = h
.sso()
.begin(authed(
&caller("gunnar-ui", true),
BeginRequest {
account: "acme".into(),
login_hint: String::new(),
console_origin: ORIGIN.into(),
client: String::new(),
},
))
.await
.expect("begun")
.into_inner();
let back =
h.op.authorize(&begun.redirect_url)
.expect("the fake signs in");
let code = back
.split("code=")
.nth(1)
.unwrap_or_default()
.split('&')
.next()
.unwrap_or_default()
.to_owned();
h.sso()
.finish(authed(
&caller("gunnar-ui", true),
FinishRequest {
state: begun.state,
code,
console_origin: ORIGIN.into(),
..Default::default()
},
))
.await
.expect("bob signs in");
let signed_in = h.calls();
assert!(
signed_in
.iter()
.any(|(u, n)| u.contains("token") && *n == 1),
"the token POST trusts acme's CA: {signed_in:?}"
);
assert!(signed_in.iter().all(|(_, n)| *n == 1), "{signed_in:?}");
}
/// ★ **A34-41: a SAML provider's metadata URL is read trusting the account's IdP CA**, as an
/// OIDC provider's calls are: the CA is the account's (`oidc.idp_ca_pem`, the one CA field),
/// asked for the metadata fetch, stored, shown by its fingerprint and kept on a re-save.
#[tokio::test(flavor = "multi_thread")]
async fn a34_41_the_saml_metadata_url_is_read_trusting_the_accounts_ca() {
let h = Harness::new();
let idp = nornir_auth_saml::fake_idp::FakeIdp::start().expect("fake IdP");
let (ca, fp) = minted(true);
let saml = |ca_pem: &str| SsoProvider {
account: "acme".into(),
protocol: SsoProtocol::Saml as i32,
display_name: "Acme SAML".into(),
saml: Some(SamlSettings {
idp_metadata_url: idp.metadata_url(),
..Default::default()
}),
oidc: Some(OidcSettings {
idp_ca_pem: ca_pem.into(),
..Default::default()
}),
console_origin: ORIGIN.into(),
..Default::default()
};
async fn set(
h: &Harness,
provider: SsoProvider,
) -> Result<tonic::Response<SetProviderResponse>, tonic::Status> {
h.sso()
.set_provider(authed(
&caller("acme", false),
SetProviderRequest {
provider: Some(provider),
client_secret: String::new(),
clear_client_secret: false,
clear_idp_ca: false,
},
))
.await
}
let view = set(&h, saml(&ca))
.await
.expect("the metadata URL behind acme's CA")
.into_inner()
.view
.expect("a view");
assert_eq!(
view.provider
.as_ref()
.unwrap()
.saml
.as_ref()
.unwrap()
.idp_entity_id,
idp.entity_id()
);
let calls = h.calls();
assert!(
calls
.iter()
.any(|(u, n)| u == &idp.metadata_url() && *n == 1),
"the metadata GET trusts acme's CA: {calls:?}"
);
assert_eq!(
Harness::fingerprints(&view),
vec![fp.clone()],
"the CA is shown by its fingerprint"
);
assert!(
h.ledger
.0
.lock()
.unwrap()
.iter()
.any(|l| l.starts_with("Sso.SetProvider") && l.contains(&format!("idp_ca={fp} (set)"))),
"{:?}",
h.ledger.0.lock().unwrap()
);
// Saved again with the URL and no CA pasted: the stored CA is kept and trusted again.
let again = set(&h, saml(""))
.await
.expect("saved again")
.into_inner()
.view
.expect("a view");
assert_eq!(Harness::fingerprints(&again), vec![fp]);
let calls = h.calls();
assert!(
calls
.iter()
.any(|(u, n)| u == &idp.metadata_url() && *n == 1),
"the kept CA is trusted again: {calls:?}"
);
}