Skip to main content

dynamic_config_server/
audit.rs

1//! Who read what, when — and never what was in it.
2//!
3//! The library's rule is that a value never reaches a diagnostic. A server
4//! makes that rule harder to keep and more important to keep: it is the one
5//! program here that *serves* values, so its log is the obvious place for
6//! one to escape on the way past.
7//!
8//! The answer is structural rather than careful. An [`AuditEntry`] has no
9//! field a configuration value could occupy: a caller name and an endpoint,
10//! both from the server's own configuration or from a fixed list, an
11//! application and a profile that have already passed the request-shape
12//! check, an outcome and a generation number. There is nowhere to put a
13//! value, so no amount of future editing puts one there.
14
15use std::fmt;
16
17/// What happened to one request.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum Outcome {
21    /// The caller was authorised and got what it asked for.
22    Served,
23    /// The caller presented no usable credential.
24    Unauthenticated,
25    /// The caller is somebody, but not somebody who may read this — or
26    /// nothing is served here. **One outcome for both**, because the server
27    /// does not distinguish them to the caller and an audit log that did
28    /// would be a way to ask it to.
29    NotFound,
30    /// The request's shape was refused before anything was looked up.
31    Malformed,
32    /// The caller was authorised and the section could not answer: it has
33    /// no document yet, or a diagnostic could not read the sources. About
34    /// the server, not about the caller — which is why it is not
35    /// [`NotFound`](Self::NotFound).
36    Unavailable,
37}
38
39impl Outcome {
40    /// A short, stable label, for a log field or a metric dimension.
41    #[must_use]
42    pub fn as_str(self) -> &'static str {
43        match self {
44            Self::Served => "served",
45            Self::Unauthenticated => "unauthenticated",
46            Self::NotFound => "not-found",
47            Self::Malformed => "malformed",
48            Self::Unavailable => "unavailable",
49        }
50    }
51}
52
53/// One line of the audit log.
54#[derive(Debug, Clone)]
55#[non_exhaustive]
56pub struct AuditEntry {
57    /// The configured client name, or `None` when nobody was identified.
58    pub caller: Option<String>,
59    /// The application asked for — `None` when the request shape was
60    /// refused, because an unvalidated path segment is attacker-controlled
61    /// text and a log line is a place newlines matter.
62    pub application: Option<String>,
63    /// The profile asked for, under the same rule.
64    pub profile: Option<String>,
65    /// Which endpoint, from a fixed list.
66    pub endpoint: &'static str,
67    /// How it ended.
68    pub outcome: Outcome,
69    /// The generation served, when something was.
70    pub generation: Option<u64>,
71}
72
73impl fmt::Display for AuditEntry {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        fn field(value: Option<&String>) -> &str {
76            value.map_or("-", String::as_str)
77        }
78
79        write!(
80            f,
81            "audit caller={} application={} profile={} endpoint={} outcome={} generation={}",
82            field(self.caller.as_ref()),
83            field(self.application.as_ref()),
84            field(self.profile.as_ref()),
85            self.endpoint,
86            self.outcome.as_str(),
87            self.generation
88                .map_or_else(|| "-".to_owned(), |it| it.to_string()),
89        )
90    }
91}
92
93/// Where audit lines go.
94///
95/// A trait rather than a `tracing` call, for two reasons that point the same
96/// way: a deployment's audit trail usually belongs somewhere other than
97/// stderr, and a test that asserts *no value ever appears in the log* needs
98/// the log in a `Vec` it can read.
99pub trait AuditSink: Send + Sync + 'static {
100    /// Records one request.
101    ///
102    /// Called on the request's own task, after the response is decided and
103    /// before it is returned. A sink that blocks blocks a request, so a sink
104    /// that talks to a network should hand off to a queue.
105    fn record(&self, entry: &AuditEntry);
106}
107
108/// The default sink: one line per request on stderr.
109#[derive(Debug, Clone, Copy, Default)]
110pub struct StderrAudit;
111
112impl AuditSink for StderrAudit {
113    fn record(&self, entry: &AuditEntry) {
114        eprintln!("{entry}");
115    }
116}
117
118/// A sink that records nothing.
119///
120/// For a deployment that audits in front of this server instead, and for
121/// tests that are not about the log.
122#[derive(Debug, Clone, Copy, Default)]
123pub struct NoAudit;
124
125impl AuditSink for NoAudit {
126    fn record(&self, _entry: &AuditEntry) {}
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn a_line_renders_every_field_and_dashes_the_absent_ones() {
135        let entry = AuditEntry {
136            caller: Some("billing-pod".to_owned()),
137            application: Some("billing".to_owned()),
138            profile: Some("prod".to_owned()),
139            endpoint: "document",
140            outcome: Outcome::Served,
141            generation: Some(3),
142        };
143
144        assert_eq!(
145            entry.to_string(),
146            "audit caller=billing-pod application=billing profile=prod endpoint=document \
147             outcome=served generation=3"
148        );
149
150        let entry = AuditEntry {
151            caller: None,
152            application: None,
153            profile: None,
154            endpoint: "document",
155            outcome: Outcome::Unauthenticated,
156            generation: None,
157        };
158
159        assert_eq!(
160            entry.to_string(),
161            "audit caller=- application=- profile=- endpoint=document \
162             outcome=unauthenticated generation=-"
163        );
164    }
165}