Skip to content

Commit 6c9b3ec

Browse files
committed
feat: remote audit storage backend
Abstract the audit log behind an async AuditSink trait with selectable backends. Keep the local file backend (default, renamed to FileAuditSink) and add an HttpAuditSink that POSTs each JSON record to a configured endpoint with optional bearer auth. Add an [audit] config section (backend, endpoint, token_env) with overlay merging and validation, build the sink in the service, and make the decision logging path async.
1 parent 769e0f4 commit 6c9b3ec

9 files changed

Lines changed: 236 additions & 60 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ anyhow.workspace = true
4444
async-trait.workspace = true
4545
chrono.workspace = true
4646
clap.workspace = true
47+
reqwest.workspace = true
4748
rmcp.workspace = true
4849
rusqlite.workspace = true
4950
schemars.workspace = true

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ Prioritized planned work:
277277
- [ ] Dependency confusion defenses for internal/private package names
278278
- [ ] Policy simulation mode (`what-if`) without enforcement
279279
- [ ] Metrics/log schema for latency, cache hit ratio, and registry error rates
280-
- [ ] Support remote audit storage backends
280+
- [x] Support remote audit storage backends
281281
- [ ] Support remote config sources (GitHub repo, HTTP endpoint, etc.)
282282
- [ ] Support for private registries
283283

docs/configuration-spec.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ Project values overlay global values.
5050
| `cache.ttl_minutes` | integer | `30` | Cache TTL in minutes. `0` resets to default. |
5151
| `lockfile.eval_concurrency` | integer | `5` | Number of packages evaluated in parallel during lockfile audits. Lower values reduce API burst load. `0` resets to default. |
5252
| `lockfile.inter_batch_delay_ms` | integer | `100` | Milliseconds to wait before spawning each replacement evaluation task after one completes. The initial batch is spawned immediately. Helps avoid rate limiting by spacing requests over time. Set to `0` for no delay. |
53+
| `audit.backend` | enum | `file` | `file \| http`. `file` appends records to the local audit log; `http` POSTs each record as JSON to `audit.endpoint`. |
54+
| `audit.endpoint` | string | unset | HTTP endpoint that receives audit records. Required when `audit.backend = http`. |
55+
| `audit.token_env` | string | unset | Name of the environment variable holding a bearer token sent as `Authorization: Bearer <token>` on HTTP audit requests. |
5356
| `custom_rules` | array(table) | `[]` | User-defined rule set evaluated alongside built-in checks. Invalid rules fail config load. |
5457

5558
## Merge rules
@@ -111,6 +114,11 @@ ttl_minutes = 30
111114
eval_concurrency = 5 # Number of packages evaluated in parallel
112115
inter_batch_delay_ms = 100 # Delay between spawning evaluation tasks (helps with rate limiting)
113116

117+
[audit]
118+
backend = "http" # "file" (default) or "http"
119+
endpoint = "https://audit.example.com/v1/records"
120+
token_env = "SAFE_PKGS_AUDIT_TOKEN" # env var holding the bearer token
121+
114122
[[custom_rules]]
115123
id = "deny-very-new-low-downloads"
116124
severity = "high"

src/audit_log.rs

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,41 @@ use std::env;
44
use std::fs::{self, File, OpenOptions};
55
use std::io::Write;
66
use std::path::PathBuf;
7+
use std::sync::Arc;
78
use std::sync::Mutex;
89

10+
use async_trait::async_trait;
911
use chrono::Utc;
1012
use serde::Serialize;
1113

14+
use crate::config::{AuditBackend, AuditConfig};
1215
use crate::types::{Evidence, Metadata, Severity};
1316

14-
/// File-backed logger that writes one JSON record per line.
15-
pub struct AuditLogger {
17+
/// Audit destination for decision records.
18+
#[async_trait]
19+
pub trait AuditSink: Send + Sync {
20+
/// Persists a single audit record.
21+
///
22+
/// # Errors
23+
///
24+
/// Returns an error if the record cannot be persisted.
25+
async fn log(&self, record: &AuditRecord) -> anyhow::Result<()>;
26+
}
27+
28+
/// File-backed sink that writes one JSON record per line.
29+
pub struct FileAuditSink {
1630
file: Mutex<File>,
1731
}
1832

19-
/// Serialized audit event written to the local audit log.
20-
#[derive(Debug, Serialize)]
33+
/// HTTP-backed sink that POSTs each record as JSON to a configured endpoint.
34+
pub struct HttpAuditSink {
35+
client: reqwest::Client,
36+
endpoint: String,
37+
token: Option<String>,
38+
}
39+
40+
/// Serialized audit event written to the audit log.
41+
#[derive(Debug, Clone, Serialize)]
2142
pub struct AuditRecord {
2243
timestamp: String,
2344
policy_snapshot_version: u8,
@@ -57,7 +78,7 @@ pub struct PackageDecision<'a> {
5778
pub cached: bool,
5879
}
5980

60-
impl AuditLogger {
81+
impl FileAuditSink {
6182
/// Creates or opens the audit log file at the default path.
6283
///
6384
/// # Errors
@@ -76,25 +97,77 @@ impl AuditLogger {
7697
file: Mutex::new(file),
7798
})
7899
}
100+
}
79101

80-
/// Appends a single JSON record followed by newline.
81-
///
82-
/// # Errors
83-
///
84-
/// Returns an error if serialization fails, writing fails, or the mutex is poisoned.
85-
pub fn log(&self, record: AuditRecord) -> anyhow::Result<()> {
102+
#[async_trait]
103+
impl AuditSink for FileAuditSink {
104+
async fn log(&self, record: &AuditRecord) -> anyhow::Result<()> {
86105
let mut file = self
87106
.file
88107
.lock()
89108
.map_err(|_| anyhow::anyhow!("audit log mutex poisoned"))?;
90-
let json = serde_json::to_string(&record)?;
109+
let json = serde_json::to_string(record)?;
91110
file.write_all(json.as_bytes())?;
92111
file.write_all(b"\n")?;
93112
file.flush()?;
94113
Ok(())
95114
}
96115
}
97116

117+
impl HttpAuditSink {
118+
/// Creates an HTTP sink that POSTs records to `endpoint` with optional bearer auth.
119+
pub fn new(endpoint: String, token: Option<String>) -> Self {
120+
Self {
121+
client: reqwest::Client::new(),
122+
endpoint,
123+
token,
124+
}
125+
}
126+
}
127+
128+
#[async_trait]
129+
impl AuditSink for HttpAuditSink {
130+
async fn log(&self, record: &AuditRecord) -> anyhow::Result<()> {
131+
let mut request = self.client.post(&self.endpoint).json(record);
132+
if let Some(token) = &self.token {
133+
request = request.bearer_auth(token);
134+
}
135+
let response = request.send().await?;
136+
let status = response.status();
137+
if !status.is_success() {
138+
return Err(anyhow::anyhow!(
139+
"audit endpoint returned non-success status: {status}"
140+
));
141+
}
142+
Ok(())
143+
}
144+
}
145+
146+
/// Builds the configured audit sink (file or HTTP).
147+
///
148+
/// # Errors
149+
///
150+
/// Returns an error if the file sink cannot be opened or the HTTP backend is misconfigured.
151+
pub fn build_audit_sink(config: &AuditConfig) -> anyhow::Result<Arc<dyn AuditSink>> {
152+
match config.backend {
153+
AuditBackend::File => Ok(Arc::new(FileAuditSink::new()?)),
154+
AuditBackend::Http => {
155+
let endpoint = config
156+
.endpoint
157+
.clone()
158+
.filter(|value| !value.is_empty())
159+
.ok_or_else(|| {
160+
anyhow::anyhow!("audit.endpoint is required for the http backend")
161+
})?;
162+
let token = config
163+
.token_env
164+
.as_deref()
165+
.and_then(|name| env::var(name).ok());
166+
Ok(Arc::new(HttpAuditSink::new(endpoint, token)))
167+
}
168+
}
169+
}
170+
98171
impl AuditRecord {
99172
/// Builds an audit record for a package decision event.
100173
pub fn package_decision(input: PackageDecision<'_>) -> Self {

src/config/mod.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,35 @@ pub struct SafePkgsConfig {
6868
pub cache: CacheConfig,
6969
/// Lockfile evaluation configuration.
7070
pub lockfile: LockfileConfig,
71+
/// Audit log backend configuration.
72+
pub audit: AuditConfig,
7173
/// User-defined custom policy rules evaluated against package metadata.
7274
pub custom_rules: Vec<CustomRuleConfig>,
7375
}
7476

77+
/// Audit log backend configuration.
78+
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
79+
#[serde(default)]
80+
pub struct AuditConfig {
81+
/// Storage backend for audit records.
82+
pub backend: AuditBackend,
83+
/// Endpoint URL for the HTTP backend. Required when `backend = http`.
84+
pub endpoint: Option<String>,
85+
/// Name of the environment variable holding the bearer token.
86+
pub token_env: Option<String>,
87+
}
88+
89+
/// Selectable audit storage backend.
90+
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
91+
#[serde(rename_all = "lowercase")]
92+
pub enum AuditBackend {
93+
/// Append records to a local file (default).
94+
#[default]
95+
File,
96+
/// POST each record as JSON to a configured HTTP endpoint.
97+
Http,
98+
}
99+
75100
/// Allowlist configuration.
76101
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
77102
#[serde(default)]
@@ -216,6 +241,7 @@ impl Default for SafePkgsConfig {
216241
checks: ChecksConfig::default(),
217242
cache: CacheConfig::default(),
218243
lockfile: LockfileConfig::default(),
244+
audit: AuditConfig::default(),
219245
custom_rules: Vec::new(),
220246
}
221247
}
@@ -249,6 +275,16 @@ impl SafePkgsConfig {
249275
}
250276

251277
pub(crate) fn validate(&self) -> anyhow::Result<()> {
278+
if self.audit.backend == AuditBackend::Http
279+
&& self
280+
.audit
281+
.endpoint
282+
.as_deref()
283+
.map(str::is_empty)
284+
.unwrap_or(true)
285+
{
286+
anyhow::bail!("audit.endpoint is required when audit.backend is \"http\"");
287+
}
252288
custom_rules::validate_rules(&self.custom_rules)
253289
}
254290

@@ -329,6 +365,17 @@ impl SafePkgsConfig {
329365
self.lockfile.inter_batch_delay_ms = inter_batch_delay_ms;
330366
}
331367
}
368+
if let Some(value) = overlay.audit {
369+
if let Some(backend) = value.backend {
370+
self.audit.backend = backend;
371+
}
372+
if let Some(endpoint) = value.endpoint {
373+
self.audit.endpoint = Some(endpoint);
374+
}
375+
if let Some(token_env) = value.token_env {
376+
self.audit.token_env = Some(token_env);
377+
}
378+
}
332379
if !overlay.custom_rules.is_empty() {
333380
custom_rules::merge_rules(&mut self.custom_rules, overlay.custom_rules);
334381
}

src/config/overlay.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use serde::Deserialize;
44

55
use crate::types::Severity;
66

7-
use super::{AllowlistConfig, CustomRuleConfig, DenylistConfig};
7+
use super::{AllowlistConfig, AuditBackend, CustomRuleConfig, DenylistConfig};
88

99
#[derive(Debug, Default, Deserialize)]
1010
#[serde(default)]
@@ -18,6 +18,7 @@ pub(super) struct ConfigOverlay {
1818
pub checks: Option<ChecksOverlay>,
1919
pub cache: Option<CacheOverlay>,
2020
pub lockfile: Option<LockfileOverlay>,
21+
pub audit: Option<AuditOverlay>,
2122
pub custom_rules: Vec<CustomRuleConfig>,
2223
}
2324

@@ -55,3 +56,11 @@ pub(super) struct LockfileOverlay {
5556
pub eval_concurrency: Option<usize>,
5657
pub inter_batch_delay_ms: Option<u64>,
5758
}
59+
60+
#[derive(Debug, Deserialize, Default)]
61+
#[serde(default)]
62+
pub(super) struct AuditOverlay {
63+
pub backend: Option<AuditBackend>,
64+
pub endpoint: Option<String>,
65+
pub token_env: Option<String>,
66+
}

0 commit comments

Comments
 (0)