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:
@@ -507,6 +507,41 @@ pub struct SubjectManifest {
|
||||
/// Empty until first audit row written.
|
||||
#[serde(default)]
|
||||
pub audit_log_chain_root: String,
|
||||
/// Biometric collection metadata. None until the first biometric
|
||||
/// upload via Phase 1.6 Gate 3 photo endpoint. Cleared on erasure.
|
||||
/// `serde(default)` so existing manifests parse without re-emit.
|
||||
#[serde(default)]
|
||||
pub biometric_collection: Option<BiometricCollection>,
|
||||
}
|
||||
|
||||
/// Per-subject biometric collection record. Written by the Gate 3
|
||||
/// photo-upload endpoint when a candidate's photo is accepted under
|
||||
/// `consent.biometric.status == "given"`.
|
||||
///
|
||||
/// Reset to None by the BIPA erasure flow (destruction runbook).
|
||||
/// Preserved across schema evolutions via the BTreeMap-canonicalized
|
||||
/// HMAC chain — old audit rows verify regardless of new fields.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BiometricCollection {
|
||||
/// Quarantined filesystem path of the original photo bytes,
|
||||
/// relative to the configured biometric storage root. NEVER under
|
||||
/// `data/headshots/` (which is reserved for synthetic StyleGAN
|
||||
/// faces). Format: `data/biometric/uploads/<sanitized_id>/<ts>.<ext>`.
|
||||
pub data_path: String,
|
||||
/// SHA-256 of the original photo bytes. Used for integrity check
|
||||
/// (file-on-disk matches what was uploaded), NOT for
|
||||
/// re-derivation of the original from the hash.
|
||||
pub template_hash: String,
|
||||
pub collected_at: chrono::DateTime<chrono::Utc>,
|
||||
/// SHA-256 of the consent template version that was in force when
|
||||
/// this photo was uploaded. Anchors the consent record to a
|
||||
/// specific document version (per Phase 1.6 Gate 2 §6).
|
||||
#[serde(default)]
|
||||
pub consent_version_hash: String,
|
||||
/// deepface classifications, when the offline classifier has run.
|
||||
/// None until classifier integration ships (Gate 3b).
|
||||
#[serde(default)]
|
||||
pub classifications: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_subject_manifest_schema() -> String { "subject_manifest.v1".into() }
|
||||
|
||||
Reference in New Issue
Block a user