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