crates/nornir-identity/src/grpc.rs
//! **The IdentityService, served the same by every product** (feature `grpc`).
//!
//! gunnar-server and holger-server mount [`IdentityGrpc`] over their own
//! [`Identity`]; consoles and CLIs call it with the generated client
//! ([`pb::identity_service_client::IdentityServiceClient`]). A refusal is a
//! status whose message starts with its [`Code`] and whose
//! [`REFUSAL_METADATA`] is the code alone ([`refusal_of`] reads it back).
use std::sync::Arc;
use tonic::{Request, Response, Status};
use crate::{
Assertion, Ceremony, ClientKey, Code, Identity, IdentityHost, InviteAsk, LoggedIn, Refused,
Registration, Relay, SessionView, SignUp,
};
/// The generated messages, server and client.
#[allow(clippy::all, missing_docs)]
pub mod pb {
tonic::include_proto!("nornir.identity.v1");
}
use pb::identity_service_server::{IdentityService, IdentityServiceServer};
/// The metadata key a refusal's code travels under.
pub const REFUSAL_METADATA: &str = "nornir-identity-refusal";
/// The gRPC status of a refusal.
pub fn status(r: Refused) -> Status {
use tonic::Code as G;
let grpc = match r.code {
Code::SignUpClosed
| Code::NotAdmin
| Code::InviteWouldBeAdmin
| Code::NeedsStepUp
| Code::NotActive => G::PermissionDenied,
Code::InviteNotOpen => G::FailedPrecondition,
Code::Bootstrapped => G::AlreadyExists,
Code::Ceremony | Code::NoSession => G::Unauthenticated,
Code::Busy => G::ResourceExhausted,
Code::MailRefused => G::Unavailable,
Code::Malformed => G::InvalidArgument,
Code::Store => G::Internal,
};
let mut status = Status::new(grpc, r.to_string());
if let Ok(v) = r.code.as_str().parse() {
status.metadata_mut().insert(REFUSAL_METADATA, v);
}
status
}
/// The refusal code a status carries, when it is one of ours.
pub fn refusal_of(status: &Status) -> Option<Code> {
let v = status.metadata().get(REFUSAL_METADATA)?.to_str().ok()?;
Code::ALL.into_iter().find(|c| c.as_str() == v)
}
fn info(v: SessionView) -> pb::SessionInfo {
pb::SessionInfo {
user_id: v.user_id,
display: v.display,
roles: v.roles,
admin: v.admin,
expires_at: v.expires_at,
stepped_up: v.stepped_up,
}
}
fn session(l: LoggedIn) -> pb::SessionReply {
pb::SessionReply {
token: l.token,
session: Some(info(l.session)),
}
}
fn ceremony(c: Ceremony) -> pb::CeremonyReply {
pb::CeremonyReply {
ceremony_id: c.id,
challenge: c.challenge,
rp_id: c.rp_id,
user_id: c.user_id,
user_name: c.user_name,
display: c.display,
user_verification_required: c.user_verification_required,
expires_at_ms: c.expires_at_ms,
exclude_credentials: c.exclude,
}
}
/// **Whose `client` field the adapter believes** (RA5-32). The field is the browser's
/// address as a relay (the product's console) saw it, and it partitions the ceremony
/// store's per-client bound. Believed from anybody, a caller names a fresh client on
/// every call, escapes that bound and fills the server-wide one, so every login
/// answers busy.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ClientTrust {
/// The field is believed only from a relay on this machine: a loopback peer, or a
/// local socket with no peer address. Any other caller is partitioned by its own
/// address, and what it names is ignored.
#[default]
Loopback,
/// Every peer that reaches the listener is a relay the product's transport
/// authenticated (Gunnar: mTLS off loopback), so the field is believed from all.
EveryPeer,
}
fn client<T>(trust: ClientTrust, named: &str, req: &Request<T>) -> Option<ClientKey> {
let peer = req.remote_addr();
let relay = match trust {
ClientTrust::EveryPeer => true,
ClientTrust::Loopback => peer.is_none_or(|a| a.ip().to_canonical().is_loopback()),
};
if relay && !named.trim().is_empty() {
return Some(ClientKey::of_peer(named));
}
peer.map(|a| ClientKey::of_ip(a.ip()))
}
/// **The one server adapter**: the IdentityService over a product's core.
pub struct IdentityGrpc<H: IdentityHost + 'static> {
core: Arc<Identity<H>>,
trust: ClientTrust,
}
impl<H: IdentityHost + 'static> Clone for IdentityGrpc<H> {
fn clone(&self) -> Self {
IdentityGrpc {
core: Arc::clone(&self.core),
trust: self.trust,
}
}
}
impl<H: IdentityHost + 'static> IdentityGrpc<H> {
/// The adapter over `core`; it believes a `client` field only from loopback
/// ([`ClientTrust::Loopback`]).
pub fn new(core: Arc<Identity<H>>) -> Self {
IdentityGrpc {
core,
trust: ClientTrust::default(),
}
}
/// Whose `client` field to believe ([`ClientTrust`]).
pub fn trust_clients(mut self, trust: ClientTrust) -> Self {
self.trust = trust;
self
}
/// The core it serves.
pub fn core(&self) -> &Arc<Identity<H>> {
&self.core
}
/// The tonic service to mount.
pub fn into_server(self) -> IdentityServiceServer<Self> {
IdentityServiceServer::new(self)
}
}
type Answer<T> = Result<Response<T>, Status>;
fn answer<T, U>(r: Result<T, Refused>, f: impl FnOnce(T) -> U) -> Answer<U> {
r.map(|v| Response::new(f(v))).map_err(status)
}
#[tonic::async_trait]
impl<H: IdentityHost + 'static> IdentityService for IdentityGrpc<H> {
async fn sign_up(&self, req: Request<pb::SignUpRequest>) -> Answer<pb::SignUpReply> {
let r = req.into_inner();
answer(
self.core.sign_up(SignUp {
email: r.email,
display: r.display,
}),
|()| pb::SignUpReply {},
)
}
async fn create_invite(
&self,
req: Request<pb::CreateInviteRequest>,
) -> Answer<pb::CreateInviteReply> {
let r = req.into_inner();
let ask = InviteAsk {
display: r.display,
email: Some(r.email).filter(|e| !e.trim().is_empty()),
groups: r.groups,
ttl_secs: Some(r.ttl_secs).filter(|t| *t > 0),
person: Some(r.person).filter(|p| !p.trim().is_empty()),
};
answer(self.core.invite(&r.session, ask), |i| {
pb::CreateInviteReply {
secret: i.secret,
invite_id: i.invite_id,
expires_at: i.expires_at,
mailed: i.mailed,
mail_refusal: i.mail_refusal.unwrap_or_default(),
}
})
}
async fn list_invites(
&self,
req: Request<pb::ListInvitesRequest>,
) -> Answer<pb::ListInvitesReply> {
answer(self.core.list_invites(&req.into_inner().session), |all| {
pb::ListInvitesReply {
invites: all
.into_iter()
.map(|i| pb::InviteInfo {
invite_id: i.id,
display: i.display,
groups: i.groups,
person: i.person.unwrap_or_default(),
created_by: i.created_by,
created_at: i.created,
expires_at: i.expires,
})
.collect(),
}
})
}
async fn revoke_invite(
&self,
req: Request<pb::RevokeInviteRequest>,
) -> Answer<pb::RevokeInviteReply> {
let r = req.into_inner();
answer(self.core.revoke_invite(&r.session, &r.invite_id), |()| {
pb::RevokeInviteReply {}
})
}
async fn redeem_start(
&self,
req: Request<pb::RedeemStartRequest>,
) -> Answer<pb::CeremonyReply> {
let client = client(self.trust, &req.get_ref().client, &req);
let r = req.into_inner();
let relay = Relay {
rp_id: r.rp_id,
origin: r.origin,
client,
};
answer(
self.core.redeem_start(&r.secret, &r.display, &relay),
ceremony,
)
}
async fn redeem_finish(
&self,
req: Request<pb::RedeemFinishRequest>,
) -> Answer<pb::SessionReply> {
let r = req.into_inner();
let reg = Registration {
ceremony_id: r.ceremony_id,
credential_id: r.credential_id,
public_key: r.public_key,
client_data_json: r.client_data_json,
attestation_object: r.attestation_object,
rp_id: r.rp_id,
origin: r.origin,
};
answer(self.core.redeem_finish(reg), session)
}
async fn login_start(&self, req: Request<pb::LoginStartRequest>) -> Answer<pb::CeremonyReply> {
let client = client(self.trust, &req.get_ref().client, &req);
let r = req.into_inner();
answer(
self.core.login_start(&Relay {
rp_id: r.rp_id,
origin: r.origin,
client,
}),
ceremony,
)
}
async fn login_finish(&self, req: Request<pb::LoginFinishRequest>) -> Answer<pb::SessionReply> {
let r = req.into_inner();
let a = Assertion {
ceremony_id: r.ceremony_id,
credential_id: r.credential_id,
client_data_json: r.client_data_json,
authenticator_data: r.authenticator_data,
signature: r.signature,
rp_id: r.rp_id,
origin: r.origin,
};
answer(self.core.login_finish(a), session)
}
async fn whoami(&self, req: Request<pb::WhoamiRequest>) -> Answer<pb::SessionInfo> {
answer(self.core.session(&req.into_inner().session), info)
}
async fn step_up_start(
&self,
req: Request<pb::StepUpStartRequest>,
) -> Answer<pb::ChallengeReply> {
let client = client(self.trust, &req.get_ref().client, &req);
let r = req.into_inner();
answer(self.core.step_up_start(&r.session, client.as_ref()), |c| {
pb::ChallengeReply {
challenge: c.challenge,
expires_at_ms: c.expires_at_ms,
}
})
}
async fn step_up_finish(
&self,
req: Request<pb::StepUpFinishRequest>,
) -> Answer<pb::SessionInfo> {
let r = req.into_inner();
let a = Assertion {
ceremony_id: String::new(),
credential_id: r.credential_id,
client_data_json: r.client_data_json,
authenticator_data: r.authenticator_data,
signature: r.signature,
rp_id: r.rp_id,
origin: r.origin,
};
answer(self.core.step_up_finish(&r.session, a), info)
}
async fn logout(&self, req: Request<pb::LogoutRequest>) -> Answer<pb::LogoutReply> {
let r = req.into_inner();
answer(self.core.logout(&r.session, r.everywhere), |()| {
pb::LogoutReply {}
})
}
async fn deactivate(&self, req: Request<pb::DeactivateRequest>) -> Answer<pb::DeactivateReply> {
let r = req.into_inner();
answer(self.core.deactivate_as(&r.session, &r.user_id), |()| {
pb::DeactivateReply {}
})
}
async fn end_all_sessions(
&self,
req: Request<pb::EndAllSessionsRequest>,
) -> Answer<pb::EndAllSessionsReply> {
answer(self.core.end_all(&req.into_inner().session), |()| {
pb::EndAllSessionsReply {}
})
}
}