use std::collections::HashMap;
use std::sync::RwLock;
use serde::{Deserialize, Serialize};
use tracing::info;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedactionPolicy {
pub name: String,
pub collection: String,
pub for_role: String,
pub rules: Vec<RedactionRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RedactionRule {
pub field: String,
pub mode: RedactionMode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RedactionMode {
Mask(String),
Hash,
Null,
}
pub struct RedactionStore {
policies: RwLock<HashMap<String, RedactionPolicy>>,
}
impl RedactionStore {
pub fn new() -> Self {
Self {
policies: RwLock::new(HashMap::new()),
}
}
pub fn create_policy(&self, policy: RedactionPolicy) {
let key = format!("{}:{}", policy.collection, policy.for_role);
let mut policies = self.policies.write().unwrap_or_else(|p| p.into_inner());
info!(
name = %policy.name,
collection = %policy.collection,
role = %policy.for_role,
rules = policy.rules.len(),
"redaction policy created"
);
policies.insert(key, policy);
}
pub fn drop_policy(&self, collection: &str, for_role: &str) -> bool {
let key = format!("{collection}:{for_role}");
let mut policies = self.policies.write().unwrap_or_else(|p| p.into_inner());
policies.remove(&key).is_some()
}
pub fn rules_for(&self, collection: &str, role: &str) -> Vec<RedactionRule> {
let key = format!("{collection}:{role}");
let policies = self.policies.read().unwrap_or_else(|p| p.into_inner());
policies
.get(&key)
.map(|p| p.rules.clone())
.unwrap_or_default()
}
pub fn apply(&self, collection: &str, roles: &[String], doc: &mut serde_json::Value) {
let policies = self.policies.read().unwrap_or_else(|p| p.into_inner());
for role in roles {
let key = format!("{collection}:{role}");
if let Some(policy) = policies.get(&key) {
for rule in &policy.rules {
if let Some(obj) = doc.as_object_mut()
&& obj.contains_key(&rule.field)
{
let redacted = match &rule.mode {
RedactionMode::Mask(mask) => serde_json::Value::String(mask.clone()),
RedactionMode::Hash => {
let original = obj
.get(&rule.field)
.map(|v| v.to_string())
.unwrap_or_default();
serde_json::Value::String(hash_value(&original))
}
RedactionMode::Null => serde_json::Value::Null,
};
obj.insert(rule.field.clone(), redacted);
}
}
}
}
}
pub fn list(&self) -> Vec<RedactionPolicy> {
let policies = self.policies.read().unwrap_or_else(|p| p.into_inner());
policies.values().cloned().collect()
}
}
impl Default for RedactionStore {
fn default() -> Self {
Self::new()
}
}
fn hash_value(input: &str) -> String {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(input.as_bytes());
format!("hash:{:x}", hash)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn mask_redaction() {
let store = RedactionStore::new();
store.create_policy(RedactionPolicy {
name: "mask_pii".into(),
collection: "users".into(),
for_role: "support".into(),
rules: vec![
RedactionRule {
field: "email".into(),
mode: RedactionMode::Mask("***@***.com".into()),
},
RedactionRule {
field: "ssn".into(),
mode: RedactionMode::Mask("***-**-****".into()),
},
],
});
let mut doc = json!({"email": "alice@example.com", "ssn": "123-45-6789", "name": "Alice"});
store.apply("users", &["support".into()], &mut doc);
assert_eq!(doc["email"], "***@***.com");
assert_eq!(doc["ssn"], "***-**-****");
assert_eq!(doc["name"], "Alice"); }
#[test]
fn hash_pseudonymization() {
let store = RedactionStore::new();
store.create_policy(RedactionPolicy {
name: "pseudo".into(),
collection: "users".into(),
for_role: "analyst".into(),
rules: vec![RedactionRule {
field: "email".into(),
mode: RedactionMode::Hash,
}],
});
let mut doc1 = json!({"email": "alice@example.com"});
let mut doc2 = json!({"email": "alice@example.com"});
store.apply("users", &["analyst".into()], &mut doc1);
store.apply("users", &["analyst".into()], &mut doc2);
assert_eq!(doc1["email"], doc2["email"]);
assert_ne!(doc1["email"], "alice@example.com");
assert!(doc1["email"].as_str().unwrap().starts_with("hash:"));
}
#[test]
fn no_policy_no_redaction() {
let store = RedactionStore::new();
let mut doc = json!({"email": "alice@example.com"});
store.apply("users", &["admin".into()], &mut doc);
assert_eq!(doc["email"], "alice@example.com");
}
}