Skip to main content

basil_core/core/
decision.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! The per-request authorization decision record (the audit hook point).
6//!
7//! Every **PDP-gated** op produces exactly one [`DecisionRecord`]: the broker
8//! decides `(subject, op, key) -> Allow|Deny` (via [`crate::catalog::pdp::Pdp`]),
9//! builds a record, and logs it structurally with `tracing`. This is the single
10//! place a decision is materialized; `vault-vq5` will persist these records to a
11//! JSONL audit file by hooking [`DecisionRecord::record`] (or reusing the fields
12//! it carries). This module deliberately does **not** open or write any file.
13//!
14//! A record never carries secret bytes, only the policy generation id it was
15//! decided against, the actor subject, presenter summary, op, key name, outcome,
16//! and reason.
17
18use 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/// The outcome of a gated request, in audit-friendly form.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Outcome {
28    /// The request was permitted.
29    Allow,
30    /// The request was denied.
31    Deny,
32}
33
34impl Outcome {
35    /// The lowercase wire string for this outcome (`allow` / `deny`).
36    #[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/// A structured record of one authorization decision (the audit hook point).
46///
47/// Built from a [`Decision`] plus the request's `(subject, op, key)` and
48/// presenter context. It carries everything the audit sink needs to serialize a
49/// JSONL audit line and **nothing** secret: no payloads, no key bytes, no
50/// signatures.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DecisionRecord {
53    /// The id of the policy generation this decision was made against
54    /// (`basil-y3e`). Lets the audit trail tie a decision to the exact
55    /// catalog/policy snapshot that produced it across a hot reload.
56    pub generation: u64,
57    /// The op that was gated (the policy [`Op`], e.g. `sign`, `new_key`).
58    pub op: Op,
59    /// The dotted catalog key the op targeted.
60    pub key: String,
61    /// Actor kind. M1 authorization records are subject-based.
62    pub actor_kind: String,
63    /// The policy subject being authorized.
64    pub actor_id: String,
65    /// Evidence summaries that established the actor.
66    pub authenticated_by: Vec<String>,
67    /// Presenter kind, e.g. `unix_peercred`.
68    pub presenter_kind: String,
69    /// Presenter id, preferably the configured `name(uid)` label.
70    pub presenter_id: String,
71    /// Allow or deny.
72    pub outcome: Outcome,
73    /// On an allow, *what* granted it (subject / public-class); on a
74    /// deny, *which* check failed (unknown-key / not-writable / not-permitted).
75    /// Always a short stable token, never secret.
76    pub reason: String,
77}
78
79impl DecisionRecord {
80    /// Build a record from a `PDP` [`Decision`] for an [`AuthenticatedActor`].
81    #[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    /// Build a denied record for a request whose presenter could not resolve to
108    /// a subject.
109    #[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    /// Emit this record to the tracing log (the in-handler audit hook).
133    ///
134    /// Allows log at `info`, denials at `warn`. `vault-vq5` will tap the same
135    /// records to append a JSONL audit line; this method is intentionally the
136    /// only side effect today.
137    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
182/// Escape control characters in a client-influenced value (the requested key
183/// name, the subject) before it enters the record. The catalog/policy loader
184/// rejects control characters in real key/subject names, but a *denied* request
185/// is recorded with the raw client-supplied key (`UnknownKey` included), and
186/// the text `tracing` sinks (stdout `fmt`, rolling file) do not escape `%`
187/// Display fields: a newline in the key would forge a log line in the security
188/// record. The JSONL audit sink escapes independently via serde; sanitized
189/// values contain only printables, so it is unaffected.
190fn 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/// The stable wire spelling for a broker policy op (delegates to [`Op::token`],
251/// the single source of truth for the op→token mapping).
252#[must_use]
253pub const fn op_token(op: Op) -> &'static str {
254    op.token()
255}
256
257/// A stable token for the grant that allowed a request (for audit).
258fn 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
265/// A stable token for the check that denied a request (for audit).
266const 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        // Logging must not panic.
316        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        // A client-supplied key is recorded even on a denied UnknownKey request;
335        // a newline/control char in it must not be able to forge a line in the
336        // text tracing sinks.
337        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        // A benign dotted-lowercase key is unchanged.
349        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}