1use tracing::{info, warn};
19
20use crate::actor::{AuthenticatedActor, ProofKind};
21use crate::catalog::policy::Op;
22use crate::catalog::{AllowVia, Decision, DenyReason};
23use crate::peer::PeerInfo;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Outcome {
28 Allow,
30 Deny,
32}
33
34impl Outcome {
35 #[must_use]
37 pub const fn as_str(self) -> &'static str {
38 match self {
39 Self::Allow => "allow",
40 Self::Deny => "deny",
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DecisionRecord {
53 pub generation: u64,
57 pub op: Op,
59 pub key: String,
61 pub actor_kind: String,
63 pub actor_id: String,
65 pub authenticated_by: Vec<String>,
67 pub presenter_kind: String,
69 pub presenter_id: String,
71 pub outcome: Outcome,
73 pub reason: String,
77}
78
79impl DecisionRecord {
80 #[must_use]
82 pub fn from_actor_decision(
83 generation: u64,
84 actor: &AuthenticatedActor,
85 op: Op,
86 key: &str,
87 decision: &Decision,
88 ) -> Self {
89 let (outcome, reason) = match decision {
90 Decision::Allow { via } => (Outcome::Allow, allow_via_str(via)),
91 Decision::Deny { reason } => (Outcome::Deny, deny_reason_str(*reason).to_string()),
92 };
93 Self {
94 generation,
95 op,
96 key: sanitize_log_value(key),
97 actor_kind: "subject".to_string(),
98 actor_id: sanitize_log_value(&actor.subject),
99 authenticated_by: authenticated_by(actor),
100 presenter_kind: presenter_kind(actor),
101 presenter_id: presenter_id(actor),
102 outcome,
103 reason,
104 }
105 }
106
107 #[must_use]
110 pub fn from_resolution_error(
111 generation: u64,
112 peer: &PeerInfo,
113 op: Op,
114 key: &str,
115 reason: String,
116 ) -> Self {
117 let (presenter_kind, presenter_id) = presenter_from_peer(peer);
118 Self {
119 generation,
120 op,
121 key: sanitize_log_value(key),
122 actor_kind: "subject".to_string(),
123 actor_id: "unresolved".to_string(),
124 authenticated_by: Vec::new(),
125 presenter_kind,
126 presenter_id,
127 outcome: Outcome::Deny,
128 reason,
129 }
130 }
131
132 pub fn record(&self) {
138 let event_kind = "basil.audit.authz";
139 let event_version = 2_u16;
140 let op = op_token(self.op);
141 match self.outcome {
142 Outcome::Allow => info!(
143 name: "basil.audit.authz",
144 event_kind = event_kind,
145 event_version = event_version,
146 generation = self.generation,
147 op = op,
148 target_kind = "catalog_key",
149 target_id = %self.key,
150 actor_kind = %self.actor_kind,
151 actor_id = %self.actor_id,
152 authenticated_by = ?self.authenticated_by,
153 presenter_kind = %self.presenter_kind,
154 presenter_id = %self.presenter_id,
155 decision = self.outcome.as_str(),
156 outcome = self.outcome.as_str(),
157 reason = %self.reason,
158 "authz decision",
159 ),
160 Outcome::Deny => warn!(
161 name: "basil.audit.authz",
162 event_kind = event_kind,
163 event_version = event_version,
164 generation = self.generation,
165 op = op,
166 target_kind = "catalog_key",
167 target_id = %self.key,
168 actor_kind = %self.actor_kind,
169 actor_id = %self.actor_id,
170 authenticated_by = ?self.authenticated_by,
171 presenter_kind = %self.presenter_kind,
172 presenter_id = %self.presenter_id,
173 decision = self.outcome.as_str(),
174 outcome = self.outcome.as_str(),
175 reason = %self.reason,
176 "authz decision: denied",
177 ),
178 }
179 }
180}
181
182fn sanitize_log_value(value: &str) -> String {
191 if value.chars().any(char::is_control) {
192 value
193 .chars()
194 .flat_map(|c| {
195 let escaped: Box<dyn Iterator<Item = char>> = if c.is_control() {
196 Box::new(c.escape_default())
197 } else {
198 Box::new(std::iter::once(c))
199 };
200 escaped
201 })
202 .collect()
203 } else {
204 value.to_string()
205 }
206}
207
208fn authenticated_by(actor: &AuthenticatedActor) -> Vec<String> {
209 actor
210 .authenticated_by
211 .iter()
212 .map(|proof| match proof.kind {
213 ProofKind::UnixPeerCredentials => format!("unix_peercred:{}", proof.subject),
214 ProofKind::Unauthenticated => format!("unauthenticated:{}", proof.subject),
215 ProofKind::SignatureKey => format!("signature-key:{}", proof.subject),
216 })
217 .collect()
218}
219
220fn presenter_kind(actor: &AuthenticatedActor) -> String {
221 if actor.presenter.uid.is_some() {
222 "unix_peercred".to_string()
223 } else {
224 "none".to_string()
225 }
226}
227
228fn presenter_id(actor: &AuthenticatedActor) -> String {
229 actor.presenter.display_label.clone().unwrap_or_else(|| {
230 actor
231 .presenter
232 .uid
233 .map_or_else(|| "unknown".to_string(), |uid| format!("uid:{uid}"))
234 })
235}
236
237fn presenter_from_peer(peer: &PeerInfo) -> (String, String) {
238 let kind = if peer.uid.is_some() {
239 "unix_peercred"
240 } else {
241 "none"
242 };
243 let id = peer.display_label.clone().unwrap_or_else(|| {
244 peer.uid
245 .map_or_else(|| "unknown".to_string(), |uid| format!("uid:{uid}"))
246 });
247 (kind.to_string(), id)
248}
249
250#[must_use]
253pub const fn op_token(op: Op) -> &'static str {
254 op.token()
255}
256
257fn allow_via_str(via: &AllowVia) -> String {
259 match via {
260 AllowVia::Subject(subject) => format!("subject:{subject}"),
261 AllowVia::PublicClass => "public_class".to_string(),
262 }
263}
264
265const fn deny_reason_str(reason: DenyReason) -> &'static str {
267 match reason {
268 DenyReason::UnknownKey => "unknown_key",
269 DenyReason::NotWritable => "not_writable",
270 DenyReason::IssuerRawSign => "issuer_raw_sign",
271 DenyReason::NotPermitted => "not_permitted",
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::actor::{PresenterInfo, ProofSummary, TransportInfo};
279
280 fn actor() -> AuthenticatedActor {
281 AuthenticatedActor {
282 subject: "svc.nats".to_string(),
283 authenticated_by: vec![ProofSummary {
284 kind: ProofKind::UnixPeerCredentials,
285 subject: "svc.nats".to_string(),
286 }],
287 presenter: PresenterInfo {
288 pid: Some(123),
289 uid: Some(9002),
290 gid: Some(9002),
291 executable_path: None,
292 display_label: Some("svc-nats(9002)".to_string()),
293 },
294 transport: TransportInfo::default(),
295 }
296 }
297
298 #[test]
299 fn allow_via_subject_record_is_audit_shaped() {
300 let decision = Decision::Allow {
301 via: AllowVia::Subject("svc.nats".to_string()),
302 };
303 let rec =
304 DecisionRecord::from_actor_decision(3, &actor(), Op::Sign, "nats.account", &decision);
305 assert_eq!(rec.generation, 3);
306 assert_eq!(rec.op, Op::Sign);
307 assert_eq!(rec.key, "nats.account");
308 assert_eq!(rec.actor_kind, "subject");
309 assert_eq!(rec.actor_id, "svc.nats");
310 assert_eq!(rec.authenticated_by, ["unix_peercred:svc.nats"]);
311 assert_eq!(rec.presenter_kind, "unix_peercred");
312 assert_eq!(rec.presenter_id, "svc-nats(9002)");
313 assert_eq!(rec.outcome, Outcome::Allow);
314 assert_eq!(rec.reason, "subject:svc.nats");
315 rec.record();
317 }
318
319 #[test]
320 fn deny_record_carries_reason_and_subject() {
321 let decision = Decision::Deny {
322 reason: DenyReason::NotPermitted,
323 };
324 let rec =
325 DecisionRecord::from_actor_decision(1, &actor(), Op::Get, "nats.account", &decision);
326 assert_eq!(rec.outcome, Outcome::Deny);
327 assert_eq!(rec.reason, "not_permitted");
328 assert_eq!(rec.actor_id, "svc.nats");
329 rec.record();
330 }
331
332 #[test]
333 fn hostile_key_and_subject_names_are_escaped_before_logging() {
334 let decision = Decision::Deny {
338 reason: DenyReason::UnknownKey,
339 };
340 let hostile = "x\n{\"event_kind\":\"basil.audit.authz\",\"outcome\":\"allow\"}\u{1b}[0m";
341 let rec = DecisionRecord::from_actor_decision(1, &actor(), Op::Get, hostile, &decision);
342 assert!(
343 !rec.key.contains('\n') && !rec.key.contains('\u{1b}'),
344 "control chars must be escaped, got {:?}",
345 rec.key
346 );
347 assert!(rec.key.contains("\\n"), "newline is visibly escaped");
348 let rec =
350 DecisionRecord::from_actor_decision(1, &actor(), Op::Get, "web.tls.key", &decision);
351 assert_eq!(rec.key, "web.tls.key");
352 rec.record();
353 }
354
355 #[test]
356 fn deny_reasons_have_stable_tokens() {
357 for (reason, token) in [
358 (DenyReason::UnknownKey, "unknown_key"),
359 (DenyReason::NotWritable, "not_writable"),
360 (DenyReason::IssuerRawSign, "issuer_raw_sign"),
361 (DenyReason::NotPermitted, "not_permitted"),
362 ] {
363 let rec = DecisionRecord::from_actor_decision(
364 1,
365 &actor(),
366 Op::Set,
367 "k",
368 &Decision::Deny { reason },
369 );
370 assert_eq!(rec.reason, token);
371 }
372 }
373}