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_platform_scoped: principal.tenant.is_none(),
155 };
156 if auth_store.check_policy_authz_with_role(
157 &principal,
158 EVIDENCE_EXPORT_ACTION,
159 &resource,
160 &ctx,
161 role,
162 ) {
163 Ok(())
164 } else {
165 Err(RedDBError::Query(format!(
166 "principal=`{principal}` action=`{EVIDENCE_EXPORT_ACTION}` resource=`{}:{}` denied by IAM policy",
167 resource.kind, resource.name
168 )))
169 }
170 }
171
172 fn filtered_control_events(&self, filter: &EvidenceExportFilter) -> Vec<EvidenceExportEvent> {
173 let Some(manager) = self.db().store().get_collection(CONTROL_EVENTS_COLLECTION) else {
174 return Vec::new();
175 };
176 manager
177 .query_all(|_| true)
178 .into_iter()
179 .filter_map(|entity| {
180 let EntityData::Row(row) = entity.data else {
181 return None;
182 };
183 let event = EvidenceExportEvent::from_row(&row.named?)?;
184 if filter.matches(&event) {
185 Some(event)
186 } else {
187 None
188 }
189 })
190 .collect()
191 }
192}
193
194fn apply_scope_guard(filter: &mut EvidenceExportFilter) -> RedDBResult<()> {
195 let Some(active_scope) = current_tenant() else {
196 return Ok(());
197 };
198 match filter.scope.as_deref() {
199 Some(scope) if scope != active_scope => Err(RedDBError::Query(format!(
200 "evidence export scope `{scope}` is outside active tenant `{active_scope}`"
201 ))),
202 Some(_) => Ok(()),
203 None => {
204 filter.scope = Some(active_scope);
205 Ok(())
206 }
207 }
208}
209
210impl EvidenceExportFilter {
211 fn matches(&self, event: &EvidenceExportEvent) -> bool {
212 if self.start_ts.is_some_and(|start| event.ts < start) {
213 return false;
214 }
215 if self.end_ts.is_some_and(|end| event.ts > end) {
216 return false;
217 }
218 if self
219 .actor_user_id
220 .as_ref()
221 .is_some_and(|actor| event.actor_user_id.as_ref() != Some(actor))
222 {
223 return false;
224 }
225 if self
226 .scope
227 .as_ref()
228 .is_some_and(|scope| event.scope.as_ref() != Some(scope))
229 {
230 return false;
231 }
232 if self
233 .resource
234 .as_ref()
235 .is_some_and(|resource| event.resource.as_ref() != Some(resource))
236 {
237 return false;
238 }
239 if self
240 .evidence_type
241 .as_ref()
242 .is_some_and(|kind| &event.kind != kind)
243 {
244 return false;
245 }
246 true
247 }
248}
249
250impl EvidenceExportEvent {
251 fn from_row(row: &std::collections::HashMap<String, Value>) -> Option<Self> {
252 let mut event = Self {
253 id: text(row, "id")?,
254 ts: integer(row, "ts")?,
255 kind: text(row, "kind")?,
256 outcome: text(row, "outcome")?,
257 actor_kind: text(row, "actor_kind")?,
258 actor_user_id: nullable_text(row, "actor_user_id"),
259 scope: nullable_text(row, "scope"),
260 action: text(row, "action")?,
261 resource: nullable_text(row, "resource"),
262 reason: nullable_text(row, "reason"),
263 matched_policy_id: nullable_text(row, "matched_policy_id"),
264 request_id: nullable_text(row, "request_id"),
265 trace_id: nullable_text(row, "trace_id"),
266 fields_json: text(row, "fields_json").unwrap_or_else(|| "{}".to_string()),
267 integrity_hash: String::new(),
268 };
269 event.integrity_hash = event_integrity_hash(&event);
270 Some(event)
271 }
272}
273
274fn allowed_export_fields(
275 filter: &EvidenceExportFilter,
276 event_count: usize,
277 high_water_ts: Option<i64>,
278 integrity_hash: &str,
279) -> Vec<(String, Sensitivity)> {
280 let mut fields = export_filter_fields(filter);
281 fields.push((
282 "event_count".to_string(),
283 Sensitivity::raw(event_count.to_string()),
284 ));
285 if let Some(ts) = high_water_ts {
286 fields.push((
287 "high_water_ts".to_string(),
288 Sensitivity::raw(ts.to_string()),
289 ));
290 }
291 fields.push((
292 "integrity_hash".to_string(),
293 Sensitivity::raw(integrity_hash.to_string()),
294 ));
295 fields
296}
297
298fn export_filter_fields(filter: &EvidenceExportFilter) -> Vec<(String, Sensitivity)> {
299 let mut fields = Vec::new();
300 if let Some(ts) = filter.start_ts {
301 fields.push((
302 "filter_start_ts".to_string(),
303 Sensitivity::raw(ts.to_string()),
304 ));
305 }
306 if let Some(ts) = filter.end_ts {
307 fields.push((
308 "filter_end_ts".to_string(),
309 Sensitivity::raw(ts.to_string()),
310 ));
311 }
312 if let Some(actor) = &filter.actor_user_id {
313 fields.push(("filter_actor_user_id".to_string(), Sensitivity::raw(actor)));
314 }
315 if let Some(scope) = &filter.scope {
316 fields.push(("filter_scope".to_string(), Sensitivity::raw(scope)));
317 }
318 if let Some(resource) = &filter.resource {
319 fields.push(("filter_resource".to_string(), Sensitivity::raw(resource)));
320 }
321 if let Some(kind) = &filter.evidence_type {
322 fields.push(("filter_evidence_type".to_string(), Sensitivity::raw(kind)));
323 }
324 fields
325}
326
327fn text(row: &std::collections::HashMap<String, Value>, field: &str) -> Option<String> {
328 match row.get(field) {
329 Some(Value::Text(value)) => Some(value.to_string()),
330 _ => None,
331 }
332}
333
334fn nullable_text(row: &std::collections::HashMap<String, Value>, field: &str) -> Option<String> {
335 match row.get(field) {
336 Some(Value::Text(value)) => Some(value.to_string()),
337 _ => None,
338 }
339}
340
341fn integer(row: &std::collections::HashMap<String, Value>, field: &str) -> Option<i64> {
342 match row.get(field) {
343 Some(Value::Integer(value)) | Some(Value::TimestampMs(value)) => Some(*value),
344 _ => None,
345 }
346}
347
348fn export_integrity_hash(filter: &EvidenceExportFilter, events: &[EvidenceExportEvent]) -> String {
349 let mut body = String::new();
350 push_filter_canonical(filter, &mut body);
351 for event in events {
352 body.push('\n');
353 body.push_str(&event.integrity_hash);
354 }
355 format!("blake3:{}", blake3::hash(body.as_bytes()).to_hex())
356}
357
358fn event_integrity_hash(event: &EvidenceExportEvent) -> String {
359 let mut body = String::new();
360 push_json_field("id", Some(&event.id), &mut body);
361 push_json_field("ts", Some(&event.ts.to_string()), &mut body);
362 push_json_field("kind", Some(&event.kind), &mut body);
363 push_json_field("outcome", Some(&event.outcome), &mut body);
364 push_json_field("actor_kind", Some(&event.actor_kind), &mut body);
365 push_json_field("actor_user_id", event.actor_user_id.as_deref(), &mut body);
366 push_json_field("scope", event.scope.as_deref(), &mut body);
367 push_json_field("action", Some(&event.action), &mut body);
368 push_json_field("resource", event.resource.as_deref(), &mut body);
369 push_json_field("reason", event.reason.as_deref(), &mut body);
370 push_json_field(
371 "matched_policy_id",
372 event.matched_policy_id.as_deref(),
373 &mut body,
374 );
375 push_json_field("request_id", event.request_id.as_deref(), &mut body);
376 push_json_field("trace_id", event.trace_id.as_deref(), &mut body);
377 push_json_field("fields_json", Some(&event.fields_json), &mut body);
378 format!("blake3:{}", blake3::hash(body.as_bytes()).to_hex())
379}
380
381fn push_filter_canonical(filter: &EvidenceExportFilter, out: &mut String) {
382 push_json_field(
383 "start_ts",
384 filter.start_ts.as_ref().map(|v| v.to_string()).as_deref(),
385 out,
386 );
387 push_json_field(
388 "end_ts",
389 filter.end_ts.as_ref().map(|v| v.to_string()).as_deref(),
390 out,
391 );
392 push_json_field("actor_user_id", filter.actor_user_id.as_deref(), out);
393 push_json_field("scope", filter.scope.as_deref(), out);
394 push_json_field("resource", filter.resource.as_deref(), out);
395 push_json_field("evidence_type", filter.evidence_type.as_deref(), out);
396}
397
398fn push_json_field(name: &str, value: Option<&str>, out: &mut String) {
399 out.push('"');
400 json_escape_into(name, out);
401 out.push_str("\":");
402 match value {
403 Some(value) => {
404 out.push('"');
405 json_escape_into(value, out);
406 out.push('"');
407 }
408 None => out.push_str("null"),
409 }
410 out.push(';');
411}
412
413fn json_escape_into(s: &str, out: &mut String) {
414 for c in s.chars() {
415 match c {
416 '"' => out.push_str("\\\""),
417 '\\' => out.push_str("\\\\"),
418 '\n' => out.push_str("\\n"),
419 '\r' => out.push_str("\\r"),
420 '\t' => out.push_str("\\t"),
421 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
422 c => out.push(c),
423 }
424 }
425}