use crate::handlers::subject_predicate;
use cratefield_core::{
CatalogEntry, Database, Disposition, Kid, Payload, Problem, Signer, Statement,
is_plain_identifier,
};
use serde_json::{Value, json};
use std::sync::Arc;
pub(crate) const PURPOSE_ERASE: &str = "privacy-erase";
pub(crate) const CONFIRM_TTL_SECS: u64 = 15 * 60;
pub(crate) struct Planned {
pub(crate) entry: CatalogEntry,
pub(crate) rows: u64,
}
pub(crate) async fn plan(
db: &Arc<dyn Database>,
catalog: &cratefield_core::PersonalDataCatalog,
subject: &str,
) -> Result<Vec<Planned>, String> {
let mut planned = Vec::new();
for entry in catalog.subject_sets() {
if !is_plain_identifier(entry.set.table) || !is_plain_identifier(entry.set.subject) {
return Err(format!(
"declaration for `{}` is not queryable",
entry.set.table
));
}
let predicate = subject_predicate(&entry.set)
.ok_or_else(|| format!("declaration for `{}` is not queryable", entry.set.table))?;
let statement = Statement::with_values(
format!(
"SELECT COUNT(*) AS n FROM {} WHERE {predicate}",
entry.set.table
),
vec![subject.into()],
);
let rows = db
.query(&statement)
.await
.map_err(|err| format!("counting `{}` failed: {err}", entry.set.table))?;
let count: i64 = rows.first().and_then(|row| row.get("n")).unwrap_or(0);
planned.push(Planned {
entry: *entry,
rows: count.max(0).unsigned_abs(),
});
}
Ok(planned)
}
pub(crate) fn statements(planned: &[Planned], subject: &str) -> Vec<Statement> {
let mut out = Vec::new();
for step in planned.iter().rev() {
let set = step.entry.set;
let Some(predicate) = subject_predicate(&set) else {
continue;
};
match set.disposition {
Disposition::Erase => out.push(Statement::with_values(
format!("DELETE FROM {} WHERE {predicate}", set.table),
vec![subject.into()],
)),
Disposition::Anonymise(columns) => {
let assignments = columns
.iter()
.map(|column| format!("{column} = NULL"))
.collect::<Vec<_>>()
.join(", ");
out.push(Statement::with_values(
format!("UPDATE {} SET {assignments} WHERE {predicate}", set.table),
vec![subject.into()],
));
}
_ => {}
}
}
out
}
pub(crate) async fn verify(
db: &Arc<dyn Database>,
planned: &[Planned],
subject: &str,
) -> Result<Vec<String>, String> {
let mut remaining = Vec::new();
for step in planned {
if !matches!(step.entry.set.disposition, Disposition::Erase) {
continue;
}
let set = step.entry.set;
let predicate = subject_predicate(&set)
.ok_or_else(|| format!("declaration for `{}` is not queryable", set.table))?;
let statement = Statement::with_values(
format!("SELECT COUNT(*) AS n FROM {} WHERE {predicate}", set.table),
vec![subject.into()],
);
let rows = db
.query(&statement)
.await
.map_err(|err| format!("verifying `{}` failed: {err}", set.table))?;
let count: i64 = rows.first().and_then(|row| row.get("n")).unwrap_or(0);
if count > 0 {
remaining.push(set.table.to_owned());
}
}
Ok(remaining)
}
pub(crate) fn render(planned: &[Planned]) -> Vec<Value> {
planned
.iter()
.map(|step| {
let set = step.entry.set;
let mut row = json!({
"module": step.entry.module,
"table": set.table,
"kind": set.kind.as_str(),
"rows": step.rows,
});
let object = row.as_object_mut().expect("object");
match set.disposition {
Disposition::Erase => {
object.insert("action".into(), json!("erase"));
}
Disposition::Anonymise(columns) => {
object.insert("action".into(), json!("anonymise"));
object.insert("columns".into(), json!(columns));
}
Disposition::Retain(reason) => {
object.insert("action".into(), json!("retain"));
object.insert("reason".into(), json!(reason));
}
_ => {
object.insert("action".into(), json!("unknown"));
}
}
row
})
.collect()
}
pub(crate) fn mint(signer: &Arc<dyn Signer>, subject: &str, now: u64) -> String {
signer.sign(&Payload {
purpose: PURPOSE_ERASE.to_owned(),
subject: subject.to_owned(),
exp: Some(now.saturating_add(CONFIRM_TTL_SECS)),
kid: Kid::Cur,
})
}
pub(crate) fn subject_of(signer: &Arc<dyn Signer>, token: &str) -> Option<String> {
signer.verify(token, PURPOSE_ERASE).map(|p| p.subject)
}
pub(crate) fn not_verified(tables: &[String]) -> Problem {
Problem::internal().with_detail(format!(
"erasure completed without error but {} still holds rows for this subject",
tables.join(", ")
))
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_core::{DataKind, PersonalDataSet, SubjectVia};
fn planned(sets: &'static [PersonalDataSet]) -> Vec<Planned> {
sets.iter()
.map(|set| Planned {
entry: CatalogEntry {
module: "fixture",
set: *set,
},
rows: 1,
})
.collect()
}
const PARENT: PersonalDataSet = PersonalDataSet {
table: "accounts",
subject: "id",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "The account.",
redacted: &[],
subject_via: None,
};
const CHILD: PersonalDataSet = PersonalDataSet {
table: "practice_sessions",
subject: "account_id",
kind: DataKind::Fitness,
disposition: Disposition::Erase,
description: "Practices.",
redacted: &[],
subject_via: None,
};
#[test]
fn children_are_erased_before_their_parents() {
const SETS: &[PersonalDataSet] = &[PARENT, CHILD];
let statements = statements(&planned(SETS), "acct-1");
assert_eq!(statements.len(), 2);
assert!(
statements[0].sql.contains("practice_sessions"),
"the parent was deleted first: {}",
statements[0].sql
);
assert!(
statements[1].sql.contains("accounts"),
"{}",
statements[1].sql
);
}
#[test]
fn a_retained_set_emits_no_statement() {
const SETS: &[PersonalDataSet] = &[PersonalDataSet {
table: "invoices",
subject: "account_id",
kind: DataKind::Financial,
disposition: Disposition::Retain("Tax law requires seven years."),
description: "Invoices.",
redacted: &[],
subject_via: None,
}];
assert!(statements(&planned(SETS), "acct-1").is_empty());
}
#[test]
fn an_anonymised_set_updates_rather_than_deletes() {
const SETS: &[PersonalDataSet] = &[PersonalDataSet {
table: "commission_ledger",
subject: "account_id",
kind: DataKind::Financial,
disposition: Disposition::Anonymise(&["name", "email"]),
description: "Commission entries.",
redacted: &[],
subject_via: None,
}];
let statements = statements(&planned(SETS), "acct-1");
assert_eq!(statements.len(), 1);
let sql = &statements[0].sql;
assert!(sql.starts_with("UPDATE commission_ledger SET "), "{sql}");
assert!(sql.contains("name = NULL"), "{sql}");
assert!(sql.contains("email = NULL"), "{sql}");
assert!(!sql.contains("DELETE"), "{sql}");
}
#[test]
fn a_set_reached_through_a_join_is_deleted_through_it_too() {
const SETS: &[PersonalDataSet] = &[PersonalDataSet {
table: "deletion_jobs",
subject: "provider_subject",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "Deletion requests.",
redacted: &[],
subject_via: Some(SubjectVia {
table: "identities",
subject: "user_id",
key: "provider_subject",
}),
}];
let statements = statements(&planned(SETS), "acct-1");
assert_eq!(statements.len(), 1);
let sql = &statements[0].sql;
assert!(
sql.contains(
"WHERE provider_subject IN (SELECT provider_subject FROM identities \
WHERE user_id = ?)"
),
"{sql}"
);
}
}
#[cfg(test)]
mod verification_tests {
use super::*;
use cratefield_core::{DataKind, PersonalDataSet};
use cratefield_testing::TestHarness;
#[pollster::test]
async fn verification_follows_a_declared_join() {
const JOINED: PersonalDataSet = PersonalDataSet {
table: "jobs",
subject: "provider_subject",
kind: DataKind::Identifier,
disposition: Disposition::Erase,
description: "Rows keyed on the provider's id for a person.",
redacted: &[],
subject_via: Some(cratefield_core::SubjectVia {
table: "identities",
subject: "user_id",
key: "provider_subject",
}),
};
for kit in TestHarness::all_dialects(Vec::new) {
kit.db
.execute(&Statement::new(
"CREATE TABLE identities (user_id TEXT NOT NULL, provider_subject TEXT NOT NULL)",
))
.await
.expect("create identities");
kit.db
.execute(&Statement::new(
"CREATE TABLE jobs (id TEXT PRIMARY KEY, provider_subject TEXT NOT NULL)",
))
.await
.expect("create jobs");
kit.db
.execute(&Statement::with_values(
"INSERT INTO identities (user_id, provider_subject) VALUES (?, ?)",
vec!["acct-1".into(), "sub-9".into()],
))
.await
.expect("insert identity");
kit.db
.execute(&Statement::with_values(
"INSERT INTO jobs (id, provider_subject) VALUES (?, ?)",
vec!["j1".into(), "sub-9".into()],
))
.await
.expect("insert job");
let planned = vec![Planned {
entry: CatalogEntry {
module: "fixture",
set: JOINED,
},
rows: 1,
}];
let remaining = verify(&kit.db, &planned, "acct-1").await.expect("verify");
assert_eq!(
remaining,
vec!["jobs".to_owned()],
"verification did not follow the join, so a surviving row read as erased"
);
kit.db
.execute(&Statement::with_values(
"DELETE FROM jobs WHERE provider_subject = ?",
vec!["sub-9".into()],
))
.await
.expect("delete");
let remaining = verify(&kit.db, &planned, "acct-1").await.expect("verify");
assert!(
remaining.is_empty(),
"verification named a table it no longer holds rows for: {remaining:?}"
);
}
}
const SET: PersonalDataSet = PersonalDataSet {
table: "leftovers",
subject: "account_id",
kind: DataKind::Usage,
disposition: Disposition::Erase,
description: "Rows that should not survive an erasure.",
redacted: &[],
subject_via: None,
};
fn planned() -> Vec<Planned> {
vec![Planned {
entry: CatalogEntry {
module: "fixture",
set: SET,
},
rows: 1,
}]
}
#[pollster::test]
async fn verification_names_a_table_that_still_holds_rows() {
for kit in TestHarness::all_dialects(Vec::new) {
kit.db
.execute(&Statement::new(
"CREATE TABLE leftovers (id TEXT PRIMARY KEY, account_id TEXT NOT NULL)",
))
.await
.expect("create");
kit.db
.execute(&Statement::with_values(
"INSERT INTO leftovers (id, account_id) VALUES (?, ?)",
vec!["l1".into(), "acct-1".into()],
))
.await
.expect("insert");
let remaining = verify(&kit.db, &planned(), "acct-1").await.expect("verify");
assert_eq!(
remaining,
vec!["leftovers".to_owned()],
"verification reported a clean erasure over surviving rows"
);
kit.db
.execute(&Statement::with_values(
"DELETE FROM leftovers WHERE account_id = ?",
vec!["acct-1".into()],
))
.await
.expect("delete");
let remaining = verify(&kit.db, &planned(), "acct-1").await.expect("verify");
assert!(remaining.is_empty(), "{remaining:?}");
}
}
}