use tracing::{info, warn};
use crate::actor::{AuthenticatedActor, ProofKind};
use crate::catalog::policy::Op;
use crate::catalog::{AllowVia, Decision, DenyReason};
use crate::peer::PeerInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Allow,
Deny,
}
impl Outcome {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Allow => "allow",
Self::Deny => "deny",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecisionRecord {
pub generation: u64,
pub op: Op,
pub key: String,
pub actor_kind: String,
pub actor_id: String,
pub authenticated_by: Vec<String>,
pub presenter_kind: String,
pub presenter_id: String,
pub outcome: Outcome,
pub reason: String,
}
impl DecisionRecord {
#[must_use]
pub fn from_actor_decision(
generation: u64,
actor: &AuthenticatedActor,
op: Op,
key: &str,
decision: &Decision,
) -> Self {
let (outcome, reason) = match decision {
Decision::Allow { via } => (Outcome::Allow, allow_via_str(via)),
Decision::Deny { reason } => (Outcome::Deny, deny_reason_str(*reason).to_string()),
};
Self {
generation,
op,
key: sanitize_log_value(key),
actor_kind: "subject".to_string(),
actor_id: sanitize_log_value(&actor.subject),
authenticated_by: authenticated_by(actor),
presenter_kind: presenter_kind(actor),
presenter_id: presenter_id(actor),
outcome,
reason,
}
}
#[must_use]
pub fn from_resolution_error(
generation: u64,
peer: &PeerInfo,
op: Op,
key: &str,
reason: String,
) -> Self {
let (presenter_kind, presenter_id) = presenter_from_peer(peer);
Self {
generation,
op,
key: sanitize_log_value(key),
actor_kind: "subject".to_string(),
actor_id: "unresolved".to_string(),
authenticated_by: Vec::new(),
presenter_kind,
presenter_id,
outcome: Outcome::Deny,
reason,
}
}
pub fn record(&self) {
let event_kind = "basil.audit.authz";
let event_version = 2_u16;
let op = op_token(self.op);
match self.outcome {
Outcome::Allow => info!(
name: "basil.audit.authz",
event_kind = event_kind,
event_version = event_version,
generation = self.generation,
op = op,
target_kind = "catalog_key",
target_id = %self.key,
actor_kind = %self.actor_kind,
actor_id = %self.actor_id,
authenticated_by = ?self.authenticated_by,
presenter_kind = %self.presenter_kind,
presenter_id = %self.presenter_id,
decision = self.outcome.as_str(),
outcome = self.outcome.as_str(),
reason = %self.reason,
"authz decision",
),
Outcome::Deny => warn!(
name: "basil.audit.authz",
event_kind = event_kind,
event_version = event_version,
generation = self.generation,
op = op,
target_kind = "catalog_key",
target_id = %self.key,
actor_kind = %self.actor_kind,
actor_id = %self.actor_id,
authenticated_by = ?self.authenticated_by,
presenter_kind = %self.presenter_kind,
presenter_id = %self.presenter_id,
decision = self.outcome.as_str(),
outcome = self.outcome.as_str(),
reason = %self.reason,
"authz decision: denied",
),
}
}
}
fn sanitize_log_value(value: &str) -> String {
if value.chars().any(char::is_control) {
value
.chars()
.flat_map(|c| {
let escaped: Box<dyn Iterator<Item = char>> = if c.is_control() {
Box::new(c.escape_default())
} else {
Box::new(std::iter::once(c))
};
escaped
})
.collect()
} else {
value.to_string()
}
}
fn authenticated_by(actor: &AuthenticatedActor) -> Vec<String> {
actor
.authenticated_by
.iter()
.map(|proof| match proof.kind {
ProofKind::UnixPeerCredentials => format!("unix_peercred:{}", proof.subject),
ProofKind::Unauthenticated => format!("unauthenticated:{}", proof.subject),
ProofKind::SignatureKey => format!("signature-key:{}", proof.subject),
})
.collect()
}
fn presenter_kind(actor: &AuthenticatedActor) -> String {
if actor.presenter.uid.is_some() {
"unix_peercred".to_string()
} else {
"none".to_string()
}
}
fn presenter_id(actor: &AuthenticatedActor) -> String {
actor.presenter.display_label.clone().unwrap_or_else(|| {
actor
.presenter
.uid
.map_or_else(|| "unknown".to_string(), |uid| format!("uid:{uid}"))
})
}
fn presenter_from_peer(peer: &PeerInfo) -> (String, String) {
let kind = if peer.uid.is_some() {
"unix_peercred"
} else {
"none"
};
let id = peer.display_label.clone().unwrap_or_else(|| {
peer.uid
.map_or_else(|| "unknown".to_string(), |uid| format!("uid:{uid}"))
});
(kind.to_string(), id)
}
#[must_use]
pub const fn op_token(op: Op) -> &'static str {
op.token()
}
fn allow_via_str(via: &AllowVia) -> String {
match via {
AllowVia::Subject(subject) => format!("subject:{subject}"),
AllowVia::PublicClass => "public_class".to_string(),
}
}
const fn deny_reason_str(reason: DenyReason) -> &'static str {
match reason {
DenyReason::UnknownKey => "unknown_key",
DenyReason::NotWritable => "not_writable",
DenyReason::IssuerRawSign => "issuer_raw_sign",
DenyReason::NotPermitted => "not_permitted",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::actor::{PresenterInfo, ProofSummary, TransportInfo};
fn actor() -> AuthenticatedActor {
AuthenticatedActor {
subject: "svc.nats".to_string(),
authenticated_by: vec![ProofSummary {
kind: ProofKind::UnixPeerCredentials,
subject: "svc.nats".to_string(),
}],
presenter: PresenterInfo {
pid: Some(123),
uid: Some(9002),
gid: Some(9002),
executable_path: None,
display_label: Some("svc-nats(9002)".to_string()),
},
transport: TransportInfo::default(),
}
}
#[test]
fn allow_via_subject_record_is_audit_shaped() {
let decision = Decision::Allow {
via: AllowVia::Subject("svc.nats".to_string()),
};
let rec =
DecisionRecord::from_actor_decision(3, &actor(), Op::Sign, "nats.account", &decision);
assert_eq!(rec.generation, 3);
assert_eq!(rec.op, Op::Sign);
assert_eq!(rec.key, "nats.account");
assert_eq!(rec.actor_kind, "subject");
assert_eq!(rec.actor_id, "svc.nats");
assert_eq!(rec.authenticated_by, ["unix_peercred:svc.nats"]);
assert_eq!(rec.presenter_kind, "unix_peercred");
assert_eq!(rec.presenter_id, "svc-nats(9002)");
assert_eq!(rec.outcome, Outcome::Allow);
assert_eq!(rec.reason, "subject:svc.nats");
rec.record();
}
#[test]
fn deny_record_carries_reason_and_subject() {
let decision = Decision::Deny {
reason: DenyReason::NotPermitted,
};
let rec =
DecisionRecord::from_actor_decision(1, &actor(), Op::Get, "nats.account", &decision);
assert_eq!(rec.outcome, Outcome::Deny);
assert_eq!(rec.reason, "not_permitted");
assert_eq!(rec.actor_id, "svc.nats");
rec.record();
}
#[test]
fn hostile_key_and_subject_names_are_escaped_before_logging() {
let decision = Decision::Deny {
reason: DenyReason::UnknownKey,
};
let hostile = "x\n{\"event_kind\":\"basil.audit.authz\",\"outcome\":\"allow\"}\u{1b}[0m";
let rec = DecisionRecord::from_actor_decision(1, &actor(), Op::Get, hostile, &decision);
assert!(
!rec.key.contains('\n') && !rec.key.contains('\u{1b}'),
"control chars must be escaped, got {:?}",
rec.key
);
assert!(rec.key.contains("\\n"), "newline is visibly escaped");
let rec =
DecisionRecord::from_actor_decision(1, &actor(), Op::Get, "web.tls.key", &decision);
assert_eq!(rec.key, "web.tls.key");
rec.record();
}
#[test]
fn deny_reasons_have_stable_tokens() {
for (reason, token) in [
(DenyReason::UnknownKey, "unknown_key"),
(DenyReason::NotWritable, "not_writable"),
(DenyReason::IssuerRawSign, "issuer_raw_sign"),
(DenyReason::NotPermitted, "not_permitted"),
] {
let rec = DecisionRecord::from_actor_decision(
1,
&actor(),
Op::Set,
"k",
&Decision::Deny { reason },
);
assert_eq!(rec.reason, token);
}
}
}