use sqlx::PgPool;
use time::OffsetDateTime;
use tracing::{info, warn};
use zeroize::Zeroizing;
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<Zeroizing<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()
}
}
pub const MIN_ERASURE_SECRET_BYTES: usize = 32;
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() < MIN_ERASURE_SECRET_BYTES {
return Err(Error::config(format!(
"erasure secret must be at least {MIN_ERASURE_SECRET_BYTES} 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(Zeroizing::new(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> {
self.erase_triggered_by(subject, reason, actor, now, crate::observe::TRIGGER_REQUEST)
.await
}
pub(crate) async fn erase_triggered_by(
&self,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
trigger: &'static str,
) -> Result<ErasureRecord> {
let mut tx = self.pool.begin().await.map_err(pg)?;
let (record, destroyed) = self
.erase_in_inner(&mut tx, subject, reason, actor, now)
.await?;
tx.commit().await.map_err(pg)?;
if destroyed {
crate::observe::metrics()
.subjects_erased
.add(1, &crate::observe::erasure_trigger(trigger));
}
Ok(record)
}
pub async fn erase_in(
&self,
conn: &mut sqlx::PgConnection,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<ErasureRecord> {
let (record, destroyed) = self
.erase_in_inner(conn, subject, reason, actor, now)
.await?;
if destroyed {
crate::observe::metrics().subjects_erased.add(
1,
&crate::observe::erasure_trigger(crate::observe::TRIGGER_REQUEST),
);
}
Ok(record)
}
async fn erase_in_inner(
&self,
conn: &mut sqlx::PgConnection,
subject: &SubjectRef,
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<(ErasureRecord, bool)> {
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(),
},
deleted > 0,
))
}
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()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Retention {
CalendarYears(u32),
Rolling(time::Duration),
}
impl Retention {
#[must_use]
pub fn cutoff(self, now: OffsetDateTime) -> OffsetDateTime {
match self {
Self::CalendarYears(years) => {
let year = metering::calendar::local_year(now)
.saturating_sub(i32::try_from(years).unwrap_or(i32::MAX))
.max(EARLIEST_CUTOFF_YEAR);
metering::calendar::year_start_utc(year)
}
Self::Rolling(window) => now
.checked_sub(window)
.unwrap_or_else(|| metering::calendar::year_start_utc(EARLIEST_CUTOFF_YEAR)),
}
}
}
const EARLIEST_CUTOFF_YEAR: i32 = -9998;
pub(crate) async fn anonymise(
registry: &SubjectRegistry,
due: &[String],
reason: &str,
actor: &str,
now: OffsetDateTime,
) -> Result<Vec<ErasureRecord>> {
let mut erased = Vec::new();
for reference in due {
let subject = SubjectRef::new(reference.clone())?;
if registry.resolve(&subject).await?.is_none() {
continue;
}
erased.push(
registry
.erase_triggered_by(
&subject,
reason,
actor,
now,
crate::observe::TRIGGER_RETENTION,
)
.await?,
);
}
Ok(erased)
}
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());
}
fn lazy_pool() -> PgPool {
PgPool::connect_lazy("postgresql://unused@localhost/unused").expect("a well-formed URL")
}
#[tokio::test]
async fn the_erasure_key_never_reaches_a_log_line() {
let registry = SubjectRegistry::with_erasure_secret(lazy_pool(), &[0xAB; 32]).unwrap();
let shown = format!("{registry:?}");
assert!(!shown.contains("171"), "{shown}");
assert!(!shown.contains("ab"), "{shown}");
assert!(shown.contains("suppression: true"), "{shown}");
assert!(format!("{:?}", SubjectRegistry::new(lazy_pool())).contains("suppression: false"));
}
#[tokio::test]
async fn a_cloned_registry_keeps_a_key_of_its_own() {
let original = SubjectRegistry::with_erasure_secret(lazy_pool(), &[7; 32]).unwrap();
let derived = original.clone();
let expected = original
.tombstone("41373559241")
.expect("a key is configured");
drop(original);
assert_eq!(derived.tombstone("41373559241"), Some(expected));
}
#[tokio::test]
async fn a_tombstone_is_keyed_rather_than_a_bare_hash() {
let a = SubjectRegistry::with_erasure_secret(lazy_pool(), &[1; 32]).unwrap();
let b = SubjectRegistry::with_erasure_secret(lazy_pool(), &[2; 32]).unwrap();
assert_ne!(a.tombstone("41373559241"), b.tombstone("41373559241"));
assert_eq!(a.tombstone("41373559241"), a.tombstone("41373559241"));
assert!(
SubjectRegistry::new(lazy_pool())
.tombstone("41373559241")
.is_none()
);
}
#[test]
fn the_statutory_ceiling_starts_at_the_end_of_the_collection_year() {
use time::macros::datetime;
let policy = Retention::CalendarYears(3);
assert_eq!(
policy.cutoff(datetime!(2028-01-02 00:00 UTC)),
datetime!(2024-12-31 23:00 UTC)
);
assert_eq!(
policy.cutoff(datetime!(2028-12-31 23:00 UTC)),
datetime!(2025-12-31 23:00 UTC)
);
assert_eq!(
policy.cutoff(datetime!(2028-03-01 00:00 UTC)),
policy.cutoff(datetime!(2028-11-30 00:00 UTC)),
);
}
#[test]
fn a_rolling_window_is_measured_from_the_sweep() {
use time::macros::datetime;
let now = datetime!(2028-06-01 00:00 UTC);
assert_eq!(
Retention::Rolling(time::Duration::days(90)).cutoff(now),
now - time::Duration::days(90)
);
}
#[test]
fn a_period_the_calendar_cannot_express_keeps_everything() {
use time::macros::datetime;
let now = datetime!(2028-06-01 00:00 UTC);
let epoch = datetime!(1970-01-01 00:00 UTC);
for years in [u32::MAX, i32::MAX as u32, 100_000, 12_030, 10_000] {
let cutoff = Retention::CalendarYears(years).cutoff(now);
assert!(
cutoff < epoch,
"CalendarYears({years}) put the cutoff at {cutoff}, which makes \
readings due that are nowhere near the ceiling"
);
}
assert!(Retention::Rolling(time::Duration::MAX).cutoff(now) < epoch);
assert_eq!(
Retention::CalendarYears(3).cutoff(now),
datetime!(2024-12-31 23:00 UTC)
);
}
#[tokio::test]
async fn a_short_erasure_key_is_refused() {
assert!(SubjectRegistry::with_erasure_secret(lazy_pool(), &[0; 31]).is_err());
assert!(SubjectRegistry::with_erasure_secret(lazy_pool(), &[0; 32]).is_ok());
}
#[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"));
}
}