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}