1use std::collections::BTreeMap;
2
3use crate::api::{RedDBError, RedDBResult};
4use crate::auth::policies::{EvalContext, ResourceRef};
5use crate::auth::UserId;
6use crate::runtime::control_events::{EventKind, Outcome, Sensitivity, CONTROL_EVENTS_COLLECTION};
7use crate::runtime::impl_core::{current_auth_identity, current_tenant};
8use crate::storage::schema::Value;
9use crate::storage::EntityData;
10use crate::RedDBRuntime;
11
12pub const EVIDENCE_EXPORT_ACTION: &str = "evidence:export";
13pub const EVIDENCE_EXPORT_RESOURCE_KIND: &str = "evidence";
14pub const EVIDENCE_EXPORT_RESOURCE_NAME: &str = "control_events";
15
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct EvidenceExportRequest {
18 pub filter: EvidenceExportFilter,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct EvidenceExportFilter {
23 pub start_ts: Option<i64>,
24 pub end_ts: Option<i64>,
25 pub actor_user_id: Option<String>,
26 pub scope: Option<String>,
27 pub resource: Option<String>,
28 pub evidence_type: Option<String>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct EvidenceExportReport {
33 pub filters: EvidenceExportFilter,
34 pub export_started_at_ms: u64,
35 pub export_completed_at_ms: u64,
36 pub event_count: usize,
37 pub counts_by_type: BTreeMap<String, usize>,
38 pub high_water_ts: Option<i64>,
39 pub high_water_event_id: Option<String>,
40 pub integrity_hash: String,
41 pub events: Vec<EvidenceExportEvent>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct EvidenceExportEvent {
46 pub id: String,
47 pub ts: i64,
48 pub kind: String,
49 pub outcome: String,
50 pub actor_kind: String,
51 pub actor_user_id: Option<String>,
52 pub scope: Option<String>,
53 pub action: String,
54 pub resource: Option<String>,
55 pub reason: Option<String>,
56 pub matched_policy_id: Option<String>,
57 pub request_id: Option<String>,
58 pub trace_id: Option<String>,
59 pub fields_json: String,
60 pub integrity_hash: String,
61}
62
63impl RedDBRuntime {
64 pub fn export_evidence(
65 &self,
66 request: EvidenceExportRequest,
67 ) -> RedDBResult<EvidenceExportReport> {
68 let export_started_at_ms = crate::utils::now_unix_millis();
69 let mut filter = request.filter;
70 if let Err(err) = self.check_evidence_export_policy() {
71 let reason = err.to_string();
72 self.emit_control_event(
73 EventKind::EvidenceExport,
74 Outcome::Denied,
75 "evidence_export",
76 Some("evidence:control_events".to_string()),
77 Some(reason.clone()),
78 export_filter_fields(&filter),
79 )?;
80 return Err(RedDBError::Query(reason));
81 }
82 if let Err(err) = apply_scope_guard(&mut filter) {
83 let reason = err.to_string();
84 self.emit_control_event(
85 EventKind::EvidenceExport,
86 Outcome::Denied,
87 "evidence_export",
88 Some("evidence:control_events".to_string()),
89 Some(reason.clone()),
90 export_filter_fields(&filter),
91 )?;
92 return Err(RedDBError::Query(reason));
93 }
94
95 let mut events = self.filtered_control_events(&filter);
96 events.sort_by(|a, b| a.ts.cmp(&b.ts).then_with(|| a.id.cmp(&b.id)));
97 let mut counts_by_type = BTreeMap::new();
98 let mut high_water_ts = None;
99 let mut high_water_event_id = None;
100 for event in &events {
101 *counts_by_type.entry(event.kind.clone()).or_insert(0) += 1;
102 high_water_ts = Some(event.ts);
103 high_water_event_id = Some(event.id.clone());
104 }
105 let integrity_hash = export_integrity_hash(&filter, &events);
106 let event_count = events.len();
107 let export_completed_at_ms = crate::utils::now_unix_millis();
108
109 self.emit_control_event(
110 EventKind::EvidenceExport,
111 Outcome::Allowed,
112 "evidence_export",
113 Some("evidence:control_events".to_string()),
114 None,
115 allowed_export_fields(&filter, event_count, high_water_ts, &integrity_hash),
116 )?;
117
118 Ok(EvidenceExportReport {
119 filters: filter,
120 export_started_at_ms,
121 export_completed_at_ms,
122 event_count,
123 counts_by_type,
124 high_water_ts,
125 high_water_event_id,
126 integrity_hash,
127 events,
128 })
129 }
130
131 fn check_evidence_export_policy(&self) -> RedDBResult<()> {
132 let tenant = current_tenant();
133 let Some((username, role)) = current_auth_identity() else {
134 return Err(RedDBError::Query(format!(
135 "{EVIDENCE_EXPORT_ACTION} requires an authenticated principal"
136 )));
137 };
138 let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
139 RedDBError::Query(format!("{EVIDENCE_EXPORT_ACTION} requires an auth store"))
140 })?;
141 let principal = UserId::from_parts(tenant.as_deref(), &username);
142 let mut resource =
143 ResourceRef::new(EVIDENCE_EXPORT_RESOURCE_KIND, EVIDENCE_EXPORT_RESOURCE_NAME);
144 if let Some(tenant) = tenant.as_deref() {
145 resource = resource.with_tenant(tenant.to_string());
146 }
147 let ctx = EvalContext {
148 principal_tenant: tenant.clone(),
149 current_tenant: tenant,
150 peer_ip: None,
151 mfa_present: false,
152 now_ms: crate::auth::now_ms(),
153 principal_is_admin_role: role == crate::auth::Role::Admin,
154 principal_is_system_owned: auth_store.principal_is_system_owned(&principal),
155 principal_is_platform_scoped: principal.tenant.is_none(),
156 };
157 if auth_store.check_policy_authz_with_role(
158 &principal,
159 EVIDENCE_EXPORT_ACTION,
160 &resource,
161 &ctx,
162 role,
163 ) {
164 Ok(())
165 } else {
166 Err(RedDBError::Query(format!(
167 "principal=`{principal}` action=`{EVIDENCE_EXPORT_ACTION}` resource=`{}:{}` denied by IAM policy",
168 resource.kind, resource.name
169 )))
170 }
171 }
172
173 fn filtered_control_events(&self, filter: &EvidenceExportFilter) -> Vec<EvidenceExportEvent> {
174 let Some(manager) = self.db().store().get_collection(CONTROL_EVENTS_COLLECTION) else {
175 return Vec::new();
176 };
177 manager
178 .query_all(|_| true)
179 .into_iter()
180 .filter_map(|entity| {
181 let EntityData::Row(row) = entity.data else {
182 return None;
183 };
184 let event = EvidenceExportEvent::from_row(&row.named?)?;
185 if filter.matches(&event) {
186 Some(event)
187 } else {
188 None
189 }
190 })
191 .collect()
192 }
193}
194
195fn apply_scope_guard(filter: &mut EvidenceExportFilter) -> RedDBResult<()> {
196 let Some(active_scope) = current_tenant() else {
197 return Ok(());
198 };
199 match filter.scope.as_deref() {
200 Some(scope) if scope != active_scope => Err(RedDBError::Query(format!(
201 "evidence export scope `{scope}` is outside active tenant `{active_scope}`"
202 ))),
203 Some(_) => Ok(()),
204 None => {
205 filter.scope = Some(active_scope);
206 Ok(())
207 }
208 }
209}
210
211impl EvidenceExportFilter {
212 fn matches(&self, event: &EvidenceExportEvent) -> bool {
213 if self.start_ts.is_some_and(|start| event.ts < start) {
214 return false;
215 }
216 if self.end_ts.is_some_and(|end| event.ts > end) {
217 return false;
218 }
219 if self
220 .actor_user_id
221 .as_ref()
222 .is_some_and(|actor| event.actor_user_id.as_ref() != Some(actor))
223 {
224 return false;
225 }
226 if self
227 .scope
228 .as_ref()
229 .is_some_and(|scope| event.scope.as_ref() != Some(scope))
230 {
231 return false;
232 }
233 if self
234 .resource
235 .as_ref()
236 .is_some_and(|resource| event.resource.as_ref() != Some(resource))
237 {
238 return false;
239 }
240 if self
241 .evidence_type
242 .as_ref()
243 .is_some_and(|kind| &event.kind != kind)
244 {
245 return false;
246 }
247 true
248 }
249}
250
251impl EvidenceExportEvent {
252 fn from_row(row: &std::collections::HashMap<String, Value>) -> Option<Self> {
253 let mut event = Self {
254 id: text(row, "id")?,
255 ts: integer(row, "ts")?,
256 kind: text(row, "kind")?,
257 outcome: text(row, "outcome")?,
258 actor_kind: text(row, "actor_kind")?,
259 actor_user_id: nullable_text(row, "actor_user_id"),
260 scope: nullable_text(row, "scope"),
261 action: text(row, "action")?,
262 resource: nullable_text(row, "resource"),
263 reason: nullable_text(row, "reason"),
264 matched_policy_id: nullable_text(row, "matched_policy_id"),
265 request_id: nullable_text(row, "request_id"),
266 trace_id: nullable_text(row, "trace_id"),
267 fields_json: text(row, "fields_json").unwrap_or_else(|| "{}".to_string()),
268 integrity_hash: String::new(),
269 };
270 event.integrity_hash = event_integrity_hash(&event);
271 Some(event)
272 }
273}
274
275fn allowed_export_fields(
276 filter: &EvidenceExportFilter,
277 event_count: usize,
278 high_water_ts: Option<i64>,
279 integrity_hash: &str,
280) -> Vec<(String, Sensitivity)> {
281 let mut fields = export_filter_fields(filter);
282 fields.push((
283 "event_count".to_string(),
284 Sensitivity::raw(event_count.to_string()),
285 ));
286 if let Some(ts) = high_water_ts {
287 fields.push((
288 "high_water_ts".to_string(),
289 Sensitivity::raw(ts.to_string()),
290 ));
291 }
292 fields.push((
293 "integrity_hash".to_string(),
294 Sensitivity::raw(integrity_hash.to_string()),
295 ));
296 fields
297}
298
299fn export_filter_fields(filter: &EvidenceExportFilter) -> Vec<(String, Sensitivity)> {
300 let mut fields = Vec::new();
301 if let Some(ts) = filter.start_ts {
302 fields.push((
303 "filter_start_ts".to_string(),
304 Sensitivity::raw(ts.to_string()),
305 ));
306 }
307 if let Some(ts) = filter.end_ts {
308 fields.push((
309 "filter_end_ts".to_string(),
310 Sensitivity::raw(ts.to_string()),
311 ));
312 }
313 if let Some(actor) = &filter.actor_user_id {
314 fields.push(("filter_actor_user_id".to_string(), Sensitivity::raw(actor)));
315 }
316 if let Some(scope) = &filter.scope {
317 fields.push(("filter_scope".to_string(), Sensitivity::raw(scope)));
318 }
319 if let Some(resource) = &filter.resource {
320 fields.push(("filter_resource".to_string(), Sensitivity::raw(resource)));
321 }
322 if let Some(kind) = &filter.evidence_type {
323 fields.push(("filter_evidence_type".to_string(), Sensitivity::raw(kind)));
324 }
325 fields
326}
327
328fn text(row: &std::collections::HashMap<String, Value>, field: &str) -> Option<String> {
329 match row.get(field) {
330 Some(Value::Text(value)) => Some(value.to_string()),
331 _ => None,
332 }
333}
334
335fn nullable_text(row: &std::collections::HashMap<String, Value>, field: &str) -> Option<String> {
336 match row.get(field) {
337 Some(Value::Text(value)) => Some(value.to_string()),
338 _ => None,
339 }
340}
341
342fn integer(row: &std::collections::HashMap<String, Value>, field: &str) -> Option<i64> {
343 match row.get(field) {
344 Some(Value::Integer(value)) | Some(Value::TimestampMs(value)) => Some(*value),
345 _ => None,
346 }
347}
348
349fn export_integrity_hash(filter: &EvidenceExportFilter, events: &[EvidenceExportEvent]) -> String {
350 let mut body = String::new();
351 push_filter_canonical(filter, &mut body);
352 for event in events {
353 body.push('\n');
354 body.push_str(&event.integrity_hash);
355 }
356 format!("blake3:{}", blake3::hash(body.as_bytes()).to_hex())
357}
358
359fn event_integrity_hash(event: &EvidenceExportEvent) -> String {
360 let mut body = String::new();
361 push_json_field("id", Some(&event.id), &mut body);
362 push_json_field("ts", Some(&event.ts.to_string()), &mut body);
363 push_json_field("kind", Some(&event.kind), &mut body);
364 push_json_field("outcome", Some(&event.outcome), &mut body);
365 push_json_field("actor_kind", Some(&event.actor_kind), &mut body);
366 push_json_field("actor_user_id", event.actor_user_id.as_deref(), &mut body);
367 push_json_field("scope", event.scope.as_deref(), &mut body);
368 push_json_field("action", Some(&event.action), &mut body);
369 push_json_field("resource", event.resource.as_deref(), &mut body);
370 push_json_field("reason", event.reason.as_deref(), &mut body);
371 push_json_field(
372 "matched_policy_id",
373 event.matched_policy_id.as_deref(),
374 &mut body,
375 );
376 push_json_field("request_id", event.request_id.as_deref(), &mut body);
377 push_json_field("trace_id", event.trace_id.as_deref(), &mut body);
378 push_json_field("fields_json", Some(&event.fields_json), &mut body);
379 format!("blake3:{}", blake3::hash(body.as_bytes()).to_hex())
380}
381
382fn push_filter_canonical(filter: &EvidenceExportFilter, out: &mut String) {
383 push_json_field(
384 "start_ts",
385 filter.start_ts.as_ref().map(|v| v.to_string()).as_deref(),
386 out,
387 );
388 push_json_field(
389 "end_ts",
390 filter.end_ts.as_ref().map(|v| v.to_string()).as_deref(),
391 out,
392 );
393 push_json_field("actor_user_id", filter.actor_user_id.as_deref(), out);
394 push_json_field("scope", filter.scope.as_deref(), out);
395 push_json_field("resource", filter.resource.as_deref(), out);
396 push_json_field("evidence_type", filter.evidence_type.as_deref(), out);
397}
398
399fn push_json_field(name: &str, value: Option<&str>, out: &mut String) {
400 out.push('"');
401 json_escape_into(name, out);
402 out.push_str("\":");
403 match value {
404 Some(value) => {
405 out.push('"');
406 json_escape_into(value, out);
407 out.push('"');
408 }
409 None => out.push_str("null"),
410 }
411 out.push(';');
412}
413
414fn json_escape_into(s: &str, out: &mut String) {
415 for c in s.chars() {
416 match c {
417 '"' => out.push_str("\\\""),
418 '\\' => out.push_str("\\\\"),
419 '\n' => out.push_str("\\n"),
420 '\r' => out.push_str("\\r"),
421 '\t' => out.push_str("\\t"),
422 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
423 c => out.push(c),
424 }
425 }
426}