phase 1.6: ops dashboard + consent_versions allowlist + subject timeline tool
Closes the afternoon's "all four" wave (per J's request to do all the
items in one pass instead of pick-one-of-options):
(1) Live demo on WORKER-100 — full lifecycle exercised end-to-end
against the running gateway. 3 audit rows landed in correct
order (consent_grant → biometric_collection →
consent_withdrawal), chain_verified=true, photo on disk at
data/biometric/uploads/WORKER-100/1778011967957907731_027b6bb1.jpg
(180 bytes JFIF). retention_until=2026-06-04 (30d from
withdrawal per consent template v1 §2).
(2) GET /biometric/stats — read-only aggregate over all subjects.
Returns counts by biometric.status + subject.status, photo
count, oldest_active_retention_until, and the last 20
state-change events (consent_grant / collection / withdrawal /
erasure — validator_lookup and other noise filtered out).
Walks per-subject audit logs via the existing writer; cheap
for 100 subjects, would want an event-stream index at 100k.
Legal-tier auth (same posture as /audit). 4 unit tests.
(3) /biometric/dashboard mcp-server frontend. Auto-refreshes
/biometric/stats every 15s, neo-brutalist tile layout for
the per-status counts + retention horizon block + recent
events table with kind badges + event-kind breakdown pills.
sessionStorage-backed token; logout button clears state.
DOM-built throughout (textContent + createElement) — never
innerHTML on audit-row values, since trace_id et al. could
in theory carry operator-supplied strings.
(4) consent_versions allowlist. BiometricEndpointState gains
`allowed_consent_versions: Option<Arc<HashSet<String>>>`,
loaded at startup from /etc/lakehouse/consent_versions.json
(override via LH_CONSENT_VERSIONS_FILE). process_consent
refuses unknown hashes with HTTP 400 consent_version_unknown
when configured. Resolution semantics:
- Missing file → permissive (v1 compat, warn-log)
- Parse error → permissive (error-log; broken config
silently going strict would be worse)
- Empty array → strict, refuse all (deliberate freeze
mode for "counsel hasn't signed v1 yet")
- Populated → strict, lowercase-normalized comparison
5 unit tests (known/unknown/case/empty/none-permissive).
Example template at ops/consent_versions.example.json with
a counsel-tier deployment note.
(5) scripts/staffing/subject_timeline.sh — operator one-shot
pretty-print of any subject's full BIPA lifecycle. Curls
/audit/subject/{id} with legal token; renders manifest
summary + on-disk photo state + chronological audit chain
with kind badges + chain verification status. Smoke-tested
on WORKER-100 (3 rows verified).
(6) STATE_OF_PLAY.md refresh. New section "afternoon wave"
captures all four commits (76cb5ac, 7f0f500, 68d226c, this
one) + the live demo evidence + the v1 endpoint matrix +
UI/CLI inventory + the production-cutover blocking set
(counsel calendar only — eng substrate is done).
Verified live post-restart:
- /audit/health + /biometric/health both 200
- /biometric/stats returns 100 subjects, 2 withdrawn (WORKER-2 from
earlier scrum + WORKER-100 from today's demo), 1 photo on record,
6 recent state-change events
- /biometric/intake + /biometric/withdraw + /biometric/dashboard
all 200 on mcp-server :3700
- subject_timeline.sh on WORKER-100: chain_verified=true,
chain_root=a47563ff937d50de…
- 88/88 catalogd lib tests + 55/55 biometric_endpoint tests green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,22 @@ pub struct BiometricEndpointState {
|
||||
/// Default: `<repo>/data/biometric/uploads`. Set per host via
|
||||
/// LH_BIOMETRIC_STORAGE_ROOT env var.
|
||||
pub storage_root: PathBuf,
|
||||
/// Optional allowlist of accepted `consent_version_hash` values.
|
||||
/// `None` = permissive (any non-empty hash accepted, the v1
|
||||
/// behavior). `Some(set)` = strict (refuse anything not in the
|
||||
/// set). Loaded from a JSON config file at startup if one exists.
|
||||
/// Counsel-tier production deployments should populate this so
|
||||
/// operator typos / stale templates / drift can't silently land
|
||||
/// invalid consent records.
|
||||
pub allowed_consent_versions: Option<Arc<std::collections::HashSet<String>>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ConsentVersionsConfig {
|
||||
/// Array of accepted SHA-256 hash strings. Each must be 64 hex
|
||||
/// chars (lowercase). Comments / metadata can ride on the JSON
|
||||
/// object itself; the parser only consumes "versions".
|
||||
versions: Vec<String>,
|
||||
}
|
||||
|
||||
impl BiometricEndpointState {
|
||||
@@ -116,7 +132,76 @@ impl BiometricEndpointState {
|
||||
let _ = std::fs::set_permissions(&storage_root, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
}
|
||||
Self { registry, writer, legal_token, storage_root }
|
||||
Self {
|
||||
registry,
|
||||
writer,
|
||||
legal_token,
|
||||
storage_root,
|
||||
allowed_consent_versions: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a JSON allowlist of accepted consent_version_hash values.
|
||||
/// File shape:
|
||||
/// `{"versions": ["<sha256-hex>", "<sha256-hex>", ...]}`
|
||||
///
|
||||
/// Resolution rules:
|
||||
/// - File missing or unreadable → permissive (None). Logged as
|
||||
/// a warning so the operator sees the deployment is in v1
|
||||
/// compatibility mode.
|
||||
/// - File present but parse fails → permissive (None) + ERROR
|
||||
/// log (broken config silently degrading to permissive is
|
||||
/// wrong; flag loudly).
|
||||
/// - File present + parses + has ≥1 entry → strict mode
|
||||
/// (Some(set)). Each hash is normalized to lowercase before
|
||||
/// insertion to prevent operator-case drift.
|
||||
/// - File present + empty `versions` array → strict (refuse
|
||||
/// all consent grants). This is "intentionally locked,
|
||||
/// waiting for counsel to publish v1 hash" mode.
|
||||
pub async fn with_consent_versions(mut self, path: &std::path::Path) -> Self {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(body) => {
|
||||
match serde_json::from_str::<ConsentVersionsConfig>(&body) {
|
||||
Ok(cfg) => {
|
||||
let set: std::collections::HashSet<String> = cfg
|
||||
.versions
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_ascii_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
tracing::info!(
|
||||
"biometric endpoint: consent_versions allowlist loaded from {} ({} entries)",
|
||||
path.display(), set.len()
|
||||
);
|
||||
self.allowed_consent_versions = Some(Arc::new(set));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"biometric endpoint: consent_versions file at {} failed to parse ({e}); \
|
||||
running in PERMISSIVE mode (any non-empty hash accepted). \
|
||||
Fix the file or remove it to silence this warning.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::warn!(
|
||||
"biometric endpoint: consent_versions allowlist not found at {} — \
|
||||
running in PERMISSIVE mode (any non-empty hash accepted). \
|
||||
For counsel-tier deployment, create the file with the signed v1 hash.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"biometric endpoint: consent_versions file at {} unreadable ({e}); \
|
||||
running in PERMISSIVE mode.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +211,7 @@ pub fn router(state: BiometricEndpointState) -> Router {
|
||||
.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("/stats", get(stats_handler))
|
||||
.route("/health", get(biometric_health))
|
||||
.layer(DefaultBodyLimit::max(MAX_PHOTO_BYTES))
|
||||
.with_state(state)
|
||||
@@ -920,6 +1006,26 @@ pub async fn process_consent(
|
||||
consent_status: None,
|
||||
}));
|
||||
}
|
||||
// Allowlist enforcement (counsel-tier deployment safety). When
|
||||
// `allowed_consent_versions` is configured, refuse any hash not
|
||||
// in the set. Compared lowercase to defeat operator-case drift.
|
||||
// Permissive mode (None) keeps v1 compatibility for deployments
|
||||
// that haven't published a signed allowlist yet.
|
||||
if let Some(allow) = state.allowed_consent_versions.as_ref() {
|
||||
let normalized = req.consent_version_hash.trim().to_ascii_lowercase();
|
||||
if !allow.contains(&normalized) {
|
||||
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||
error: "consent_version_unknown",
|
||||
detail: format!(
|
||||
"consent_version_hash {} not in the configured allowlist; \
|
||||
this gateway accepts only hashes published in /etc/lakehouse/consent_versions.json. \
|
||||
Contact the deployment operator if a new template version needs to be authorized.",
|
||||
normalized,
|
||||
),
|
||||
consent_status: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
if req.operator_of_record.trim().is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, ErrorResponse {
|
||||
error: "bad_request",
|
||||
@@ -1342,6 +1448,193 @@ pub async fn process_withdraw(
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Stats endpoint (ops dashboard backend) ──────────────────────
|
||||
//
|
||||
// Read-only aggregate over all subjects. Returns counts by biometric
|
||||
// + subject status, total photo count, oldest active retention_until,
|
||||
// and the last 20 audit events across all subjects (consent_grant /
|
||||
// biometric_collection / consent_withdrawal / erasure rows). Used by
|
||||
// the /biometric/dashboard ops UI page.
|
||||
//
|
||||
// Auth: legal-tier (same posture as /audit/subject). The aggregate
|
||||
// numbers leak nothing per-subject by themselves, but the "recent
|
||||
// events" list contains candidate_ids — counsel-tier protection
|
||||
// is appropriate.
|
||||
|
||||
const STATS_RESPONSE_SCHEMA: &str = "biometric_stats_response.v1";
|
||||
const STATS_RECENT_EVENTS_LIMIT: usize = 20;
|
||||
|
||||
#[derive(Serialize, Debug, Default)]
|
||||
pub struct StatsCounts {
|
||||
pub never_collected: usize,
|
||||
pub pending: usize,
|
||||
pub given: usize,
|
||||
pub withdrawn: usize,
|
||||
pub expired: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Default)]
|
||||
pub struct SubjectStatusCounts {
|
||||
pub pending_consent: usize,
|
||||
pub active: usize,
|
||||
pub withdrawn: usize,
|
||||
pub retention_expired: usize,
|
||||
pub erased: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct StatsEvent {
|
||||
pub ts: chrono::DateTime<chrono::Utc>,
|
||||
pub candidate_id: String,
|
||||
pub kind: String,
|
||||
pub result: String,
|
||||
pub trace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct StatsResponse {
|
||||
pub schema: &'static str,
|
||||
pub generated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub total_subjects: usize,
|
||||
pub biometric_status: StatsCounts,
|
||||
pub subject_status: SubjectStatusCounts,
|
||||
pub photos_on_record: usize,
|
||||
pub oldest_active_retention_until: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub upcoming_destruction_window_days: Option<i64>,
|
||||
pub recent_events: Vec<StatsEvent>,
|
||||
pub recent_event_counts: std::collections::BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
async fn stats_handler(
|
||||
State(state): State<BiometricEndpointState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
let auth_token = headers
|
||||
.get(LEGAL_TOKEN_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
match process_stats(&state, auth_token.as_deref()).await {
|
||||
Ok(resp) => (StatusCode::OK, Json(resp)).into_response(),
|
||||
Err((status, err)) => (status, Json(err)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn process_stats(
|
||||
state: &BiometricEndpointState,
|
||||
legal_token: Option<&str>,
|
||||
) -> Result<StatsResponse, (StatusCode, ErrorResponse)> {
|
||||
// Auth (same posture as upload/erase/consent/withdraw).
|
||||
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 },
|
||||
));
|
||||
}
|
||||
|
||||
use shared::types::{BiometricConsentStatus, SubjectStatus};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let subjects = state.registry.list_subjects().await;
|
||||
|
||||
let mut bio = StatsCounts::default();
|
||||
let mut subj = SubjectStatusCounts::default();
|
||||
let mut photos_on_record = 0usize;
|
||||
let mut oldest_active_retention: Option<chrono::DateTime<chrono::Utc>> = None;
|
||||
|
||||
for s in &subjects {
|
||||
match s.consent.biometric.status {
|
||||
BiometricConsentStatus::NeverCollected => bio.never_collected += 1,
|
||||
BiometricConsentStatus::Pending => bio.pending += 1,
|
||||
BiometricConsentStatus::Given => bio.given += 1,
|
||||
BiometricConsentStatus::Withdrawn => bio.withdrawn += 1,
|
||||
BiometricConsentStatus::Expired => bio.expired += 1,
|
||||
}
|
||||
match s.status {
|
||||
SubjectStatus::PendingConsent => subj.pending_consent += 1,
|
||||
SubjectStatus::Active => subj.active += 1,
|
||||
SubjectStatus::Withdrawn => subj.withdrawn += 1,
|
||||
SubjectStatus::RetentionExpired => subj.retention_expired += 1,
|
||||
SubjectStatus::Erased => subj.erased += 1,
|
||||
}
|
||||
if s.biometric_collection.is_some() {
|
||||
photos_on_record += 1;
|
||||
}
|
||||
// Oldest-active = earliest retention_until among Given subjects
|
||||
// (these are the subjects whose data is currently retained
|
||||
// under canonical consent — Withdrawn subjects also have
|
||||
// retention_until set but as a 30-day countdown to destruction,
|
||||
// surfaced separately).
|
||||
if matches!(s.consent.biometric.status, BiometricConsentStatus::Given) {
|
||||
if let Some(t) = s.consent.biometric.retention_until {
|
||||
oldest_active_retention = Some(match oldest_active_retention {
|
||||
Some(prev) if prev < t => prev,
|
||||
_ => t,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let upcoming_destruction_window_days = oldest_active_retention.map(|t| (t - now).num_days());
|
||||
|
||||
// Walk every subject's audit log; collect erasure / consent /
|
||||
// upload / withdrawal rows. Sort by ts desc, keep first
|
||||
// STATS_RECENT_EVENTS_LIMIT. For 100 subjects this is cheap;
|
||||
// future-proofing against 100k would want a separate event-stream
|
||||
// index, out of scope for v1.
|
||||
let mut events: Vec<StatsEvent> = Vec::new();
|
||||
let mut event_counts: std::collections::BTreeMap<String, usize> = Default::default();
|
||||
for s in &subjects {
|
||||
let rows = state
|
||||
.writer
|
||||
.read_rows_in_range(&s.candidate_id, None, None)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for r in rows {
|
||||
// Filter: only state-changing events (skip validator_lookup
|
||||
// / gateway_lookup / etc. — they're noise for an ops dash).
|
||||
match r.accessor.kind.as_str() {
|
||||
"biometric_consent_grant"
|
||||
| "biometric_collection"
|
||||
| "biometric_consent_withdrawal"
|
||||
| "biometric_erasure"
|
||||
| "full_erasure" => {}
|
||||
_ => continue,
|
||||
}
|
||||
*event_counts.entry(r.accessor.kind.clone()).or_insert(0) += 1;
|
||||
events.push(StatsEvent {
|
||||
ts: r.ts,
|
||||
candidate_id: r.candidate_id.clone(),
|
||||
kind: r.accessor.kind.clone(),
|
||||
result: r.result.clone(),
|
||||
trace_id: r.accessor.trace_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
events.sort_by(|a, b| b.ts.cmp(&a.ts));
|
||||
events.truncate(STATS_RECENT_EVENTS_LIMIT);
|
||||
|
||||
Ok(StatsResponse {
|
||||
schema: STATS_RESPONSE_SCHEMA,
|
||||
generated_at: now,
|
||||
total_subjects: subjects.len(),
|
||||
biometric_status: bio,
|
||||
subject_status: subj,
|
||||
photos_on_record,
|
||||
oldest_active_retention_until: oldest_active_retention,
|
||||
upcoming_destruction_window_days,
|
||||
recent_events: events,
|
||||
recent_event_counts: event_counts,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1377,6 +1670,7 @@ mod tests {
|
||||
writer,
|
||||
legal_token: Some(Arc::new(TEST_TOKEN.into())),
|
||||
storage_root,
|
||||
allowed_consent_versions: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2249,4 +2543,188 @@ mod tests {
|
||||
// Withdraw row chains off the grant's hmac.
|
||||
assert_eq!(rows[1].prev_chain_hash, grant.audit_row_hmac);
|
||||
}
|
||||
|
||||
// ─── Stats endpoint tests ──────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_missing_token_rejected() {
|
||||
let state = fixture_state("stats_no_token").await;
|
||||
let err = process_stats(&state, None).await.unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_aggregates_subjects_by_status() {
|
||||
let state = fixture_state("stats_aggregate").await;
|
||||
// Plant a mix of states.
|
||||
for (id, bs, ss) in [
|
||||
("WORKER-S1", BiometricConsentStatus::NeverCollected, SubjectStatus::Active),
|
||||
("WORKER-S2", BiometricConsentStatus::NeverCollected, SubjectStatus::Active),
|
||||
("WORKER-S3", BiometricConsentStatus::Given, SubjectStatus::Active),
|
||||
("WORKER-S4", BiometricConsentStatus::Given, SubjectStatus::Active),
|
||||
("WORKER-S5", BiometricConsentStatus::Given, SubjectStatus::Active),
|
||||
("WORKER-S6", BiometricConsentStatus::Withdrawn, SubjectStatus::Active),
|
||||
("WORKER-S7", BiometricConsentStatus::Pending, SubjectStatus::PendingConsent),
|
||||
("WORKER-S8", BiometricConsentStatus::Expired, SubjectStatus::Erased),
|
||||
] {
|
||||
let _ = state.registry.put_subject(fixture_manifest(id, bs, ss)).await;
|
||||
}
|
||||
let stats = process_stats(&state, Some(TEST_TOKEN)).await.unwrap();
|
||||
assert_eq!(stats.total_subjects, 8);
|
||||
assert_eq!(stats.biometric_status.never_collected, 2);
|
||||
assert_eq!(stats.biometric_status.given, 3);
|
||||
assert_eq!(stats.biometric_status.withdrawn, 1);
|
||||
assert_eq!(stats.biometric_status.pending, 1);
|
||||
assert_eq!(stats.biometric_status.expired, 1);
|
||||
assert_eq!(stats.subject_status.active, 6);
|
||||
assert_eq!(stats.subject_status.pending_consent, 1);
|
||||
assert_eq!(stats.subject_status.erased, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_recent_events_filters_state_changes_only() {
|
||||
// The stats endpoint surfaces only state-changing event kinds
|
||||
// (consent_grant / collection / withdrawal / erasure) — not
|
||||
// gateway_lookup / validator_lookup / etc. which are noise
|
||||
// for an ops dashboard.
|
||||
let state = fixture_state("stats_event_filter").await;
|
||||
// Subject must start at NeverCollected so process_consent can flip
|
||||
// it to Given (otherwise the consent endpoint returns 409 already_given).
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-EV", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||
|
||||
// Plant a noisy validator_lookup row + a state-change row.
|
||||
use shared::types::AuditAccessor;
|
||||
let noise_row = SubjectAuditRow {
|
||||
schema: "subject_audit.v1".into(),
|
||||
ts: chrono::Utc::now(),
|
||||
candidate_id: "WORKER-EV".into(),
|
||||
accessor: AuditAccessor {
|
||||
kind: "validator_lookup".into(),
|
||||
daemon: "gateway".into(),
|
||||
purpose: "noise".into(),
|
||||
trace_id: String::new(),
|
||||
},
|
||||
fields_accessed: vec!["exists".into()],
|
||||
result: "not_found".into(),
|
||||
prev_chain_hash: String::new(),
|
||||
row_hmac: String::new(),
|
||||
};
|
||||
let _ = state.writer.append(noise_row).await;
|
||||
// grant a consent (state change) so chain has at least one
|
||||
// event the dashboard should surface.
|
||||
let _ = process_consent(&state, "WORKER-EV", Some(TEST_TOKEN), "trace-ev", fixture_consent_request())
|
||||
.await.unwrap();
|
||||
|
||||
let stats = process_stats(&state, Some(TEST_TOKEN)).await.unwrap();
|
||||
// recent_events must NOT contain the validator_lookup row.
|
||||
assert!(stats.recent_events.iter().all(|e| e.kind != "validator_lookup"));
|
||||
// The consent grant SHOULD be present.
|
||||
assert!(stats.recent_events.iter().any(|e| e.kind == "biometric_consent_grant" && e.candidate_id == "WORKER-EV"));
|
||||
// event_counts breakdown must exclude the noise.
|
||||
assert!(!stats.recent_event_counts.contains_key("validator_lookup"));
|
||||
assert_eq!(stats.recent_event_counts.get("biometric_consent_grant"), Some(&1));
|
||||
}
|
||||
|
||||
// ─── Consent version allowlist tests ──────────────────────────
|
||||
|
||||
async fn fixture_state_with_allowlist(name: &str, hashes: Vec<String>) -> BiometricEndpointState {
|
||||
let mut s = fixture_state(name).await;
|
||||
let set: std::collections::HashSet<String> = hashes.into_iter().collect();
|
||||
s.allowed_consent_versions = Some(Arc::new(set));
|
||||
s
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_allowlist_accepts_known_hash() {
|
||||
// Allowlist contains the fixture's consent_version_hash —
|
||||
// grant succeeds.
|
||||
let allowed = "abcdef0123456789".repeat(4); // matches fixture_consent_request
|
||||
let state = fixture_state_with_allowlist("al_known", vec![allowed]).await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL1", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||
let resp = process_consent(&state, "WORKER-AL1", Some(TEST_TOKEN), "trace-al", fixture_consent_request())
|
||||
.await.unwrap();
|
||||
assert_eq!(resp.status_after, "Given");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_allowlist_rejects_unknown_hash() {
|
||||
// Allowlist contains a different hash; the fixture's hash
|
||||
// is rejected with consent_version_unknown.
|
||||
let other = "0011223344556677".repeat(4);
|
||||
let state = fixture_state_with_allowlist("al_unknown", vec![other]).await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL2", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||
let err = process_consent(&state, "WORKER-AL2", Some(TEST_TOKEN), "trace-al", fixture_consent_request())
|
||||
.await.unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(err.1.error, "consent_version_unknown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_allowlist_normalizes_case() {
|
||||
// Operator might paste an uppercase hash; allowlist comparison
|
||||
// is lowercase to defeat that drift.
|
||||
let allowed_lower = "abcdef0123456789".repeat(4);
|
||||
let state = fixture_state_with_allowlist("al_case", vec![allowed_lower]).await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL3", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||
let mut req = fixture_consent_request();
|
||||
req.consent_version_hash = req.consent_version_hash.to_uppercase();
|
||||
let resp = process_consent(&state, "WORKER-AL3", Some(TEST_TOKEN), "trace-al", req)
|
||||
.await.unwrap();
|
||||
assert_eq!(resp.status_after, "Given");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_allowlist_empty_set_refuses_all() {
|
||||
// Strict-locked mode: operator created the file but populated
|
||||
// it with an empty array. Refuses every grant — used as a
|
||||
// deliberate freeze (e.g., counsel hasn't signed v1 yet).
|
||||
let state = fixture_state_with_allowlist("al_empty", vec![]).await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL4", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||
let err = process_consent(&state, "WORKER-AL4", Some(TEST_TOKEN), "trace-al", fixture_consent_request())
|
||||
.await.unwrap_err();
|
||||
assert_eq!(err.0, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(err.1.error, "consent_version_unknown");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_allowlist_none_is_permissive() {
|
||||
// The default fixture has allowed_consent_versions=None.
|
||||
// Grant succeeds with any non-empty hash (v1 compat behavior).
|
||||
let state = fixture_state("consent_allowlist_none").await;
|
||||
let _ = state.registry.put_subject(fixture_manifest("WORKER-AL5", BiometricConsentStatus::NeverCollected, SubjectStatus::Active)).await;
|
||||
let resp = process_consent(&state, "WORKER-AL5", Some(TEST_TOKEN), "trace-al", fixture_consent_request())
|
||||
.await.unwrap();
|
||||
assert_eq!(resp.status_after, "Given");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_oldest_retention_picks_earliest_given() {
|
||||
let state = fixture_state("stats_retention").await;
|
||||
let now = chrono::Utc::now();
|
||||
// Three Given subjects with different retention dates.
|
||||
let later = now + chrono::Duration::days(540); // 18 months
|
||||
let middle = now + chrono::Duration::days(300);
|
||||
let early = now + chrono::Duration::days(60); // earliest
|
||||
for (id, ret) in [("WORKER-R1", later), ("WORKER-R2", middle), ("WORKER-R3", early)] {
|
||||
let mut m = fixture_manifest(id, BiometricConsentStatus::Given, SubjectStatus::Active);
|
||||
m.consent.biometric.retention_until = Some(ret);
|
||||
let _ = state.registry.put_subject(m).await;
|
||||
}
|
||||
// Plus a Withdrawn subject with an even earlier retention date —
|
||||
// should NOT count for "oldest active" because it's not Given.
|
||||
let mut withdrawn = fixture_manifest("WORKER-R4", BiometricConsentStatus::Withdrawn, SubjectStatus::Active);
|
||||
withdrawn.consent.biometric.retention_until = Some(now + chrono::Duration::days(15));
|
||||
let _ = state.registry.put_subject(withdrawn).await;
|
||||
|
||||
let stats = process_stats(&state, Some(TEST_TOKEN)).await.unwrap();
|
||||
// The oldest_active should be the earliest Given retention (WORKER-R3, ~60d).
|
||||
// (Equality on DateTime<Utc> doesn't suffer the put_subject
|
||||
// microsecond-skew issue here because retention_until is a
|
||||
// pre-computed value we set ourselves; it round-trips intact.)
|
||||
assert_eq!(stats.oldest_active_retention_until, Some(early));
|
||||
let days = stats.upcoming_destruction_window_days.unwrap();
|
||||
// Allow ±1-day fudge for the now() taken inside process_stats.
|
||||
assert!((59..=61).contains(&days),
|
||||
"expected ~60 days, got {} (early={} now-ish={:?})", days, early, stats.generated_at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,16 +444,24 @@ async fn main() {
|
||||
let biometric_storage_root: std::path::PathBuf = std::env::var("LH_BIOMETRIC_STORAGE_ROOT")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("./data/biometric/uploads"));
|
||||
// consent_versions allowlist — counsel-tier deployment safety.
|
||||
// Defaults to /etc/lakehouse/consent_versions.json; override
|
||||
// via LH_CONSENT_VERSIONS_FILE. Missing file → permissive
|
||||
// mode (any non-empty hash accepted, the v1 behavior).
|
||||
let consent_versions_path: std::path::PathBuf = std::env::var("LH_CONSENT_VERSIONS_FILE")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from("/etc/lakehouse/consent_versions.json"));
|
||||
let biometric_state = catalogd::biometric_endpoint::BiometricEndpointState::new(
|
||||
registry.clone(),
|
||||
writer,
|
||||
std::path::Path::new(&legal_token_path),
|
||||
biometric_storage_root.clone(),
|
||||
).await;
|
||||
).await
|
||||
.with_consent_versions(&consent_versions_path).await;
|
||||
app = app.nest("/biometric", catalogd::biometric_endpoint::router(biometric_state));
|
||||
tracing::info!(
|
||||
"biometric endpoint mounted at /biometric (storage_root: {}, legal token: {})",
|
||||
biometric_storage_root.display(), legal_token_path
|
||||
"biometric endpoint mounted at /biometric (storage_root: {}, legal token: {}, consent_versions file: {})",
|
||||
biometric_storage_root.display(), legal_token_path, consent_versions_path.display()
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("/audit + /biometric endpoints NOT mounted — subject_audit writer is None (no signing key)");
|
||||
|
||||
Reference in New Issue
Block a user