crates/gunnar-grpc/tests/identity_console_doors.rs
//! **The doors the console's move onto the identity core needs** (IDENTITY-HOST,
//! gunnar-ui onto `nornir.identity.v1.IdentityService`), through the real gate:
//!
//! * `Access.EnrolmentLink`: a server admin's (the console relaying a paid
//! sign-up, an operator's CLI) first-login link onto an EXISTING person,
//! minted by the core and redeemed through it;
//! * a revoked passkey ends its person's core sessions;
//! * a machine's enrolment is asked over `Access.MachineChallenge` and approved
//! from a person's core session that STEPPED UP (no assertion in the call);
//! * the approved kit then logs in through the core like a person.
use std::net::{SocketAddr, TcpListener};
use std::sync::Arc;
use std::time::{Duration, Instant};
use gunnar_grpc::auth::SESSION_METADATA_KEY;
use gunnar_grpc::identity::{
system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, SERVER_ADMIN,
};
use gunnar_grpc::{
router, sign_credential_for, ControlPlane, EventBus, MemoryControlPlane, ServerConfig,
ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::*;
use nornir_identity::conformance::{
config, first_admin, login, relay, step_up, Harness, ORIGIN, RP_ID,
};
use nornir_identity::test_host::{Kit, SoftPasskey};
use nornir_identity::{Ceremony, Code, Identity, NoMail, Site};
use tonic::transport::Channel;
use tonic::Request;
struct Plain;
impl Harness for Plain {
type Host = GunnarHost;
fn host(&self, _: Site, _: &Kit) -> GunnarHost {
unreachable!("the identity here is built by hand")
}
fn admin_groups(&self) -> Vec<String> {
vec![SERVER_ADMIN.to_owned()]
}
fn member_groups(&self) -> Vec<String> {
Vec::new()
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
struct Box_ {
plane: Arc<MemoryControlPlane>,
identity: Arc<GunnarIdentity>,
console: ssh_key::PrivateKey,
addr: SocketAddr,
_runtime: tokio::runtime::Runtime,
}
impl Box_ {
fn up() -> Box_ {
let addr: SocketAddr = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap();
let plane = Arc::new(MemoryControlPlane::new());
let control: Arc<dyn ControlPlane> = plane.clone();
let console = ssh_key::PrivateKey::random(
&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
ssh_key::Algorithm::Ed25519,
)
.unwrap();
plane
.add_principal("gunnar-ui", "gunnar-ui", PrincipalKind::Machine)
.unwrap();
plane
.add_key(
"gunnar-ui",
&console.public_key().to_openssh().unwrap(),
"the console",
None,
"",
None,
)
.unwrap();
plane.grant_server("gunnar-ui", GrantLevel::Admin);
let store = GunnarStore::new(
Arc::clone(&control),
IdentityTables::default(),
None,
system_clock(),
);
let identity = Arc::new(Identity::new(
GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
config(),
));
let server = ServerConfig {
web_relying_parties: config().parties,
..ServerConfig::default()
};
let ctx = ServiceContext::new(control, Arc::new(EventBus::new(16)), server)
.with_identity(Arc::clone(&identity));
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
runtime.spawn(async move {
let _ = router(ctx, || 0).serve(addr).await;
});
let deadline = Instant::now() + Duration::from_secs(10);
while std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_err() {
assert!(Instant::now() < deadline, "the router never bound {addr}");
std::thread::sleep(Duration::from_millis(20));
}
Box_ {
plane,
identity,
console,
addr,
_runtime: runtime,
}
}
async fn access(&self) -> AccessClient<Channel> {
AccessClient::connect(format!("http://{}", self.addr))
.await
.unwrap()
}
/// The console's own signed call to `method`.
fn as_console<T>(&self, method: &str, message: T) -> Request<T> {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let nonce = format!(
"nonce-console-doors-{:08}",
SEQ.fetch_add(1, Ordering::Relaxed)
);
let credential = sign_credential_for(&self.console, now_ms(), &nonce, method).unwrap();
let mut request = Request::new(message);
request.metadata_mut().insert(
CREDENTIAL_METADATA_KEY,
credential.to_header_value().parse().unwrap(),
);
request
}
}
fn with_session<T>(message: T, token: &str) -> Request<T> {
let mut request = Request::new(message);
request
.metadata_mut()
.insert(SESSION_METADATA_KEY, token.parse().unwrap());
request
}
fn code<T: std::fmt::Debug>(r: Result<T, nornir_identity::Refused>) -> Code {
r.expect_err("a refusal").code
}
#[test]
fn the_console_mints_a_first_login_link_onto_an_existing_person_through_the_core() {
let b = Box_::up();
b.plane
.add_principal("ann", "ann", PrincipalKind::User)
.unwrap();
b._runtime.block_on(async {
let mut access = b.access().await;
let link = access
.enrolment_link(b.as_console(
"Access.EnrolmentLink",
EnrolmentLinkRequest {
principal_id: "ann".into(),
first_only: true,
expires_at_unix_ms: 0,
},
))
.await
.expect("the console mints ann's first-login link")
.into_inner();
assert!(
link.expires_at_unix_ms > now_ms() + 6 * 86_400_000,
"seven days by default: {}",
link.expires_at_unix_ms
);
let c = b
.identity
.redeem_start(&link.secret, "", &relay())
.expect("the core opens the ceremony");
assert_eq!(c.user_id, "ann", "onto ann herself");
let mut key = SoftPasskey::new(4);
let ann = b
.identity
.redeem_finish(key.register(&c, ORIGIN))
.expect("ann's first passkey");
assert_eq!(ann.session.user_id, "ann");
let again = access
.enrolment_link(b.as_console(
"Access.EnrolmentLink",
EnrolmentLinkRequest {
principal_id: "ann".into(),
first_only: true,
expires_at_unix_ms: 0,
},
))
.await
.unwrap_err();
assert_eq!(
again.code(),
tonic::Code::FailedPrecondition,
"a first-only link for a person who holds a passkey: {again:?}"
);
access
.enrolment_link(b.as_console(
"Access.EnrolmentLink",
EnrolmentLinkRequest {
principal_id: "ann".into(),
first_only: false,
expires_at_unix_ms: 0,
},
))
.await
.expect("another device's link");
let machine = access
.enrolment_link(b.as_console(
"Access.EnrolmentLink",
EnrolmentLinkRequest {
principal_id: "gunnar-ui".into(),
first_only: false,
expires_at_unix_ms: 0,
},
))
.await
.unwrap_err();
assert_eq!(
machine.code(),
tonic::Code::FailedPrecondition,
"only a person: {machine:?}"
);
let theirs = access
.enrolment_link(with_session(
EnrolmentLinkRequest {
principal_id: "ann".into(),
first_only: false,
expires_at_unix_ms: 0,
},
&ann.token,
))
.await
.unwrap_err();
assert_eq!(
theirs.code(),
tonic::Code::PermissionDenied,
"a server admin's door: {theirs:?}"
);
// **A revoked passkey ends its person's sessions.**
access
.revoke_credential(with_session(
RevokeCredentialRequest {
credential_id: key.id.clone(),
},
&ann.token,
))
.await
.expect("ann revokes her own passkey");
assert_eq!(
code(b.identity.session(&ann.token)),
Code::NoSession,
"her sessions end with it"
);
});
}
#[test]
fn a_machine_is_approved_from_a_stepped_up_session_and_logs_in_through_the_core() {
let b = Box_::up();
let (admin, mut admin_key) = first_admin(&Plain, &b.identity);
b._runtime.block_on(async {
let mut access = b.access().await;
let challenge = access
.machine_challenge(b.as_console(
"Access.MachineChallenge",
MachineChallengeRequest {
client: "198.51.100.7:443".into(),
},
))
.await
.expect("the relay gets a machine's challenge")
.into_inner();
let mut kit = SoftPasskey::new(9);
let ceremony = Ceremony {
id: String::new(),
challenge: gunnar_grpc::webauthn::b64url(&challenge.challenge),
rp_id: RP_ID.into(),
user_id: "kit-ops".into(),
user_name: "kit-ops".into(),
display: "kit-ops".into(),
user_verification_required: false,
expires_at_ms: challenge.expires_at_unix_ms,
exclude: Vec::new(),
};
let made = kit.register(&ceremony, ORIGIN);
let asked = access
.request_machine_enrolment(b.as_console(
"Access.RequestMachineEnrolment",
RequestMachineEnrolmentRequest {
machine: "kit-ops".into(),
label: "the ops kit".into(),
server_level: GrantLevel::Admin as i32,
credential_id: made.credential_id.clone(),
public_key: made.public_key.clone(),
client_data_json: made.client_data_json.clone(),
rp_id: RP_ID.into(),
origin: ORIGIN.into(),
sign_count: 0,
attestation_object: made.attestation_object.clone(),
},
))
.await
.expect("the kit's request is held")
.into_inner()
.request
.unwrap();
let approve = |token: &str| {
with_session(
ApproveMachineEnrolmentRequest {
request_id: asked.request_id.clone(),
code: asked.code.clone(),
server_level: GrantLevel::Unspecified as i32,
},
token,
)
};
let unstepped = access
.approve_machine_enrolment(approve(&admin.token))
.await
.unwrap_err();
assert_eq!(
unstepped.code(),
tonic::Code::PermissionDenied,
"{unstepped:?}"
);
assert!(
unstepped.message().contains("[stepup]"),
"the console answers it with a touch: {unstepped:?}"
);
step_up(&b.identity, &admin.token, &mut admin_key).expect("the admin steps up");
access
.approve_machine_enrolment(approve(&admin.token))
.await
.expect("a stepped-up admin approves");
let mut kit = kit;
let session = login(&b.identity, &mut kit, true).expect("the kit logs in through the core");
assert_eq!(session.session.user_id, "kit-ops");
assert!(session.session.admin, "as the role it was approved with");
});
}