phase 1.6: BIPA withdrawal endpoint + UI + retention sweep timer
Closes the four production gaps that were live after the consent
endpoint shipped (76cb5ac):
(1) Withdrawal endpoint POST /biometric/subject/{id}/withdraw
backs the BIPA right of withdrawal that consent template v1 §2
explicitly promises. Without it, the only way to honor a
candidate's withdrawal request was the heavier /erase, which
destroys immediately rather than starting the 30-day SLA clock
that the consent template commits to. Side-effects:
- manifest.consent.biometric.status: Given → Withdrawn
- manifest.consent.biometric.retention_until: 18mo → 30d
- audit row kind=biometric_consent_withdrawal, captures
reason + operator_of_record + evidence_path
- DOES NOT touch general_pii or subject.status — biometric
is independently revocable
State machine: Given→Withdrawn (happy), NeverCollected/Pending→
409 nothing_to_withdraw, Withdrawn→409 already_withdrawn (won't
advance the destruction clock), Expired→409 already_expired,
subject Erased/RetentionExpired→403 subject_inactive.
12 new unit tests covering happy path + all guards + a full
grant→withdraw cycle that asserts retention_until is correctly
accelerated and the audit chain has 2 rows in correct order.
(2) Withdraw UI at /biometric/withdraw (mcp-server-served HTML).
3-screen flow: operator auth (token + name in sessionStorage),
withdrawal form (candidate_id + free-text reason ≥10 chars +
optional evidence path), confirmation showing the audit row
HMAC + the 30-day retention_until clock + a curl recipe for
/audit/subject/{id} verification. Same neo-brutalist styling
as biometric_intake.html. Mounted at
http://localhost:3700/biometric/withdraw and externally at
https://devop.live/lakehouse/biometric/withdraw.
(3) Retention sweep systemd timer. crates/catalogd/bin/retention_sweep
binary already existed; this commit schedules it. Daily 03:00 UTC,
Persistent=true so a missed boot triggers on next start. Service
runs as oneshot with --apply (writes a date-stamped JSONL to
data/_catalog/subjects/_retention_sweep_<date>.jsonl ONLY when
overdue subjects exist, per the binary's existing semantics).
install.sh updated to handle .timer + paired .service correctly:
enables the timer, skips direct start of the oneshot service
(the timer pulls it in). One-shot manual test confirmed clean:
100 subjects scanned, 0 overdue (all backfill subjects within
their 4-year general retention window).
(4) operator_of_record bug fix in intake UI. Previously the page
hardcoded the literal string 'intake_ui_operator' as the
operator_of_record sent to /consent — meaning every audit row
captured the same useless placeholder, defeating the whole
point of operator traceability. Fixed by adding an operator
name field to the token-paste step (sessionStorage-backed),
passed through to consent + photo POSTs as the actual operator.
Verified live post-restart:
- gateway /audit/health + /biometric/health both 200
- mcp-server /biometric/intake + /biometric/withdraw both 200
- Live withdraw probes: 401 (no token), 400 (empty body), 404
(ghost subject), 409 nothing_to_withdraw on WORKER-1 (which
is NeverCollected per backfill default) — all expected
- Binary strings contain: process_withdraw, withdraw_consent,
biometric_consent_withdrawal, biometric_withdraw_response.v1,
nothing_to_withdraw, already_withdrawn, already_expired,
/subject/{candidate_id}/withdraw route
- systemd: lakehouse-retention-sweep.timer active+enabled,
next fire Tue 2026-05-05 22:00 CDT (= 03:00 UTC May 6)
- Manual one-shot of retention sweep service: exit 0/SUCCESS,
100 subjects loaded, 0 overdue
83/83 catalogd lib tests + 46/46 biometric_endpoint tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7f0f500050
commit
68d226c314
@ -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<chrono::Utc>,
|
||||
pub retention_until: chrono::DateTime<chrono::Utc>,
|
||||
pub audit_row_hmac: String,
|
||||
}
|
||||
|
||||
async fn withdraw_consent(
|
||||
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: 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<WithdrawResponse, (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. 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,10 +87,12 @@ canvas{display:none}
|
||||
<!-- Step 1: operator auth -->
|
||||
<div id="screen-token" class="screen active">
|
||||
<h2>Operator authentication</h2>
|
||||
<p class="lede">Paste the legal-tier audit token. Stored in this tab's session only; cleared on close. Never persists to disk.</p>
|
||||
<p class="lede">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 <code>operator_of_record</code> in the audit row for legal traceability.</p>
|
||||
<div class="card">
|
||||
<label for="token-input">Legal audit token</label>
|
||||
<input type="password" id="token-input" placeholder="44-char token from /etc/lakehouse/legal_audit.token" autocomplete="off">
|
||||
<label for="op-name" style="margin-top:14px">Your name (operator of record)</label>
|
||||
<input type="text" id="op-name" placeholder="First Last" autocomplete="off">
|
||||
<div style="margin-top:14px">
|
||||
<button id="token-submit">Continue →</button>
|
||||
</div>
|
||||
@ -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',
|
||||
|
||||
253
mcp-server/biometric_withdraw.html
Normal file
253
mcp-server/biometric_withdraw.html
Normal file
@ -0,0 +1,253 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Lakehouse — Biometric Consent Withdrawal</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Inter',-apple-system,system-ui,sans-serif;background:#090c10;color:#b0b8c4;font-size:14px;line-height:1.55;-webkit-font-smoothing:antialiased}
|
||||
a{color:#58a6ff;text-decoration:none}
|
||||
a:hover{color:#79c0ff}
|
||||
|
||||
.bar{background:#0d1117;padding:0 24px;height:56px;border-bottom:1px solid #171d27;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||||
.bar h1{font-size:14px;font-weight:600;color:#e6edf3;letter-spacing:-0.2px}
|
||||
.bar .step{font-size:11px;color:#7d8590;text-transform:uppercase;letter-spacing:1px}
|
||||
|
||||
.wrap{max-width:680px;margin:0 auto;padding:28px 20px 60px}
|
||||
.screen{display:none}
|
||||
.screen.active{display:block}
|
||||
|
||||
h2{font-size:20px;color:#e6edf3;font-weight:600;margin-bottom:8px;letter-spacing:-0.3px}
|
||||
h3{font-size:14px;color:#e6edf3;font-weight:600;margin:16px 0 8px}
|
||||
.lede{color:#7d8590;font-size:13px;margin-bottom:24px}
|
||||
|
||||
.card{background:#0d1117;border:1px solid #171d27;padding:20px;margin-bottom:16px}
|
||||
.card.warn{border-left:3px solid #d29922}
|
||||
.card.bad{border-left:3px solid #f85149}
|
||||
.card.good{border-left:3px solid #3fb950}
|
||||
|
||||
label{display:block;margin-bottom:12px;color:#7d8590;font-size:12px;text-transform:uppercase;letter-spacing:0.5px}
|
||||
input[type=text],input[type=password],textarea{width:100%;background:#0d1117;border:1px solid #2d333b;color:#e6edf3;padding:10px 12px;font-family:inherit;font-size:14px;border-radius:0;margin-top:6px;transition:border-color .15s;resize:vertical}
|
||||
input[type=text]:focus,input[type=password]:focus,textarea:focus{outline:none;border-color:#58a6ff}
|
||||
textarea{font-family:inherit;line-height:1.5;min-height:80px}
|
||||
|
||||
button{background:#1f6feb;color:#fff;border:none;padding:10px 20px;font-family:inherit;font-size:14px;font-weight:500;cursor:pointer;border-radius:0;transition:background .15s;margin-right:8px}
|
||||
button:hover:not(:disabled){background:#388bfd}
|
||||
button:disabled{background:#21262d;color:#545d68;cursor:not-allowed}
|
||||
button.secondary{background:#21262d;color:#c9d1d9}
|
||||
button.secondary:hover:not(:disabled){background:#30363d}
|
||||
button.danger{background:#da3633}
|
||||
button.danger:hover:not(:disabled){background:#f85149}
|
||||
|
||||
.notice{background:#0a0c10;border:1px solid #1f242c;padding:14px 18px;margin-bottom:16px;font-size:13px;color:#b0b8c4;line-height:1.7}
|
||||
.notice strong{color:#e6edf3}
|
||||
|
||||
.kv{display:grid;grid-template-columns:140px 1fr;gap:8px;margin:6px 0;font-size:13px}
|
||||
.kv .k{color:#7d8590;text-transform:uppercase;font-size:11px;letter-spacing:0.5px;padding-top:2px}
|
||||
.kv .v{color:#e6edf3;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px}
|
||||
|
||||
.hash{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11px;color:#7ee787;word-break:break-all;background:#0a0c10;padding:8px 12px;border:1px solid #1f242c;margin:6px 0}
|
||||
|
||||
.error{background:#3a0f0f;border:1px solid #f85149;color:#ffa198;padding:12px 16px;margin:12px 0;font-size:13px;display:none}
|
||||
.error.show{display:block}
|
||||
.error code{color:#fff}
|
||||
|
||||
.foot{margin-top:32px;padding-top:16px;border-top:1px solid #171d27;font-size:11px;color:#545d68;text-transform:uppercase;letter-spacing:0.5px}
|
||||
.foot a{color:#58a6ff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bar">
|
||||
<h1>⚡ Biometric Consent Withdrawal</h1>
|
||||
<span class="step" id="step-label">step 1 of 3</span>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<!-- Step 1: operator auth -->
|
||||
<div id="screen-token" class="screen active">
|
||||
<h2>Operator authentication</h2>
|
||||
<p class="lede">Withdrawal is operator-recorded on behalf of the candidate. Paste the legal-tier audit token + your name.</p>
|
||||
<div class="card">
|
||||
<label for="token-input">Legal audit token</label>
|
||||
<input type="password" id="token-input" placeholder="44-char token from /etc/lakehouse/legal_audit.token" autocomplete="off">
|
||||
<label for="op-name" style="margin-top:14px">Your name (operator of record)</label>
|
||||
<input type="text" id="op-name" placeholder="First Last" autocomplete="off">
|
||||
<div style="margin-top:14px">
|
||||
<button id="token-submit">Continue →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error" id="token-error"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: withdrawal form -->
|
||||
<div id="screen-form" class="screen">
|
||||
<h2>Record withdrawal</h2>
|
||||
<p class="lede">The candidate has requested withdrawal of biometric consent. This action sets a <strong>30-day SLA clock</strong> for destruction (per consent template v1 §2). The retention sweep + erase runbook handle actual destruction; this endpoint records intent + starts the clock.</p>
|
||||
|
||||
<div class="notice warn">
|
||||
<strong>What withdrawal does:</strong> sets <code>consent.biometric.status = Withdrawn</code>, accelerates <code>retention_until</code> from the 18-month default to <strong>30 days from now</strong>. Future photo uploads will be refused (403). General-PII consent is NOT touched — the candidate can keep their non-biometric data on the platform.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<label for="cand-id">Candidate ID</label>
|
||||
<input type="text" id="cand-id" placeholder="WORKER-XXX" autocomplete="off">
|
||||
|
||||
<label for="reason" style="margin-top:14px">Reason for withdrawal</label>
|
||||
<textarea id="reason" placeholder="Free-text. Captured to the audit row for legal traceability. e.g., 'candidate emailed 2026-05-05 asking to remove their photo, no specific reason given'"></textarea>
|
||||
|
||||
<label for="evidence" style="margin-top:14px">Evidence path (optional)</label>
|
||||
<input type="text" id="evidence" placeholder="/path/to/email_thread.eml or scanned signed paper" autocomplete="off">
|
||||
|
||||
<div style="margin-top:16px">
|
||||
<button id="submit-btn" class="danger" disabled>Record withdrawal</button>
|
||||
<button id="back-btn" class="secondary">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="error" id="form-error"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: confirmation -->
|
||||
<div id="screen-done" class="screen">
|
||||
<div class="card good">
|
||||
<h2>✓ Withdrawal recorded</h2>
|
||||
<p class="lede" style="margin:8px 0 0">Audit chain row appended; retention sweep will pick it up at the SLA.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>State change</h3>
|
||||
<div class="kv">
|
||||
<span class="k">Candidate</span><span class="v" id="done-cid">…</span>
|
||||
<span class="k">Status</span><span class="v" id="done-status">Withdrawn</span>
|
||||
<span class="k">Withdrawn at</span><span class="v" id="done-withdrawn-at">…</span>
|
||||
<span class="k">Retention until</span><span class="v" id="done-retention">…</span>
|
||||
</div>
|
||||
<h3>Audit row hmac</h3>
|
||||
<div class="hash" id="done-hmac">…</div>
|
||||
</div>
|
||||
|
||||
<div class="card warn">
|
||||
<h3>What happens next</h3>
|
||||
<p>The retention sweep flags this subject as overdue once <code>retention_until</code> passes. An operator with legal-tier credentials runs the destruction runbook (<code>POST /biometric/subject/<id>/erase</code>) within the 30-day SLA.</p>
|
||||
<p style="margin-top:8px">To verify the withdrawal landed cleanly:</p>
|
||||
<div class="hash" id="verify-cmd">curl -H "X-Lakehouse-Legal-Token: $TOKEN" http://localhost:3100/audit/subject/<id></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px">
|
||||
<button onclick="location.reload()" class="secondary">Record another withdrawal</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="foot">
|
||||
<a href="https://git.agentview.dev/profit/lakehouse/src/branch/main/docs/policies/consent/biometric_consent_template_v1.md">Consent template v1</a>
|
||||
· <a href="https://git.agentview.dev/profit/lakehouse/src/branch/main/docs/runbooks/BIPA_DESTRUCTION_RUNBOOK.md">Destruction runbook</a>
|
||||
· <a href="https://git.agentview.dev/profit/lakehouse/src/branch/main/docs/PHASE_1_6_BIPA_GATES.md">Phase 1.6 BIPA Gates</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const GATEWAY = (function(){
|
||||
const p = new URLSearchParams(location.search);
|
||||
return p.get('gw') || 'http://localhost:3100';
|
||||
})();
|
||||
|
||||
const state = { token: null, operator: null, response: null };
|
||||
|
||||
function show(id) {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
const map = {'screen-token':'step 1 of 3', 'screen-form':'step 2 of 3', 'screen-done':'step 3 of 3'};
|
||||
document.getElementById('step-label').textContent = map[id] || '';
|
||||
}
|
||||
function err(id, msg){ const e=document.getElementById(id); e.textContent=msg; e.classList.add('show'); }
|
||||
function clearErr(id){ document.getElementById(id).classList.remove('show'); }
|
||||
|
||||
// Pre-populate candidate_id from URL if present.
|
||||
(function init(){
|
||||
const p = new URLSearchParams(location.search);
|
||||
const cid = p.get('candidate_id') || p.get('cid');
|
||||
if (cid) document.getElementById('cand-id').value = cid;
|
||||
// Restore token 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;
|
||||
}
|
||||
})();
|
||||
|
||||
// Step 1.
|
||||
document.getElementById('token-submit').addEventListener('click', () => {
|
||||
clearErr('token-error');
|
||||
const v = document.getElementById('token-input').value.trim();
|
||||
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.'); return; }
|
||||
state.token = tok;
|
||||
state.operator = op;
|
||||
sessionStorage.setItem('lh_legal_token', tok);
|
||||
sessionStorage.setItem('lh_operator_name', op);
|
||||
show('screen-form');
|
||||
refreshSubmit();
|
||||
});
|
||||
|
||||
// Step 2.
|
||||
function refreshSubmit() {
|
||||
const cid = document.getElementById('cand-id').value.trim();
|
||||
const reason = document.getElementById('reason').value.trim();
|
||||
document.getElementById('submit-btn').disabled = !(cid.length > 0 && reason.length >= 10);
|
||||
}
|
||||
['cand-id','reason'].forEach(id => document.getElementById(id).addEventListener('input', refreshSubmit));
|
||||
|
||||
document.getElementById('back-btn').addEventListener('click', () => show('screen-token'));
|
||||
|
||||
document.getElementById('submit-btn').addEventListener('click', async () => {
|
||||
clearErr('form-error');
|
||||
document.getElementById('submit-btn').disabled = true;
|
||||
try {
|
||||
const cid = document.getElementById('cand-id').value.trim();
|
||||
const body = {
|
||||
reason: document.getElementById('reason').value.trim(),
|
||||
operator_of_record: state.operator,
|
||||
evidence_path: document.getElementById('evidence').value.trim(),
|
||||
};
|
||||
const r = await fetch(`${GATEWAY}/biometric/subject/${encodeURIComponent(cid)}/withdraw`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Lakehouse-Legal-Token': state.token,
|
||||
'X-Lakehouse-Trace-Id': 'withdraw-' + Date.now(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const respBody = await r.json();
|
||||
if (!r.ok) {
|
||||
err('form-error', `${r.status} ${respBody.error || 'unknown'} — ${respBody.detail || ''}`);
|
||||
refreshSubmit();
|
||||
return;
|
||||
}
|
||||
state.response = respBody;
|
||||
document.getElementById('done-cid').textContent = respBody.candidate_id;
|
||||
document.getElementById('done-status').textContent = respBody.status_after;
|
||||
document.getElementById('done-withdrawn-at').textContent = respBody.withdrawn_at;
|
||||
document.getElementById('done-retention').textContent = respBody.retention_until;
|
||||
document.getElementById('done-hmac').textContent = respBody.audit_row_hmac;
|
||||
document.getElementById('verify-cmd').textContent =
|
||||
`curl -H "X-Lakehouse-Legal-Token: $TOKEN" ${GATEWAY}/audit/subject/${respBody.candidate_id}`;
|
||||
show('screen-done');
|
||||
} catch (e) {
|
||||
err('form-error', `Network error: ${e.message}`);
|
||||
refreshSubmit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
33
ops/systemd/lakehouse-retention-sweep.service
Normal file
33
ops/systemd/lakehouse-retention-sweep.service
Normal file
@ -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_<date>.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
|
||||
18
ops/systemd/lakehouse-retention-sweep.timer
Normal file
18
ops/systemd/lakehouse-retention-sweep.timer
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user