Skip to main content

reddb_server/runtime/
evidence_export.rs

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}