1use tracing::{info, warn};
15
16use crate::actor::{AuthenticatedActor, ProofKind};
17use crate::catalog::policy::Op;
18use crate::catalog::{AllowVia, Decision, DenyReason};
19use crate::peer::PeerInfo;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Outcome {
24 Allow,
26 Deny,
28}
29
30impl Outcome {
31 #[must_use]
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::Allow => "allow",
36 Self::Deny => "deny",
37 }
38 }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct DecisionRecord {
49 pub generation: u64,
53 pub op: Op,
55 pub key: String,
57 pub actor_kind: String,
59 pub actor_id: String,
61 pub authenticated_by: Vec<String>,
63 pub presenter_kind: String,
65 pub presenter_id: String,
67 pub outcome: Outcome,
69 pub reason: String,
73}
74
75impl DecisionRecord {
76 #[must_use]
78 pub fn from_actor_decision(
79 generation: u64,
80 actor: &AuthenticatedActor,
81 op: Op,
82 key: &str,
83 decision: &Decision,
84 ) -> Self {
85 let (outcome, reason) = match decision {
86 Decision::Allow { via } => (Outcome::Allow, allow_via_str(via)),
87 Decision::Deny { reason } => (Outcome::Deny, deny_reason_str(*reason).to_string()),
88 };
89 Self {
90 generation,
91 op,
92 key: key.to_string(),
93 actor_kind: "subject".to_string(),
94 actor_id: actor.subject.clone(),
95 authenticated_by: authenticated_by(actor),
96 presenter_kind: presenter_kind(actor),
97 presenter_id: presenter_id(actor),
98 outcome,
99 reason,
100 }
101 }
102
103 #[must_use]
106 pub fn from_resolution_error(
107 generation: u64,
108 peer: &PeerInfo,
109 op: Op,
110 key: &str,
111 reason: String,
112 ) -> Self {
113 let (presenter_kind, presenter_id) = presenter_from_peer(peer);
114 Self {
115 generation,
116 op,
117 key: key.to_string(),
118 actor_kind: "subject".to_string(),
119 actor_id: "unresolved".to_string(),
120 authenticated_by: Vec::new(),
121 presenter_kind,
122 presenter_id,
123 outcome: Outcome::Deny,
124 reason,
125 }
126 }
127
128 pub fn record(&self) {
134 let event_kind = "basil.audit.authz";
135 let event_version = 2_u16;
136 let op = op_token(self.op);
137 match self.outcome {
138 Outcome::Allow => info!(
139 name: "basil.audit.authz",
140 event_kind = event_kind,
141 event_version = event_version,
142 generation = self.generation,
143 op = op,
144 target_kind = "catalog_key",
145 target_id = %self.key,
146 actor_kind = %self.actor_kind,
147 actor_id = %self.actor_id,
148 authenticated_by = ?self.authenticated_by,
149 presenter_kind = %self.presenter_kind,
150 presenter_id = %self.presenter_id,
151 decision = self.outcome.as_str(),
152 outcome = self.outcome.as_str(),
153 reason = %self.reason,
154 "authz decision",
155 ),
156 Outcome::Deny => warn!(
157 name: "basil.audit.authz",
158 event_kind = event_kind,
159 event_version = event_version,
160 generation = self.generation,
161 op = op,
162 target_kind = "catalog_key",
163 target_id = %self.key,
164 actor_kind = %self.actor_kind,
165 actor_id = %self.actor_id,
166 authenticated_by = ?self.authenticated_by,
167 presenter_kind = %self.presenter_kind,
168 presenter_id = %self.presenter_id,
169 decision = self.outcome.as_str(),
170 outcome = self.outcome.as_str(),
171 reason = %self.reason,
172 "authz decision: denied",
173 ),
174 }
175 }
176}
177
178fn authenticated_by(actor: &AuthenticatedActor) -> Vec<String> {
179 actor
180 .authenticated_by
181 .iter()
182 .map(|proof| match proof.kind {
183 ProofKind::UnixPeerCredentials => format!("unix_peercred:{}", proof.subject),
184 ProofKind::Unauthenticated => format!("unauthenticated:{}", proof.subject),
185 ProofKind::SignatureKey => format!("signature-key:{}", proof.subject),
186 })
187 .collect()
188}
189
190fn presenter_kind(actor: &AuthenticatedActor) -> String {
191 if actor.presenter.uid.is_some() {
192 "unix_peercred".to_string()
193 } else {
194 "none".to_string()
195 }
196}
197
198fn presenter_id(actor: &AuthenticatedActor) -> String {
199 actor.presenter.display_label.clone().unwrap_or_else(|| {
200 actor
201 .presenter
202 .uid
203 .map_or_else(|| "unknown".to_string(), |uid| format!("uid:{uid}"))
204 })
205}
206
207fn presenter_from_peer(peer: &PeerInfo) -> (String, String) {
208 let kind = if peer.uid.is_some() {
209 "unix_peercred"
210 } else {
211 "none"
212 };
213 let id = peer.display_label.clone().unwrap_or_else(|| {
214 peer.uid
215 .map_or_else(|| "unknown".to_string(), |uid| format!("uid:{uid}"))
216 });
217 (kind.to_string(), id)
218}
219
220#[must_use]
223pub const fn op_token(op: Op) -> &'static str {
224 op.token()
225}
226
227fn allow_via_str(via: &AllowVia) -> String {
229 match via {
230 AllowVia::Subject(subject) => format!("subject:{subject}"),
231 AllowVia::PublicClass => "public_class".to_string(),
232 }
233}
234
235const fn deny_reason_str(reason: DenyReason) -> &'static str {
237 match reason {
238 DenyReason::UnknownKey => "unknown_key",
239 DenyReason::NotWritable => "not_writable",
240 DenyReason::NotPermitted => "not_permitted",
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::actor::{PresenterInfo, ProofSummary, TransportInfo};
248
249 fn actor() -> AuthenticatedActor {
250 AuthenticatedActor {
251 subject: "svc.nats".to_string(),
252 authenticated_by: vec![ProofSummary {
253 kind: ProofKind::UnixPeerCredentials,
254 subject: "svc.nats".to_string(),
255 }],
256 presenter: PresenterInfo {
257 pid: Some(123),
258 uid: Some(9002),
259 gid: Some(9002),
260 executable_path: None,
261 display_label: Some("svc-nats(9002)".to_string()),
262 },
263 transport: TransportInfo::default(),
264 }
265 }
266
267 #[test]
268 fn allow_via_subject_record_is_audit_shaped() {
269 let decision = Decision::Allow {
270 via: AllowVia::Subject("svc.nats".to_string()),
271 };
272 let rec =
273 DecisionRecord::from_actor_decision(3, &actor(), Op::Sign, "nats.account", &decision);
274 assert_eq!(rec.generation, 3);
275 assert_eq!(rec.op, Op::Sign);
276 assert_eq!(rec.key, "nats.account");
277 assert_eq!(rec.actor_kind, "subject");
278 assert_eq!(rec.actor_id, "svc.nats");
279 assert_eq!(rec.authenticated_by, ["unix_peercred:svc.nats"]);
280 assert_eq!(rec.presenter_kind, "unix_peercred");
281 assert_eq!(rec.presenter_id, "svc-nats(9002)");
282 assert_eq!(rec.outcome, Outcome::Allow);
283 assert_eq!(rec.reason, "subject:svc.nats");
284 rec.record();
286 }
287
288 #[test]
289 fn deny_record_carries_reason_and_subject() {
290 let decision = Decision::Deny {
291 reason: DenyReason::NotPermitted,
292 };
293 let rec =
294 DecisionRecord::from_actor_decision(1, &actor(), Op::Get, "nats.account", &decision);
295 assert_eq!(rec.outcome, Outcome::Deny);
296 assert_eq!(rec.reason, "not_permitted");
297 assert_eq!(rec.actor_id, "svc.nats");
298 rec.record();
299 }
300
301 #[test]
302 fn deny_reasons_have_stable_tokens() {
303 for (reason, token) in [
304 (DenyReason::UnknownKey, "unknown_key"),
305 (DenyReason::NotWritable, "not_writable"),
306 (DenyReason::NotPermitted, "not_permitted"),
307 ] {
308 let rec = DecisionRecord::from_actor_decision(
309 1,
310 &actor(),
311 Op::Set,
312 "k",
313 &Decision::Deny { reason },
314 );
315 assert_eq!(rec.reason, token);
316 }
317 }
318}