Skip to content
/ loom Public

Commit c091245

Browse files
ghuntleyclaude
andcommitted
Add comprehensive audit logging and automatic secret redaction
- Add missing audit event types: MirrorDeleted, WebhookCreated, WebhookUpdated, WebhookDeleted - Add LoginFailed audit logging to OAuth callbacks (GitHub, Google, Okta) and magic link verification - Add ApiKeyUsed audit logging to auth middleware - Add audit logging for invitation operations (cancel, create/reject join requests) - Add audit logging for LLM proxy routes (all 8 sync/stream endpoints) - Add OrgRestored audit logging to restore_org handler - Add DeviceCodeCompleted audit logging - Fix mirror delete to use MirrorDeleted instead of RepoDeleted - Fix webhook routes to use correct event types - Integrate loom-redact into AuditLogBuilder.build() for automatic secret redaction in details, resource_id, action, and user_agent fields Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1f8ae14 commit c091245

File tree

11 files changed

+427
-47
lines changed

11 files changed

+427
-47
lines changed

crates/loom-server-audit/src/event.rs

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
1313
use chrono::{DateTime, Utc};
1414
use serde::{Deserialize, Serialize};
15+
use std::borrow::Cow;
1516
use std::cmp::Ordering;
1617
use std::fmt;
1718
use uuid::Uuid;
1819

20+
use crate::redaction::{redact_details, redact_optional_string, redact_string};
21+
1922
/// Default retention period for audit logs in days.
2023
pub const DEFAULT_AUDIT_RETENTION_DAYS: i64 = 90;
2124

@@ -112,7 +115,11 @@ pub enum AuditEventType {
112115
RepoCreated,
113116
RepoDeleted,
114117
MirrorCreated,
118+
MirrorDeleted,
115119
MirrorSynced,
120+
WebhookCreated,
121+
WebhookUpdated,
122+
WebhookDeleted,
116123
WebhookReceived,
117124

118125
// SCIM events
@@ -274,7 +281,11 @@ impl fmt::Display for AuditEventType {
274281
AuditEventType::RepoCreated => "repo_created",
275282
AuditEventType::RepoDeleted => "repo_deleted",
276283
AuditEventType::MirrorCreated => "mirror_created",
284+
AuditEventType::MirrorDeleted => "mirror_deleted",
277285
AuditEventType::MirrorSynced => "mirror_synced",
286+
AuditEventType::WebhookCreated => "webhook_created",
287+
AuditEventType::WebhookUpdated => "webhook_updated",
288+
AuditEventType::WebhookDeleted => "webhook_deleted",
278289
AuditEventType::WebhookReceived => "webhook_received",
279290

280291
// User management events
@@ -401,6 +412,8 @@ impl AuditEventType {
401412
| AuditEventType::RepoCreated
402413
| AuditEventType::MirrorCreated
403414
| AuditEventType::MirrorSynced
415+
| AuditEventType::WebhookCreated
416+
| AuditEventType::WebhookUpdated
404417
| AuditEventType::WebhookReceived
405418
| AuditEventType::ScimUserCreated
406419
| AuditEventType::ScimUserUpdated
@@ -469,6 +482,8 @@ impl AuditEventType {
469482
| AuditEventType::UserRestored
470483
| AuditEventType::WeaverDeleted
471484
| AuditEventType::RepoDeleted
485+
| AuditEventType::MirrorDeleted
486+
| AuditEventType::WebhookDeleted
472487
| AuditEventType::ScimUserDeleted
473488
| AuditEventType::ScimUserDeprovisioned
474489
| AuditEventType::ScimGroupDeleted
@@ -767,7 +782,28 @@ impl AuditLogBuilder {
767782
}
768783

769784
/// Build the audit log entry.
770-
pub fn build(self) -> AuditLogEntry {
785+
///
786+
/// Automatically redacts secrets from sensitive fields using `loom-redact` patterns.
787+
/// The following fields are redacted:
788+
/// - `details`: All string values in the JSON structure
789+
/// - `resource_id`: May contain identifiers that could be secrets
790+
/// - `action`: Custom action descriptions may contain secrets
791+
/// - `user_agent`: Unlikely but could contain leaked tokens
792+
pub fn build(mut self) -> AuditLogEntry {
793+
// Redact secrets from fields that may contain sensitive data
794+
let details = redact_details(&self.details);
795+
redact_optional_string(&mut self.resource_id);
796+
redact_optional_string(&mut self.user_agent);
797+
798+
// Redact action if custom, otherwise use event type display
799+
let action = match self.action {
800+
Some(ref a) => match redact_string(a) {
801+
Cow::Borrowed(s) => s.to_string(),
802+
Cow::Owned(s) => s,
803+
},
804+
None => self.event_type.to_string(),
805+
};
806+
771807
AuditLogEntry {
772808
id: Uuid::new_v4(),
773809
timestamp: Utc::now(),
@@ -779,10 +815,10 @@ impl AuditLogBuilder {
779815
impersonating_user_id: self.impersonating_user_id,
780816
resource_type: self.resource_type,
781817
resource_id: self.resource_id,
782-
action: self.action.unwrap_or_else(|| self.event_type.to_string()),
818+
action,
783819
ip_address: self.ip_address,
784820
user_agent: self.user_agent,
785-
details: self.details,
821+
details,
786822
trace_id: self.trace_id,
787823
span_id: self.span_id,
788824
request_id: self.request_id,
@@ -848,7 +884,7 @@ mod tests {
848884
assert_eq!(event, AuditEventType::AccessDenied);
849885
}
850886

851-
const ALL_EVENT_TYPES: [AuditEventType; 85] = [
887+
const ALL_EVENT_TYPES: [AuditEventType; 89] = [
852888
AuditEventType::Login,
853889
AuditEventType::Logout,
854890
AuditEventType::LoginFailed,
@@ -896,7 +932,11 @@ mod tests {
896932
AuditEventType::RepoCreated,
897933
AuditEventType::RepoDeleted,
898934
AuditEventType::MirrorCreated,
935+
AuditEventType::MirrorDeleted,
899936
AuditEventType::MirrorSynced,
937+
AuditEventType::WebhookCreated,
938+
AuditEventType::WebhookUpdated,
939+
AuditEventType::WebhookDeleted,
900940
AuditEventType::WebhookReceived,
901941
// Feature flag events
902942
AuditEventType::FlagCreated,
@@ -1488,6 +1528,89 @@ mod tests {
14881528
assert_eq!(entry.actor_user_id, Some(target_user));
14891529
assert_eq!(entry.impersonating_user_id, Some(admin_user));
14901530
}
1531+
1532+
fn github_pat() -> String {
1533+
format!("ghp_{}", "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8")
1534+
}
1535+
1536+
#[test]
1537+
fn redacts_secrets_in_details() {
1538+
let entry = AuditLogBuilder::new(AuditEventType::Login)
1539+
.details(json!({
1540+
"token": format!("Bearer {}", github_pat()),
1541+
"user": "test@example.com"
1542+
}))
1543+
.build();
1544+
1545+
let token_val = entry.details["token"].as_str().unwrap();
1546+
assert!(
1547+
token_val.contains("[REDACTED:"),
1548+
"Expected redaction in details: {}",
1549+
token_val
1550+
);
1551+
assert!(!token_val.contains(&github_pat()));
1552+
assert_eq!(entry.details["user"], "test@example.com");
1553+
}
1554+
1555+
#[test]
1556+
fn redacts_secrets_in_action() {
1557+
let entry = AuditLogBuilder::new(AuditEventType::Login)
1558+
.action(format!("User logged in with token {}", github_pat()))
1559+
.build();
1560+
1561+
assert!(
1562+
entry.action.contains("[REDACTED:"),
1563+
"Expected redaction in action: {}",
1564+
entry.action
1565+
);
1566+
assert!(!entry.action.contains(&github_pat()));
1567+
}
1568+
1569+
#[test]
1570+
fn redacts_secrets_in_resource_id() {
1571+
let entry = AuditLogBuilder::new(AuditEventType::ApiKeyUsed)
1572+
.resource("api_key", github_pat())
1573+
.build();
1574+
1575+
let resource_id = entry.resource_id.as_ref().unwrap();
1576+
assert!(
1577+
resource_id.contains("[REDACTED:"),
1578+
"Expected redaction in resource_id: {}",
1579+
resource_id
1580+
);
1581+
assert!(!resource_id.contains(&github_pat()));
1582+
}
1583+
1584+
#[test]
1585+
fn redacts_secrets_in_user_agent() {
1586+
let entry = AuditLogBuilder::new(AuditEventType::Login)
1587+
.user_agent(format!("CustomClient/1.0 token={}", github_pat()))
1588+
.build();
1589+
1590+
let user_agent = entry.user_agent.as_ref().unwrap();
1591+
assert!(
1592+
user_agent.contains("[REDACTED:"),
1593+
"Expected redaction in user_agent: {}",
1594+
user_agent
1595+
);
1596+
assert!(!user_agent.contains(&github_pat()));
1597+
}
1598+
1599+
#[test]
1600+
fn preserves_non_secret_values() {
1601+
let entry = AuditLogBuilder::new(AuditEventType::Login)
1602+
.action("User logged in successfully")
1603+
.resource("session", "sess-12345")
1604+
.user_agent("Mozilla/5.0")
1605+
.details(json!({"ip": "192.168.1.1", "method": "password"}))
1606+
.build();
1607+
1608+
assert_eq!(entry.action, "User logged in successfully");
1609+
assert_eq!(entry.resource_id, Some("sess-12345".to_string()));
1610+
assert_eq!(entry.user_agent, Some("Mozilla/5.0".to_string()));
1611+
assert_eq!(entry.details["ip"], "192.168.1.1");
1612+
assert_eq!(entry.details["method"], "password");
1613+
}
14911614
}
14921615

14931616
mod constants {

crates/loom-server/src/auth_middleware.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ use crate::{
6161
db::{ApiKeyRepository, AuthSessionRepository, UserRepository},
6262
error::ErrorResponse,
6363
};
64+
use loom_server_audit::{AuditEventType, AuditLogBuilder, UserId as AuditUserId};
6465

6566
/// Authentication middleware that extracts auth context from requests.
6667
///
@@ -127,6 +128,16 @@ pub async fn auth_layer(
127128
if let Some(ref user) = auth_ctx.current_user {
128129
span.record("auth_method", "api_key");
129130
span.record("user_id", tracing::field::display(&user.user.id));
131+
132+
// Log API key usage for security auditing
133+
if let Some(api_key_id) = user.api_key_id {
134+
state.audit_service.log(
135+
AuditLogBuilder::new(AuditEventType::ApiKeyUsed)
136+
.actor(AuditUserId::new(user.user.id.into_inner()))
137+
.resource("api_key", api_key_id.to_string())
138+
.build(),
139+
);
140+
}
130141
}
131142
request.extensions_mut().insert(auth_ctx);
132143
return next.run(request).await;

0 commit comments

Comments
 (0)