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