Skip to main content

baukit_erasure/
store.rs

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/// Persistent erasure state.
30#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)]
31#[serde(rename_all = "snake_case")]
32pub enum ErasureState {
33    /// Product rows are gone; provider deletion is queued.
34    Pending,
35    /// Product erasure and any requested provider deletion finished.
36    Completed,
37    /// Operators must repair and rerun the retained job.
38    Failed,
39}
40/// Safe response and replay receipt.
41#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
42#[serde(rename_all = "camelCase")]
43pub struct ErasureOutcome {
44    /// Current operation state.
45    pub status: ErasureState,
46    /// Opaque operation identifier, required on every wire receipt.
47    pub operation_id: Uuid,
48    /// Completion timestamp, present only after success.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub completed_at: Option<DateTime<Utc>>,
51}
52impl ErasureOutcome {
53    /// HTTP response code for DELETE and its replay receipt.
54    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/// Read-only admission result before external erasure notifications.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub enum ErasurePreflight {
64    /// The request passed validation and can attempt erasure.
65    Proceed,
66    /// The key already has a receipt for this subject. Do not notify again.
67    Replay(ErasureOutcome),
68}
69/// Errors before durable acceptance. Database errors may require client reconciliation.
70#[derive(Debug, thiserror::Error)]
71pub enum ErasureError {
72    /// Required 16..128 visible ASCII key is missing or invalid.
73    #[error("invalid erasure idempotency key")]
74    InvalidKey,
75    /// The key already belongs to another subject.
76    #[error("erasure idempotency conflict")]
77    Conflict,
78    /// This session cannot start another operation or recreate its profile after erasure.
79    #[error("profile erased")]
80    ProfileErased,
81    /// Unsafe or incomplete service configuration.
82    #[error("invalid erasure configuration")]
83    Configuration,
84    /// Database operation failed.
85    #[error("erasure database operation failed")]
86    Database(#[from] sqlx::Error),
87    /// Durable job enqueue failed.
88    #[error("erasure job store operation failed")]
89    Jobs(#[from] baukit_jobs::StoreError),
90    /// Invalid stored response.
91    #[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}
132/// Product-owned deletion executed inside the erasure transaction.
133///
134/// Delete every owned database resource, API token, and owned job here. Do not
135/// contact external systems or commit the provided connection.
136pub trait ProductErasure: Send + Sync {
137    /// Deletes the resource graph for one authenticated subject.
138    fn erase<'a>(
139        &'a self,
140        connection: &'a mut PgConnection,
141        subject: &'a str,
142    ) -> ErasureFuture<'a, Result<(), sqlx::Error>>;
143}
144/// PostgreSQL receipt and fence store. Products apply the reference migration.
145#[derive(Clone)]
146pub struct PostgresErasureStore {
147    pool: PgPool,
148    hash_key: Arc<Secret<String>>,
149}
150impl PostgresErasureStore {
151    /// Requires at least 32 bytes of secret key material. Keep the key stable.
152    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    /// Underlying product pool for worker composition.
162    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    /// Keyed subject hash, never the provider subject itself.
173    pub fn subject_hash(&self, subject: &str) -> Vec<u8> {
174        self.hash(b"erasure-subject\0", subject)
175    }
176    /// Locks subject resolution against erasure and checks this session in the same transaction.
177    /// Admission requires a login after the fence and no unfinished erasure operations.
178    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    /// Returns whether this session is rejected, without creating a profile.
198    /// A missing login time keeps a subject fenced permanently.
199    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    /// Subject-authorized status lookup. Unknown and foreign operations both return None.
211    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}
253/// Axum handler helper. Call after authentication, before any product operation.
254pub 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/// Whether product erasure also deletes the identity provider account.
266#[derive(Clone)]
267pub enum IdentityRetention {
268    /// Deletes the provider account through a durable job and bounded inline attempt.
269    Delete {
270        /// Provider-neutral account deletion implementation.
271        deleter: Arc<dyn IdentityAccountDeleter>,
272        /// Provider id matching the registered worker handler.
273        provider_id: String,
274        /// Whole-provider-call budget, including token acquisition.
275        /// Keep this short because the call holds a pooled connection and job row lock.
276        inline_timeout: Duration,
277        /// Maximum durable worker attempts.
278        max_attempts: u32,
279    },
280    /// Keeps the provider account and sessions. A fresh login can re-onboard after completion.
281    Retain,
282}
283/// Coordinates product erasure and the chosen identity retention mode.
284#[derive(Clone)]
285pub struct ErasureService {
286    store: PostgresErasureStore,
287    identity_retention: IdentityRetention,
288}
289impl ErasureService {
290    /// Builds a service with an explicit identity retention mode.
291    /// Delete mode requires a nonempty provider id, nonzero timeout and attempts.
292    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    /// Receipt and fence store for status handlers and subject resolution.
312    pub fn store(&self) -> &PostgresErasureStore {
313        &self.store
314    }
315    /// Fences an operator-selected subject inside the caller's transaction.
316    ///
317    /// The caller erases product rows in this same transaction and commits it.
318    /// Retain mode completes immediately. Delete mode queues identity deletion;
319    /// register `IdentityDeletionHandler` to process it. No provider call runs here.
320    /// Existing fences do not prevent an authorized operator from erasing again.
321    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    /// Checks the key, subject, replay receipt and session fence without writes or locks.
365    ///
366    /// Use this before external notifications. `erase` repeats every check under
367    /// its locks because this result does not reserve the key or subject.
368    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    /// Erases once under key and subject locks. Replays never call product deletion again.
388    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            // Acceptance is already durable. The worker also reconciles a provider
461            // success whose receipt update failed.
462            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        // A worker may already own the job. Only an unclaimed job is completed inline.
482        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}