phase 1.6 Gate 5: erasure endpoint POST /biometric/subject/{id}/erase
Per docs/runbooks/BIPA_DESTRUCTION_RUNBOOK.md. BIPA-defensible
erasure: clears biometric collection (and optionally full PII record),
unlinks the photo file, records the destruction in the per-subject
HMAC chain. The audit row is the legal proof of compliant destruction
even after the underlying data is gone.
Two scopes:
biometric_only (default): clears biometric_collection field, unlinks
the photo, sets consent.biometric.status = withdrawn. Subject
remains active.
full: above PLUS sets status = erased and consent.general_pii.status
= withdrawn. Manifest preserved (proof of destruction); subject
record is logically erased.
Triggers (recorded but not validated against a closed set):
retention_expiry | consent_withdrawal | rtbf | court_order
Body shape:
{
"trigger": "<token>",
"trigger_evidence_path": "<optional path>",
"operator_of_record": "<name>",
"witness": "<name>",
"scope": "biometric_only|full"
}
Response (biometric_erase_response.v1):
candidate_id, scope, trigger, erased_at, fields_cleared,
photo_unlinked, photo_unlink_error, status_after,
biometric_status_after, general_pii_status_after, audit_row_hmac
Order matters for BIPA defensibility:
1. Snapshot original manifest (rollback target)
2. Update manifest (logical erasure)
3. Append audit row (LEGAL proof of intent + scope + operator)
4. Best-effort secure overwrite + unlink photo file (irreversible last)
If audit append fails, manifest is rolled back to original state and
500 returned — the alternative (manifest erased without legal record)
is exactly the silent-failure mode the spec exists to prevent.
If photo unlink fails AFTER audit commits, the response carries
photo_unlinked=false + the error string; operator must manually shred.
Tracing logs the inconsistency loudly.
Tests: 21 unit tests now pass (10 erasure-specific):
- missing token / missing subject / 404
- missing trigger / missing operator / invalid scope (400)
- biometric_only happy path (file unlinked, fields cleared, audit kind=biometric_erasure)
- full scope (status=Erased, general_pii withdrawn, audit kind=full_erasure)
- idempotent on already-erased (audit row records "already_erased" result)
- no-photo case (photo_unlinked=true with no unlink error)
- chain links off prior audit row's row_hmac (NOT GENESIS)
Live verification (post-restart):
- POST /biometric/subject/WORKER-2/erase with consent_withdrawal trigger
→ 200 with all expected fields_cleared + photo_unlinked=true
- Manifest reflects: biometric_collection=null, consent.biometric.status=withdrawn
- GET /audit/subject/WORKER-2: chain_verified=true, 4 rows total,
latest kind=biometric_erasure with operator + trigger in purpose field
- Cross-runtime parity probe: 6/6 byte-identical post-change
Known follow-up (separate bug): photo upload endpoint overwrites
biometric_collection without handling a prior file's data_path —
multiple uploads for the same candidate orphan earlier files. The
erasure endpoint correctly unlinks what the manifest knows about;
operator must shred orphans manually until the upload endpoint
either rejects re-upload (preferred) or maintains a list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7e0112beb7
commit
848a4583da
@ -123,6 +123,7 @@ impl BiometricEndpointState {
|
||||
pub fn router(state: BiometricEndpointState) -> Router {
|
||||
Router::new()
|
||||
.route("/subject/{candidate_id}/photo", post(upload_photo))
|
||||
.route("/subject/{candidate_id}/erase", post(erase_subject))
|
||||
.route("/health", get(biometric_health))
|
||||
.layer(DefaultBodyLimit::max(MAX_PHOTO_BYTES))
|
||||
.with_state(state)
|
||||
@ -444,6 +445,325 @@ pub async fn process_upload(
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Phase 1.6 Gate 5 — POST /biometric/subject/{id}/erase
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Specification: docs/runbooks/BIPA_DESTRUCTION_RUNBOOK.md.
|
||||
//
|
||||
// BIPA-defensible erasure path. Clears the subject's biometric
|
||||
// collection (always) and optionally the full PII record. The
|
||||
// destruction is recorded as an append-only audit row in the
|
||||
// per-subject HMAC chain — that row IS the legal proof of compliant
|
||||
// destruction even after the underlying data is gone.
|
||||
//
|
||||
// Triggers (from runbook §1): retention_expiry / consent_withdrawal /
|
||||
// rtbf / court_order. The endpoint records but does not validate the
|
||||
// trigger token — operator-of-record is responsible for matching the
|
||||
// trigger to a documented incident.
|
||||
//
|
||||
// Two scopes:
|
||||
// - "biometric_only" (default): clears biometric_collection field,
|
||||
// unlinks the photo file, sets consent.biometric.status = "withdrawn".
|
||||
// Subject remains active; only biometric data is destroyed.
|
||||
// - "full": above PLUS sets status = "erased" and
|
||||
// consent.general_pii.status = "withdrawn". The subject record
|
||||
// itself is preserved (BIPA requires proof of destruction; we
|
||||
// don't delete the manifest, just mark it erased).
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct EraseRequest {
|
||||
/// One of: "retention_expiry", "consent_withdrawal", "rtbf",
|
||||
/// "court_order". Caller-defined; we don't validate against a
|
||||
/// closed set so future triggers (regulatory, contractual) work
|
||||
/// without code change.
|
||||
pub trigger: String,
|
||||
/// Path or reference to the signed artifact justifying erasure
|
||||
/// (e.g. operator's withdrawal-receipt PDF, court order PDF).
|
||||
/// Recorded in the audit row but not validated.
|
||||
#[serde(default)]
|
||||
pub trigger_evidence_path: String,
|
||||
/// Operator initiating the erasure. Must be a real human name —
|
||||
/// recorded in the audit row for legal traceability.
|
||||
pub operator_of_record: String,
|
||||
/// Witness operator (per runbook §3 "two-operator action"). Must
|
||||
/// be different from operator_of_record when supplied; we don't
|
||||
/// validate same-as-operator since dev/test deployments may have
|
||||
/// only one operator.
|
||||
#[serde(default)]
|
||||
pub witness: String,
|
||||
/// "biometric_only" (default) | "full".
|
||||
#[serde(default = "default_erase_scope")]
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
fn default_erase_scope() -> String { "biometric_only".into() }
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct EraseResponse {
|
||||
pub schema: &'static str,
|
||||
pub candidate_id: String,
|
||||
pub scope: String,
|
||||
pub trigger: String,
|
||||
pub erased_at: chrono::DateTime<chrono::Utc>,
|
||||
pub fields_cleared: Vec<String>,
|
||||
pub photo_unlinked: bool,
|
||||
pub photo_unlink_error: Option<String>,
|
||||
pub status_after: String,
|
||||
pub biometric_status_after: String,
|
||||
pub general_pii_status_after: String,
|
||||
pub audit_row_hmac: String,
|
||||
}
|
||||
|
||||
const ERASE_RESPONSE_SCHEMA: &str = "biometric_erase_response.v1";
|
||||
|
||||
async fn erase_subject(
|
||||
State(state): State<BiometricEndpointState>,
|
||||
Path(candidate_id): Path<String>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let auth_token = headers
|
||||
.get(LEGAL_TOKEN_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let trace_id = headers
|
||||
.get(TRACE_ID_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let req: EraseRequest = match serde_json::from_slice(&body) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse {
|
||||
error: "bad_request",
|
||||
detail: format!("erase body must be JSON {{trigger, operator_of_record, ...}}: {e}"),
|
||||
consent_status: None,
|
||||
})).into_response(),
|
||||
};
|
||||
match process_erase(&state, &candidate_id, auth_token.as_deref(), &trace_id, req).await {
|
||||
Ok(resp) => (StatusCode::OK, Json(resp)).into_response(),
|
||||
Err((status, err)) => (status, Json(err)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-logic erasure — same testable shape as process_upload.
|
||||
pub async fn process_erase(
|
||||
state: &BiometricEndpointState,
|
||||
candidate_id: &str,
|
||||
legal_token: Option<&str>,
|
||||
trace_id: &str,
|
||||
req: EraseRequest,
|
||||
) -> Result<EraseResponse, (StatusCode, ErrorResponse)> {
|
||||
// Auth.
|
||||
let configured = state.legal_token.as_ref().ok_or((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
ErrorResponse { error: "auth_failed", detail: "no legal token configured".into(), consent_status: None },
|
||||
))?;
|
||||
let provided = legal_token.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
ErrorResponse { error: "auth_failed", detail: "missing X-Lakehouse-Legal-Token".into(), consent_status: None },
|
||||
))?;
|
||||
if !constant_time_eq(provided.as_bytes(), configured.as_bytes()) {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
ErrorResponse { error: "auth_failed", detail: "X-Lakehouse-Legal-Token mismatch".into(), consent_status: None },
|
||||
));
|
||||
}
|
||||
if candidate_id.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||
error: "bad_request", detail: "candidate_id is empty".into(), consent_status: None,
|
||||
}));
|
||||
}
|
||||
// Body validation.
|
||||
if req.trigger.trim().is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||
error: "bad_request", detail: "trigger is required (retention_expiry|consent_withdrawal|rtbf|court_order)".into(),
|
||||
consent_status: None,
|
||||
}));
|
||||
}
|
||||
if req.operator_of_record.trim().is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||
error: "bad_request", detail: "operator_of_record is required".into(),
|
||||
consent_status: None,
|
||||
}));
|
||||
}
|
||||
let scope = req.scope.as_str();
|
||||
if scope != "biometric_only" && scope != "full" {
|
||||
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||
error: "bad_request", detail: format!("scope must be biometric_only or full (got {scope})"),
|
||||
consent_status: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let original_manifest = state.registry.get_subject(candidate_id).await.ok_or((
|
||||
StatusCode::NOT_FOUND,
|
||||
ErrorResponse { error: "subject_not_found", detail: format!("no SubjectManifest for {candidate_id}"), consent_status: None },
|
||||
))?;
|
||||
|
||||
use shared::types::{BiometricConsentStatus, ConsentStatus, SubjectStatus};
|
||||
|
||||
// Idempotency: erasing an already-erased subject is a no-op for the
|
||||
// manifest BUT we still write an audit row noting the redundant
|
||||
// request — that's a useful operational signal.
|
||||
let already_erased = matches!(original_manifest.status, SubjectStatus::Erased);
|
||||
|
||||
let erased_at = chrono::Utc::now();
|
||||
let mut new_manifest = original_manifest.clone();
|
||||
let mut fields_cleared: Vec<String> = Vec::new();
|
||||
|
||||
// Always: clear biometric_collection + flip biometric consent.
|
||||
if new_manifest.biometric_collection.is_some() {
|
||||
fields_cleared.push("biometric_collection".into());
|
||||
}
|
||||
new_manifest.biometric_collection = None;
|
||||
if new_manifest.consent.biometric.status != BiometricConsentStatus::Withdrawn {
|
||||
new_manifest.consent.biometric.status = BiometricConsentStatus::Withdrawn;
|
||||
new_manifest.consent.biometric.retention_until = None;
|
||||
fields_cleared.push("consent.biometric".into());
|
||||
}
|
||||
|
||||
// Full scope: also flip status + general_pii consent.
|
||||
if scope == "full" {
|
||||
if new_manifest.status != SubjectStatus::Erased {
|
||||
new_manifest.status = SubjectStatus::Erased;
|
||||
fields_cleared.push("status".into());
|
||||
}
|
||||
if new_manifest.consent.general_pii.status != ConsentStatus::Withdrawn {
|
||||
new_manifest.consent.general_pii.status = ConsentStatus::Withdrawn;
|
||||
new_manifest.consent.general_pii.withdrawn_at = Some(erased_at);
|
||||
fields_cleared.push("consent.general_pii".into());
|
||||
}
|
||||
}
|
||||
new_manifest.updated_at = erased_at;
|
||||
|
||||
// Commit manifest BEFORE unlinking the file. Order matters for
|
||||
// the same reason as process_upload: the audit row is the legal
|
||||
// record of intent. Manifest update + audit row commit first;
|
||||
// file unlink is the last (irreversible) step.
|
||||
state.registry.put_subject(new_manifest.clone()).await.map_err(|e| (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorResponse { error: "manifest_update_failed", detail: e, consent_status: None },
|
||||
))?;
|
||||
|
||||
// Append erasure audit row. Records the trigger, operator, witness,
|
||||
// evidence path, scope, and which fields were cleared.
|
||||
let kind = if scope == "full" { "full_erasure" } else { "biometric_erasure" };
|
||||
let row = SubjectAuditRow {
|
||||
schema: "subject_audit.v1".into(),
|
||||
ts: erased_at,
|
||||
candidate_id: candidate_id.to_string(),
|
||||
accessor: AuditAccessor {
|
||||
kind: kind.into(),
|
||||
daemon: "gateway".into(),
|
||||
purpose: format!(
|
||||
"trigger={};operator={};witness={};evidence={}",
|
||||
req.trigger, req.operator_of_record, req.witness, req.trigger_evidence_path,
|
||||
),
|
||||
trace_id: trace_id.to_string(),
|
||||
},
|
||||
fields_accessed: fields_cleared.clone(),
|
||||
result: if already_erased { "already_erased".into() } else { "erased".into() },
|
||||
prev_chain_hash: String::new(),
|
||||
row_hmac: String::new(),
|
||||
};
|
||||
let audit_row_hmac = match state.writer.append(row).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
// Audit failure on erasure: the manifest is updated but
|
||||
// the legal record of WHY didn't land. Roll back the
|
||||
// manifest to the original (not yet "erased") state and
|
||||
// return 500 — the operator can retry.
|
||||
tracing::error!("erase audit row failed for {candidate_id}: {e}; rolling back");
|
||||
if let Err(re_err) = state.registry.put_subject(original_manifest).await {
|
||||
tracing::error!("rollback: could not revert manifest for {candidate_id}: {re_err}");
|
||||
}
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ErrorResponse {
|
||||
error: "audit_write_failed",
|
||||
detail: format!("audit row failed; manifest rolled back: {e}"),
|
||||
consent_status: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Last: unlink the photo file (if one was on disk). This is
|
||||
// irreversible — we deliberately do it AFTER the audit row commits
|
||||
// so the legal record exists even if the unlink fails.
|
||||
let mut photo_unlinked = true;
|
||||
let mut photo_unlink_error: Option<String> = None;
|
||||
if let Some(bc) = original_manifest.biometric_collection.as_ref() {
|
||||
let abs = state.storage_root.join(&bc.data_path);
|
||||
// Best-effort secure overwrite: write zeros equal to file
|
||||
// size, then unlink. Modern filesystems / SSDs may keep
|
||||
// copies — full secure erase requires drive-level operations
|
||||
// (out of scope for v1; documented as a limitation).
|
||||
match tokio::fs::metadata(&abs).await {
|
||||
Ok(meta) => {
|
||||
let size = meta.len() as usize;
|
||||
let zeros = vec![0u8; size.min(64 * 1024)];
|
||||
let mut written = 0usize;
|
||||
while written < size {
|
||||
let chunk = (size - written).min(zeros.len());
|
||||
if let Err(e) = tokio::fs::write(&abs, &zeros[..chunk]).await {
|
||||
photo_unlinked = false;
|
||||
photo_unlink_error = Some(format!("overwrite failed at offset {written}: {e}"));
|
||||
break;
|
||||
}
|
||||
written += chunk;
|
||||
if written < size {
|
||||
// Re-open in append mode for subsequent chunks
|
||||
// (write-truncate would zero only the chunk
|
||||
// length). For v1 simplicity we just write the
|
||||
// file once with zeros up to 64KB; larger files
|
||||
// get partial overwrite + unlink. Operators
|
||||
// wanting full secure erase should run shred(1).
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Err(e) = tokio::fs::remove_file(&abs).await {
|
||||
photo_unlinked = false;
|
||||
photo_unlink_error = Some(format!("unlink failed: {e}"));
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// File already gone — idempotent.
|
||||
photo_unlinked = true;
|
||||
}
|
||||
Err(e) => {
|
||||
photo_unlinked = false;
|
||||
photo_unlink_error = Some(format!("stat failed: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !photo_unlinked {
|
||||
tracing::error!(
|
||||
"erase: photo file remains on disk for {candidate_id}: {:?} — manifest + audit row already committed; operator must shred manually",
|
||||
photo_unlink_error
|
||||
);
|
||||
}
|
||||
|
||||
let status_after = format!("{:?}", new_manifest.status);
|
||||
let biometric_status_after = format!("{:?}", new_manifest.consent.biometric.status);
|
||||
let general_pii_status_after = format!("{:?}", new_manifest.consent.general_pii.status);
|
||||
|
||||
Ok(EraseResponse {
|
||||
schema: ERASE_RESPONSE_SCHEMA,
|
||||
candidate_id: candidate_id.to_string(),
|
||||
scope: scope.to_string(),
|
||||
trigger: req.trigger,
|
||||
erased_at,
|
||||
fields_cleared,
|
||||
photo_unlinked,
|
||||
photo_unlink_error,
|
||||
status_after,
|
||||
biometric_status_after,
|
||||
general_pii_status_after,
|
||||
audit_row_hmac,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -678,4 +998,180 @@ mod tests {
|
||||
).await.unwrap();
|
||||
assert_eq!(resp2.candidate_id, "WORKER-CT");
|
||||
}
|
||||
|
||||
// ─── Erasure tests (Gate 5) ──────────────────────────────────────
|
||||
|
||||
fn fixture_erase_request(scope: &str) -> EraseRequest {
|
||||
EraseRequest {
|
||||
trigger: "consent_withdrawal".into(),
|
||||
trigger_evidence_path: "/tmp/withdrawal_receipt.pdf".into(),
|
||||
operator_of_record: "J".into(),
|
||||
witness: "Witness A".into(),
|
||||
scope: scope.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_missing_token_rejected() {
|
||||
let state = fixture_state("erase_no_token").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E1", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||
let err = process_erase(&state, "WORKER-E1", None, "", fixture_erase_request("biometric_only")).await.unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_missing_subject_returns_404() {
|
||||
let state = fixture_state("erase_no_subject").await;
|
||||
let err = process_erase(&state, "GHOST", Some(TEST_TOKEN), "", fixture_erase_request("biometric_only")).await.unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_missing_trigger_400() {
|
||||
let state = fixture_state("erase_no_trigger").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E2", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||
let mut req = fixture_erase_request("biometric_only");
|
||||
req.trigger = "".into();
|
||||
let err = process_erase(&state, "WORKER-E2", Some(TEST_TOKEN), "", req).await.unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||
assert!(err.1.detail.contains("trigger"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_missing_operator_400() {
|
||||
let state = fixture_state("erase_no_op").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E3", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||
let mut req = fixture_erase_request("biometric_only");
|
||||
req.operator_of_record = "".into();
|
||||
let err = process_erase(&state, "WORKER-E3", Some(TEST_TOKEN), "", req).await.unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||
assert!(err.1.detail.contains("operator"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_invalid_scope_400() {
|
||||
let state = fixture_state("erase_bad_scope").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E4", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||
let req = fixture_erase_request("partial"); // not a valid scope
|
||||
let err = process_erase(&state, "WORKER-E4", Some(TEST_TOKEN), "", req).await.unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||
assert!(err.1.detail.contains("scope"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_biometric_only_clears_collection_and_unlinks_photo() {
|
||||
let state = fixture_state("erase_bio_happy").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E5", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||
// Upload first so there's something to erase.
|
||||
let upload = process_upload(&state, "WORKER-E5", Some(TEST_TOKEN), Some("image/jpeg"), "consent-v1", "", &jpeg_bytes())
|
||||
.await.unwrap();
|
||||
let abs = state.storage_root.join(&upload.data_path);
|
||||
assert!(abs.exists(), "photo should exist before erase");
|
||||
|
||||
let registry = state.registry.clone();
|
||||
let writer = state.writer.clone();
|
||||
let resp = process_erase(&state, "WORKER-E5", Some(TEST_TOKEN), "trace-erase", fixture_erase_request("biometric_only"))
|
||||
.await.unwrap();
|
||||
assert_eq!(resp.scope, "biometric_only");
|
||||
assert_eq!(resp.trigger, "consent_withdrawal");
|
||||
assert!(resp.fields_cleared.contains(&"biometric_collection".to_string()));
|
||||
assert!(resp.fields_cleared.contains(&"consent.biometric".to_string()));
|
||||
assert!(resp.photo_unlinked);
|
||||
assert_eq!(resp.biometric_status_after, "Withdrawn");
|
||||
// biometric_only does NOT touch general status / general_pii.
|
||||
assert_eq!(resp.status_after, "Active");
|
||||
assert_eq!(resp.general_pii_status_after, "Given");
|
||||
assert!(!resp.audit_row_hmac.is_empty());
|
||||
|
||||
// Photo file is gone.
|
||||
assert!(!abs.exists(), "photo should be unlinked after erase");
|
||||
|
||||
// Manifest reflects clearing.
|
||||
let updated = registry.get_subject("WORKER-E5").await.unwrap();
|
||||
assert!(updated.biometric_collection.is_none());
|
||||
assert_eq!(updated.consent.biometric.status, BiometricConsentStatus::Withdrawn);
|
||||
assert_eq!(updated.status, SubjectStatus::Active);
|
||||
|
||||
// Audit chain has 2 rows: collection + erasure. Chain verifies.
|
||||
assert_eq!(writer.verify_chain("WORKER-E5").await.unwrap(), 2);
|
||||
let rows = writer.read_rows_in_range("WORKER-E5", None, None).await.unwrap();
|
||||
assert_eq!(rows.last().unwrap().accessor.kind, "biometric_erasure");
|
||||
assert!(rows.last().unwrap().accessor.purpose.contains("trigger=consent_withdrawal"));
|
||||
assert!(rows.last().unwrap().accessor.purpose.contains("operator=J"));
|
||||
assert_eq!(rows.last().unwrap().result, "erased");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_full_marks_subject_erased_and_withdraws_general_pii() {
|
||||
let state = fixture_state("erase_full_happy").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E6", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||
let registry = state.registry.clone();
|
||||
let writer = state.writer.clone();
|
||||
let resp = process_erase(&state, "WORKER-E6", Some(TEST_TOKEN), "", fixture_erase_request("full"))
|
||||
.await.unwrap();
|
||||
assert_eq!(resp.scope, "full");
|
||||
assert_eq!(resp.status_after, "Erased");
|
||||
assert_eq!(resp.biometric_status_after, "Withdrawn");
|
||||
assert_eq!(resp.general_pii_status_after, "Withdrawn");
|
||||
assert!(resp.fields_cleared.contains(&"status".to_string()));
|
||||
assert!(resp.fields_cleared.contains(&"consent.general_pii".to_string()));
|
||||
|
||||
let updated = registry.get_subject("WORKER-E6").await.unwrap();
|
||||
assert_eq!(updated.status, SubjectStatus::Erased);
|
||||
assert_eq!(updated.consent.general_pii.status, ConsentStatus::Withdrawn);
|
||||
assert!(updated.consent.general_pii.withdrawn_at.is_some());
|
||||
assert_eq!(writer.verify_chain("WORKER-E6").await.unwrap(), 1);
|
||||
let rows = writer.read_rows_in_range("WORKER-E6", None, None).await.unwrap();
|
||||
assert_eq!(rows[0].accessor.kind, "full_erasure");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_idempotent_on_already_erased() {
|
||||
// Erasing an already-erased subject still writes an audit row
|
||||
// (operational signal) but doesn't 500.
|
||||
let state = fixture_state("erase_idempotent").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E7", BiometricConsentStatus::Withdrawn, SubjectStatus::Erased)).await;
|
||||
let writer = state.writer.clone();
|
||||
let resp = process_erase(&state, "WORKER-E7", Some(TEST_TOKEN), "", fixture_erase_request("full"))
|
||||
.await.unwrap();
|
||||
assert_eq!(resp.status_after, "Erased");
|
||||
let rows = writer.read_rows_in_range("WORKER-E7", None, None).await.unwrap();
|
||||
assert_eq!(rows[0].result, "already_erased");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_with_no_photo_succeeds_without_unlink_error() {
|
||||
// Subject has biometric consent but never uploaded a photo;
|
||||
// erasure should still succeed (idempotent, photo_unlinked stays
|
||||
// true because there's nothing to unlink).
|
||||
let state = fixture_state("erase_no_photo").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E8", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||
let resp = process_erase(&state, "WORKER-E8", Some(TEST_TOKEN), "", fixture_erase_request("biometric_only"))
|
||||
.await.unwrap();
|
||||
assert!(resp.photo_unlinked);
|
||||
assert!(resp.photo_unlink_error.is_none());
|
||||
assert_eq!(resp.biometric_status_after, "Withdrawn");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn erase_chain_links_off_prior_audit_row() {
|
||||
// Critical defensive test: erasure audit row's prev_chain_hash
|
||||
// must point to the prior row's row_hmac, NOT GENESIS.
|
||||
// Without this, the chain breaks at the erasure row.
|
||||
let state = fixture_state("erase_chain_link").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-E9", BiometricConsentStatus::Given, SubjectStatus::Active)).await;
|
||||
let upload = process_upload(&state, "WORKER-E9", Some(TEST_TOKEN), Some("image/jpeg"), "", "", &jpeg_bytes())
|
||||
.await.unwrap();
|
||||
let writer = state.writer.clone();
|
||||
let _ = process_erase(&state, "WORKER-E9", Some(TEST_TOKEN), "", fixture_erase_request("biometric_only"))
|
||||
.await.unwrap();
|
||||
|
||||
let rows = writer.read_rows_in_range("WORKER-E9", None, None).await.unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].accessor.kind, "biometric_collection");
|
||||
assert_eq!(rows[1].accessor.kind, "biometric_erasure");
|
||||
// Erasure row chains off the upload's hmac.
|
||||
assert_eq!(rows[1].prev_chain_hash, upload.audit_row_hmac);
|
||||
assert_eq!(writer.verify_chain("WORKER-E9").await.unwrap(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user