use crate::id::Uid;
use crate::query::LogQuery;
use serde::{Deserialize, Serialize};
pub const RESERVED_TYPE_PREFIX: &str = "quipu_";
pub const ACCESS_TYPE: &str = "quipu_access";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessRecord {
pub id: Uid,
pub timestamp: u64,
pub actor: String,
pub operation: String,
pub params: String,
pub result_count: Option<u64>,
}
impl AccessRecord {
pub fn new(
actor: impl Into<String>,
operation: impl Into<String>,
params: impl Into<String>,
result_count: Option<u64>,
) -> Self {
Self {
id: Uid::generate(),
timestamp: crate::time::now_micros(),
actor: actor.into(),
operation: operation.into(),
params: params.into(),
result_count,
}
}
pub fn at(mut self, timestamp: u64) -> Self {
self.timestamp = timestamp;
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct AccessQuery {
pub from_micros: Option<u64>,
pub to_micros: Option<u64>,
pub actor: Option<String>,
pub operation: Option<String>,
pub limit: Option<usize>,
}
impl AccessQuery {
pub fn matches(&self, rec: &AccessRecord) -> bool {
if self.from_micros.is_some_and(|from| rec.timestamp < from) {
return false;
}
if self.to_micros.is_some_and(|to| rec.timestamp > to) {
return false;
}
if self.actor.as_ref().is_some_and(|a| *a != rec.actor) {
return false;
}
if self
.operation
.as_ref()
.is_some_and(|op| *op != rec.operation)
{
return false;
}
true
}
}
pub fn summarize_log_query(q: &LogQuery) -> String {
fn filter_shape(f: &crate::query::TargetFilter) -> serde_json::Value {
serde_json::json!({
"entity_type": f.entity_type,
"field": f.field,
"mode": f.mode,
"include_past": f.include_past,
})
}
let mut out = serde_json::Map::new();
if let Some(v) = q.from_micros {
out.insert("from_micros".into(), v.into());
}
if let Some(v) = q.to_micros {
out.insert("to_micros".into(), v.into());
}
if let Some(v) = &q.method {
out.insert("method".into(), v.clone().into());
}
if let Some(v) = &q.url_prefix {
out.insert("url_prefix".into(), v.clone().into());
}
if let Some(f) = &q.actor {
out.insert("actor_filter".into(), filter_shape(f));
}
if !q.targets.is_empty() {
out.insert(
"target_filters".into(),
q.targets.iter().map(filter_shape).collect(),
);
}
if !q.custom.is_empty() {
out.insert(
"custom_keys".into(),
q.custom.keys().cloned().collect::<Vec<_>>().into(),
);
}
if let Some(v) = q.limit {
out.insert("limit".into(), v.into());
}
serde_json::Value::Object(out).to_string()
}
pub fn summarize_access_query(q: &AccessQuery) -> String {
let mut out = serde_json::Map::new();
if let Some(v) = q.from_micros {
out.insert("from_micros".into(), v.into());
}
if let Some(v) = q.to_micros {
out.insert("to_micros".into(), v.into());
}
if let Some(v) = &q.actor {
out.insert("actor".into(), v.clone().into());
}
if let Some(v) = &q.operation {
out.insert("operation".into(), v.clone().into());
}
if let Some(v) = q.limit {
out.insert("limit".into(), v.into());
}
serde_json::Value::Object(out).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Value;
use crate::query::TargetFilter;
#[test]
fn summary_never_contains_probe_values() {
let q = LogQuery {
actor: Some(TargetFilter::exact(
"user",
"name",
Value::Text("SECRET-ACTOR".into()),
)),
targets: vec![
TargetFilter::exact("patient", "ssn", Value::Text("SECRET-123".into())).contains(),
],
custom: [("note".to_string(), Value::Text("SECRET-NOTE".into()))]
.into_iter()
.collect(),
..Default::default()
};
let s = summarize_log_query(&q);
assert!(!s.contains("SECRET-123"), "target probe leaked: {s}");
assert!(!s.contains("SECRET-ACTOR"), "actor probe leaked: {s}");
assert!(!s.contains("SECRET-NOTE"), "custom value leaked: {s}");
assert!(s.contains("patient"));
assert!(s.contains("ssn"));
assert!(s.contains("contains"));
assert!(s.contains("note"));
}
}