Skip to content

Commit 4771e5c

Browse files
committed
feat(server): add rate-limited API auth and audit logs
1 parent b8e6422 commit 4771e5c

File tree

16 files changed

+631
-21
lines changed

16 files changed

+631
-21
lines changed

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
@@ -54,6 +54,7 @@ otel = ["opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", "tracing-ope
5454
[dev-dependencies]
5555
tempfile = "3.8"
5656
mockito = "1.2"
57+
tower = "0.5"
5758

5859
[profile.release]
5960
lto = "thin"

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
122122
77. [x] Add an MCP server for DiffScope with review, analytics, and rule-management tools.
123123
78. [x] Add reusable agent skills/workflows for checking PR readiness and running fix loops.
124124
79. [x] Add signed webhook or event-stream integration for downstream automation consumers.
125-
80. [ ] Add rate-limited API auth and audit trails for automation-heavy deployments.
125+
80. [x] Add rate-limited API auth and audit trails for automation-heavy deployments.
126126

127127
## 9. Infra, Self-Hosting, and Enterprise Operations
128128

src/config.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,19 @@ pub struct AutomationConfig {
127127
pub webhook_secret: Option<String>,
128128
}
129129

130+
pub(crate) const DEFAULT_SERVER_RATE_LIMIT_PER_MINUTE: u32 = 60;
131+
132+
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
133+
pub struct ServerSecurityConfig {
134+
/// Shared API key required for protected server mutations when configured.
135+
#[serde(default, rename = "server_api_key")]
136+
pub api_key: Option<String>,
137+
138+
/// Maximum protected API mutations allowed per minute when auth is enabled.
139+
#[serde(default, rename = "server_rate_limit_per_minute")]
140+
pub rate_limit_per_minute: Option<u32>,
141+
}
142+
130143
#[derive(Debug, Clone, Serialize, Deserialize)]
131144
pub struct AgentConfig {
132145
/// Enable agent loop for iterative tool-calling review (default false).
@@ -467,6 +480,9 @@ pub struct Config {
467480
#[serde(default, flatten)]
468481
pub automation: AutomationConfig,
469482

483+
#[serde(default, flatten)]
484+
pub server_security: ServerSecurityConfig,
485+
470486
/// When true, run separate specialized LLM passes for security, correctness,
471487
/// and style instead of a single monolithic review prompt.
472488
#[serde(default = "default_false")]
@@ -670,6 +686,7 @@ impl Default for Config {
670686
providers: HashMap::new(),
671687
github: GitHubConfig::default(),
672688
automation: AutomationConfig::default(),
689+
server_security: ServerSecurityConfig::default(),
673690
multi_pass_specialized: false,
674691
agent: AgentConfig::default(),
675692
verification: VerificationConfig::default(),
@@ -856,6 +873,23 @@ impl Config {
856873
.ok()
857874
.filter(|s| !s.trim().is_empty());
858875
}
876+
if self.server_security.api_key.is_none() {
877+
self.server_security.api_key = std::env::var("DIFFSCOPE_SERVER_API_KEY")
878+
.ok()
879+
.filter(|s| !s.trim().is_empty());
880+
}
881+
if self.server_security.rate_limit_per_minute.is_none() {
882+
self.server_security.rate_limit_per_minute =
883+
std::env::var("DIFFSCOPE_SERVER_RATE_LIMIT_PER_MINUTE")
884+
.ok()
885+
.and_then(|raw| raw.trim().parse::<u32>().ok())
886+
.filter(|value| *value > 0);
887+
}
888+
if self.server_security.api_key.is_some()
889+
&& self.server_security.rate_limit_per_minute.unwrap_or(0) == 0
890+
{
891+
self.server_security.rate_limit_per_minute = Some(DEFAULT_SERVER_RATE_LIMIT_PER_MINUTE);
892+
}
859893

860894
validate_optional_http_url(&mut self.base_url, "base_url");
861895
validate_optional_http_url(&mut self.automation.webhook_url, "automation_webhook_url");
@@ -1811,6 +1845,39 @@ mod tests {
18111845
assert!(config.automation.webhook_url.is_none());
18121846
}
18131847

1848+
#[test]
1849+
fn normalize_defaults_server_rate_limit_when_api_key_present() {
1850+
let mut config = Config {
1851+
server_security: ServerSecurityConfig {
1852+
api_key: Some("shared-key".to_string()),
1853+
rate_limit_per_minute: None,
1854+
},
1855+
..Config::default()
1856+
};
1857+
1858+
config.normalize();
1859+
1860+
assert_eq!(
1861+
config.server_security.rate_limit_per_minute,
1862+
Some(DEFAULT_SERVER_RATE_LIMIT_PER_MINUTE)
1863+
);
1864+
}
1865+
1866+
#[test]
1867+
fn normalize_preserves_explicit_server_rate_limit() {
1868+
let mut config = Config {
1869+
server_security: ServerSecurityConfig {
1870+
api_key: Some("shared-key".to_string()),
1871+
rate_limit_per_minute: Some(120),
1872+
},
1873+
..Config::default()
1874+
};
1875+
1876+
config.normalize();
1877+
1878+
assert_eq!(config.server_security.rate_limit_per_minute, Some(120));
1879+
}
1880+
18141881
#[test]
18151882
fn normalize_clamps_max_tokens_above_limit() {
18161883
let mut config = Config {

src/server/api.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,8 @@ mod tests {
230230
"automation_webhook_secret".to_string(),
231231
serde_json::json!("secret6"),
232232
);
233-
obj.insert("vault_token".to_string(), serde_json::json!("secret7"));
233+
obj.insert("server_api_key".to_string(), serde_json::json!("secret7"));
234+
obj.insert("vault_token".to_string(), serde_json::json!("secret8"));
234235
mask_config_secrets(&mut obj);
235236
assert_eq!(obj.get("api_key").unwrap(), &serde_json::json!("***"));
236237
assert_eq!(obj.get("github_token").unwrap(), &serde_json::json!("***"));
@@ -250,6 +251,10 @@ mod tests {
250251
obj.get("automation_webhook_secret").unwrap(),
251252
&serde_json::json!("***")
252253
);
254+
assert_eq!(
255+
obj.get("server_api_key").unwrap(),
256+
&serde_json::json!("***")
257+
);
253258
assert_eq!(obj.get("vault_token").unwrap(), &serde_json::json!("***"));
254259
}
255260

src/server/api/admin.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ pub(crate) fn mask_config_secrets(obj: &mut serde_json::Map<String, serde_json::
133133
"github_private_key",
134134
"github_webhook_secret",
135135
"automation_webhook_secret",
136+
"server_api_key",
136137
"vault_token",
137138
] {
138139
if obj.get(*key).and_then(|v| v.as_str()).is_some() {

0 commit comments

Comments
 (0)