use crate::authn::ids::{TenantId, UserId};
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PiiToken(Arc<str>);
impl PiiToken {
pub fn new() -> Self {
Self::from_uuid(Uuid::new_v4())
}
pub fn from_uuid(uuid: Uuid) -> Self {
Self(Arc::from(format!("pii:{}", uuid.as_hyphenated())))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_well_formed(s: &str) -> bool {
s.strip_prefix("pii:")
.and_then(|rest| Uuid::parse_str(rest).ok())
.is_some()
}
pub fn parse(s: &str) -> Option<Self> {
if Self::is_well_formed(s) {
Some(Self(Arc::from(s)))
} else {
None
}
}
}
impl Default for PiiToken {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for PiiToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DevicePiiCategory {
DisplayName,
UserAgentString,
AcceptLanguage,
IpAddress,
ScreenMetrics,
Other,
}
impl DevicePiiCategory {
pub fn as_str(&self) -> &'static str {
match self {
DevicePiiCategory::DisplayName => "display_name",
DevicePiiCategory::UserAgentString => "user_agent_string",
DevicePiiCategory::AcceptLanguage => "accept_language",
DevicePiiCategory::IpAddress => "ip_address",
DevicePiiCategory::ScreenMetrics => "screen_metrics",
DevicePiiCategory::Other => "other",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DevicePiiMapping {
pub token: PiiToken,
pub subject_id: UserId,
pub tenant_id: TenantId,
pub category: DevicePiiCategory,
pub value: String,
pub created_at: DateTime<Utc>,
}
pub trait DevicePiiStore: Send + Sync {
type Error: std::error::Error + Send + Sync + 'static;
fn record(
&self,
subject_id: &UserId,
tenant_id: &TenantId,
category: DevicePiiCategory,
value: String,
now: DateTime<Utc>,
) -> impl std::future::Future<Output = Result<PiiToken, Self::Error>> + Send;
fn resolve(
&self,
token: &PiiToken,
expected_tenant: &TenantId,
) -> impl std::future::Future<Output = Result<Option<String>, Self::Error>> + Send;
fn erase_subject(
&self,
subject_id: &UserId,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<u64, Self::Error>> + Send;
fn list_for_subject(
&self,
subject_id: &UserId,
tenant_id: &TenantId,
) -> impl std::future::Future<Output = Result<Vec<DevicePiiMapping>, Self::Error>> + Send;
}
pub trait DevicePiiResolver: Send + Sync {
type Error: std::error::Error + Send + Sync + 'static;
fn resolve_or_redacted(
&self,
token: &PiiToken,
expected_tenant: &TenantId,
) -> impl std::future::Future<Output = Result<String, Self::Error>> + Send;
}
pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
#[derive(Debug, Clone, Default)]
pub struct MemoryDevicePiiStore {
mappings: Arc<DashMap<PiiToken, DevicePiiMapping>>,
}
impl MemoryDevicePiiStore {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.mappings.len()
}
pub fn is_empty(&self) -> bool {
self.mappings.is_empty()
}
}
pub type MemoryDevicePiiStoreError = std::convert::Infallible;
impl DevicePiiStore for MemoryDevicePiiStore {
type Error = MemoryDevicePiiStoreError;
async fn record(
&self,
subject_id: &UserId,
tenant_id: &TenantId,
category: DevicePiiCategory,
value: String,
now: DateTime<Utc>,
) -> Result<PiiToken, Self::Error> {
let token = PiiToken::new();
let mapping = DevicePiiMapping {
token: token.clone(),
subject_id: *subject_id,
tenant_id: *tenant_id,
category,
value,
created_at: now,
};
self.mappings.insert(token.clone(), mapping);
Ok(token)
}
async fn resolve(
&self,
token: &PiiToken,
expected_tenant: &TenantId,
) -> Result<Option<String>, Self::Error> {
Ok(self
.mappings
.get(token)
.filter(|m| &m.tenant_id == expected_tenant)
.map(|m| m.value.clone()))
}
async fn erase_subject(
&self,
subject_id: &UserId,
tenant_id: &TenantId,
) -> Result<u64, Self::Error> {
let mut erased = 0u64;
let to_remove: Vec<PiiToken> = self
.mappings
.iter()
.filter(|m| &m.subject_id == subject_id && &m.tenant_id == tenant_id)
.map(|m| m.key().clone())
.collect();
for key in to_remove {
if self.mappings.remove(&key).is_some() {
erased += 1;
}
}
Ok(erased)
}
async fn list_for_subject(
&self,
subject_id: &UserId,
tenant_id: &TenantId,
) -> Result<Vec<DevicePiiMapping>, Self::Error> {
Ok(self
.mappings
.iter()
.filter(|m| &m.subject_id == subject_id && &m.tenant_id == tenant_id)
.map(|m| m.value().clone())
.collect())
}
}
impl<S: DevicePiiStore> DevicePiiResolver for S {
type Error = <S as DevicePiiStore>::Error;
async fn resolve_or_redacted(
&self,
token: &PiiToken,
expected_tenant: &TenantId,
) -> Result<String, Self::Error> {
Ok(self
.resolve(token, expected_tenant)
.await?
.unwrap_or_else(|| REDACTED_PLACEHOLDER.to_string()))
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RedactedResolver;
impl DevicePiiResolver for RedactedResolver {
type Error = std::convert::Infallible;
async fn resolve_or_redacted(
&self,
token: &PiiToken,
expected_tenant: &TenantId,
) -> Result<String, Self::Error> {
tracing::trace!(
target: "axess::device::pii",
?token,
%expected_tenant,
"RedactedResolver: returning placeholder regardless of token",
);
Ok(REDACTED_PLACEHOLDER.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn user(s: &str) -> UserId {
axess_identity::testing::user(s)
}
fn tenant(s: &str) -> TenantId {
axess_identity::testing::tenant(s)
}
#[test]
fn token_wire_format_is_pii_colon_uuid() {
let uuid = Uuid::nil();
let token = PiiToken::from_uuid(uuid);
assert_eq!(token.as_str(), "pii:00000000-0000-0000-0000-000000000000");
let parsed = PiiToken::parse(token.as_str()).expect("round-trip parse");
assert_eq!(parsed, token);
}
#[test]
fn is_well_formed_rejects_malformed() {
assert!(!PiiToken::is_well_formed(
"00000000-0000-0000-0000-000000000000"
));
assert!(!PiiToken::is_well_formed(
"pii_00000000-0000-0000-0000-000000000000"
));
assert!(!PiiToken::is_well_formed("pii:not-a-uuid"));
assert!(!PiiToken::is_well_formed(""));
assert!(!PiiToken::is_well_formed("pii:"));
assert!(PiiToken::is_well_formed(
"pii:00000000-0000-0000-0000-000000000000"
));
}
#[tokio::test]
async fn record_then_resolve_round_trips_value() {
let store = MemoryDevicePiiStore::new();
let now = Utc::now();
let token = store
.record(
&user("u1"),
&tenant("t1"),
DevicePiiCategory::DisplayName,
"Alice's iPhone".to_string(),
now,
)
.await
.unwrap();
assert!(PiiToken::is_well_formed(token.as_str()));
let value = store.resolve(&token, &tenant("t1")).await.unwrap();
assert_eq!(value.as_deref(), Some("Alice's iPhone"));
}
#[tokio::test]
async fn resolve_refuses_cross_tenant_lookup() {
let store = MemoryDevicePiiStore::new();
let token = store
.record(
&user("u1"),
&tenant("alpha"),
DevicePiiCategory::IpAddress,
"203.0.113.42".to_string(),
Utc::now(),
)
.await
.unwrap();
let same = store.resolve(&token, &tenant("alpha")).await.unwrap();
assert_eq!(same.as_deref(), Some("203.0.113.42"));
let cross = store.resolve(&token, &tenant("beta")).await.unwrap();
assert!(
cross.is_none(),
"cross-tenant lookup must be invisible, not leak the value"
);
}
#[tokio::test]
async fn erase_subject_removes_only_target_subject_mappings() {
let store = MemoryDevicePiiStore::new();
let now = Utc::now();
let t = tenant("t1");
let alice_dn = store
.record(
&user("alice"),
&t,
DevicePiiCategory::DisplayName,
"Alice".into(),
now,
)
.await
.unwrap();
let alice_ip = store
.record(
&user("alice"),
&t,
DevicePiiCategory::IpAddress,
"203.0.113.1".into(),
now,
)
.await
.unwrap();
let bob_dn = store
.record(
&user("bob"),
&t,
DevicePiiCategory::DisplayName,
"Bob".into(),
now,
)
.await
.unwrap();
let erased = store.erase_subject(&user("alice"), &t).await.unwrap();
assert_eq!(erased, 2, "must erase exactly Alice's 2 rows");
assert!(store.resolve(&alice_dn, &t).await.unwrap().is_none());
assert!(store.resolve(&alice_ip, &t).await.unwrap().is_none());
assert_eq!(
store.resolve(&bob_dn, &t).await.unwrap().as_deref(),
Some("Bob"),
);
}
#[tokio::test]
async fn erase_subject_is_tenant_scoped() {
let store = MemoryDevicePiiStore::new();
let alice_alpha = store
.record(
&user("alice"),
&tenant("alpha"),
DevicePiiCategory::DisplayName,
"Alice@alpha".into(),
Utc::now(),
)
.await
.unwrap();
let alice_beta = store
.record(
&user("alice"),
&tenant("beta"),
DevicePiiCategory::DisplayName,
"Alice@beta".into(),
Utc::now(),
)
.await
.unwrap();
let erased = store
.erase_subject(&user("alice"), &tenant("alpha"))
.await
.unwrap();
assert_eq!(erased, 1);
assert!(
store
.resolve(&alice_alpha, &tenant("alpha"))
.await
.unwrap()
.is_none()
);
assert_eq!(
store
.resolve(&alice_beta, &tenant("beta"))
.await
.unwrap()
.as_deref(),
Some("Alice@beta"),
"erasure in tenant alpha must not touch tenant beta"
);
}
#[tokio::test]
async fn list_for_subject_returns_all_mappings_in_tenant() {
let store = MemoryDevicePiiStore::new();
let t = tenant("t1");
let now = Utc::now();
store
.record(
&user("alice"),
&t,
DevicePiiCategory::DisplayName,
"Alice".into(),
now,
)
.await
.unwrap();
store
.record(
&user("alice"),
&t,
DevicePiiCategory::UserAgentString,
"Mozilla/5.0".into(),
now,
)
.await
.unwrap();
store
.record(
&user("bob"),
&t,
DevicePiiCategory::DisplayName,
"Bob".into(),
now,
)
.await
.unwrap();
let alice_rows = store.list_for_subject(&user("alice"), &t).await.unwrap();
assert_eq!(
alice_rows.len(),
2,
"portability export must include all of Alice's mappings"
);
assert!(
alice_rows.iter().all(|m| m.subject_id == user("alice")),
"list_for_subject must not leak other subjects' rows"
);
}
#[tokio::test]
async fn resolver_returns_redacted_for_missing_token() {
let store = MemoryDevicePiiStore::new();
let bogus = PiiToken::new();
let resolved = store
.resolve_or_redacted(&bogus, &tenant("t1"))
.await
.unwrap();
assert_eq!(resolved, REDACTED_PLACEHOLDER);
}
#[tokio::test]
async fn resolver_returns_real_value_when_present() {
let store = MemoryDevicePiiStore::new();
let token = store
.record(
&user("u1"),
&tenant("t1"),
DevicePiiCategory::DisplayName,
"Alice".into(),
Utc::now(),
)
.await
.unwrap();
let resolved = store
.resolve_or_redacted(&token, &tenant("t1"))
.await
.unwrap();
assert_eq!(resolved, "Alice");
}
#[tokio::test]
async fn redacted_resolver_unconditionally_returns_placeholder() {
let resolver = RedactedResolver;
let token = PiiToken::new();
let resolved = resolver
.resolve_or_redacted(&token, &tenant("t1"))
.await
.unwrap();
assert_eq!(resolved, REDACTED_PLACEHOLDER);
}
#[test]
fn category_wire_strings_are_stable() {
for (variant, expected) in [
(DevicePiiCategory::DisplayName, "display_name"),
(DevicePiiCategory::UserAgentString, "user_agent_string"),
(DevicePiiCategory::AcceptLanguage, "accept_language"),
(DevicePiiCategory::IpAddress, "ip_address"),
(DevicePiiCategory::ScreenMetrics, "screen_metrics"),
(DevicePiiCategory::Other, "other"),
] {
assert_eq!(variant.as_str(), expected);
}
}
#[test]
fn pii_token_display_matches_as_str() {
let token = PiiToken::new();
assert_eq!(format!("{token}"), token.as_str());
}
#[tokio::test]
async fn memory_store_len_and_is_empty_track_inserts() {
let store = MemoryDevicePiiStore::new();
assert_eq!(store.len(), 0);
assert!(store.is_empty());
let token = PiiToken::new();
let mapping = DevicePiiMapping {
token: token.clone(),
subject_id: axess_identity::testing::user("u-pii"),
tenant_id: axess_identity::testing::tenant("t-pii"),
category: DevicePiiCategory::IpAddress,
value: "1.2.3.4".to_string(),
created_at: chrono::Utc::now(),
};
store.mappings.insert(token, mapping);
assert_eq!(store.len(), 1);
assert!(!store.is_empty());
}
}