Skip to main content

everruns_core/
audit.rs

1// Audit logging types and trait (EVE-226)
2//
3// Decision: Two audit domains — Management (admin ops) and Agent (execution ops).
4// Decision: AuditLogger trait is generic enough for SaaS to extend with custom event types.
5// Decision: Fire-and-forget pattern — audit failures never block operations (TM-OBS-007).
6// Decision: Domain-specific builders enforce type safety at compile time.
7// See specs/audit-logging.md for full design.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use uuid::Uuid;
13
14// ============================================================================
15// Audit Domain
16// ============================================================================
17
18/// Top-level audit domain separating management ops from agent execution.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum AuditDomain {
22    /// Org CRUD, member management, API keys, settings, role changes.
23    Management,
24    /// Agent runs, tool calls, LLM interactions.
25    Agent,
26}
27
28impl AuditDomain {
29    pub const fn as_str(&self) -> &'static str {
30        match self {
31            AuditDomain::Management => "management",
32            AuditDomain::Agent => "agent",
33        }
34    }
35}
36
37impl fmt::Display for AuditDomain {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.write_str(self.as_str())
40    }
41}
42
43impl std::str::FromStr for AuditDomain {
44    type Err = String;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s {
48            "management" => Ok(AuditDomain::Management),
49            "agent" => Ok(AuditDomain::Agent),
50            other => Err(format!("unknown audit domain: {other}")),
51        }
52    }
53}
54
55// ============================================================================
56// Audit Action
57// ============================================================================
58
59/// Typed audit actions grouped by domain.
60///
61/// Convention: `domain.resource.verb` string form (e.g. `management.member.invited`).
62#[derive(Debug, Clone, PartialEq, Eq, Hash)]
63pub enum ManagementAction {
64    // Organization
65    OrgCreated,
66    OrgUpdated,
67    OrgDeleted,
68    // Members
69    MemberInvited,
70    MemberRemoved,
71    MemberRoleChanged,
72    // API keys
73    ApiKeyCreated,
74    ApiKeyRevoked,
75    // Settings
76    SettingsUpdated,
77    // Agents
78    AgentCreated,
79    AgentUpdated,
80    AgentDeleted,
81    // Harnesses
82    HarnessCreated,
83    HarnessUpdated,
84    HarnessDeleted,
85    // LLM Providers
86    LlmProviderCreated,
87    LlmProviderUpdated,
88    LlmProviderDeleted,
89    // MCP Servers
90    McpServerCreated,
91    McpServerUpdated,
92    McpServerDeleted,
93    // Apps
94    AppCreated,
95    AppUpdated,
96    AppDeleted,
97    // Skills
98    SkillCreated,
99    SkillUpdated,
100    SkillDeleted,
101}
102
103impl ManagementAction {
104    pub fn as_str(&self) -> &'static str {
105        match self {
106            Self::OrgCreated => "management.org.created",
107            Self::OrgUpdated => "management.org.updated",
108            Self::OrgDeleted => "management.org.deleted",
109            Self::MemberInvited => "management.member.invited",
110            Self::MemberRemoved => "management.member.removed",
111            Self::MemberRoleChanged => "management.member.role_changed",
112            Self::ApiKeyCreated => "management.api_key.created",
113            Self::ApiKeyRevoked => "management.api_key.revoked",
114            Self::SettingsUpdated => "management.settings.updated",
115            Self::AgentCreated => "management.agent.created",
116            Self::AgentUpdated => "management.agent.updated",
117            Self::AgentDeleted => "management.agent.deleted",
118            Self::HarnessCreated => "management.harness.created",
119            Self::HarnessUpdated => "management.harness.updated",
120            Self::HarnessDeleted => "management.harness.deleted",
121            Self::LlmProviderCreated => "management.llm_provider.created",
122            Self::LlmProviderUpdated => "management.llm_provider.updated",
123            Self::LlmProviderDeleted => "management.llm_provider.deleted",
124            Self::McpServerCreated => "management.mcp_server.created",
125            Self::McpServerUpdated => "management.mcp_server.updated",
126            Self::McpServerDeleted => "management.mcp_server.deleted",
127            Self::AppCreated => "management.app.created",
128            Self::AppUpdated => "management.app.updated",
129            Self::AppDeleted => "management.app.deleted",
130            Self::SkillCreated => "management.skill.created",
131            Self::SkillUpdated => "management.skill.updated",
132            Self::SkillDeleted => "management.skill.deleted",
133        }
134    }
135}
136
137impl fmt::Display for ManagementAction {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.write_str(self.as_str())
140    }
141}
142
143/// Agent-domain audit actions.
144#[derive(Debug, Clone, PartialEq, Eq, Hash)]
145pub enum AgentAction {
146    RunStarted,
147    RunCompleted,
148    RunFailed,
149    ToolExecuted,
150    LlmRequest,
151    AppInvocationStarted,
152}
153
154impl AgentAction {
155    pub fn as_str(&self) -> &'static str {
156        match self {
157            Self::RunStarted => "agent.run.started",
158            Self::RunCompleted => "agent.run.completed",
159            Self::RunFailed => "agent.run.failed",
160            Self::ToolExecuted => "agent.tool.executed",
161            Self::LlmRequest => "agent.llm.request",
162            Self::AppInvocationStarted => "agent.app_invocation.started",
163        }
164    }
165}
166
167impl fmt::Display for AgentAction {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.write_str(self.as_str())
170    }
171}
172
173/// Unified action enum wrapping domain-specific actions.
174#[derive(Debug, Clone, PartialEq, Eq, Hash)]
175pub enum AuditAction {
176    Management(ManagementAction),
177    Agent(AgentAction),
178}
179
180impl AuditAction {
181    pub fn as_str(&self) -> &'static str {
182        match self {
183            Self::Management(a) => a.as_str(),
184            Self::Agent(a) => a.as_str(),
185        }
186    }
187
188    pub fn domain(&self) -> AuditDomain {
189        match self {
190            Self::Management(_) => AuditDomain::Management,
191            Self::Agent(_) => AuditDomain::Agent,
192        }
193    }
194}
195
196impl fmt::Display for AuditAction {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        f.write_str(self.as_str())
199    }
200}
201
202impl From<ManagementAction> for AuditAction {
203    fn from(a: ManagementAction) -> Self {
204        Self::Management(a)
205    }
206}
207
208impl From<AgentAction> for AuditAction {
209    fn from(a: AgentAction) -> Self {
210        Self::Agent(a)
211    }
212}
213
214// Custom serde: serialize action enums as their `as_str()` dotted string form
215// (e.g. "management.member.invited") for consistency with DB storage.
216
217macro_rules! impl_action_serde {
218    ($ty:ty) => {
219        impl Serialize for $ty {
220            fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
221                serializer.serialize_str(self.as_str())
222            }
223        }
224
225        impl<'de> Deserialize<'de> for $ty {
226            fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
227                let s = String::deserialize(deserializer)?;
228                s.parse().map_err(serde::de::Error::custom)
229            }
230        }
231    };
232}
233
234impl std::str::FromStr for ManagementAction {
235    type Err = String;
236    fn from_str(s: &str) -> Result<Self, Self::Err> {
237        match s {
238            "management.org.created" => Ok(Self::OrgCreated),
239            "management.org.updated" => Ok(Self::OrgUpdated),
240            "management.org.deleted" => Ok(Self::OrgDeleted),
241            "management.member.invited" => Ok(Self::MemberInvited),
242            "management.member.removed" => Ok(Self::MemberRemoved),
243            "management.member.role_changed" => Ok(Self::MemberRoleChanged),
244            "management.api_key.created" => Ok(Self::ApiKeyCreated),
245            "management.api_key.revoked" => Ok(Self::ApiKeyRevoked),
246            "management.settings.updated" => Ok(Self::SettingsUpdated),
247            "management.agent.created" => Ok(Self::AgentCreated),
248            "management.agent.updated" => Ok(Self::AgentUpdated),
249            "management.agent.deleted" => Ok(Self::AgentDeleted),
250            "management.harness.created" => Ok(Self::HarnessCreated),
251            "management.harness.updated" => Ok(Self::HarnessUpdated),
252            "management.harness.deleted" => Ok(Self::HarnessDeleted),
253            "management.llm_provider.created" => Ok(Self::LlmProviderCreated),
254            "management.llm_provider.updated" => Ok(Self::LlmProviderUpdated),
255            "management.llm_provider.deleted" => Ok(Self::LlmProviderDeleted),
256            "management.mcp_server.created" => Ok(Self::McpServerCreated),
257            "management.mcp_server.updated" => Ok(Self::McpServerUpdated),
258            "management.mcp_server.deleted" => Ok(Self::McpServerDeleted),
259            "management.app.created" => Ok(Self::AppCreated),
260            "management.app.updated" => Ok(Self::AppUpdated),
261            "management.app.deleted" => Ok(Self::AppDeleted),
262            "management.skill.created" => Ok(Self::SkillCreated),
263            "management.skill.updated" => Ok(Self::SkillUpdated),
264            "management.skill.deleted" => Ok(Self::SkillDeleted),
265            other => Err(format!("unknown management action: {other}")),
266        }
267    }
268}
269
270impl std::str::FromStr for AgentAction {
271    type Err = String;
272    fn from_str(s: &str) -> Result<Self, Self::Err> {
273        match s {
274            "agent.run.started" => Ok(Self::RunStarted),
275            "agent.run.completed" => Ok(Self::RunCompleted),
276            "agent.run.failed" => Ok(Self::RunFailed),
277            "agent.tool.executed" => Ok(Self::ToolExecuted),
278            "agent.llm.request" => Ok(Self::LlmRequest),
279            "agent.app_invocation.started" => Ok(Self::AppInvocationStarted),
280            other => Err(format!("unknown agent action: {other}")),
281        }
282    }
283}
284
285impl std::str::FromStr for AuditAction {
286    type Err = String;
287    fn from_str(s: &str) -> Result<Self, Self::Err> {
288        if let Ok(a) = s.parse::<ManagementAction>() {
289            return Ok(Self::Management(a));
290        }
291        if let Ok(a) = s.parse::<AgentAction>() {
292            return Ok(Self::Agent(a));
293        }
294        Err(format!("unknown audit action: {s}"))
295    }
296}
297
298impl_action_serde!(ManagementAction);
299impl_action_serde!(AgentAction);
300impl_action_serde!(AuditAction);
301
302// ============================================================================
303// Audit Target
304// ============================================================================
305
306/// Identifies the resource affected by an audit event.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct AuditTarget {
309    /// Resource type (e.g. "member", "agent", "harness", "session").
310    pub target_type: String,
311    /// Resource identifier (public ID or UUID string).
312    pub target_id: String,
313}
314
315impl AuditTarget {
316    pub fn new(target_type: impl Into<String>, target_id: impl Into<String>) -> Self {
317        Self {
318            target_type: target_type.into(),
319            target_id: target_id.into(),
320        }
321    }
322}
323
324// ============================================================================
325// Audit Event
326// ============================================================================
327
328/// A complete audit event ready for storage.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct AuditEvent {
331    pub domain: AuditDomain,
332    pub action: AuditAction,
333    pub actor_user_id: Option<Uuid>,
334    pub org_id: i64,
335    pub target: Option<AuditTarget>,
336    pub details: serde_json::Value,
337    pub ip_address: Option<String>,
338    pub timestamp: DateTime<Utc>,
339}
340
341// ============================================================================
342// Builders
343// ============================================================================
344
345/// Builder for constructing audit events with type safety.
346pub struct AuditEventBuilder {
347    domain: AuditDomain,
348    action: AuditAction,
349    actor_user_id: Option<Uuid>,
350    org_id: i64,
351    target: Option<AuditTarget>,
352    details: serde_json::Map<String, serde_json::Value>,
353    ip_address: Option<String>,
354}
355
356impl AuditEventBuilder {
357    pub fn target(mut self, target_type: impl Into<String>, target_id: impl Into<String>) -> Self {
358        self.target = Some(AuditTarget::new(target_type, target_id));
359        self
360    }
361
362    pub fn detail(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
363        self.details.insert(key.into(), value.into());
364        self
365    }
366
367    pub fn ip(mut self, ip: impl Into<String>) -> Self {
368        self.ip_address = Some(ip.into());
369        self
370    }
371
372    pub fn build(self) -> AuditEvent {
373        AuditEvent {
374            domain: self.domain,
375            action: self.action,
376            actor_user_id: self.actor_user_id,
377            org_id: self.org_id,
378            target: self.target,
379            details: serde_json::Value::Object(self.details),
380            ip_address: self.ip_address,
381            timestamp: Utc::now(),
382        }
383    }
384}
385
386impl AuditEvent {
387    /// Start building a management audit event.
388    pub fn management(
389        action: ManagementAction,
390        org_id: i64,
391        actor: Option<Uuid>,
392    ) -> AuditEventBuilder {
393        AuditEventBuilder {
394            domain: AuditDomain::Management,
395            action: AuditAction::Management(action),
396            actor_user_id: actor,
397            org_id,
398            target: None,
399            details: serde_json::Map::new(),
400            ip_address: None,
401        }
402    }
403
404    /// Start building an agent audit event.
405    pub fn agent(action: AgentAction, org_id: i64, actor: Option<Uuid>) -> AuditEventBuilder {
406        AuditEventBuilder {
407            domain: AuditDomain::Agent,
408            action: AuditAction::Agent(action),
409            actor_user_id: actor,
410            org_id,
411            target: None,
412            details: serde_json::Map::new(),
413            ip_address: None,
414        }
415    }
416}
417
418// ============================================================================
419// HasAuditTargetId — extract target ID from service return values
420// ============================================================================
421
422/// Trait for extracting an audit target ID from a service method result.
423///
424/// Implement on domain types (e.g. `Harness`, `Agent`) so the `#[audit]` macro
425/// can automatically capture the target ID from the `Ok(value)` return.
426pub trait HasAuditTargetId {
427    fn audit_target_id(&self) -> Option<String>;
428}
429
430/// Blanket: if there's no meaningful target, return None.
431impl HasAuditTargetId for () {
432    fn audit_target_id(&self) -> Option<String> {
433        None
434    }
435}
436
437impl HasAuditTargetId for bool {
438    fn audit_target_id(&self) -> Option<String> {
439        None
440    }
441}
442
443impl HasAuditTargetId for String {
444    fn audit_target_id(&self) -> Option<String> {
445        Some(self.clone())
446    }
447}
448
449impl<T: HasAuditTargetId> HasAuditTargetId for Vec<T> {
450    fn audit_target_id(&self) -> Option<String> {
451        None
452    }
453}
454
455// Domain type implementations
456impl HasAuditTargetId for crate::Harness {
457    fn audit_target_id(&self) -> Option<String> {
458        Some(self.id.to_string())
459    }
460}
461
462impl HasAuditTargetId for crate::Agent {
463    fn audit_target_id(&self) -> Option<String> {
464        Some(self.public_id.to_string())
465    }
466}
467
468impl HasAuditTargetId for crate::Session {
469    fn audit_target_id(&self) -> Option<String> {
470        Some(self.id.to_string())
471    }
472}
473
474// ============================================================================
475// AuditLogger Trait
476// ============================================================================
477
478/// Contract for audit log persistence.
479///
480/// Implementations must be non-blocking and failure-tolerant (TM-OBS-007).
481/// The default strategy is fire-and-forget via `tokio::spawn`.
482#[async_trait::async_trait]
483pub trait AuditLogger: Send + Sync {
484    /// Persist an audit event. Implementations should not propagate errors
485    /// to callers — log warnings internally on failure.
486    async fn log_event(&self, event: AuditEvent) -> anyhow::Result<()>;
487
488    /// Fire-and-forget helper: spawns `log_event` on a background task.
489    fn emit(&self, event: AuditEvent)
490    where
491        Self: 'static + Clone,
492    {
493        let this = self.clone();
494        tokio::spawn(async move {
495            if let Err(e) = this.log_event(event).await {
496                tracing::warn!(error = %e, "Failed to write audit log");
497            }
498        });
499    }
500}
501
502// ============================================================================
503// Tests
504// ============================================================================
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn management_builder_produces_correct_domain() {
512        let event = AuditEvent::management(ManagementAction::MemberInvited, 1, Some(Uuid::nil()))
513            .target("member", "usr_abc123")
514            .detail("role", "admin")
515            .build();
516
517        assert_eq!(event.domain, AuditDomain::Management);
518        assert_eq!(event.action.as_str(), "management.member.invited");
519        assert_eq!(event.org_id, 1);
520        assert_eq!(event.actor_user_id, Some(Uuid::nil()));
521        assert!(event.target.is_some());
522        let target = event.target.unwrap();
523        assert_eq!(target.target_type, "member");
524        assert_eq!(target.target_id, "usr_abc123");
525        assert_eq!(event.details["role"], "admin");
526    }
527
528    #[test]
529    fn agent_builder_produces_correct_domain() {
530        let event = AuditEvent::agent(AgentAction::RunStarted, 1, None)
531            .target("session", "ses_abc123")
532            .build();
533
534        assert_eq!(event.domain, AuditDomain::Agent);
535        assert_eq!(event.action.as_str(), "agent.run.started");
536        assert!(event.target.is_some());
537    }
538
539    #[test]
540    fn action_domain_derivation() {
541        let mgmt: AuditAction = ManagementAction::ApiKeyCreated.into();
542        assert_eq!(mgmt.domain(), AuditDomain::Management);
543
544        let agent: AuditAction = AgentAction::ToolExecuted.into();
545        assert_eq!(agent.domain(), AuditDomain::Agent);
546    }
547
548    #[test]
549    fn domain_roundtrip() {
550        assert_eq!(
551            "management".parse::<AuditDomain>().unwrap(),
552            AuditDomain::Management
553        );
554        assert_eq!("agent".parse::<AuditDomain>().unwrap(), AuditDomain::Agent);
555        assert!("unknown".parse::<AuditDomain>().is_err());
556    }
557
558    #[test]
559    fn event_serialization() {
560        let event =
561            AuditEvent::management(ManagementAction::OrgCreated, 1, Some(Uuid::nil())).build();
562
563        let json = serde_json::to_value(&event).unwrap();
564        assert_eq!(json["domain"], "management");
565    }
566}