1use async_trait::async_trait;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7use uuid::Uuid;
8
9#[derive(Debug, Error)]
11pub enum SinkError {
12 #[error("io error: {0}")]
13 Io(#[from] std::io::Error),
14 #[error("json error: {0}")]
15 Json(#[from] serde_json::Error),
16 #[error("send error: {0}")]
17 Send(String),
18 #[error("other: {0}")]
19 Other(String),
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SinkEvent {
25 pub id: Uuid,
27 pub timestamp: DateTime<Utc>,
29 pub kind: SinkEventKind,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum SinkEventKind {
37 Decision {
39 effect: String,
40 principal: String,
41 action: String,
42 resource: String,
43 policies: Vec<String>,
44 reasons: Vec<String>,
45 duration_micros: u64,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 trace_id: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 span_id: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 tenant_id: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 subject_id: Option<String>,
54 #[serde(default)]
55 cached: bool,
56 },
57 DelegationMint {
59 issuer: String,
60 subject: String,
61 actions: Vec<String>,
62 ttl_seconds: i64,
63 },
64 DelegationVerify {
66 success: bool,
67 issuer: Option<String>,
68 subject: Option<String>,
69 },
70 PolicyReload {
72 version: String,
73 policy_count: usize,
74 source: String,
75 },
76 CacheLookup {
78 hit: bool,
79 principal: String,
80 action: String,
81 },
82 PdpError { error: String, fallback: String },
84}
85
86impl SinkEvent {
87 pub fn decision(
88 effect: impl Into<String>,
89 principal: impl Into<String>,
90 action: impl Into<String>,
91 resource: impl Into<String>,
92 policies: Vec<String>,
93 reasons: Vec<String>,
94 duration_micros: u64,
95 ) -> Self {
96 Self {
97 id: Uuid::new_v4(),
98 timestamp: Utc::now(),
99 kind: SinkEventKind::Decision {
100 effect: effect.into(),
101 principal: principal.into(),
102 action: action.into(),
103 resource: resource.into(),
104 policies,
105 reasons,
106 duration_micros,
107 trace_id: None,
108 span_id: None,
109 tenant_id: None,
110 subject_id: None,
111 cached: false,
112 },
113 }
114 }
115
116 pub fn with_trace(mut self, trace_id: String, span_id: String) -> Self {
117 if let SinkEventKind::Decision {
118 trace_id: ref mut t,
119 span_id: ref mut s,
120 ..
121 } = self.kind
122 {
123 *t = Some(trace_id);
124 *s = Some(span_id);
125 }
126 self
127 }
128
129 pub fn with_tenant(mut self, tenant_id: String, subject_id: Option<String>) -> Self {
130 if let SinkEventKind::Decision {
131 tenant_id: ref mut t,
132 subject_id: ref mut s,
133 ..
134 } = self.kind
135 {
136 *t = Some(tenant_id);
137 *s = subject_id;
138 }
139 self
140 }
141
142 pub fn mark_cached(mut self) -> Self {
143 if let SinkEventKind::Decision {
144 cached: ref mut c, ..
145 } = self.kind
146 {
147 *c = true;
148 }
149 self
150 }
151}
152
153#[async_trait]
166pub trait Sink: Send + Sync {
167 fn name(&self) -> &str;
169
170 async fn emit(&self, event: &SinkEvent) -> Result<(), SinkError>;
172
173 async fn flush(&self) -> Result<(), SinkError> {
175 Ok(())
176 }
177
178 async fn shutdown(&self) -> Result<(), SinkError> {
181 Ok(())
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::{SinkEvent, SinkEventKind};
188
189 #[test]
190 fn decision_event_builders_attach_trace_tenant_and_cache_metadata() {
191 let event = SinkEvent::decision("allow", "agent", "read", "repo", vec![], vec![], 12)
192 .with_trace("trace-id".into(), "span-id".into())
193 .with_tenant("tenant-a".into(), Some("subject-a".into()))
194 .mark_cached();
195 let SinkEventKind::Decision {
196 trace_id,
197 span_id,
198 tenant_id,
199 subject_id,
200 cached,
201 ..
202 } = event.kind
203 else {
204 panic!("decision constructor returned another event kind");
205 };
206 assert_eq!(trace_id.as_deref(), Some("trace-id"));
207 assert_eq!(span_id.as_deref(), Some("span-id"));
208 assert_eq!(tenant_id.as_deref(), Some("tenant-a"));
209 assert_eq!(subject_id.as_deref(), Some("subject-a"));
210 assert!(cached);
211 }
212
213 #[test]
214 fn decision_metadata_builders_leave_other_event_kinds_unchanged() {
215 let event = SinkEvent {
216 id: uuid::Uuid::new_v4(),
217 timestamp: chrono::Utc::now(),
218 kind: SinkEventKind::PdpError {
219 error: "unavailable".into(),
220 fallback: "deny".into(),
221 },
222 }
223 .with_trace("trace-id".into(), "span-id".into())
224 .with_tenant("tenant-a".into(), Some("subject-a".into()))
225 .mark_cached();
226
227 assert!(matches!(event.kind, SinkEventKind::PdpError { .. }));
228 }
229}