Skip to main content

auths_telemetry/
config.rs

1//! Audit sink configuration.
2//!
3//! Customers define audit sinks in a TOML file (typically `~/.auths/audit.toml`).
4//! Each sink entry specifies a type, destination, and optional credentials
5//! (resolved from environment variables at runtime).
6
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use serde::Deserialize;
12use tracing::warn;
13
14use crate::ports::EventSink;
15use crate::sinks::stdout::WriterSink;
16
17/// Top-level audit configuration.
18///
19/// Usage:
20/// ```ignore
21/// let config = load_audit_config(Path::new("/home/user/.auths/audit.toml"));
22/// let sinks = build_sinks_from_config(&config, |name| std::env::var(name).ok());
23/// ```
24#[derive(Debug, Deserialize, Default)]
25pub struct AuditConfig {
26    #[serde(default)]
27    pub sinks: Vec<SinkConfig>,
28}
29
30/// Authentication scheme for HTTP sinks.
31#[derive(Debug, Deserialize, Clone, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum AuthScheme {
34    /// `Authorization: Splunk <token>`
35    Splunk,
36    /// `Authorization: Bearer <token>`
37    #[default]
38    Bearer,
39    /// Custom header name (e.g. `DD-API-KEY`)
40    ApiKeyHeader { header: String },
41}
42
43/// Payload format for HTTP sinks (matches `sinks::http::PayloadFormat`).
44#[derive(Debug, Deserialize, Clone)]
45#[serde(rename_all = "snake_case")]
46pub enum ConfigPayloadFormat {
47    SplunkHec,
48    DatadogLogs,
49    NdJson,
50}
51
52/// Individual sink configuration entry.
53#[derive(Debug, Deserialize)]
54#[serde(tag = "type")]
55pub enum SinkConfig {
56    #[serde(rename = "http")]
57    Http {
58        url: String,
59        token_env: String,
60        #[serde(default)]
61        auth_scheme: AuthScheme,
62        payload_format: ConfigPayloadFormat,
63        #[serde(default = "default_batch_size")]
64        batch_size: usize,
65        #[serde(default = "default_flush_interval")]
66        flush_interval_ms: u64,
67    },
68    #[serde(rename = "file")]
69    File { path: PathBuf },
70    #[serde(rename = "stdout")]
71    Stdout,
72}
73
74fn default_batch_size() -> usize {
75    10
76}
77
78fn default_flush_interval() -> u64 {
79    5000
80}
81
82/// Load audit config from a TOML file.
83///
84/// Returns an empty config if the file does not exist. Logs a warning and
85/// returns empty config if the file is malformed.
86///
87/// Args:
88/// * `path`: Path to the audit TOML config file.
89///
90/// Usage:
91/// ```ignore
92/// let config = load_audit_config(Path::new("/home/user/.auths/audit.toml"));
93/// ```
94pub fn load_audit_config(path: &Path) -> AuditConfig {
95    let content = match fs::read_to_string(path) {
96        Ok(c) => c,
97        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return AuditConfig::default(),
98        Err(e) => {
99            warn!("could not read audit config {}: {e}", path.display());
100            return AuditConfig::default();
101        }
102    };
103
104    match toml::from_str(&content) {
105        Ok(config) => config,
106        Err(e) => {
107            warn!("invalid audit config {}: {e}", path.display());
108            AuditConfig::default()
109        }
110    }
111}
112
113/// Build concrete [`EventSink`] instances from parsed config.
114///
115/// The `resolve_env` closure resolves environment variable names to values.
116/// Skips sinks whose env vars are missing (with a warning). Returns sinks
117/// ready for [`CompositeSink`](crate::sinks::composite::CompositeSink).
118///
119/// Args:
120/// * `config`: Parsed audit configuration.
121/// * `resolve_env`: Closure that resolves env var names to values.
122///
123/// Usage:
124/// ```ignore
125/// let sinks = build_sinks_from_config(&config, |name| std::env::var(name).ok());
126/// let composite = CompositeSink::new(sinks);
127/// ```
128pub fn build_sinks_from_config(
129    config: &AuditConfig,
130    resolve_env: impl Fn(&str) -> Option<String>,
131) -> Vec<Arc<dyn EventSink>> {
132    let mut sinks: Vec<Arc<dyn EventSink>> = Vec::new();
133
134    for sink_config in &config.sinks {
135        match sink_config {
136            SinkConfig::Http { .. } => {
137                build_http_sink(&mut sinks, sink_config, &resolve_env);
138            }
139            SinkConfig::File { path } => {
140                build_file_sink(&mut sinks, path);
141            }
142            SinkConfig::Stdout => {
143                sinks.push(Arc::new(crate::sinks::stdout::new_stdout_sink()));
144            }
145        }
146    }
147
148    sinks
149}
150
151#[cfg(feature = "sink-http")]
152fn build_http_sink(
153    sinks: &mut Vec<Arc<dyn EventSink>>,
154    sink_config: &SinkConfig,
155    resolve_env: &dyn Fn(&str) -> Option<String>,
156) {
157    use std::collections::HashMap;
158
159    use crate::sinks::http::{HttpSink, HttpSinkConfig, PayloadFormat};
160
161    let SinkConfig::Http {
162        url,
163        token_env,
164        auth_scheme,
165        payload_format,
166        batch_size,
167        flush_interval_ms,
168    } = sink_config
169    else {
170        return;
171    };
172
173    let Some(token) = resolve_env(token_env) else {
174        warn!("skipping audit sink: env var '{token_env}' not set");
175        return;
176    };
177
178    let mut headers = HashMap::new();
179    match auth_scheme {
180        AuthScheme::Splunk => {
181            headers.insert("Authorization".to_string(), format!("Splunk {token}"));
182        }
183        AuthScheme::Bearer => {
184            headers.insert("Authorization".to_string(), format!("Bearer {token}"));
185        }
186        AuthScheme::ApiKeyHeader { header } => {
187            headers.insert(header.clone(), token);
188        }
189    }
190
191    let format = match payload_format {
192        ConfigPayloadFormat::SplunkHec => PayloadFormat::SplunkHec,
193        ConfigPayloadFormat::DatadogLogs => PayloadFormat::DatadogLogs,
194        ConfigPayloadFormat::NdJson => PayloadFormat::NdJson,
195    };
196
197    let config = HttpSinkConfig {
198        url: url.to_string(),
199        headers,
200        batch_size: *batch_size,
201        flush_interval_ms: *flush_interval_ms,
202        timeout_ms: 2000,
203        payload_format: format,
204    };
205
206    sinks.push(Arc::new(HttpSink::new(config)));
207}
208
209#[cfg(not(feature = "sink-http"))]
210fn build_http_sink(
211    _sinks: &mut Vec<Arc<dyn EventSink>>,
212    _sink_config: &SinkConfig,
213    _resolve_env: &dyn Fn(&str) -> Option<String>,
214) {
215    warn!("HTTP audit sinks require the 'sink-http' feature; skipping");
216}
217
218fn build_file_sink(sinks: &mut Vec<Arc<dyn EventSink>>, path: &Path) {
219    if let Some(parent) = path.parent()
220        && let Err(e) = fs::create_dir_all(parent)
221    {
222        warn!("could not create directory {}: {e}", parent.display());
223        return;
224    }
225
226    match fs::OpenOptions::new().create(true).append(true).open(path) {
227        Ok(file) => {
228            sinks.push(Arc::new(WriterSink::new(file)));
229        }
230        Err(e) => {
231            warn!("could not open audit log {}: {e}", path.display());
232        }
233    }
234}