1use crate::{
2 ErasureFuture, IDENTITY_DELETE_JOB_TYPE, IdentityAccountDeleter, IdentityDeletionError,
3};
4use aws_lc_rs::hmac;
5use axum::{
6 http::StatusCode,
7 response::{IntoResponse, Response},
8};
9use baukit_config::Secret;
10use baukit_http::{ApiError, IdempotencyKeyRule};
11use baukit_jobs::{NewJob, PostgresJobStore};
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use sqlx::{PgConnection, PgPool, Postgres, Transaction};
16use std::{sync::Arc, time::Duration};
17use uuid::Uuid;
18
19const REJECTED_SESSION_SQL: &str = "SELECT EXISTS (
20 SELECT 1 FROM erasure_fences AS fence
21 WHERE fence.subject_hash = $1 AND (
22 $2::timestamptz IS NULL OR $2 <= fence.erased_at OR EXISTS (
23 SELECT 1 FROM erasure_operations AS operation
24 WHERE operation.subject_hash = fence.subject_hash AND operation.state <> 'completed'
25 )
26 )
27)";
28
29#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
31#[serde(rename_all = "snake_case")]
32pub enum ErasureState {
33 Pending,
35 Completed,
37 Failed,
39}
40#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
42#[serde(rename_all = "camelCase")]
43pub struct ErasureOutcome {
44 pub status: ErasureState,
46 pub operation_id: Uuid,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub completed_at: Option<DateTime<Utc>>,
51}
52impl ErasureOutcome {
53 pub fn status_code(&self) -> StatusCode {
55 match self.status {
56 ErasureState::Pending => StatusCode::ACCEPTED,
57 ErasureState::Completed | ErasureState::Failed => StatusCode::OK,
58 }
59 }
60}
61#[derive(Clone, Debug, PartialEq, Eq)]
63pub enum ErasurePreflight {
64 Proceed,
66 Replay(ErasureOutcome),
68}
69#[derive(Debug, thiserror::Error)]
71pub enum ErasureError {
72 #[error("invalid erasure idempotency key")]
74 InvalidKey,
75 #[error("erasure idempotency conflict")]
77 Conflict,
78 #[error("profile erased")]
80 ProfileErased,
81 #[error("invalid erasure configuration")]
83 Configuration,
84 #[error("erasure database operation failed")]
86 Database(#[from] sqlx::Error),
87 #[error("erasure job store operation failed")]
89 Jobs(#[from] baukit_jobs::StoreError),
90 #[error("invalid erasure receipt")]
92 Receipt(#[from] serde_json::Error),
93}
94impl ErasureError {
95 fn class(&self) -> &'static str {
96 match self {
97 Self::InvalidKey => "invalid_key",
98 Self::Conflict => "conflict",
99 Self::ProfileErased => "profile_erased",
100 Self::Configuration => "configuration",
101 Self::Database(_) => "database",
102 Self::Jobs(_) => "job_store",
103 Self::Receipt(_) => "receipt",
104 }
105 }
106}
107impl IntoResponse for ErasureError {
108 fn into_response(self) -> Response {
109 match self {
110 Self::InvalidKey => ApiError::new(
111 StatusCode::BAD_REQUEST,
112 "erasure_idempotency_key_invalid",
113 "A valid Idempotency-Key is required",
114 )
115 .into_response(),
116 Self::Conflict => ApiError::new(
117 StatusCode::CONFLICT,
118 "erasure_idempotency_conflict",
119 "The erasure key belongs to another identity",
120 )
121 .into_response(),
122 Self::ProfileErased => ApiError::new(
123 StatusCode::UNAUTHORIZED,
124 "profile_erased",
125 "The profile has been erased",
126 )
127 .into_response(),
128 error => ApiError::internal(error).into_response(),
129 }
130 }
131}
132pub trait ProductErasure: Send + Sync {
137 fn erase<'a>(
139 &'a self,
140 connection: &'a mut PgConnection,
141 subject: &'a str,
142 ) -> ErasureFuture<'a, Result<(), sqlx::Error>>;
143}
144#[derive(Clone)]
146pub struct PostgresErasureStore {
147 pool: PgPool,
148 hash_key: Arc<Secret<String>>,
149}
150impl PostgresErasureStore {
151 pub fn new(pool: PgPool, hash_key: Secret<String>) -> Result<Self, ErasureError> {
153 if hash_key.expose().len() < 32 {
154 return Err(ErasureError::Configuration);
155 }
156 Ok(Self {
157 pool,
158 hash_key: Arc::new(hash_key),
159 })
160 }
161 pub fn pool(&self) -> &PgPool {
163 &self.pool
164 }
165 fn hash(&self, domain: &[u8], input: &str) -> Vec<u8> {
166 let key = hmac::Key::new(hmac::HMAC_SHA256, self.hash_key.expose().as_bytes());
167 let mut context = hmac::Context::with_key(&key);
168 context.update(domain);
169 context.update(input.as_bytes());
170 context.sign().as_ref().to_vec()
171 }
172 pub fn subject_hash(&self, subject: &str) -> Vec<u8> {
174 self.hash(b"erasure-subject\0", subject)
175 }
176 pub async fn guard_subject(
179 &self,
180 transaction: &mut Transaction<'_, Postgres>,
181 subject: &str,
182 authenticated_at: Option<DateTime<Utc>>,
183 ) -> Result<(), ErasureError> {
184 let hash = self.subject_hash(subject);
185 lock(transaction, &hash).await?;
186 let fenced: bool = sqlx::query_scalar(REJECTED_SESSION_SQL)
187 .bind(hash)
188 .bind(authenticated_at)
189 .fetch_one(&mut **transaction)
190 .await?;
191 if fenced {
192 Err(ErasureError::ProfileErased)
193 } else {
194 Ok(())
195 }
196 }
197 pub async fn is_fenced(
200 &self,
201 subject: &str,
202 authenticated_at: Option<DateTime<Utc>>,
203 ) -> Result<bool, ErasureError> {
204 Ok(sqlx::query_scalar(REJECTED_SESSION_SQL)
205 .bind(self.subject_hash(subject))
206 .bind(authenticated_at)
207 .fetch_one(&self.pool)
208 .await?)
209 }
210 pub async fn status(
212 &self,
213 subject: &str,
214 operation_id: Uuid,
215 ) -> Result<Option<ErasureOutcome>, ErasureError> {
216 let response: Option<Value> = sqlx::query_scalar(
217 "SELECT response FROM erasure_operations WHERE id = $1 AND subject_hash = $2",
218 )
219 .bind(operation_id)
220 .bind(self.subject_hash(subject))
221 .fetch_optional(&self.pool)
222 .await?;
223 response
224 .map(serde_json::from_value)
225 .transpose()
226 .map_err(Into::into)
227 }
228 pub(crate) async fn complete(
229 &self,
230 transaction: &mut Transaction<'_, Postgres>,
231 operation_id: Uuid,
232 ) -> Result<ErasureOutcome, ErasureError> {
233 let outcome = ErasureOutcome {
234 status: ErasureState::Completed,
235 operation_id,
236 completed_at: Some(Utc::now()),
237 };
238 let response: Value = sqlx::query_scalar("UPDATE erasure_operations SET state = 'completed', completed_at = COALESCE(completed_at, $2), response = CASE WHEN state = 'completed' THEN response ELSE $3 END WHERE id = $1 RETURNING response")
239 .bind(operation_id).bind(outcome.completed_at).bind(serde_json::to_value(&outcome)?)
240 .fetch_one(&mut **transaction).await?;
241 Ok(serde_json::from_value(response)?)
242 }
243}
244async fn lock(transaction: &mut Transaction<'_, Postgres>, hash: &[u8]) -> Result<(), sqlx::Error> {
245 let mut bytes = [0; 8];
246 bytes.copy_from_slice(&hash[..8]);
247 sqlx::query("SELECT pg_advisory_xact_lock($1)")
248 .bind(i64::from_be_bytes(bytes))
249 .execute(&mut **transaction)
250 .await?;
251 Ok(())
252}
253pub async fn reject_fenced_subject(
255 store: &PostgresErasureStore,
256 subject: &str,
257 authenticated_at: Option<DateTime<Utc>>,
258) -> Result<(), ErasureError> {
259 if store.is_fenced(subject, authenticated_at).await? {
260 Err(ErasureError::ProfileErased)
261 } else {
262 Ok(())
263 }
264}
265#[derive(Clone)]
267pub enum IdentityRetention {
268 Delete {
270 deleter: Arc<dyn IdentityAccountDeleter>,
272 provider_id: String,
274 inline_timeout: Duration,
277 max_attempts: u32,
279 },
280 Retain,
282}
283#[derive(Clone)]
285pub struct ErasureService {
286 store: PostgresErasureStore,
287 identity_retention: IdentityRetention,
288}
289impl ErasureService {
290 pub fn new(
293 store: PostgresErasureStore,
294 identity_retention: IdentityRetention,
295 ) -> Result<Self, ErasureError> {
296 if let IdentityRetention::Delete {
297 provider_id,
298 inline_timeout,
299 max_attempts,
300 ..
301 } = &identity_retention
302 && (provider_id.trim().is_empty() || inline_timeout.is_zero() || *max_attempts == 0)
303 {
304 return Err(ErasureError::Configuration);
305 }
306 Ok(Self {
307 store,
308 identity_retention,
309 })
310 }
311 pub fn store(&self) -> &PostgresErasureStore {
313 &self.store
314 }
315 pub async fn fence_as_operator_in_transaction(
322 &self,
323 transaction: &mut Transaction<'_, Postgres>,
324 subject: &str,
325 ) -> Result<ErasureOutcome, ErasureError> {
326 if subject.is_empty() {
327 return Err(ErasureError::Configuration);
328 }
329 let subject_hash = self.store.subject_hash(subject);
330 lock(transaction, &subject_hash).await?;
331 let outcome = ErasureOutcome {
332 status: ErasureState::Pending,
333 operation_id: Uuid::now_v7(),
334 completed_at: None,
335 };
336 let key_hash = self
337 .store
338 .hash(b"erasure-operator-key\0", &outcome.operation_id.to_string());
339 sqlx::query("INSERT INTO erasure_operations (id, subject_hash, key_hash, state, response) VALUES ($1, $2, $3, 'pending', $4)")
340 .bind(outcome.operation_id).bind(&subject_hash).bind(key_hash)
341 .bind(serde_json::to_value(&outcome)?)
342 .execute(&mut **transaction).await?;
343 sqlx::query("INSERT INTO erasure_fences (subject_hash, erased_at) VALUES ($1, now()) ON CONFLICT (subject_hash) DO UPDATE SET erased_at = now()")
344 .bind(subject_hash).execute(&mut **transaction).await?;
345 if let IdentityRetention::Delete {
346 provider_id,
347 max_attempts,
348 ..
349 } = &self.identity_retention
350 {
351 PostgresJobStore::new(self.store.pool.clone()).enqueue_in_transaction(
352 transaction,
353 NewJob::new(
354 IDENTITY_DELETE_JOB_TYPE,
355 serde_json::json!({"subject": subject, "providerId": provider_id, "operationId": outcome.operation_id}),
356 *max_attempts,
357 ).idempotency_key(outcome.operation_id.to_string()),
358 ).await?;
359 Ok(outcome)
360 } else {
361 self.store.complete(transaction, outcome.operation_id).await
362 }
363 }
364 pub async fn preflight(
369 &self,
370 subject: &str,
371 authenticated_at: Option<DateTime<Utc>>,
372 key: &str,
373 ) -> Result<ErasurePreflight, ErasureError> {
374 validate_request(subject, key)?;
375 let previous = sqlx::query_as(
376 "SELECT subject_hash, response FROM erasure_operations WHERE key_hash = $1",
377 )
378 .bind(self.store.hash(b"erasure-key\0", key))
379 .fetch_optional(&self.store.pool)
380 .await?;
381 if let Some(outcome) = replay_outcome(&self.store.subject_hash(subject), previous)? {
382 return Ok(ErasurePreflight::Replay(outcome));
383 }
384 reject_fenced_subject(&self.store, subject, authenticated_at).await?;
385 Ok(ErasurePreflight::Proceed)
386 }
387 pub async fn erase(
389 &self,
390 subject: &str,
391 authenticated_at: Option<DateTime<Utc>>,
392 key: &str,
393 product: &dyn ProductErasure,
394 ) -> Result<ErasureOutcome, ErasureError> {
395 validate_request(subject, key)?;
396 let key_hash = self.store.hash(b"erasure-key\0", key);
397 let subject_hash = self.store.subject_hash(subject);
398 let mut transaction = self.store.pool.begin().await?;
399 lock(&mut transaction, &key_hash).await?;
400 let previous: Option<(Vec<u8>, Value)> = sqlx::query_as(
401 "SELECT subject_hash, response FROM erasure_operations WHERE key_hash = $1",
402 )
403 .bind(&key_hash)
404 .fetch_optional(&mut *transaction)
405 .await?;
406 if let Some(outcome) = replay_outcome(&subject_hash, previous)? {
407 return Ok(outcome);
408 }
409 self.store
410 .guard_subject(&mut transaction, subject, authenticated_at)
411 .await?;
412 product.erase(&mut transaction, subject).await?;
413 let outcome = ErasureOutcome {
414 status: ErasureState::Pending,
415 operation_id: Uuid::now_v7(),
416 completed_at: None,
417 };
418 sqlx::query("INSERT INTO erasure_operations (id, subject_hash, key_hash, state, response) VALUES ($1, $2, $3, 'pending', $4)")
419 .bind(outcome.operation_id).bind(&subject_hash).bind(key_hash).bind(serde_json::to_value(&outcome)?)
420 .execute(&mut *transaction).await?;
421 sqlx::query("INSERT INTO erasure_fences (subject_hash, erased_at) VALUES ($1, now()) ON CONFLICT (subject_hash) DO UPDATE SET erased_at = now()")
422 .bind(subject_hash)
423 .execute(&mut *transaction)
424 .await?;
425 let IdentityRetention::Delete {
426 deleter,
427 provider_id,
428 inline_timeout,
429 max_attempts,
430 } = &self.identity_retention
431 else {
432 let completed = self
433 .store
434 .complete(&mut transaction, outcome.operation_id)
435 .await?;
436 transaction.commit().await?;
437 return Ok(completed);
438 };
439 let payload = serde_json::json!({"subject": subject, "providerId": provider_id, "operationId": outcome.operation_id});
440 let job = PostgresJobStore::new(self.store.pool.clone())
441 .enqueue_in_transaction(
442 &mut transaction,
443 NewJob::new(IDENTITY_DELETE_JOB_TYPE, payload, *max_attempts)
444 .idempotency_key(outcome.operation_id.to_string()),
445 )
446 .await?
447 .job;
448 transaction.commit().await?;
449 match self
450 .inline(
451 subject,
452 job.id,
453 outcome.clone(),
454 deleter.as_ref(),
455 *inline_timeout,
456 )
457 .await
458 {
459 Ok(outcome) => Ok(outcome),
460 Err(error) => {
463 tracing::warn!(
464 operation_id = %outcome.operation_id,
465 error_class = error.class(),
466 "inline erasure reconciliation failed",
467 );
468 Ok(outcome)
469 }
470 }
471 }
472 async fn inline(
473 &self,
474 subject: &str,
475 job_id: Uuid,
476 outcome: ErasureOutcome,
477 deleter: &dyn IdentityAccountDeleter,
478 inline_timeout: Duration,
479 ) -> Result<ErasureOutcome, ErasureError> {
480 let mut transaction = self.store.pool.begin().await?;
481 let pending: Option<String> =
483 sqlx::query_scalar("SELECT status FROM job_outbox WHERE id = $1 FOR UPDATE")
484 .bind(job_id)
485 .fetch_optional(&mut *transaction)
486 .await?;
487 if pending.as_deref() != Some("pending") {
488 transaction.rollback().await?;
489 return Ok(self
490 .store
491 .status(subject, outcome.operation_id)
492 .await?
493 .unwrap_or(outcome));
494 }
495 let error_class =
496 match tokio::time::timeout(inline_timeout, deleter.delete_account(subject)).await {
497 Ok(Ok(())) => None,
498 Ok(Err(IdentityDeletionError::Retryable)) => Some("retryable"),
499 Ok(Err(IdentityDeletionError::Permanent)) => Some("permanent"),
500 Err(_) => Some("timeout"),
501 };
502 if let Some(error_class) = error_class {
503 tracing::warn!(
504 operation_id = %outcome.operation_id,
505 error_class,
506 "inline identity deletion failed",
507 );
508 transaction.rollback().await?;
509 return Ok(outcome);
510 }
511 let completed = self
512 .store
513 .complete(&mut transaction, outcome.operation_id)
514 .await?;
515 sqlx::query("DELETE FROM job_outbox WHERE id = $1")
516 .bind(job_id)
517 .execute(&mut *transaction)
518 .await?;
519 transaction.commit().await?;
520 Ok(completed)
521 }
522}
523
524fn validate_request(subject: &str, key: &str) -> Result<(), ErasureError> {
525 let value = axum::http::HeaderValue::from_str(key).map_err(|_| ErasureError::InvalidKey)?;
526 IdempotencyKeyRule::new(16, 128)
527 .parse(&value)
528 .map_err(|_| ErasureError::InvalidKey)?;
529 if subject.is_empty() {
530 return Err(ErasureError::Configuration);
531 }
532 Ok(())
533}
534
535fn replay_outcome(
536 subject_hash: &[u8],
537 previous: Option<(Vec<u8>, Value)>,
538) -> Result<Option<ErasureOutcome>, ErasureError> {
539 let Some((owner, response)) = previous else {
540 return Ok(None);
541 };
542 if owner != subject_hash {
543 return Err(ErasureError::Conflict);
544 }
545 Ok(Some(serde_json::from_value(response)?))
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 #[test]
552 fn receipts_match_shared_wire_vectors() {
553 #[derive(Deserialize)]
554 #[serde(rename_all = "camelCase")]
555 struct ReceiptVector {
556 name: String,
557 http_status: u16,
558 receipt: Value,
559 }
560 #[derive(Deserialize)]
561 struct Vectors {
562 cases: Vec<ReceiptVector>,
563 }
564 let vectors: Vectors = serde_json::from_str(include_str!(
565 "../../../../fixtures/erasure/receipts-v1.json"
566 ))
567 .expect("shared erasure receipts");
568 for vector in vectors.cases {
569 let receipt: ErasureOutcome =
570 serde_json::from_value(vector.receipt.clone()).expect("receipt");
571 assert_eq!(
572 receipt.status_code().as_u16(),
573 vector.http_status,
574 "{}",
575 vector.name
576 );
577 assert_eq!(
578 serde_json::to_value(receipt).expect("receipt"),
579 vector.receipt,
580 "{}",
581 vector.name
582 );
583 }
584 }
585 #[test]
586 fn shared_wire_receipts_require_operation_ids() {
587 let vectors: Value = serde_json::from_str(include_str!(
588 "../../../../fixtures/erasure/receipts-v1.json"
589 ))
590 .expect("shared receipts");
591 for vector in vectors["cases"].as_array().expect("receipt cases") {
592 let mut receipt = vector["receipt"].clone();
593 receipt
594 .as_object_mut()
595 .expect("receipt object")
596 .remove("operationId");
597 assert!(serde_json::from_value::<ErasureOutcome>(receipt.clone()).is_err());
598 receipt["operationId"] = Value::Null;
599 assert!(serde_json::from_value::<ErasureOutcome>(receipt).is_err());
600 }
601 }
602
603 #[test]
604 fn outcomes_use_contract_status_codes_and_camel_case() {
605 let mut outcome = ErasureOutcome {
606 status: ErasureState::Pending,
607 operation_id: Uuid::nil(),
608 completed_at: None,
609 };
610 assert_eq!(outcome.status_code(), StatusCode::ACCEPTED);
611 assert_eq!(
612 serde_json::to_value(&outcome).expect("serialize"),
613 serde_json::json!({"status":"pending", "operationId":Uuid::nil()})
614 );
615 outcome.status = ErasureState::Completed;
616 outcome.completed_at = Some(Utc::now());
617 assert_eq!(outcome.status_code(), StatusCode::OK);
618 assert!(serde_json::to_value(outcome).expect("serialize")["completedAt"].is_string());
619 }
620}