Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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(());
}