phase 1.6 Gate 3a: photo upload endpoint with consent gate
Per docs/PHASE_1_6_BIPA_GATES.md §1 Gate 3 (consent-gate substrate).
Deepface classification (Gate 3b) deferred to its own session — needs
Python subprocess design conversation after the 2026-05-02 sidecar drop.
What ships:
shared/types.rs:
- new BiometricCollection sub-struct: data_path, template_hash,
collected_at, consent_version_hash, classifications (Option<JSON>)
- SubjectManifest gains biometric_collection: Option<BiometricCollection>
with #[serde(default)] so existing on-disk manifests parse and
re-emit without drift
catalogd/biometric_endpoint.rs (NEW, ~600 LOC):
POST /subject/{candidate_id}/photo
- Auth: X-Lakehouse-Legal-Token, constant-time-eq compared against
same legal token file as /audit. Same 32-byte minimum.
- Content-Type: must be image/jpeg or image/png (415 otherwise)
- Body: raw image bytes, max 10MB
- 401: missing or wrong token
- 404: subject not registered
- 403: consent.biometric.status != "given" (returns current status)
- 403: subject status in {Withdrawn, Erased, RetentionExpired}
- 200: writes photo to data/biometric/uploads/<sanitized_id>/<ts>.<ext>
with mode 0700 dir + 0600 file, updates SubjectManifest with
BiometricCollection record, appends audit row
(kind="biometric_collection", purpose="photo_upload"), returns
UploadResponse with template_hash + audit_row_hmac.
Logic split: pure async fn process_upload() takes the headers-as-args
so unit tests exercise every branch without HTTP machinery; the
axum handler is just glue. 10 tests covering all 4 reject paths +
happy path + repeated uploads chaining + structural assertion that
the quarantine path is NOT under data/headshots/ (synthetic faces).
gateway/main.rs:
Mounts /biometric on the same condition as /audit — only when the
SubjectAuditWriter is present AND the legal token loads. Storage
root configurable via LH_BIOMETRIC_STORAGE_ROOT (default
./data/biometric/uploads).
Live verification on the running gateway (post-restart):
- GET /biometric/health → "biometric endpoint ready"
- POST without token → 401 auth_failed
- POST with token, no consent → 403 consent_required (status=NeverCollected)
- Flipped WORKER-2 to consent=given, POST → 200 with hash + path
- File at data/biometric/uploads/WORKER-2/<ts>.jpg, mode 0600
- Manifest biometric_collection field reflects the upload
- Audit row chain links cleanly off the prior validator_lookup row
- GET /audit/subject/WORKER-2 returns chain_verified=true, 2 rows
- Cross-runtime parity probe still 6/6 byte-identical post-change
Phase 1.6 status table updated: Gate 3a DONE, Gate 3b (deepface)
deferred. Calendar bottleneck remains counsel review of items 1/2/5/6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -430,13 +430,33 @@ async fn main() {
|
||||
.unwrap_or_else(|_| "/etc/lakehouse/legal_audit.token".into());
|
||||
let audit_state = catalogd::audit_endpoint::AuditEndpointState::new(
|
||||
registry.clone(),
|
||||
writer,
|
||||
writer.clone(),
|
||||
std::path::Path::new(&legal_token_path),
|
||||
).await;
|
||||
app = app.nest("/audit", catalogd::audit_endpoint::router(audit_state));
|
||||
tracing::info!("audit endpoint mounted at /audit (legal token: {})", legal_token_path);
|
||||
|
||||
// Phase 1.6 Gate 3 — biometric photo upload endpoint. Same
|
||||
// legal-token gate as /audit (writes to legal-tier storage are
|
||||
// privileged). Storage root defaults to ./data/biometric/uploads
|
||||
// unless overridden via LH_BIOMETRIC_STORAGE_ROOT (operators on
|
||||
// multi-host deployments will want a dedicated mount point).
|
||||
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"));
|
||||
let biometric_state = catalogd::biometric_endpoint::BiometricEndpointState::new(
|
||||
registry.clone(),
|
||||
writer,
|
||||
std::path::Path::new(&legal_token_path),
|
||||
biometric_storage_root.clone(),
|
||||
).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
|
||||
);
|
||||
} else {
|
||||
tracing::warn!("/audit endpoint NOT mounted — subject_audit writer is None (no signing key)");
|
||||
tracing::warn!("/audit + /biometric endpoints NOT mounted — subject_audit writer is None (no signing key)");
|
||||
}
|
||||
|
||||
// Auth middleware (if enabled) — P5-001 fix 2026-04-23:
|
||||
|
||||
Reference in New Issue
Block a user