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