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: 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    /// 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: 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    /// 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
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/// The stable wire spelling for a broker policy op (delegates to [`Op::token`],
225/// the single source of truth for the op→token mapping).
226#[must_use]
227pub const fn op_token(op: Op) -> &'static str {
228    op.token()
229}
230
231/// A stable token for the grant that allowed a request (for audit).
232fn 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
239/// A stable token for the check that denied a request (for audit).
240const 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        // Logging must not panic.
289        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}