257 lines
8.0 KiB
Rust
257 lines
8.0 KiB
Rust
//! 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<Message, AdminError> {
|
|
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<Vec<PendingApproval>, 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<std::io::Error> 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<bool, AdminError> {
|
|
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<bool, AdminError> {
|
|
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<bool, AdminError> {
|
|
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<bool, AdminError> {
|
|
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",
|
|
}
|
|
}
|