use sqlx::PgPool;
use time::OffsetDateTime;
use tracing::{info, warn};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SubjectRef(String);
impl SubjectRef {
pub fn new(reference: impl Into<String>) -> Result<Self> {
let reference = reference.into();
if reference.trim().is_empty() {
return Err(Error::config("subject reference must not be empty"));
}
Ok(Self(reference))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SubjectRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ErasureRecord {
pub subject: SubjectRef,
pub erased_at: OffsetDateTime,
pub reason: String,
pub actor: String,
}
#[derive(Clone)]
pub struct SubjectRegistry {
pool: PgPool,
erasure_secret: Option<Vec<u8>>,
}
impl std::fmt::Debug for SubjectRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubjectRegistry")
.field("suppression", &self.erasure_secret.is_some())
.finish_non_exhaustive()
}
}
impl SubjectRegistry {
pub fn new(pool: PgPool) -> Self {
Self {
pool,
erasure_secret: None,
}
}
pub fn with_erasure_secret(pool: PgPool, secret: &[u8]) -> Result<Self> {
if secret.len() < 32 {
return Err(Error::config(
"erasure secret must be at least 32 bytes: a shorter key can be \
brute-forced, and the suppression list would then leak the \
identifiers it exists to forget",
));
}
Ok(Self {
pool,
erasure_secret: Some(secret.to_vec()),
})
}
pub fn suppresses_reregistration(&self) -> bool {
self.erasure_secret.is_some()
}
fn tombstone(&self, natural_id: &str) -> Option<Vec<u8>> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let secret = self.erasure_secret.as_ref()?;
let mut mac =
<Hmac<Sha256>>::new_from_slice(secret).expect("HMAC accepts keys of any length");
mac.update(natural_id.as_bytes());
Some(mac.finalize().into_bytes().to_vec())
}
pub async fn create_tables(&self) -> Result<()> {
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS meterstore_subject_map (
subject_ref TEXT PRIMARY KEY,
natural_id TEXT NOT NULL UNIQUE,
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
)"#,
)
.execute(&self.pool)
.await
.map_err(pg)?;
sqlx::query(
r#"CREATE TABLE IF NOT EXISTS meterstore_erasures (
subject_ref TEXT PRIMARY KEY,
erased_at TIMESTAMPTZ NOT NULL,
reason TEXT NOT NULL,
actor TEXT NOT NULL,
-- Keyed hash of the erased identifier. NULL when the
-- deployment configured no suppression key, in which case a
-- replayed message can re-register the subject.
natural_id_hmac BYTEA
)"#,
)
.execute(&self.pool)
.await
.map_err(pg)?;
sqlx::query(
r#"CREATE INDEX IF NOT EXISTS meterstore_erasures_hmac
ON meterstore_erasures (natural_id_hmac)
WHERE natural_id_hmac IS NOT NULL"#,
)
.execute(&self.pool)
.await
.map_err(pg)?;
info!(
suppression = self.suppresses_reregistration(),
"subject registry ready"
);
Ok(())
}
pub async fn register(&self, natural_id: &str) -> Result<SubjectRef> {
if natural_id.trim().is_empty() {
return Err(Error::config("natural identifier must not be empty"));
}
if let Some(existing) = self.lookup(natural_id).await? {
return Ok(existing);
}
if let Some(tombstone) = self.tombstone(natural_id) {
let suppressed = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM meterstore_erasures WHERE natural_id_hmac = $1)",
)
.bind(&tombstone)
.fetch_one(&self.pool)
.await
.map_err(pg)?;
if suppressed {
warn!("registration refused for an erased identifier");
return Err(Error::config(
"this identifier was erased and must not be re-registered: \
registering it would rebuild the link Article 17 destroyed. \
A subject who genuinely returns should arrive under a new \
identifier; if the erasure itself was mistaken, lift it \
explicitly with `lift_suppression`",
));
}
}
let reference = new_reference();
let inserted = sqlx::query_scalar::<_, String>(
r#"INSERT INTO meterstore_subject_map (subject_ref, natural_id)
VALUES ($1, $2)
ON CONFLICT (natural_id) DO UPDATE SET natural_id = EXCLUDED.natural_id
RETURNING subject_ref"#,
)
.bind(&reference)
.bind(natural_id)
.fetch_one(&self.pool)
.await
.map_err(pg)?;
SubjectRef::new(inserted)
}
pub async fn lookup(&self, natural_id: &str) -> Result<Option<SubjectRef>> {
let found = sqlx::query_scalar::<_, String>(
"SELECT subject_ref FROM meterstore_subject_map WHERE natural_id = $1",
)
.bind(natural_id)
.fetch_optional(&self.pool)
.await
.map_err(pg)?;
found.map(SubjectRef::new).transpose()
}
pub async fn resolve(&self, subject: &SubjectRef) -> Result<Option<String>> {
sqlx::query_scalar::<_, String>(
"SELECT natural_id FROM meterstore_subject_map WHERE subject_ref = $1",
)
.bind(subject.as_str())
.fetch_optional(&self.pool)
.await
.map_err(pg)
}
pub async fn erase(
&self,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<ErasureRecord> {
let mut tx = self.pool.begin().await.map_err(pg)?;
let record = self.erase_in(&mut tx, subject, reason, actor, now).await?;
tx.commit().await.map_err(pg)?;
Ok(record)
}
pub async fn erase_in(
&self,
conn: &mut sqlx::PgConnection,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<ErasureRecord> {
if reason.trim().is_empty() {
return Err(Error::config("erasure needs a reason for the audit trail"));
}
let tx = conn;
let natural_id = sqlx::query_scalar::<_, String>(
"SELECT natural_id FROM meterstore_subject_map WHERE subject_ref = $1 FOR UPDATE",
)
.bind(subject.as_str())
.fetch_optional(&mut *tx)
.await
.map_err(pg)?;
let tombstone = natural_id.as_deref().and_then(|id| self.tombstone(id));
let deleted = sqlx::query("DELETE FROM meterstore_subject_map WHERE subject_ref = $1")
.bind(subject.as_str())
.execute(&mut *tx)
.await
.map_err(pg)?
.rows_affected();
sqlx::query(
r#"INSERT INTO meterstore_erasures
(subject_ref, erased_at, reason, actor, natural_id_hmac)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (subject_ref) DO UPDATE
SET natural_id_hmac =
COALESCE(meterstore_erasures.natural_id_hmac, EXCLUDED.natural_id_hmac)"#,
)
.bind(subject.as_str())
.bind(now)
.bind(reason)
.bind(actor)
.bind(tombstone.as_deref())
.execute(&mut *tx)
.await
.map_err(pg)?;
if deleted == 0 {
warn!(%subject, "erasure requested for an unmapped reference");
} else {
info!(%subject, actor, "subject linkage destroyed");
}
Ok(ErasureRecord {
subject: subject.clone(),
erased_at: now,
reason: reason.to_string(),
actor: actor.to_string(),
})
}
pub async fn is_suppressed(&self, natural_id: &str) -> Result<bool> {
let Some(tombstone) = self.tombstone(natural_id) else {
return Ok(false);
};
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM meterstore_erasures WHERE natural_id_hmac = $1)",
)
.bind(&tombstone)
.fetch_one(&self.pool)
.await
.map_err(pg)
}
pub async fn lift_suppression(
&self,
natural_id: &str,
reason: &str,
actor: &str,
) -> Result<bool> {
if reason.trim().is_empty() {
return Err(Error::config(
"lifting a suppression needs a reason: it reverses a compliance \
action and must not be an anonymous edit",
));
}
let Some(tombstone) = self.tombstone(natural_id) else {
return Err(Error::config(
"no suppression key is configured, so there is no suppression to lift",
));
};
let lifted = sqlx::query(
"UPDATE meterstore_erasures SET natural_id_hmac = NULL WHERE natural_id_hmac = $1",
)
.bind(&tombstone)
.execute(&self.pool)
.await
.map_err(pg)?
.rows_affected();
if lifted > 0 {
warn!(actor, reason, "erasure suppression lifted");
}
Ok(lifted > 0)
}
pub async fn is_erased(&self, subject: &SubjectRef) -> Result<bool> {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM meterstore_erasures WHERE subject_ref = $1)",
)
.bind(subject.as_str())
.fetch_one(&self.pool)
.await
.map_err(pg)
}
pub async fn erasures(&self, limit: i64) -> Result<Vec<ErasureRecord>> {
let rows = sqlx::query_as::<_, (String, OffsetDateTime, String, String)>(
r#"SELECT subject_ref, erased_at, reason, actor
FROM meterstore_erasures
ORDER BY erased_at DESC
LIMIT $1"#,
)
.bind(limit)
.fetch_all(&self.pool)
.await
.map_err(pg)?;
rows.into_iter()
.map(|(subject, erased_at, reason, actor)| {
Ok(ErasureRecord {
subject: SubjectRef::new(subject)?,
erased_at,
reason,
actor,
})
})
.collect()
}
}
fn new_reference() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
format!("sub_{}", hex(&bytes))
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes.iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
fn pg(e: sqlx::Error) -> Error {
Error::Storage(e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reference_must_not_be_empty() {
assert!(SubjectRef::new("").is_err());
assert!(SubjectRef::new(" ").is_err());
assert!(SubjectRef::new("sub_abc").is_ok());
}
#[test]
fn generated_references_do_not_repeat() {
let refs: std::collections::HashSet<_> = (0..1_000).map(|_| new_reference()).collect();
assert_eq!(refs.len(), 1_000, "references must be unique");
}
#[test]
fn a_generated_reference_is_not_derived_from_anything() {
assert_ne!(new_reference(), new_reference());
}
#[test]
fn an_erasure_record_carries_no_natural_identifier() {
let record = ErasureRecord {
subject: SubjectRef::new("sub_abc").unwrap(),
erased_at: OffsetDateTime::UNIX_EPOCH,
reason: "DSAR-2026-0042".to_string(),
actor: "privacy-team".to_string(),
};
let rendered = format!("{record:?}");
assert!(rendered.contains("sub_abc"));
assert!(rendered.contains("DSAR-2026-0042"));
}
}