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::EntityData;
9use crate::RedDBRuntime;
10use reddb_types::encoding::json_escape;
11use reddb_types::Value;
12
13pub const EVIDENCE_EXPORT_ACTION: &str = "evidence:export";
14pub const EVIDENCE_EXPORT_RESOURCE_KIND: &str = "evidence";
15pub const EVIDENCE_EXPORT_RESOURCE_NAME: &str = "control_events";
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
18pub struct EvidenceExportRequest {
19 pub filter: EvidenceExportFilter,
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct EvidenceExportFilter {
24 pub start_ts: Option<i64>,
25 pub end_ts: Option<i64>,
26 pub actor_user_id: Option<String>,
27 pub scope: Option<String>,
28 pub resource: Option<String>,
29 pub evidence_type: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct EvidenceExportReport {
34 pub filters: EvidenceExportFilter,
35 pub export_started_at_ms: u64,
36 pub export_completed_at_ms: u64,
37 pub event_count: usize,
38 pub counts_by_type: BTreeMap<String, usize>,
39 pub high_water_ts: Option<i64>,
40 pub high_water_event_id: Option<String>,
41 pub integrity_hash: String,
42 pub events: Vec<EvidenceExportEvent>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct EvidenceExportEvent {
47 pub id: String,
48 pub ts: i64,
49 pub kind: String,
50 pub outcome: String,
51 pub actor_kind: String,
52 pub actor_user_id: Option<String>,
53 pub scope: Option<String>,
54 pub action: String,
55 pub resource: Option<String>,
56 pub reason: Option<String>,
57 pub matched_policy_id: Option<String>,
58 pub request_id: Option<String>,
59 pub trace_id: Option<String>,
60 pub fields_json: String,
61 pub integrity_hash: String,
62}
63
64impl RedDBRuntime {
65 pub fn export_evidence(
66 &self,
67 request: EvidenceExportRequest,
68 ) -> RedDBResult<EvidenceExportReport> {
69 let export_started_at_ms = crate::utils::now_unix_millis();
70 let mut filter = request.filter;
71 if let Err(err) = self.check_evidence_export_policy() {
72 let reason = err.to_string();
73 self.emit_control_event(
74 EventKind::EvidenceExport,
75 Outcome::Denied,
76 "evidence_export",
77 Some("evidence:control_events".to_string()),
78 Some(reason.clone()),
79 export_filter_fields(&filter),
80 )?;
81 return Err(RedDBError::Query(reason));
82 }
83 if let Err(err) = apply_scope_guard(&mut filter) {
84 let reason = err.to_string();
85 self.emit_control_event(
86 EventKind::EvidenceExport,
87 Outcome::Denied,
88 "evidence_export",
89 Some("evidence:control_events".to_string()),
90 Some(reason.clone()),
91 export_filter_fields(&filter),
92 )?;
93 return Err(RedDBError::Query(reason));
94 }
95
96 let mut events = self.filtered_control_events(&filter);
97 events.sort_by(|a, b| a.ts.cmp(&b.ts).then_with(|| a.id.cmp(&b.id)));
98 let mut counts_by_type = BTreeMap::new();
99 let mut high_water_ts = None;
100 let mut high_water_event_id = None;
101 for event in &events {
102 *counts_by_type.entry(event.kind.clone()).or_insert(0) += 1;
103 high_water_ts = Some(event.ts);
104 high_water_event_id = Some(event.id.clone());
105 }
106 let integrity_hash = export_integrity_hash(&filter, &events);
107 let event_count = events.len();
108 let export_completed_at_ms = crate::utils::now_unix_millis();
109
110 self.emit_control_event(
111 EventKind::EvidenceExport,
112 Outcome::Allowed,
113 "evidence_export",
114 Some("evidence:control_events".to_string()),
115 None,
116 allowed_export_fields(&filter, event_count, high_water_ts, &integrity_hash),
117 )?;
118
119 Ok(EvidenceExportReport {
120 filters: filter,
121 export_started_at_ms,
122 export_completed_at_ms,
123 event_count,
124 counts_by_type,
125 high_water_ts,
126 high_water_event_id,
127 integrity_hash,
128 events,
129 })
130 }
131
132 fn check_evidence_export_policy(&self) -> RedDBResult<()> {
133 let tenant = current_tenant();
134 let Some((username, role)) = current_auth_identity() else {
135 return Err(RedDBError::Query(format!(
136 "{EVIDENCE_EXPORT_ACTION} requires an authenticated principal"
137 )));
138 };
139 let auth_store = self.inner.auth_store.read().clone().ok_or_else(|| {
140 RedDBError::Query(format!("{EVIDENCE_EXPORT_ACTION} requires an auth store"))
141 })?;
142 let principal = UserId::from_parts(tenant.as_deref(), &username);
143 let mut resource =
144 ResourceRef::new(EVIDENCE_EXPORT_RESOURCE_KIND, EVIDENCE_EXPORT_RESOURCE_NAME);
145 if let Some(tenant) = tenant.as_deref() {
146 resource = resource.with_tenant(tenant.to_string());
147 }
148 let ctx = EvalContext {
149 principal_tenant: tenant.clone(),
150 current_tenant: tenant,
151 peer_ip: None,
152 mfa_present: false,
153 now_ms: crate::auth::now_ms(),
154 principal_is_admin_role: role == crate::auth::Role::Admin,
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 out.push_str(&json_escape(name));
402 out.push_str("\":");
403 match value {
404 Some(value) => {
405 out.push('"');
406 out.push_str(&json_escape(value));
407 out.push('"');
408 }
409 None => out.push_str("null"),
410 }
411 out.push(';');
412}