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

nordisk/holger

server/lib/src/grpc/doors.rs

//! `holger.doors.v1` (lane B3, 2026-10-26): the two acts the thin client `holger-cli`
//! had no gRPC door for — the airgap bundle export and the box's first admin key. Both
//! are ADMIN only and both are audited.

use std::sync::Arc;

use tonic::{Request, Response, Status};

use super::holger_doors_proto as pb;
use super::{coded, HolgerGrpc};

/// One chunk of a member's bytes on the wire. Under the client's default 4 MiB decode cap.
pub const EXPORT_CHUNK_BYTES: usize = 1024 * 1024;

/// The member map the export door streams (holger-update-bundle's `MemberMap`).
pub type MemberMap = std::collections::BTreeMap<String, Vec<u8>>;

/// **A candidate's members as the stream says them** — each member's bytes in chunks of at
/// most `chunk` bytes that name its path (an empty member is one empty chunk), then the
/// summary alone — made ONE AT A TIME, as the stream is polled (RA1-13). It owns the member
/// map: a member is dropped once its last chunk is made, so the bundle is held once and
/// shrinks as it is sent, never a second time as its chunks.
pub struct ExportChunks {
    members: std::collections::btree_map::IntoIter<String, Vec<u8>>,
    /// The member being sent: its path, its bytes, how far.
    current: Option<(String, Vec<u8>, usize)>,
    chunk: usize,
    summary: Option<pb::ExportBundleSummary>,
}

impl Iterator for ExportChunks {
    type Item = pb::ExportBundleChunk;

    fn next(&mut self) -> Option<pb::ExportBundleChunk> {
        loop {
            if let Some((path, bytes, at)) = self.current.as_mut() {
                if bytes.is_empty() && *at == 0 {
                    *at = 1;
                    let path = path.clone();
                    self.current = None;
                    return Some(pb::ExportBundleChunk {
                        path,
                        data: Vec::new(),
                        summary: None,
                    });
                }
                if *at < bytes.len() {
                    let end = (*at + self.chunk).min(bytes.len());
                    let out = pb::ExportBundleChunk {
                        path: path.clone(),
                        data: bytes[*at..end].to_vec(),
                        summary: None,
                    };
                    *at = end;
                    if end == bytes.len() {
                        self.current = None;
                    }
                    return Some(out);
                }
                self.current = None;
            }
            match self.members.next() {
                Some((path, bytes)) => self.current = Some((path, bytes, 0)),
                None => {
                    return self.summary.take().map(|s| pb::ExportBundleChunk {
                        path: String::new(),
                        data: Vec::new(),
                        summary: Some(s),
                    })
                }
            }
        }
    }
}

/// [`ExportChunks`] over `members`.
pub fn export_chunks(
    members: MemberMap,
    chunk: usize,
    summary: pb::ExportBundleSummary,
) -> ExportChunks {
    ExportChunks {
        members: members.into_iter(),
        current: None,
        chunk: chunk.max(1),
        summary: Some(summary),
    }
}

/// **The candidate's chunks, made as they are asked for** (RA1-13): the export door's
/// stream ([`ExportChunks`]). tonic polls it as HTTP/2 flow control lets the bytes go, so
/// at most a few chunks exist beside the (shrinking) member map.
pub fn export_stream(
    members: MemberMap,
    chunk: usize,
    summary: pb::ExportBundleSummary,
) -> ExportChunks {
    export_chunks(members, chunk, summary)
}

#[cfg(feature = "intake")]
fn ok_chunk(c: pb::ExportBundleChunk) -> Result<pb::ExportBundleChunk, Status> {
    Ok(c)
}

/// The principal an audit line names.
#[cfg(any(feature = "intake", feature = "auth-passkey"))]
fn who(identity: &Option<crate::auth::AuthIdentity>) -> String {
    identity
        .as_ref()
        .map(|i| i.subject.clone())
        .unwrap_or_else(|| "anonymous".to_owned())
}

type ExportStream = tokio_stream::Iter<
    std::iter::Map<
        ExportChunks,
        fn(pb::ExportBundleChunk) -> Result<pb::ExportBundleChunk, Status>,
    >,
>;

#[tonic::async_trait]
impl pb::bundle_service_server::BundleService for Arc<HolgerGrpc> {
    type ExportStream = ExportStream;

    /// ★ The admin's first-import authorization (lane HOL-NCSC): the thin client's door to the
    /// same act as `holger bundle allow-first-import` and `POST /-/bundle/first-import`.
    async fn allow_first_import(
        &self,
        request: Request<pb::AllowFirstImportRequest>,
    ) -> Result<Response<pb::AllowFirstImportReply>, Status> {
        let source = self.audit_peer(&request);
        let identity = self.authorize_admin(&request, "").await?;
        #[cfg(feature = "intake")]
        {
            let Some(door) = self.imports.clone() else {
                return Err(coded(
                    &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                    tonic::Code::FailedPrecondition,
                    "this server has no import door",
                ));
            };
            let principal = crate::import_door::ImportDoor::authorize_admin(
                &self.auth_config,
                identity.as_ref(),
            )
            .map_err(|e| super::import_status(&e))?;
            let m = request.into_inner();
            let g = tokio::task::spawn_blocking(move || {
                door.allow_first_import(&m.vendor, &m.product, m.sequence, &principal, &source)
            })
            .await
            .map_err(|e| super::internal_err(format!("the authorization task: {e}")))?
            .map_err(|e| super::import_status(&e))?;
            Ok(Response::new(pb::AllowFirstImportReply {
                vendor: g.vendor,
                product: g.product,
                sequence: g.sequence,
                by: g.by,
                at: g.at,
            }))
        }
        #[cfg(not(feature = "intake"))]
        {
            let _ = (identity, source, request);
            Err(coded(
                &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                tonic::Code::Unimplemented,
                "this build carries no import door (no supply chain)",
            ))
        }
    }

    async fn export(
        &self,
        request: Request<pb::ExportBundleRequest>,
    ) -> Result<Response<Self::ExportStream>, Status> {
        let source = self.audit_peer(&request);
        let identity = self.authorize_admin(&request, "").await?;
        #[cfg(feature = "intake")]
        {
            let Some(door) = self.exports.clone() else {
                return Err(coded(
                    &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                    tonic::Code::FailedPrecondition,
                    "this server declares no intake chain (`promotion.intake`): there is no approved set to export",
                ));
            };
            let m = request.into_inner();
            let ask = crate::export_door::ExportAsk {
                bundle_id: m.bundle_id,
                sequence: m.sequence,
                supplier: m.supplier,
                product: m.product,
                release_version: m.release_version,
                expires_days: if m.expires_days == 0 {
                    30
                } else {
                    i64::from(m.expires_days)
                },
                artifacts: m.artifacts,
            };
            let actor = who(&identity);
            let producer = format!("holger {} BundleService.Export", env!("CARGO_PKG_VERSION"));
            let (asked, by, from) = (ask.clone(), actor.clone(), source.clone());
            let exported =
                tokio::task::spawn_blocking(move || door.export(&asked, &by, &from, &producer))
                    .await
                    .map_err(|e| super::internal_err(format!("the export task: {e}")))?;
            let exported = match exported {
                Ok(x) => x,
                Err(e) => {
                    let ev = crate::audit::AuditEvent::new(
                        &actor,
                        crate::audit::AuditAction::Other,
                        "",
                        ask.bundle_id.clone(),
                        &source,
                        412,
                        0,
                    )
                    .with_detail(format!(
                        "airgap export {} seq {} refused: {}",
                        ask.bundle_id,
                        ask.sequence,
                        e.tagged()
                    ));
                    self.record_audit(ev);
                    super::functional_status(
                        "holger-grpc/export_bundle",
                        "bundle_exported",
                        false,
                        &e.tagged(),
                    );
                    return Err(match e {
                        crate::export_door::ExportError::Invalid(why) => coded(
                            &holger_errcode::SERVER_BAD_REQUEST,
                            tonic::Code::InvalidArgument,
                            why,
                        ),
                        // Each refusal already carries its own code (`holger-airgap-*`).
                        other => super::refusal(tonic::Code::FailedPrecondition, other.tagged()),
                    });
                }
            };
            let summary = pb::ExportBundleSummary {
                bundle_id: ask.bundle_id.clone(),
                sequence: ask.sequence,
                verdict: exported.verdict.clone(),
                candidate_sha256: exported.candidate_digest.clone(),
                artifacts: exported.artifacts.iter().map(|a| a.line()).collect(),
                members: u32::try_from(exported.members.len()).unwrap_or(u32::MAX),
            };
            super::functional_status(
                "holger-grpc/export_bundle",
                "bundle_exported",
                true,
                &format!(
                    "{} seq {}: {} artifact(s), {} member(s)",
                    ask.bundle_id,
                    ask.sequence,
                    exported.artifacts.len(),
                    exported.members.len()
                ),
            );
            // Streamed as it is polled, the members moved in: the bundle is held once (RA1-13).
            let chunks = export_stream(exported.members, EXPORT_CHUNK_BYTES, summary)
                .map(ok_chunk as fn(_) -> _);
            Ok(Response::new(tokio_stream::iter(chunks)))
        }
        #[cfg(not(feature = "intake"))]
        {
            let _ = (identity, source);
            Err(coded(
                &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                tonic::Code::Unimplemented,
                "this build carries no bundle export (no supply chain)",
            ))
        }
    }
}

#[tonic::async_trait]
impl pb::first_key_service_server::FirstKeyService for Arc<HolgerGrpc> {
    async fn invite(
        &self,
        request: Request<pb::FirstKeyRequest>,
    ) -> Result<Response<pb::FirstKeyReply>, Status> {
        let source = self.audit_peer(&request);
        let identity = self.authorize_admin(&request, "").await?;
        #[cfg(feature = "auth-passkey")]
        {
            let Some(store) = crate::passkey::door_for(&self.auth_config) else {
                return Err(coded(
                    &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                    tonic::Code::FailedPrecondition,
                    "this server configures no `Passkey` auth method, so it has no keys to enrol",
                ));
            };
            let actor = who(&identity);
            let display = request.get_ref().display.clone();
            match store.mint_first_key_invite(&display, &actor, &self.auth_config) {
                Ok((secret, invite)) => {
                    let ev = crate::audit::AuditEvent::new(
                        &actor,
                        crate::audit::AuditAction::Other,
                        "",
                        invite.id.clone(),
                        &source,
                        200,
                        0,
                    )
                    .with_detail(format!(
                        "first-key invite minted for {} ({}), expires {}",
                        if invite.display.is_empty() {
                            "(their choice of name)"
                        } else {
                            invite.display.as_str()
                        },
                        invite.groups.join(", "),
                        invite.expires
                    ));
                    self.record_audit(ev);
                    super::functional_status(
                        "holger-grpc/first_key",
                        "first_key_invite_minted",
                        true,
                        &invite.groups.join(", "),
                    );
                    Ok(Response::new(pb::FirstKeyReply {
                        path: format!("/login?invite={secret}"),
                        invite: secret,
                        id: invite.id,
                        expires: invite.expires,
                        groups: invite.groups,
                        words: "open the link on the console and touch your key: it enrols this box's first admin key. The invite is shown once, works once and lives a day."
                            .to_owned(),
                    }))
                }
                Err((status, msg)) => {
                    let ev = crate::audit::AuditEvent::new(
                        &actor,
                        crate::audit::AuditAction::Other,
                        "",
                        "first-key",
                        &source,
                        status,
                        0,
                    )
                    .with_detail(format!("first-key invite refused: {msg}"));
                    self.record_audit(ev);
                    super::functional_status(
                        "holger-grpc/first_key",
                        "first_key_invite_minted",
                        false,
                        &msg,
                    );
                    Err(match status {
                        409 if msg == crate::passkey::FIRST_KEY_ENROLLED => coded(
                            &holger_errcode::AUTH_FIRST_KEY_ENROLLED,
                            tonic::Code::FailedPrecondition,
                            msg,
                        ),
                        400 | 403 => coded(
                            &holger_errcode::SERVER_BAD_REQUEST,
                            tonic::Code::InvalidArgument,
                            msg,
                        ),
                        _ => coded(
                            &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                            tonic::Code::FailedPrecondition,
                            msg,
                        ),
                    })
                }
            }
        }
        #[cfg(not(feature = "auth-passkey"))]
        {
            let _ = (identity, source);
            Err(coded(
                &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                tonic::Code::Unimplemented,
                "this build carries no passkeys, so it has no keys to enrol",
            ))
        }
    }
}

/// **The downstream invite door** (lane HOL-PRODUCTS-7): `holger-cli downstream invite` and
/// the console's Invite button, over the sync hub's one act ([`crate::sync::SyncHub::invite_for`]).
#[tonic::async_trait]
impl pb::downstream_service_server::DownstreamService for Arc<HolgerGrpc> {
    async fn invite(
        &self,
        request: Request<pb::DownstreamInviteRequest>,
    ) -> Result<Response<pb::DownstreamInviteReply>, Status> {
        let source = self.audit_peer(&request);
        let identity = self.authorize_admin(&request, "").await?;
        #[cfg(all(feature = "sync", feature = "intake"))]
        {
            let Some(hub) = self.intake.as_ref().and_then(|c| c.sync()) else {
                return Err(coded(
                    &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                    tonic::Code::FailedPrecondition,
                    "this server declares no `sync:` block, so it has no downstreams to invite",
                ));
            };
            let actor = who(&identity);
            let m = request.into_inner();
            let issued = tokio::task::spawn_blocking(move || {
                hub.invite_for(&m.name, &m.door, &m.hub_key, &m.endpoint, &actor, &source)
            })
            .await
            .map_err(|e| super::internal_err(format!("the invite task: {e}")))?;
            match issued {
                Ok(i) => Ok(Response::new(pb::DownstreamInviteReply {
                    invite: i.signed.to_bytes(),
                    schema: i.schema,
                    door: i.door,
                    address: i.address.unwrap_or_default(),
                    upstream: i.upstream,
                    key: i.key,
                })),
                Err(e) => Err(super::refusal(
                    match e.status {
                        404 => tonic::Code::NotFound,
                        400 => tonic::Code::InvalidArgument,
                        _ => tonic::Code::FailedPrecondition,
                    },
                    e.tagged(),
                )),
            }
        }
        #[cfg(not(all(feature = "sync", feature = "intake")))]
        {
            let _ = (identity, source, request);
            Err(coded(
                &holger_errcode::SERVER_CAPABILITY_NOT_CONFIGURED,
                tonic::Code::Unimplemented,
                "this build carries no sync, so it has no downstreams to invite",
            ))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_candidate_streams_member_by_member_then_its_summary() {
        let members: std::collections::BTreeMap<String, Vec<u8>> = [
            ("a".to_string(), vec![1u8; 5]),
            ("b/empty".to_string(), vec![]),
            ("c".to_string(), vec![2u8; 2]),
        ]
        .into_iter()
        .collect();
        let summary = pb::ExportBundleSummary {
            bundle_id: "b".into(),
            members: 3,
            ..Default::default()
        };
        let chunks: Vec<_> = export_chunks(members, 2, summary.clone()).collect();
        let said: Vec<(&str, usize)> = chunks
            .iter()
            .map(|c| (c.path.as_str(), c.data.len()))
            .collect();
        assert_eq!(
            said,
            vec![
                ("a", 2),
                ("a", 2),
                ("a", 1),
                ("b/empty", 0),
                ("c", 2),
                ("", 0)
            ]
        );
        assert_eq!(chunks.last().and_then(|c| c.summary.clone()), Some(summary));
        assert!(
            chunks[..chunks.len() - 1]
                .iter()
                .all(|c| c.summary.is_none()),
            "only the last chunk carries the summary"
        );
    }
}