crates/gunnar-server/tests/sso_directory_rpc.rs
//! **SCIM and the CSV import through the real entry point** (highlight 9): the `Sso`
//! service as a box serves it, the real gRPC router with its gate in front, over the
//! real `CoreControlPlane` on a real store. `tests/sso.rs` drives the same verbs by
//! calling `SsoService` with a `Caller` put in by hand; here every call carries a
//! credential the gate must verify:
//!
//! * the account admin's identity-core session, stepped up by a user-verified passkey
//! login (the console's `nornir-identity-session` header), imports a CSV as a job
//! polled to `done` and mints a SCIM bearer;
//! * the same admin's signed SSH key is no step-up: the import is refused `[stepup]`;
//! * another account's admin is refused on acme's directory;
//! * the console (a server admin, its signed key) relays SCIM: a user created and listed
//! back; a wrong bearer is a 401;
//! * the directory names who the import and SCIM made.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use gunnar_core::ssh_key::PrivateKey;
use gunnar_grpc::auth::SESSION_METADATA_KEY;
use gunnar_grpc::client_signer::{authed, plain_channel, ControlChannel};
use gunnar_grpc::{ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::sso_client::SsoClient;
use gunnar_proto::{
CreateScimTokenRequest, ImportStatusRequest, ImportUsersRequest, ImportUsersResponse,
ListDirectoryRequest, PrincipalKind, ScimRequest,
};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::stores::ServerStores;
use p256::ecdsa::signature::Signer as _;
use sha2::Digest as _;
use tonic::{Code, Request};
const ORIGIN: &str = "http://localhost:7000";
const RP_ID: &str = "localhost";
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)
}
/// A software passkey (the same one `tests/sso.rs` uses): P-256, user-verified.
struct Authenticator {
key: p256::ecdsa::SigningKey,
id: Vec<u8>,
}
impl Authenticator {
fn new(seed: u8, id: &[u8]) -> Authenticator {
Authenticator {
key: p256::ecdsa::SigningKey::from_slice(&[seed; 32]).unwrap(),
id: id.to_vec(),
}
}
fn public(&self) -> Vec<u8> {
self.key
.verifying_key()
.to_sec1_point(false)
.as_bytes()
.to_vec()
}
fn assert(&self, challenge: &[u8], counter: u32) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
let cd = format!(
r#"{{"type":"webauthn.get","challenge":"{}","origin":"{ORIGIN}","crossOrigin":false}}"#,
gunnar_grpc::webauthn::b64url(challenge)
)
.into_bytes();
let mut ad = sha2::Sha256::digest(RP_ID.as_bytes()).to_vec();
// UP | UV: a user-verified touch, so the session it mints has stepped up.
ad.push(0x05);
ad.extend_from_slice(&counter.to_be_bytes());
let mut msg = ad.clone();
msg.extend_from_slice(&sha2::Sha256::digest(&cd));
let sig: p256::ecdsa::Signature = self.key.sign(&msg);
(cd, ad, sig.to_der().as_bytes().to_vec())
}
}
/// A user-verified passkey login through the identity core, as the console relays it:
/// the session token.
fn login(identity: &gunnar_grpc::identity::GunnarIdentity, by: &Authenticator) -> String {
let relay = nornir_identity::Relay {
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
client: None,
};
let c = identity.login_start(&relay).expect("login start");
let challenge = gunnar_grpc::webauthn::from_b64url(&c.challenge).expect("b64url");
let (cd, ad, sig) = by.assert(&challenge, 1);
identity
.login_finish(nornir_identity::Assertion {
ceremony_id: c.id,
credential_id: by.id.clone(),
client_data_json: cd,
authenticator_data: ad,
signature: sig,
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
})
.expect("the passkey logs in")
.token
}
/// A call carrying a person's identity-core session, as the console sends it.
fn with_session<T>(token: &str, body: T) -> Request<T> {
let mut r = Request::new(body);
r.metadata_mut()
.insert(SESSION_METADATA_KEY, token.parse().expect("ascii"));
r
}
async fn import(
sso: &mut SsoClient<ControlChannel>,
token: &str,
csv: &str,
) -> ImportUsersResponse {
let mut job = sso
.import_users(with_session(
token,
ImportUsersRequest {
account: "acme".into(),
csv: csv.into(),
dry_run: false,
},
))
.await
.expect("Sso.ImportUsers through the gate")
.into_inner();
assert!(!job.job_id.is_empty(), "the import is a job: {job:?}");
for _ in 0..1000 {
if job.state == "done" || job.state == "failed" {
return job;
}
tokio::time::sleep(Duration::from_millis(10)).await;
job = sso
.import_status(with_session(
token,
ImportStatusRequest {
account: "acme".into(),
job_id: job.job_id.clone(),
},
))
.await
.expect("Sso.ImportStatus through the gate")
.into_inner();
}
panic!("the import job never ended: {job:?}");
}
#[test]
fn scim_and_the_csv_import_go_through_the_real_router_and_gate() {
let d = tempfile::tempdir().unwrap();
let stores = Arc::new(ServerStores::open(d.path()).expect("stores"));
// The console's own credential: the box's first admin, a signed SSH key.
let (console, console_line) = mint();
stores
.auth()
.bootstrap_admin(&console_line)
.expect("the first admin");
let plane = Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
let control: Arc<dyn ControlPlane> = plane.clone();
// Two accounts, each with its admin's passkey; acme's admin holds an SSH key too.
let acme_key = Authenticator::new(6, b"acme-key");
let globex_key = Authenticator::new(7, b"globex-key");
for (account, key) in [("acme", &acme_key), ("globex", &globex_key)] {
plane
.add_principal(account, account, PrincipalKind::User)
.expect("account");
plane
.add_credential(account, &key.id, &key.public(), account, 0)
.expect("the admin's passkey");
}
let (acme_ssh, acme_line) = mint();
stores
.auth()
.register_key("acme", &acme_line, None)
.expect("acme's SSH key");
let identity = gunnar_server::identity::build(
d.path(),
Arc::clone(&control),
&ServerConfig::default(),
false,
);
let ctx = ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig::default(),
)
.with_identity(Arc::clone(&identity));
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));
}
let at = addr.to_string();
let acme = login(&identity, &acme_key);
let globex = login(&identity, &globex_key);
rt.block_on(async {
let mut sso = SsoClient::new(plain_channel(&at).expect("a channel"));
let csv = "user_name,display_name,email,active\nann@acme.example,Ann,ann@acme.example,true\nben@acme.example,Ben,,true\n";
// A signed key is never a step-up: the gate's caller, the verb's refusal.
let unstepped = sso
.import_users(authed(&acme_ssh, ImportUsersRequest { account: "acme".into(), csv: csv.into(), dry_run: false }))
.await
.unwrap_err();
assert_eq!(unstepped.code(), Code::PermissionDenied, "{unstepped:?}");
assert!(unstepped.message().contains("[stepup]"), "{}", unstepped.message());
// Another account's admin, stepped up, is not acme's.
let outsider = sso
.import_users(with_session(&globex, ImportUsersRequest { account: "acme".into(), csv: csv.into(), dry_run: false }))
.await
.unwrap_err();
assert_eq!(outsider.code(), Code::PermissionDenied, "{outsider:?}");
// No credential at all.
let anon = sso.import_status(Request::new(ImportStatusRequest { account: "acme".into(), job_id: String::new() })).await.unwrap_err();
assert_eq!(anon.code(), Code::Unauthenticated, "{anon:?}");
// The CSV import from acme's admin's stepped-up session: a job, polled to done.
let done = import(&mut sso, &acme, csv).await;
assert_eq!(done.state, "done", "{done:?}");
assert_eq!((done.created, done.errors.len()), (2, 0), "{done:?}");
// SCIM: the bearer from the same session (shown once), the console relays.
let bearer = sso
.create_scim_token(with_session(&acme, CreateScimTokenRequest { account: "acme".into(), label: "Entra".into(), ..Default::default() }))
.await
.expect("Sso.CreateScimToken through the gate")
.into_inner()
.secret;
assert!(bearer.starts_with("gscim_"), "a bearer");
let scim = |bearer: &str, method: &str, path: &str, body: serde_json::Value| ScimRequest {
bearer: bearer.into(),
method: method.into(),
path: path.into(),
query: String::new(),
body: if body.is_null() { Vec::new() } else { serde_json::to_vec(&body).unwrap() },
base_url: format!("{ORIGIN}/scim/v2"),
..Default::default()
};
let made = sso
.scim(authed(
&console,
scim(
&bearer,
"POST",
"/Users",
serde_json::json!({
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "carol@acme.example",
"displayName": "Carol",
"active": true,
}),
),
))
.await
.expect("Sso.Scim through the gate")
.into_inner();
assert_eq!(made.status, 201, "{}", String::from_utf8_lossy(&made.body));
let listed = sso.scim(authed(&console, scim(&bearer, "GET", "/Users", serde_json::Value::Null))).await.expect("listed").into_inner();
assert_eq!(listed.status, 200, "{}", String::from_utf8_lossy(&listed.body));
let list: serde_json::Value = serde_json::from_slice(&listed.body).unwrap();
let names: Vec<&str> = list["Resources"].as_array().expect("Resources").iter().filter_map(|u| u["userName"].as_str()).collect();
assert!(names.contains(&"carol@acme.example"), "{list}");
let wrong = sso.scim(authed(&console, scim("gscim_wrong", "GET", "/Users", serde_json::Value::Null))).await.expect("relayed").into_inner();
assert_eq!(wrong.status, 401, "a wrong bearer");
// The relay's door is the console's: the account admin's session is not a relay.
let not_relay = sso.scim(with_session(&acme, scim(&bearer, "GET", "/Users", serde_json::Value::Null))).await.unwrap_err();
assert_eq!(not_relay.code(), Code::PermissionDenied, "{not_relay:?}");
// The directory names who the import and SCIM made.
let dir = sso
.list_directory(with_session(&acme, ListDirectoryRequest { account: "acme".into() }))
.await
.expect("Sso.ListDirectory through the gate")
.into_inner();
let mut seen: Vec<(&str, &str)> = dir.users.iter().map(|u| (u.user_name.as_str(), u.source.as_str())).collect();
seen.sort();
assert_eq!(seen, [("ann@acme.example", "csv"), ("ben@acme.example", "csv"), ("carol@acme.example", "scim")], "{dir:?}");
});
let mut employees: Vec<String> = plane
.employees("acme")
.expect("employees")
.into_iter()
.map(|p| p.id)
.collect();
employees.sort();
assert_eq!(employees, ["acme.ann", "acme.ben", "acme.carol"]);
let _ = tx.send(());
}