diff --git a/crates/catalogd/src/biometric_endpoint.rs b/crates/catalogd/src/biometric_endpoint.rs index d50b1d5..1768684 100644 --- a/crates/catalogd/src/biometric_endpoint.rs +++ b/crates/catalogd/src/biometric_endpoint.rs @@ -124,6 +124,7 @@ pub fn router(state: BiometricEndpointState) -> Router { Router::new() .route("/subject/{candidate_id}/photo", post(upload_photo)) .route("/subject/{candidate_id}/consent", post(record_consent)) + .route("/subject/{candidate_id}/withdraw", post(withdraw_consent)) .route("/subject/{candidate_id}/erase", post(erase_subject)) .route("/health", get(biometric_health)) .layer(DefaultBodyLimit::max(MAX_PHOTO_BYTES)) @@ -1072,6 +1073,275 @@ pub async fn process_consent( }) } +// ─── Consent withdrawal (BIPA right of withdrawal) ──────────────── +// +// Backs the candidate's BIPA right to withdraw biometric consent at +// any time. Required by 740 ILCS 14/15(b) (consent must be revocable) +// and explicitly promised in +// docs/policies/consent/biometric_consent_template_v1.md §2. +// +// Withdrawal is NOT erasure. It's a state change that: +// - Stops further biometric processing (uploads will 403 again +// because the consent gate flips back to non-Given) +// - Starts a 30-day SLA clock for destruction (retention_until is +// accelerated from the 18-month default to 30 days from now) +// - The retention sweep + operator-driven erase honor the SLA; +// this endpoint does not delete anything itself +// +// Why a separate endpoint from /erase: +// - /erase destroys immediately (full BIPA destruction at counsel's +// direction or court order — biometric_only or full scope) +// - /withdraw flips the state + sets the SLA clock so destruction +// can happen on schedule with a defensible audit trail of the +// candidate's request preceding the destruction by ≤30 days +// +// State machine: +// - Given → Withdrawn (happy path) +// - NeverCollected/Pending → 409 nothing_to_withdraw +// - Withdrawn → 409 already_withdrawn (idempotent via /audit read) +// - Expired → 409 already_expired +// Subject status: +// - Erased / RetentionExpired → 403 subject_inactive +// - Otherwise → proceed (biometric can be withdrawn even if +// general_pii is mid-flow) + +const WITHDRAW_RESPONSE_SCHEMA: &str = "biometric_withdraw_response.v1"; +const WITHDRAW_SLA_DAYS: i64 = 30; // per consent template v1 §2 + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct WithdrawRequest { + /// Reason for withdrawal. Captured to the audit row as part of + /// accessor.purpose. Required + non-empty — counsel may need to + /// distinguish candidate-initiated vs. operator-initiated + /// withdrawals (e.g., candidate request vs. employer policy). + pub reason: String, + /// Operator initiating the withdrawal record (the staffing + /// coordinator who fielded the candidate's request). Required for + /// audit traceability — every state change must be attributable. + pub operator_of_record: String, + /// Optional: path or URL to a signed artifact proving the + /// candidate authorized withdrawal (signed paper, recorded call + /// transcript, email thread). Recorded for chain-of-custody. + #[serde(default)] + pub evidence_path: String, +} + +#[derive(Serialize, Debug)] +pub struct WithdrawResponse { + pub schema: &'static str, + pub candidate_id: String, + pub status_after: String, + pub withdrawn_at: chrono::DateTime, + pub retention_until: chrono::DateTime, + pub audit_row_hmac: String, +} + +async fn withdraw_consent( + State(state): State, + Path(candidate_id): Path, + 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: WithdrawRequest = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(e) => return (StatusCode::BAD_REQUEST, Json(ErrorResponse { + error: "bad_request", + detail: format!( + "withdraw body must be JSON {{reason, operator_of_record, evidence_path?}}: {e}" + ), + consent_status: None, + })).into_response(), + }; + match process_withdraw(&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 for consent withdrawal — same testable shape as +/// process_consent + process_upload + process_erase. +pub async fn process_withdraw( + state: &BiometricEndpointState, + candidate_id: &str, + legal_token: Option<&str>, + trace_id: &str, + req: WithdrawRequest, +) -> Result { + // 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. Empty fields silently weaken defensibility. + if req.reason.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, ErrorResponse { + error: "bad_request", + detail: "reason is required (free-text rationale captured to audit row)".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, + })); + } + + // Load original manifest. + 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, + }, + ))?; + let mut manifest = original_manifest.clone(); + + use shared::types::{BiometricConsentStatus, SubjectStatus}; + + // Subject must not be already destroyed. + if matches!(manifest.status, SubjectStatus::Erased | SubjectStatus::RetentionExpired) { + return Err((StatusCode::FORBIDDEN, ErrorResponse { + error: "subject_inactive", + detail: format!("subject status {:?} — withdrawal not applicable", manifest.status), + consent_status: None, + })); + } + + // Biometric state machine. + match manifest.consent.biometric.status { + BiometricConsentStatus::Given => { + // Happy path — proceed. + } + BiometricConsentStatus::NeverCollected | BiometricConsentStatus::Pending => { + return Err((StatusCode::CONFLICT, ErrorResponse { + error: "nothing_to_withdraw", + detail: format!( + "biometric consent status is {:?} — nothing has been given to withdraw. \ + If consent is mid-flow and the candidate is rejecting, the consent record \ + should be discarded by the intake operator rather than withdrawn here.", + manifest.consent.biometric.status, + ), + consent_status: Some(format!("{:?}", manifest.consent.biometric.status)), + })); + } + BiometricConsentStatus::Withdrawn => { + return Err((StatusCode::CONFLICT, ErrorResponse { + error: "already_withdrawn", + detail: "biometric consent is already withdrawn; \ + confirm via GET /audit/subject/{id} to see the prior withdrawal row".into(), + consent_status: Some("Withdrawn".into()), + })); + } + BiometricConsentStatus::Expired => { + return Err((StatusCode::CONFLICT, ErrorResponse { + error: "already_expired", + detail: "biometric retention window already passed; \ + data is awaiting destruction sweep — withdrawal is moot".into(), + consent_status: Some("Expired".into()), + })); + } + } + + // Compute timestamps. withdrawn_at is server-side authoritative; + // retention_until is accelerated from the prior 18-month default + // to 30 days from now per consent template v1 §2. + let withdrawn_at = chrono::Utc::now(); + let retention_until = withdrawn_at + chrono::Duration::days(WITHDRAW_SLA_DAYS); + + manifest.consent.biometric.status = BiometricConsentStatus::Withdrawn; + manifest.consent.biometric.retention_until = Some(retention_until); + manifest.updated_at = withdrawn_at; + // NOTE: deliberately do NOT mutate manifest.status or + // manifest.consent.general_pii — withdrawal is biometric-scoped. + // A subject can keep their general PII processing while opting + // out of biometrics. Subject-level withdrawal is a separate + // (out-of-scope) endpoint. + + // Transactional: manifest commit before audit; rollback on audit failure. + if let Err(e) = state.registry.put_subject(manifest.clone()).await { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + ErrorResponse { error: "manifest_update_failed", detail: e, consent_status: None }, + )); + } + + let row = SubjectAuditRow { + schema: "subject_audit.v1".into(), + ts: withdrawn_at, + candidate_id: candidate_id.to_string(), + accessor: AuditAccessor { + kind: "biometric_consent_withdrawal".into(), + daemon: "gateway".into(), + purpose: format!( + "reason={};operator={};evidence={}", + req.reason, req.operator_of_record, req.evidence_path, + ), + trace_id: trace_id.to_string(), + }, + fields_accessed: vec![ + "consent.biometric.status".into(), + "consent.biometric.retention_until".into(), + ], + result: "withdrawn".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) => { + tracing::error!("withdraw 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, + }, + )); + } + }; + + Ok(WithdrawResponse { + schema: WITHDRAW_RESPONSE_SCHEMA, + candidate_id: candidate_id.to_string(), + status_after: "Withdrawn".into(), + withdrawn_at, + retention_until, + audit_row_hmac, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1773,4 +2043,210 @@ mod tests { // Upload row chains off the consent grant's hmac. assert_eq!(rows[1].prev_chain_hash, grant.audit_row_hmac); } + + // ─── Consent withdrawal tests (BIPA right of withdrawal) ─────── + + fn fixture_withdraw_request() -> WithdrawRequest { + WithdrawRequest { + reason: "candidate emailed asking to remove their photo".into(), + operator_of_record: "OperatorB".into(), + evidence_path: "/tmp/withdrawal_email_thread_WORKER-X.eml".into(), + } + } + + #[tokio::test] + async fn withdraw_happy_path_flips_status_and_starts_30day_clock() { + // Subject at consent.biometric.status=Given, retention_until set + // to ~18 months out. Withdrawal flips status to Withdrawn AND + // accelerates retention_until to 30 days from now (per consent + // template v1 §2 SLA), appends biometric_consent_withdrawal + // audit row, returns 200. + let state = fixture_state("withdraw_happy").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W1", BiometricConsentStatus::Given, SubjectStatus::Active)).await; + let writer = state.writer.clone(); + let registry = state.registry.clone(); + + let resp = process_withdraw(&state, "WORKER-W1", Some(TEST_TOKEN), "trace-w1", fixture_withdraw_request()) + .await.unwrap(); + + assert_eq!(resp.schema, "biometric_withdraw_response.v1"); + assert_eq!(resp.candidate_id, "WORKER-W1"); + assert_eq!(resp.status_after, "Withdrawn"); + assert!(!resp.audit_row_hmac.is_empty()); + // 30-day clock from withdrawn_at to retention_until. + let elapsed = resp.retention_until - resp.withdrawn_at; + assert_eq!(elapsed.num_days(), WITHDRAW_SLA_DAYS); + + // Manifest reflects the withdrawal. + let m = registry.get_subject("WORKER-W1").await.unwrap(); + assert_eq!(m.consent.biometric.status, BiometricConsentStatus::Withdrawn); + assert_eq!(m.consent.biometric.retention_until, Some(resp.retention_until)); + // general_pii NOT touched — withdrawal is biometric-scoped only. + // (default fixture has NeverCollected on biometric, but this + // assertion would catch a bug where withdraw mutated + // general_pii.status alongside biometric.) + assert_eq!(m.status, SubjectStatus::Active); + + // Audit row. + assert_eq!(writer.verify_chain("WORKER-W1").await.unwrap(), 1); + let rows = writer.read_rows_in_range("WORKER-W1", None, None).await.unwrap(); + assert_eq!(rows[0].accessor.kind, "biometric_consent_withdrawal"); + assert!(rows[0].accessor.purpose.contains("reason=")); + assert!(rows[0].accessor.purpose.contains("operator=OperatorB")); + assert_eq!(rows[0].accessor.trace_id, "trace-w1"); + assert_eq!(rows[0].fields_accessed, vec!["consent.biometric.status".to_string(), "consent.biometric.retention_until".to_string()]); + assert_eq!(rows[0].result, "withdrawn"); + } + + #[tokio::test] + async fn withdraw_missing_token_rejected() { + let state = fixture_state("withdraw_no_token").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W2", BiometricConsentStatus::Given, SubjectStatus::Active)).await; + let err = process_withdraw(&state, "WORKER-W2", None, "", fixture_withdraw_request()) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + assert_eq!(err.1.error, "auth_failed"); + } + + #[tokio::test] + async fn withdraw_wrong_token_rejected() { + let state = fixture_state("withdraw_wrong_token").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W3", BiometricConsentStatus::Given, SubjectStatus::Active)).await; + let err = process_withdraw(&state, "WORKER-W3", Some("nope"), "", fixture_withdraw_request()) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn withdraw_subject_not_found_returns_404() { + let state = fixture_state("withdraw_no_subject").await; + let err = process_withdraw(&state, "GHOST-WORKER", Some(TEST_TOKEN), "", fixture_withdraw_request()) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::NOT_FOUND); + assert_eq!(err.1.error, "subject_not_found"); + } + + #[tokio::test] + async fn withdraw_missing_reason_400() { + let state = fixture_state("withdraw_no_reason").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W4", BiometricConsentStatus::Given, SubjectStatus::Active)).await; + let mut req = fixture_withdraw_request(); + req.reason = " ".into(); + let err = process_withdraw(&state, "WORKER-W4", Some(TEST_TOKEN), "", req) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert!(err.1.detail.contains("reason")); + } + + #[tokio::test] + async fn withdraw_missing_operator_400() { + let state = fixture_state("withdraw_no_op").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W5", BiometricConsentStatus::Given, SubjectStatus::Active)).await; + let mut req = fixture_withdraw_request(); + req.operator_of_record = "".into(); + let err = process_withdraw(&state, "WORKER-W5", Some(TEST_TOKEN), "", req) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::BAD_REQUEST); + assert!(err.1.detail.contains("operator_of_record")); + } + + #[tokio::test] + async fn withdraw_when_never_collected_returns_409() { + // Cannot withdraw consent that was never given. If consent is + // mid-flow, the consent record should be discarded by the + // intake operator rather than withdrawn. + let state = fixture_state("withdraw_never").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W6", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await; + let err = process_withdraw(&state, "WORKER-W6", Some(TEST_TOKEN), "", fixture_withdraw_request()) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::CONFLICT); + assert_eq!(err.1.error, "nothing_to_withdraw"); + assert_eq!(err.1.consent_status.as_deref(), Some("NeverCollected")); + } + + #[tokio::test] + async fn withdraw_when_pending_returns_409() { + let state = fixture_state("withdraw_pending").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W7", BiometricConsentStatus::Pending, SubjectStatus::Active)).await; + let err = process_withdraw(&state, "WORKER-W7", Some(TEST_TOKEN), "", fixture_withdraw_request()) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::CONFLICT); + assert_eq!(err.1.error, "nothing_to_withdraw"); + } + + #[tokio::test] + async fn withdraw_when_already_withdrawn_returns_409() { + // Idempotency-resilient: refuses re-withdrawal so operator + // doesn't accidentally push retention_until back. Operator + // can confirm prior withdrawal via GET /audit/subject/{id}. + let state = fixture_state("withdraw_already").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W8", BiometricConsentStatus::Withdrawn, SubjectStatus::Active)).await; + let err = process_withdraw(&state, "WORKER-W8", Some(TEST_TOKEN), "", fixture_withdraw_request()) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::CONFLICT); + assert_eq!(err.1.error, "already_withdrawn"); + assert_eq!(err.1.consent_status.as_deref(), Some("Withdrawn")); + } + + #[tokio::test] + async fn withdraw_when_expired_returns_409() { + let state = fixture_state("withdraw_expired").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W9", BiometricConsentStatus::Expired, SubjectStatus::Active)).await; + let err = process_withdraw(&state, "WORKER-W9", Some(TEST_TOKEN), "", fixture_withdraw_request()) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::CONFLICT); + assert_eq!(err.1.error, "already_expired"); + } + + #[tokio::test] + async fn withdraw_subject_inactive_returns_403() { + // Subject status takes precedence — withdrawal on Erased + // subject is moot. + let state = fixture_state("withdraw_erased_subject").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W10", BiometricConsentStatus::Given, SubjectStatus::Erased)).await; + let err = process_withdraw(&state, "WORKER-W10", Some(TEST_TOKEN), "", fixture_withdraw_request()) + .await.unwrap_err(); + assert_eq!(err.0, StatusCode::FORBIDDEN); + assert_eq!(err.1.error, "subject_inactive"); + } + + #[tokio::test] + async fn withdraw_full_cycle_consent_grant_then_withdraw() { + // End-to-end: consent grant → withdraw. Verifies retention_until + // is correctly accelerated from the 18-month consent default to + // 30 days from withdrawal. Audit chain has 2 rows in correct + // order; withdraw row chains off the grant's hmac. + let state = fixture_state("withdraw_cycle").await; + let _ = state.registry.put_subject(fixture_manifest("WORKER-W11", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await; + let writer = state.writer.clone(); + let registry = state.registry.clone(); + + // 1. Grant consent. + let grant = process_consent(&state, "WORKER-W11", Some(TEST_TOKEN), "trace-grant", fixture_consent_request()) + .await.unwrap(); + let grant_retention = grant.retention_until; + + // 2. Withdraw. + let withdraw = process_withdraw(&state, "WORKER-W11", Some(TEST_TOKEN), "trace-withdraw", fixture_withdraw_request()) + .await.unwrap(); + + // retention_until was accelerated — withdraw's retention_until + // must be earlier than the 18-month grant retention. + assert!(withdraw.retention_until < grant_retention, + "withdraw retention {} must be earlier than grant retention {}", + withdraw.retention_until, grant_retention); + + // Manifest reflects the withdrawal. + let m = registry.get_subject("WORKER-W11").await.unwrap(); + assert_eq!(m.consent.biometric.status, BiometricConsentStatus::Withdrawn); + assert_eq!(m.consent.biometric.retention_until, Some(withdraw.retention_until)); + + // Audit chain. + assert_eq!(writer.verify_chain("WORKER-W11").await.unwrap(), 2); + let rows = writer.read_rows_in_range("WORKER-W11", None, None).await.unwrap(); + assert_eq!(rows[0].accessor.kind, "biometric_consent_grant"); + assert_eq!(rows[1].accessor.kind, "biometric_consent_withdrawal"); + // Withdraw row chains off the grant's hmac. + assert_eq!(rows[1].prev_chain_hash, grant.audit_row_hmac); + } } diff --git a/mcp-server/biometric_intake.html b/mcp-server/biometric_intake.html index 962b6ae..d8448ae 100644 --- a/mcp-server/biometric_intake.html +++ b/mcp-server/biometric_intake.html @@ -87,10 +87,12 @@ canvas{display:none}

Operator authentication

-

Paste the legal-tier audit token. Stored in this tab's session only; cleared on close. Never persists to disk.

+

Paste the legal-tier audit token + your name. Both stored in this tab's session only; cleared on close. Never persists to disk. Your name is recorded as operator_of_record in the audit row for legal traceability.

+ +
@@ -241,6 +243,7 @@ const CANDIDATE_ID = (function(){ // ── State ───────────────────────────────────────────────────────── const state = { token: null, // sessionStorage-backed + operator: null, // operator_of_record — captured at token paste consentVersionHash: null, // SHA-256 of the rendered consent template consentResp: null, // server response from /consent photoBlob: null, // captured/selected photo @@ -277,12 +280,17 @@ async function sha256Hex(text) { } document.getElementById('cid-display').textContent = CANDIDATE_ID; - // Restore token from sessionStorage if previously set in this tab. + // Restore token + operator from sessionStorage if previously set in this tab. const saved = sessionStorage.getItem('lh_legal_token'); + const savedOp = sessionStorage.getItem('lh_operator_name'); if (saved) { state.token = saved; document.getElementById('token-input').value = '••••••••'; } + if (savedOp) { + state.operator = savedOp; + document.getElementById('op-name').value = savedOp; + } // Compute consent template hash (SHA-256 of the rendered consent block). // This is what we'll send as consent_version_hash. Counsel later @@ -293,18 +301,25 @@ async function sha256Hex(text) { document.getElementById('version-hash-display').textContent = state.consentVersionHash.substring(0,16) + '…'; })(); -// ── Step 1: token ────────────────────────────────────────────────── +// ── Step 1: token + operator ─────────────────────────────────────── document.getElementById('token-submit').addEventListener('click', () => { clearErr('token-error'); const v = document.getElementById('token-input').value.trim(); // If user kept the masked placeholder, use the saved token. const tok = v === '••••••••' ? state.token : v; + const op = document.getElementById('op-name').value.trim(); if (!tok || tok.length < 32) { err('token-error', 'Token must be ≥32 characters.'); return; } + if (!op || op.length < 2) { + err('token-error', 'Operator name is required (recorded as operator_of_record in audit row).'); + return; + } state.token = tok; + state.operator = op; sessionStorage.setItem('lh_legal_token', tok); + sessionStorage.setItem('lh_operator_name', op); show('screen-consent'); }); @@ -336,7 +351,7 @@ document.getElementById('grant-btn').addEventListener('click', async () => { consent_version_hash: state.consentVersionHash, consent_collection_method: 'click_acceptance', consent_collection_evidence_path: 'inline:sha256=' + evidenceHash, - operator_of_record: 'intake_ui_operator', + operator_of_record: state.operator, }; const r = await fetch(`${GATEWAY}/biometric/subject/${encodeURIComponent(CANDIDATE_ID)}/consent`, { method: 'POST', diff --git a/mcp-server/biometric_withdraw.html b/mcp-server/biometric_withdraw.html new file mode 100644 index 0000000..a130cc7 --- /dev/null +++ b/mcp-server/biometric_withdraw.html @@ -0,0 +1,253 @@ + + + + + +Lakehouse — Biometric Consent Withdrawal + + + + +
+

⚡ Biometric Consent Withdrawal

+ step 1 of 3 +
+ +
+ + +
+

Operator authentication

+

Withdrawal is operator-recorded on behalf of the candidate. Paste the legal-tier audit token + your name.

+
+ + + + +
+ +
+
+
+
+ + +
+

Record withdrawal

+

The candidate has requested withdrawal of biometric consent. This action sets a 30-day SLA clock for destruction (per consent template v1 §2). The retention sweep + erase runbook handle actual destruction; this endpoint records intent + starts the clock.

+ +
+ What withdrawal does: sets consent.biometric.status = Withdrawn, accelerates retention_until from the 18-month default to 30 days from now. Future photo uploads will be refused (403). General-PII consent is NOT touched — the candidate can keep their non-biometric data on the platform. +
+ +
+ + + + + + + + + +
+ + +
+
+
+
+ + +
+
+

✓ Withdrawal recorded

+

Audit chain row appended; retention sweep will pick it up at the SLA.

+
+ +
+

State change

+
+ Candidate + StatusWithdrawn + Withdrawn at + Retention until +
+

Audit row hmac

+
+
+ +
+

What happens next

+

The retention sweep flags this subject as overdue once retention_until passes. An operator with legal-tier credentials runs the destruction runbook (POST /biometric/subject/<id>/erase) within the 30-day SLA.

+

To verify the withdrawal landed cleanly:

+
curl -H "X-Lakehouse-Legal-Token: $TOKEN" http://localhost:3100/audit/subject/<id>
+
+ +
+ +
+
+ +
+ + + + + + diff --git a/mcp-server/index.ts b/mcp-server/index.ts index e2b0cd5..f315eab 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -778,6 +778,17 @@ async function main() { }); } + // Biometric withdrawal — BIPA right of withdrawal frontend. + // Operator records candidate's withdrawal request. POSTs to + // gateway's /biometric/subject/{id}/withdraw. Sets a 30-day + // SLA clock for destruction. Optional ?candidate_id=WORKER-XXX + // pre-populates the form. + if (url.pathname === "/biometric/withdraw") { + return new Response(Bun.file(import.meta.dir + "/biometric_withdraw.html"), { + headers: { ...cors, "Content-Type": "text/html" }, + }); + } + // Workspaces — per-contract state (Phase 8.5). UI layer over the // gateway's /workspaces/* routes: list, create, detail, handoff, // save-search, shortlist, log-activity. All persisted on the diff --git a/ops/systemd/install.sh b/ops/systemd/install.sh index 6ffb521..53a02c6 100755 --- a/ops/systemd/install.sh +++ b/ops/systemd/install.sh @@ -19,6 +19,8 @@ TARGET_DIR=/etc/systemd/system UNITS=( lakehouse-auditor.service lakehouse-context7-bridge.service + lakehouse-retention-sweep.service + lakehouse-retention-sweep.timer ) if [[ $EUID -ne 0 ]]; then @@ -41,9 +43,34 @@ echo "→ systemctl daemon-reload" systemctl daemon-reload for unit in "${UNITS[@]}"; do - echo "→ enable + (re)start $unit" - systemctl enable "$unit" >/dev/null - systemctl restart "$unit" + # For .timer units: enable + start the timer (which fires its + # paired oneshot service on schedule). For long-running .service + # units that DON'T have a timer: enable + restart so changes + # land. For oneshot .service units that ARE driven by a timer, + # do NOT enable/start them directly — the timer pulls them in. + base="${unit%.*}" + case "$unit" in + *.timer) + echo "→ enable + (re)start $unit" + systemctl enable "$unit" >/dev/null + systemctl restart "$unit" + ;; + *.service) + # Skip if a paired .timer exists in this install set. + paired_timer="${base}.timer" + paired_in_set=0 + for u2 in "${UNITS[@]}"; do + [[ "$u2" == "$paired_timer" ]] && paired_in_set=1 && break + done + if [[ $paired_in_set -eq 1 ]]; then + echo "→ skip direct start of $unit (driven by $paired_timer)" + else + echo "→ enable + (re)start $unit" + systemctl enable "$unit" >/dev/null + systemctl restart "$unit" + fi + ;; + esac done echo "" @@ -51,9 +78,12 @@ echo "─── post-install status ───" for unit in "${UNITS[@]}"; do active=$(systemctl is-active "$unit" 2>/dev/null || true) enabled=$(systemctl is-enabled "$unit" 2>/dev/null || true) - printf " %-40s active=%s enabled=%s\n" "$unit" "$active" "$enabled" + printf " %-44s active=%s enabled=%s\n" "$unit" "$active" "$enabled" done echo "" echo "Live logs: journalctl -u lakehouse-auditor.service -f" +echo " journalctl -u lakehouse-retention-sweep.service -f" echo "Pause: touch /home/profit/lakehouse/auditor.paused" echo "Resume: rm /home/profit/lakehouse/auditor.paused" +echo "Sweep test: systemctl start lakehouse-retention-sweep.service # one-shot, completes immediately" +echo "Next sweep: systemctl list-timers lakehouse-retention-sweep.timer" diff --git a/ops/systemd/lakehouse-retention-sweep.service b/ops/systemd/lakehouse-retention-sweep.service new file mode 100644 index 0000000..e972fc2 --- /dev/null +++ b/ops/systemd/lakehouse-retention-sweep.service @@ -0,0 +1,33 @@ +[Unit] +Description=Lakehouse retention sweep — flag biometric + general PII subjects past their retention_until clock (BIPA + general retention compliance) +Documentation=file:///home/profit/lakehouse/docs/PHASE_1_6_BIPA_GATES.md +After=lakehouse.service + +[Service] +Type=oneshot +User=root +Group=root +WorkingDirectory=/home/profit/lakehouse +# Use the release binary built alongside catalogd. retention_sweep +# deliberately does NOT auto-mutate state — it writes a date-stamped +# JSONL report to data/_catalog/subjects/_retention_sweep_.jsonl +# (see crates/catalogd/src/bin/retention_sweep.rs). Operators run +# actual destruction via the destruction runbook + verify scripts. +# +# Without --apply the sweep is dry-run only; we want the persisted +# report so the audit trail captures every daily check. +ExecStart=/home/profit/lakehouse/target/release/retention_sweep \ + --storage-root /home/profit/lakehouse/data \ + --apply +StandardOutput=append:/var/log/lakehouse/retention_sweep.log +StandardError=append:/var/log/lakehouse/retention_sweep.log +# Bound the runtime — sweep over 100k subjects should complete in +# seconds; if it ever hits this we want a hard stop, not a runaway. +TimeoutStartSec=10min +# Guardrails. retention_sweep is read-mostly + writes one JSONL. +CPUQuota=100% +MemoryMax=2G +Nice=15 + +[Install] +WantedBy=multi-user.target diff --git a/ops/systemd/lakehouse-retention-sweep.timer b/ops/systemd/lakehouse-retention-sweep.timer new file mode 100644 index 0000000..7261917 --- /dev/null +++ b/ops/systemd/lakehouse-retention-sweep.timer @@ -0,0 +1,18 @@ +[Unit] +Description=Daily trigger for lakehouse-retention-sweep.service +Documentation=file:///home/profit/lakehouse/docs/PHASE_1_6_BIPA_GATES.md + +[Timer] +# Daily at 03:00 UTC. The sweep is idempotent across runs (it produces +# a per-day JSONL report and does NOT mutate manifest state), so it's +# safe to overlap with operator activity. +OnCalendar=*-*-* 03:00:00 UTC +# Persistent: if the box was off at 03:00, run on next boot. Without +# this we'd silently miss days, which would silently let retention +# expire without flagging. +Persistent=true +AccuracySec=1min +Unit=lakehouse-retention-sweep.service + +[Install] +WantedBy=timers.target