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_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}