use dashmap::DashMap;
use lazy_static::lazy_static;
use regex::Regex;
use serde::Serialize;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
lazy_static! {
static ref SSN_REGEX: Regex = Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap();
static ref EMAIL_REGEX: Regex =
Regex::new(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b").unwrap();
static ref CC_REGEX: Regex = Regex::new(r"\b(?:\d[ -]*?){13,16}\b").unwrap();
static ref PHONE_REGEX: Regex =
Regex::new(r"\b\+?1?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b").unwrap();
}
static TOKEN_COUNTER: AtomicU64 = AtomicU64::new(1);
const AUDIT_LOG_CAPACITY: usize = 1000;
pub struct PiiVault {
vault: DashMap<String, PiiEntry>,
audit: Mutex<Vec<AuditEvent>>,
}
#[derive(Clone)]
pub struct PiiEntry {
pub original: String,
pub pii_type: String,
pub tenant_id: String,
pub created_at: u64,
}
#[derive(Clone, Serialize)]
pub struct PiiTokenInfo {
pub token: String,
pub pii_type: String,
pub tenant_id: String,
pub created_at: u64,
}
#[derive(Clone, Serialize)]
pub struct AuditEvent {
pub timestamp: u64,
pub actor: String,
pub action: String,
pub token: String,
pub tenant_id: String,
pub pii_type: String,
}
pub enum RevealResult {
Revealed(String),
Forbidden,
NotFound,
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl Default for PiiVault {
fn default() -> Self {
Self::new()
}
}
impl PiiVault {
pub fn new() -> Self {
Self {
vault: DashMap::new(),
audit: Mutex::new(Vec::new()),
}
}
pub fn reveal(&self, token: &str) -> Option<String> {
self.vault.get(token).map(|e| e.original.clone())
}
pub fn reveal_for(
&self,
token: &str,
actor: &str,
caller_tenant: &str,
super_admin: bool,
) -> RevealResult {
match self.vault.get(token) {
None => {
self.record_audit(actor, "reveal_not_found", token, caller_tenant, "");
RevealResult::NotFound
}
Some(entry) => {
if !super_admin && entry.tenant_id != caller_tenant {
self.record_audit(
actor,
"reveal_denied",
token,
&entry.tenant_id,
&entry.pii_type,
);
RevealResult::Forbidden
} else {
self.record_audit(actor, "reveal", token, &entry.tenant_id, &entry.pii_type);
RevealResult::Revealed(entry.original.clone())
}
}
}
}
pub fn list_tokens(&self, caller_tenant: &str, super_admin: bool) -> Vec<PiiTokenInfo> {
self.vault
.iter()
.filter(|e| super_admin || e.value().tenant_id == caller_tenant)
.map(|e| PiiTokenInfo {
token: e.key().clone(),
pii_type: e.value().pii_type.clone(),
tenant_id: e.value().tenant_id.clone(),
created_at: e.value().created_at,
})
.collect()
}
pub fn get_audit(
&self,
caller_tenant: &str,
super_admin: bool,
limit: usize,
) -> Vec<AuditEvent> {
let audit = self.audit.lock().unwrap();
audit
.iter()
.rev()
.filter(|e| super_admin || e.tenant_id == caller_tenant)
.take(limit)
.cloned()
.collect()
}
fn record_audit(
&self,
actor: &str,
action: &str,
token: &str,
tenant_id: &str,
pii_type: &str,
) {
let event = AuditEvent {
timestamp: now_secs(),
actor: actor.to_string(),
action: action.to_string(),
token: token.to_string(),
tenant_id: tenant_id.to_string(),
pii_type: pii_type.to_string(),
};
let mut audit = self.audit.lock().unwrap();
audit.push(event);
if audit.len() > AUDIT_LOG_CAPACITY {
let drain_to = audit.len() - AUDIT_LOG_CAPACITY;
audit.drain(0..drain_to);
}
}
fn store(&self, original: &str, pii_type: &str, tenant_id: &str) -> String {
let id = TOKEN_COUNTER.fetch_add(1, Ordering::Relaxed);
let token = format!("PII_{}_{:04}", pii_type, id);
self.vault.insert(
token.clone(),
PiiEntry {
original: original.to_string(),
pii_type: pii_type.to_string(),
tenant_id: tenant_id.to_string(),
created_at: now_secs(),
},
);
token
}
}
pub fn redact_pii(text: &mut String, vault: &PiiVault, tenant_id: &str) -> bool {
let mut modified = false;
let matches: Vec<String> = SSN_REGEX
.find_iter(text)
.map(|m| m.as_str().to_string())
.collect();
for m in matches {
let token = vault.store(&m, "SSN", tenant_id);
*text = text.replace(&m, &format!("[{}]", token));
modified = true;
}
let matches: Vec<String> = EMAIL_REGEX
.find_iter(text)
.map(|m| m.as_str().to_string())
.collect();
for m in matches {
let token = vault.store(&m, "EMAIL", tenant_id);
*text = text.replace(&m, &format!("[{}]", token));
modified = true;
}
let matches: Vec<String> = CC_REGEX
.find_iter(text)
.map(|m| m.as_str().to_string())
.collect();
for m in matches {
let token = vault.store(&m, "CC", tenant_id);
*text = text.replace(&m, &format!("[{}]", token));
modified = true;
}
let matches: Vec<String> = PHONE_REGEX
.find_iter(text)
.map(|m| m.as_str().to_string())
.collect();
for m in matches {
let token = vault.store(&m, "PHONE", tenant_id);
*text = text.replace(&m, &format!("[{}]", token));
modified = true;
}
modified
}
pub fn redact_pii_strip(text: &mut String) -> bool {
let mut modified = false;
if SSN_REGEX.is_match(text) {
*text = SSN_REGEX.replace_all(text, "[REDACTED_SSN]").to_string();
modified = true;
}
if EMAIL_REGEX.is_match(text) {
*text = EMAIL_REGEX
.replace_all(text, "[REDACTED_EMAIL]")
.to_string();
modified = true;
}
if CC_REGEX.is_match(text) {
*text = CC_REGEX.replace_all(text, "[REDACTED_CC]").to_string();
modified = true;
}
if PHONE_REGEX.is_match(text) {
*text = PHONE_REGEX
.replace_all(text, "[REDACTED_PHONE]")
.to_string();
modified = true;
}
modified
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reversible_pii_masking() {
let vault = PiiVault::new();
let mut text = "My SSN is 123-45-6789 and email is test@example.com".to_string();
let modified = redact_pii(&mut text, &vault, "test-tenant");
assert!(modified);
assert!(!text.contains("123-45-6789"));
assert!(!text.contains("test@example.com"));
assert!(text.contains("[PII_SSN_"));
assert!(text.contains("[PII_EMAIL_"));
let ssn_token = text.split('[').nth(1).unwrap().split(']').next().unwrap();
let revealed = vault.reveal(ssn_token).unwrap();
assert_eq!(revealed, "123-45-6789");
}
#[test]
fn test_no_pii() {
let vault = PiiVault::new();
let mut text = "Hello world, no PII here".to_string();
assert!(!redact_pii(&mut text, &vault, "test"));
}
}