1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::fmt;
12use uuid::Uuid;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum AuditDomain {
22 Management,
24 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
63pub enum ManagementAction {
64 OrgCreated,
66 OrgUpdated,
67 OrgDeleted,
68 MemberInvited,
70 MemberRemoved,
71 MemberRoleChanged,
72 InvitationCreated,
74 InvitationRevoked,
75 InvitationAccepted,
76 ApiKeyCreated,
78 ApiKeyRevoked,
79 SettingsUpdated,
81 AgentCreated,
83 AgentUpdated,
84 AgentDeleted,
85 HarnessCreated,
87 HarnessUpdated,
88 HarnessDeleted,
89 ProviderCreated,
91 ProviderUpdated,
92 ProviderDeleted,
93 McpServerCreated,
95 McpServerUpdated,
96 McpServerDeleted,
97 AppCreated,
99 AppUpdated,
100 AppDeleted,
101 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#[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#[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
221macro_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#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct AuditTarget {
319 pub target_type: String,
321 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#[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
351pub 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 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 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
428pub trait HasAuditTargetId {
437 fn audit_target_id(&self) -> Option<String>;
438}
439
440impl 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
465impl 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#[async_trait::async_trait]
493pub trait AuditLogger: Send + Sync {
494 async fn log_event(&self, event: AuditEvent) -> anyhow::Result<()>;
497
498 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#[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}