//! The owner's admin commands over admin.sock: approvals, approve, refuse, grants check. use std::io::{self, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use proto::{ Approve, DecisionRecord, DenyReason, Empty, Envelope, ErrorCode, FrameError, Message, PROTOCOL_VERSION, PendingApproval, Refuse, Timestamp, WireError, read_frame, write_frame, }; use crate::chat::code_name; use crate::escape::escape_json_text; // Ask brokerd for one message and read one answer. pub fn request(socket: &Path, msg: Message) -> Result { let mut stream = UnixStream::connect(socket).map_err(|e| AdminError::Connect(socket.to_path_buf(), e))?; let env = Envelope { v: PROTOCOL_VERSION, id: 1, r#final: true, msg, }; write_frame(&mut stream, &env).map_err(AdminError::Frame)?; let answer = read_frame(&mut stream).map_err(AdminError::Frame)?; if answer.id != 1 || !answer.r#final { return Err(AdminError::Protocol( "expected an answer for request 1".to_string(), )); } match answer.msg { Message::Error(w) => Err(AdminError::Refused(w)), other => Ok(other), } } // Ask brokerd for the list of approvals waiting, or report an answer of the wrong kind. pub fn list(socket: &Path) -> Result, AdminError> { match request(socket, Message::Approvals(Empty {}))? { Message::ApprovalList(list) => Ok(list.items), _ => Err(AdminError::Protocol( "expected an approval list".to_string(), )), } } #[derive(Debug)] pub enum AdminError { Connect(PathBuf, std::io::Error), Frame(FrameError), Refused(WireError), Protocol(String), } impl std::fmt::Display for AdminError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AdminError::Connect(p, e) => write!(f, "cannot reach brokerd at {}: {e}", p.display()), AdminError::Frame(e) => write!(f, "{e}"), AdminError::Refused(w) => write!(f, "{}: {}", code_name(w.code), w.detail), AdminError::Protocol(s) => write!(f, "{s}"), } } } // An error writing the output is reported like any other failure, with the message preserved. impl From for AdminError { fn from(e: std::io::Error) -> Self { AdminError::Protocol(e.to_string()) } } impl std::error::Error for AdminError {} pub fn cmd_approvals( socket: &Path, now: Timestamp, out: &mut dyn Write, ) -> Result { let items = list(socket)?; if items.is_empty() { writeln!(out, "no pending approvals")?; } else { for item in &items { write_block(out, item, now)?; } } Ok(true) } pub fn cmd_approve(socket: &Path, approval: u64, out: &mut dyn Write) -> Result { let msg = match request(socket, Message::Approve(Approve { approval })) { Ok(m) => m, Err(AdminError::Refused(w)) if w.code == ErrorCode::NoSuchApproval => { writeln!( out, "{approval}: no such approval (already answered or expired)" )?; return Ok(false); } Err(e) => return Err(e), }; match msg { Message::ApproveResult(result) => match result.outcome { DecisionRecord::Allowed {} | DecisionRecord::Ask {} => { writeln!(out, "approved {approval}: runs")?; Ok(true) } DecisionRecord::Denied { reason } => { writeln!(out, "approved {approval}: denied ({})", reason_name(reason))?; Ok(false) } }, _ => Err(AdminError::Protocol("unexpected answer".to_string())), } } pub fn cmd_refuse( socket: &Path, approval: u64, reason: Option<&str>, out: &mut dyn Write, ) -> Result { let msg = match request( socket, Message::Refuse(Refuse { approval, reason: reason.map(str::to_string), }), ) { Ok(m) => m, Err(AdminError::Refused(w)) if w.code == ErrorCode::NoSuchApproval => { writeln!( out, "{approval}: no such approval (already answered or expired)" )?; return Ok(false); } Err(e) => return Err(e), }; match msg { Message::Ok(_) => { writeln!(out, "refused {approval}")?; Ok(true) } _ => Err(AdminError::Protocol("unexpected answer".to_string())), } } pub fn cmd_grants_check(socket: &Path, out: &mut dyn Write) -> Result { let msg = request(socket, Message::CheckGrants(Empty {}))?; match msg { Message::GrantsReport(report) => { if report.problems.is_empty() { writeln!(out, "grants: ok")?; Ok(true) } else { for problem in &report.problems { writeln!( out, "{}{}: {}", escape_json_text(&problem.file), match problem.line { Some(line) => format!(":{line}"), None => String::new(), }, escape_json_text(&problem.problem) )?; } Ok(false) } } _ => Err(AdminError::Protocol("unexpected answer".to_string())), } } pub fn reason_name(reason: DenyReason) -> &'static str { match reason { DenyReason::NoGrant => "no_grant", DenyReason::GrantExpired => "grant_expired", DenyReason::TaintTooHigh => "taint_too_high", DenyReason::DeniedByGrant => "denied_by_grant", DenyReason::ApprovalRefused => "approval_refused", DenyReason::ApprovalExpired => "approval_expired", DenyReason::GrantsInvalid => "grants_invalid", DenyReason::AuditUnavailable => "audit_unavailable", DenyReason::InvalidArguments => "invalid_arguments", DenyReason::StateUnreadable => "state_unreadable", } } // The approvals table is one block per item: a summary line, then the tool and arguments. pub fn write_block(out: &mut dyn Write, item: &PendingApproval, now: Timestamp) -> io::Result<()> { let age = now.unix_millis().saturating_sub(item.created.unix_millis()); let until = item.expires.unix_millis().saturating_sub(now.unix_millis()); writeln!( out, "{} {} ago {} session {} grant {} taint {}", item.approval, span(age), expiry(until, now >= item.expires), session_shown(item.session.as_str()), escape_json_text(&item.grant), taint_name(item.taint), )?; writeln!( out, " {} {}", escape_json_text(&item.tool), escape_json_text(&item.arguments) )?; Ok(()) } // A span of whole seconds, minutes, or hours, rounded down. fn span(ms: u64) -> String { let secs = ms / 1000; let mins = secs / 60; let hours = mins / 60; if mins == 0 { format!("{secs} s") } else if hours == 0 { format!("{mins} min") } else { format!("{hours} h") } } // Whether the approval has expired, or how long until it does. fn expiry(until: u64, expired: bool) -> String { if expired { "expired".to_string() } else { format!("expires in {}", span(until)) } } // A session id longer than ten characters is cut to nine and an ellipsis. fn session_shown(session: &str) -> String { if session.chars().count() > 10 { let head: String = session.chars().take(9).collect(); format!("{head}…") } else { session.to_string() } } // The taint of a decision is its wire name. fn taint_name(taint: proto::DataClass) -> &'static str { match taint { proto::DataClass::Public => "public", proto::DataClass::Private => "private", proto::DataClass::Secret => "secret", } }