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: key.to_string(),
97 actor_kind: "subject".to_string(),
98 actor_id: actor.subject.clone(),
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: key.to_string(),
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 authenticated_by(actor: &AuthenticatedActor) -> Vec<String> {
183 actor
184 .authenticated_by
185 .iter()
186 .map(|proof| match proof.kind {
187 ProofKind::UnixPeerCredentials => format!("unix_peercred:{}", proof.subject),
188 ProofKind::Unauthenticated => format!("unauthenticated:{}", proof.subject),
189 ProofKind::SignatureKey => format!("signature-key:{}", proof.subject),
190 })
191 .collect()
192}
193
194fn presenter_kind(actor: &AuthenticatedActor) -> String {
195 if actor.presenter.uid.is_some() {
196 "unix_peercred".to_string()
197 } else {
198 "none".to_string()
199 }
200}
201
202fn presenter_id(actor: &AuthenticatedActor) -> String {
203 actor.presenter.display_label.clone().unwrap_or_else(|| {
204 actor
205 .presenter
206 .uid
207 .map_or_else(|| "unknown".to_string(), |uid| format!("uid:{uid}"))
208 })
209}
210
211fn presenter_from_peer(peer: &PeerInfo) -> (String, String) {
212 let kind = if peer.uid.is_some() {
213 "unix_peercred"
214 } else {
215 "none"
216 };
217 let id = peer.display_label.clone().unwrap_or_else(|| {
218 peer.uid
219 .map_or_else(|| "unknown".to_string(), |uid| format!("uid:{uid}"))
220 });
221 (kind.to_string(), id)
222}
223
224#[must_use]
227pub const fn op_token(op: Op) -> &'static str {
228 op.token()
229}
230
231fn allow_via_str(via: &AllowVia) -> String {
233 match via {
234 AllowVia::Subject(subject) => format!("subject:{subject}"),
235 AllowVia::PublicClass => "public_class".to_string(),
236 }
237}
238
239const fn deny_reason_str(reason: DenyReason) -> &'static str {
241 match reason {
242 DenyReason::UnknownKey => "unknown_key",
243 DenyReason::NotWritable => "not_writable",
244 DenyReason::NotPermitted => "not_permitted",
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::actor::{PresenterInfo, ProofSummary, TransportInfo};
252
253 fn actor() -> AuthenticatedActor {
254 AuthenticatedActor {
255 subject: "svc.nats".to_string(),
256 authenticated_by: vec![ProofSummary {
257 kind: ProofKind::UnixPeerCredentials,
258 subject: "svc.nats".to_string(),
259 }],
260 presenter: PresenterInfo {
261 pid: Some(123),
262 uid: Some(9002),
263 gid: Some(9002),
264 executable_path: None,
265 display_label: Some("svc-nats(9002)".to_string()),
266 },
267 transport: TransportInfo::default(),
268 }
269 }
270
271 #[test]
272 fn allow_via_subject_record_is_audit_shaped() {
273 let decision = Decision::Allow {
274 via: AllowVia::Subject("svc.nats".to_string()),
275 };
276 let rec =
277 DecisionRecord::from_actor_decision(3, &actor(), Op::Sign, "nats.account", &decision);
278 assert_eq!(rec.generation, 3);
279 assert_eq!(rec.op, Op::Sign);
280 assert_eq!(rec.key, "nats.account");
281 assert_eq!(rec.actor_kind, "subject");
282 assert_eq!(rec.actor_id, "svc.nats");
283 assert_eq!(rec.authenticated_by, ["unix_peercred:svc.nats"]);
284 assert_eq!(rec.presenter_kind, "unix_peercred");
285 assert_eq!(rec.presenter_id, "svc-nats(9002)");
286 assert_eq!(rec.outcome, Outcome::Allow);
287 assert_eq!(rec.reason, "subject:svc.nats");
288 rec.record();
290 }
291
292 #[test]
293 fn deny_record_carries_reason_and_subject() {
294 let decision = Decision::Deny {
295 reason: DenyReason::NotPermitted,
296 };
297 let rec =
298 DecisionRecord::from_actor_decision(1, &actor(), Op::Get, "nats.account", &decision);
299 assert_eq!(rec.outcome, Outcome::Deny);
300 assert_eq!(rec.reason, "not_permitted");
301 assert_eq!(rec.actor_id, "svc.nats");
302 rec.record();
303 }
304
305 #[test]
306 fn deny_reasons_have_stable_tokens() {
307 for (reason, token) in [
308 (DenyReason::UnknownKey, "unknown_key"),
309 (DenyReason::NotWritable, "not_writable"),
310 (DenyReason::NotPermitted, "not_permitted"),
311 ] {
312 let rec = DecisionRecord::from_actor_decision(
313 1,
314 &actor(),
315 Op::Set,
316 "k",
317 &Decision::Deny { reason },
318 );
319 assert_eq!(rec.reason, token);
320 }
321 }
322}