Skip to main content

basil_core/core/
decision.rs

1//! The per-request authorization decision record (the audit hook point).
2//!
3//! Every **PDP-gated** op produces exactly one [`DecisionRecord`]: the broker
4//! decides `(subject, op, key) -> Allow|Deny` (via [`crate::catalog::pdp::Pdp`]),
5//! builds a record, and logs it structurally with `tracing`. This is the single
6//! place a decision is materialized; `vault-vq5` will persist these records to a
7//! JSONL audit file by hooking [`DecisionRecord::record`] (or reusing the fields
8//! it carries). This module deliberately does **not** open or write any file.
9//!
10//! A record never carries secret bytes, only the policy generation id it was
11//! decided against, the actor subject, presenter summary, op, key name, outcome,
12//! and reason.
13
14use 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/// The outcome of a gated request, in audit-friendly form.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Outcome {
24    /// The request was permitted.
25    Allow,
26    /// The request was denied.
27    Deny,
28}
29
30impl Outcome {
31    /// The lowercase wire string for this outcome (`allow` / `deny`).
32    #[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/// A structured record of one authorization decision (the audit hook point).
42///
43/// Built from a [`Decision`] plus the request's `(subject, op, key)` and
44/// presenter context. It carries everything the audit sink needs to serialize a
45/// JSONL audit line and **nothing** secret: no payloads, no key bytes, no
46/// signatures.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct DecisionRecord {
49    /// The id of the policy generation this decision was made against
50    /// (`basil-y3e`). Lets the audit trail tie a decision to the exact
51    /// catalog/policy snapshot that produced it across a hot reload.
52    pub generation: u64,
53    /// The op that was gated (the policy [`Op`], e.g. `sign`, `new_key`).
54    pub op: Op,
55    /// The dotted catalog key the op targeted.
56    pub key: String,
57    /// Actor kind. M1 authorization records are subject-based.
58    pub actor_kind: String,
59    /// The policy subject being authorized.
60    pub actor_id: String,
61    /// Evidence summaries that established the actor.
62    pub authenticated_by: Vec<String>,
63    /// Presenter kind, e.g. `unix_peercred`.
64    pub presenter_kind: String,
65    /// Presenter id, preferably the configured `name(uid)` label.
66    pub presenter_id: String,
67    /// Allow or deny.
68    pub outcome: Outcome,
69    /// On an allow, *what* granted it (subject / public-class); on a
70    /// deny, *which* check failed (unknown-key / not-writable / not-permitted).
71    /// Always a short stable token, never secret.
72    pub reason: String,
73}
74
75impl DecisionRecord {
76    /// Build a record from a `PDP` [`Decision`] for an [`AuthenticatedActor`].
77    #[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    /// Build a denied record for a request whose presenter could not resolve to
104    /// a subject.
105    #[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    /// Emit this record to the tracing log (the in-handler audit hook).
129    ///
130    /// Allows log at `info`, denials at `warn`. `vault-vq5` will tap the same
131    /// records to append a JSONL audit line; this method is intentionally the
132    /// only side effect today.
133    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/// The stable wire spelling for a broker policy op (delegates to [`Op::token`],
221/// the single source of truth for the op→token mapping).
222#[must_use]
223pub const fn op_token(op: Op) -> &'static str {
224    op.token()
225}
226
227/// A stable token for the grant that allowed a request (for audit).
228fn 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
235/// A stable token for the check that denied a request (for audit).
236const 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        // Logging must not panic.
285        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}