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 ApiKeyCreated,
74 ApiKeyRevoked,
75 SettingsUpdated,
77 AgentCreated,
79 AgentUpdated,
80 AgentDeleted,
81 HarnessCreated,
83 HarnessUpdated,
84 HarnessDeleted,
85 LlmProviderCreated,
87 LlmProviderUpdated,
88 LlmProviderDeleted,
89 McpServerCreated,
91 McpServerUpdated,
92 McpServerDeleted,
93 AppCreated,
95 AppUpdated,
96 AppDeleted,
97 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#[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#[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
214macro_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#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct AuditTarget {
309 pub target_type: String,
311 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#[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
341pub 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 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 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
418pub trait HasAuditTargetId {
427 fn audit_target_id(&self) -> Option<String>;
428}
429
430impl 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
455impl 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#[async_trait::async_trait]
483pub trait AuditLogger: Send + Sync {
484 async fn log_event(&self, event: AuditEvent) -> anyhow::Result<()>;
487
488 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#[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}