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

nordisk/edda

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 {}
        })
    }
}