feat(uploads): ADR-0011 manual-upload safety gate (Now slice)
Browse filesStandalone, non-agent-facing upload gate (quarantine -> validate -> admin register). Inert until wired to a UI: no MCP tool, no server.py change, no new dependency. See docs/adr/ADR-0011-upload-safety-gate.md. 24 new tests green. This PR does NOT deploy until merged into main.
- .gitignore +4 -0
- TODO.md +10 -0
- docs/adr/ADR-0010-dataset-integrity-verification.md +67 -0
- docs/adr/ADR-0011-upload-safety-gate.md +78 -0
- memory.md +39 -1
- src/core/integrity.py +43 -0
- src/uploads/__init__.py +37 -0
- src/uploads/promotion.py +80 -0
- src/uploads/records.py +117 -0
- src/uploads/staging.py +199 -0
- src/uploads/validation.py +116 -0
- tests/test_upload_gate.py +268 -0
.gitignore
CHANGED
|
@@ -10,3 +10,7 @@ tests/results/
|
|
| 10 |
tmp/
|
| 11 |
outputs/
|
| 12 |
reports/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
tmp/
|
| 11 |
outputs/
|
| 12 |
reports/
|
| 13 |
+
# ADR-0011 upload safety gate — local quarantine / promoted-manifest dirs
|
| 14 |
+
upload_staging/
|
| 15 |
+
upload_registered/
|
| 16 |
+
run_logs/
|
TODO.md
CHANGED
|
@@ -414,6 +414,16 @@ correct (single-dataset, Path B, ULM, 98.3% coverage); these are the gaps.
|
|
| 414 |
|
| 415 |
## Done (recent)
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
- 2026-06-22 — **TF semantic-annotation coverage + igraph network plot**
|
| 418 |
(commit `ed98c5b` on `main`, **NOT pushed**). Fixed 70 valid HGNC TF symbols
|
| 419 |
being mis-bucketed as `unresolved_label`: bundled `resources/semantic/hgnc_cache.tsv`
|
|
|
|
| 414 |
|
| 415 |
## Done (recent)
|
| 416 |
|
| 417 |
+
- 2026-07-02 — **ADR-0011 upload safety gate ("Now" slice)** — branch
|
| 418 |
+
`feat/adr-0011-upload-gate` (off `origin/main`), **NOT pushed, no deploy**. New non-agent-facing
|
| 419 |
+
`src/uploads/` (`stage_upload` → `validate_upload` → `register_upload`) + shared
|
| 420 |
+
`src/core/integrity.py` (`compute_sha256`/`verify_sha256`, reused later by ADR-0010). Quarantine
|
| 421 |
+
+ type/size allow-list + manifest-required + de-id attestation + validate-via-vetted-loader
|
| 422 |
+
(never-exec) + SHA-256 provenance into the ADR-0008 audit sink + admin-only registration
|
| 423 |
+
(`UPLOAD_ADMIN_IDS`, fail-closed). `tests/test_upload_gate.py` (24) green; reused suites (133)
|
| 424 |
+
green. ADR-0011 flipped Proposed→Accepted for this slice. **Follow-ups (open):** tabular
|
| 425 |
+
auto-validation (h5ad-only today); AWS staging bucket + pre-validation malware scan (ADR-0011
|
| 426 |
+
"At AWS"); wire `src/core/integrity.py` into `resolve_to_local_path` for ADR-0010 on-load verify.
|
| 427 |
- 2026-06-22 — **TF semantic-annotation coverage + igraph network plot**
|
| 428 |
(commit `ed98c5b` on `main`, **NOT pushed**). Fixed 70 valid HGNC TF symbols
|
| 429 |
being mis-bucketed as `unresolved_label`: bundled `resources/semantic/hgnc_cache.tsv`
|
docs/adr/ADR-0010-dataset-integrity-verification.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-0010 — Dataset Integrity / Tamper Verification on Load
|
| 2 |
+
|
| 3 |
+
**Status:** Proposed
|
| 4 |
+
**Date:** 2026-07-01
|
| 5 |
+
**Deciders:** Annie Voigt (project lead)
|
| 6 |
+
**Driver:** OHSU security review Q7 — "detect unavailable/modified/compromised datasets." Today
|
| 7 |
+
this is only *partial*: manifest validation catches schema/shape drift and load failures, but a
|
| 8 |
+
silently altered file with the same shape would not be caught.
|
| 9 |
+
**Related:** ADR-0007 Phase 4 (pre-stage-with-checksums), `biodata-registry` manifest schema
|
| 10 |
+
(where the expected hash lives).
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## Context
|
| 15 |
+
|
| 16 |
+
External datasets are fetched dynamically at analysis time from their declared
|
| 17 |
+
`expression_source` (GEO series matrix, GDC, hosted h5ad) and cached in-process (memory + disk)
|
| 18 |
+
for the container lifetime; local resolution goes through `src/core/data_io.resolve_to_local_path`
|
| 19 |
+
and the loaders (`src/tools/rna/loaders.py`, `src/workflows/geo.py`, `read_h5ad_cached`).
|
| 20 |
+
|
| 21 |
+
`dataset_validate_manifest_against_data` verifies **semantics** (feature-ID type, sample counts,
|
| 22 |
+
data level) — so a truncated download or a wrong-shape file is caught. What is *not* caught is
|
| 23 |
+
**content tampering that preserves shape**: same dimensions, altered values. For restricted
|
| 24 |
+
research data that is the gap the review is pointing at.
|
| 25 |
+
|
| 26 |
+
## Decision
|
| 27 |
+
|
| 28 |
+
Add **content-hash verification on load**, keyed off the manifest.
|
| 29 |
+
|
| 30 |
+
- **Expected hash in the manifest.** Extend the `biodata_registry` manifest with an optional
|
| 31 |
+
`integrity:` block: `sha256` (and/or per-file hashes for multi-file sources) recorded when a
|
| 32 |
+
dataset is first validated/registered. Optional so existing manifests keep loading; verification
|
| 33 |
+
is enforced only where a hash is present.
|
| 34 |
+
- **Verify at resolve time.** After `resolve_to_local_path` materializes a file (post-download,
|
| 35 |
+
pre-parse), compute its SHA-256 and compare to the manifest. On mismatch: **refuse to load**,
|
| 36 |
+
emit a clear integrity error naming the dataset, and never hand the file to the analysis tools
|
| 37 |
+
or the cache. This is a Layer-1-style *refusal* (cf. ADR-0002): it blocks, because a hash
|
| 38 |
+
mismatch on restricted data is not a "caution," it's a stop.
|
| 39 |
+
- **Cache is keyed to the verified file.** `read_h5ad_cached` already keys on path+mtime+size;
|
| 40 |
+
integrity check runs before the cache admit so a bad file is never cached.
|
| 41 |
+
- **Record-on-register.** A small helper computes and writes the hash when a dataset is validated
|
| 42 |
+
(external sources record on first successful validated fetch, acknowledging trust-on-first-use
|
| 43 |
+
for third-party sources).
|
| 44 |
+
|
| 45 |
+
## Plan
|
| 46 |
+
|
| 47 |
+
1. **Now (AWS-independent, ~2–3 days):** add the `integrity:` manifest field + schema test; add
|
| 48 |
+
SHA-256 verify in `resolve_to_local_path`; refusal path + test (good hash loads, altered file
|
| 49 |
+
refused, absent hash = load with a "no integrity baseline" note); backfill hashes for the
|
| 50 |
+
current registered datasets.
|
| 51 |
+
*(Available now: the shared hashing helper `src/core/integrity.py`
|
| 52 |
+
— `compute_sha256` / `verify_sha256`, streamed — landed with ADR-0011 and is the exact code
|
| 53 |
+
this step wires into `resolve_to_local_path`.)*
|
| 54 |
+
2. **At AWS (ADR-0007 Phase 4):** the pre-stage sync job writes checksums into the read-only OHSU
|
| 55 |
+
source bucket, so integrity is anchored to an OHSU-controlled copy rather than trust-on-first-use
|
| 56 |
+
against the public source. This ADR's on-load check is the same code; only the hash's provenance
|
| 57 |
+
improves.
|
| 58 |
+
|
| 59 |
+
## Consequences
|
| 60 |
+
|
| 61 |
+
- **Positive:** closes the Q7 tamper gap with a deterministic control, independent of AWS; dovetails
|
| 62 |
+
with pre-staging (Phase 4) without rework.
|
| 63 |
+
- **Cost / caveat:** trust-on-first-use for external sources until pre-staging exists — a mid-flight
|
| 64 |
+
change at the public source before first registration would be baselined as "correct." Pre-staging
|
| 65 |
+
(Phase 4) is what fully closes this; state that limitation to the review rather than implying the
|
| 66 |
+
on-load hash alone guarantees upstream authenticity. Hashing large h5ads adds seconds to first
|
| 67 |
+
load (negligible vs. parse; cached thereafter).
|
docs/adr/ADR-0011-upload-safety-gate.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ADR-0011 — Safety Gate for Manual Dataset Uploads
|
| 2 |
+
|
| 3 |
+
**Status:** Accepted — "Now" (AWS-independent) slice implemented 2026-07-02; AWS pieces deferred (see Plan)
|
| 4 |
+
**Date:** 2026-07-01
|
| 5 |
+
**Deciders:** Annie Voigt (project lead)
|
| 6 |
+
**Driver:** OHSU security review Q4 — researchers should be able to supply their own datasets.
|
| 7 |
+
**This ADR is about the security controls wrapping any such capability, NOT about enabling
|
| 8 |
+
free-form uploads.** An uploaded file is untrusted input; this defines the gate it must clear
|
| 9 |
+
before it is ever loaded or made available.
|
| 10 |
+
**Related:** ADR-0010 (upload records an integrity hash), ADR-0007 (uploaded data is only *read*
|
| 11 |
+
by the vetted toolset / sandbox — never executed), `biodata-registry` (manifest is required),
|
| 12 |
+
Data-Classification Q1–Q3 (uploads must stay de-identified / no PHI).
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Context
|
| 17 |
+
|
| 18 |
+
Every dataset today enters through a **reviewed manifest** in `biodata-registry` +
|
| 19 |
+
`dataset_validate_manifest_against_data`. That gate — semantics, refusal rules, prohibited
|
| 20 |
+
inferences — is exactly what keeps the agent grounded. Any manual-upload feature must not bypass
|
| 21 |
+
it; an uploaded file is untrusted input, and (per Data Classification) must remain de-identified
|
| 22 |
+
with no PHI/PSI.
|
| 23 |
+
|
| 24 |
+
The design principle: **an upload is not "available" until it has passed the same gate a
|
| 25 |
+
registered dataset passes, plus an admin approval.** The value of this ADR is the gate, not the
|
| 26 |
+
convenience of uploading.
|
| 27 |
+
|
| 28 |
+
## Decision
|
| 29 |
+
|
| 30 |
+
Treat uploaded files as quarantined-until-validated, never as executable content, routed through
|
| 31 |
+
the existing manifest/validation machinery.
|
| 32 |
+
|
| 33 |
+
1. **Quarantine on arrival.** Uploads land in an isolated staging area, not the active data path.
|
| 34 |
+
Enforce a **type allow-list** (expression matrices / h5ad / supported tabular only) and **size
|
| 35 |
+
limits**; reject anything else at the door.
|
| 36 |
+
2. **Manifest required.** The uploader must supply (or the UI must capture) a manifest with the
|
| 37 |
+
same required fields as any `biodata_registry` dataset. No manifest ⇒ not ingestible.
|
| 38 |
+
3. **Validate before availability.** Run `dataset_validate_manifest_against_data` on the staged
|
| 39 |
+
file. Failure ⇒ stays quarantined, surfaced back to the uploader; it never reaches the tools.
|
| 40 |
+
4. **Never execute uploaded content.** Uploaded files are *data* opened by the vetted
|
| 41 |
+
decoupleR/scanpy loaders only — the same loaders as registered data, inside the sandbox
|
| 42 |
+
(ADR-0007) with read-only source mounts. Nothing in an upload is ever `exec`'d or interpreted
|
| 43 |
+
as code.
|
| 44 |
+
5. **Record integrity + provenance.** On successful validation, compute and store the SHA-256
|
| 45 |
+
(ADR-0010) and uploader/time metadata, so the file is thereafter tamper-checked like any dataset.
|
| 46 |
+
6. **De-identification attestation.** The upload step requires the uploader to confirm the data is
|
| 47 |
+
de-identified / contains no PHI/PSI (matching the Data-Classification answers); this is captured
|
| 48 |
+
in the audit trace (ADR-0008).
|
| 49 |
+
7. **Admin approval to register.** An admin (the 1–2 admin accounts) promotes a validated upload
|
| 50 |
+
into the active registry. Researchers can stage + validate; only admins register. No shared
|
| 51 |
+
accounts (consistent with Auth Q6).
|
| 52 |
+
|
| 53 |
+
## Plan
|
| 54 |
+
|
| 55 |
+
- **Now (AWS-independent, ~1 week):** staging area + type/size gate + manifest-required flow +
|
| 56 |
+
validation hook + "never exec" guarantee (it's just a loader path) + integrity record (ADR-0010)
|
| 57 |
+
+ admin-approval step. Testable locally end-to-end with a synthetic upload.
|
| 58 |
+
**✅ Implemented 2026-07-02** as `src/uploads/` (non-agent-facing Python API):
|
| 59 |
+
`stage_upload` (attestation → manifest → type allow-list → size → quarantine + SHA-256, via the
|
| 60 |
+
shared `src/core/integrity.py`), `validate_upload` (manifest-schema + `validate_manifest_against_data`
|
| 61 |
+
through the vetted `scanpy.read_h5ad` loader only — no `exec`/`eval`/`pickle`), and `register_upload`
|
| 62 |
+
(admin-only promotion, `UPLOAD_ADMIN_IDS`, into the live registry). Every state transition persists an
|
| 63 |
+
`UploadRecord` (with the de-id attestation + hash) through the always-on ADR-0008 audit sink.
|
| 64 |
+
End-to-end tests: `tests/test_upload_gate.py` (24). Auto-validation currently covers `.h5ad`;
|
| 65 |
+
tabular uploads stage but stay quarantined pending a tabular loader.
|
| 66 |
+
- **At AWS:** staging bucket is a separate scoped prefix with its own encryption; the optional
|
| 67 |
+
malware scan runs on the staged object *before* validation. The basic type/size/quarantine gate
|
| 68 |
+
here does **not** require AWS; the malware scan does.
|
| 69 |
+
|
| 70 |
+
## Consequences
|
| 71 |
+
|
| 72 |
+
- **Positive:** delivers the requested capability without opening an ingress hole — uploads inherit
|
| 73 |
+
the same grounding gate as curated datasets, stay de-identified by attestation, are tamper-checked,
|
| 74 |
+
and are never executable. Clean story for the review.
|
| 75 |
+
- **Cost / caveat:** a manifest requirement adds friction for uploaders (mitigate with a UI that
|
| 76 |
+
drafts the manifest from the file + a few prompts). Full anti-malware coverage is AWS-dependent;
|
| 77 |
+
until then the gate is type/size/quarantine/validation/attestation, which should be stated
|
| 78 |
+
honestly rather than described as malware-scanned.
|
memory.md
CHANGED
|
@@ -4,7 +4,45 @@ This file tracks current status, recent decisions, and next steps.
|
|
| 4 |
Update it whenever meaningful work is completed or the direction changes.
|
| 5 |
Stable architectural facts belong in `CLAUDE.md`, not here.
|
| 6 |
|
| 7 |
-
Last updated: 2026-07-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
---
|
| 10 |
|
|
|
|
| 4 |
Update it whenever meaningful work is completed or the direction changes.
|
| 5 |
Stable architectural facts belong in `CLAUDE.md`, not here.
|
| 6 |
|
| 7 |
+
Last updated: 2026-07-02
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## 2026-07-02 — ADR-0011 upload safety gate "Now" slice IMPLEMENTED (branch `feat/adr-0011-upload-gate`, NOT pushed — no deploy)
|
| 12 |
+
|
| 13 |
+
Built the AWS-independent slice of ADR-0011 (manual-dataset-upload safety gate) as a
|
| 14 |
+
**standalone, non-agent-facing** Python package `src/uploads/` + a shared hash helper
|
| 15 |
+
`src/core/integrity.py`. An upload is quarantined-until-validated, never executable, and only an
|
| 16 |
+
admin can register it — the value is the gate, not the upload.
|
| 17 |
+
|
| 18 |
+
- **Branch/worktree:** built off `origin/main` (`05fca82`, has the ADR-0008 audit sink) in an
|
| 19 |
+
isolated worktree so the unrelated `feat/sandbox-executor` work (incl. its uncommitted ADR-0009
|
| 20 |
+
Appendix A edit) was left untouched. The ADR-0010/0011 docs (which only existed on the sandbox
|
| 21 |
+
branch's `cc0ad55`) were pulled onto this branch so the ADR travels with its implementation.
|
| 22 |
+
- **`src/core/integrity.py`** — `compute_sha256` / `verify_sha256` (streamed). Deliberately the
|
| 23 |
+
*shared* helper ADR-0010's on-load verification will reuse; ADR-0010 doc updated to point at it.
|
| 24 |
+
- **`src/uploads/`** — `stage_upload` (gate order: de-id attestation → manifest required → type
|
| 25 |
+
allow-list `.h5ad/.csv/.tsv/.txt` + gz → size limit → isolated per-upload quarantine dir +
|
| 26 |
+
SHA-256), `validate_upload` (manifest-schema via `validate_manifest` **then**
|
| 27 |
+
`validate_manifest_against_data` through the vetted `scanpy.read_h5ad` loader only — the
|
| 28 |
+
"never exec" guarantee is structural: no `exec`/`eval`/`pickle` anywhere, asserted by a test),
|
| 29 |
+
`register_upload` (admin-only, `UPLOAD_ADMIN_IDS` fail-closed; requires `validated`; writes the
|
| 30 |
+
manifest into a registered-overlay dir + `get_registry().register`). Every transition persists an
|
| 31 |
+
`UploadRecord` (de-id attestation + hash + status) through the always-on ADR-0008 log sink, so the
|
| 32 |
+
attestation lands in the durable audit trail.
|
| 33 |
+
- **Config resolved at call time** (`UPLOAD_STAGING_DIR`, `UPLOAD_REGISTERED_DIR`, `UPLOAD_MAX_BYTES`,
|
| 34 |
+
`UPLOAD_ADMIN_IDS`) — caught + fixed an import-time-constant bug that made env overrides inert and
|
| 35 |
+
leaked staging dirs into the repo root; tests are now hermetic (tmp dirs) and the default dirs are
|
| 36 |
+
gitignored.
|
| 37 |
+
- **Tests:** `tests/test_upload_gate.py` (24) green — type/size/manifest/attestation gates,
|
| 38 |
+
integrity record + tamper detection, validate happy-path + data-level mismatch stays quarantined,
|
| 39 |
+
tabular-not-yet-supported, admin refusal (non-admin, and register-before-validate), promotion into
|
| 40 |
+
the live registry, never-exec source scan, attestation persisted to the audit sink. Reused
|
| 41 |
+
validator/registry suites still green (133). **Limitation:** auto-validation is h5ad-only this
|
| 42 |
+
phase (tabular stages but stays quarantined); AWS staging-bucket + malware scan deferred, stated
|
| 43 |
+
honestly in the ADR.
|
| 44 |
+
- **No runtime/agent surface:** no MCP tool, no `server.py` change, no prompt change, **no Space
|
| 45 |
+
deploy/rebuild.** Local-only.
|
| 46 |
|
| 47 |
---
|
| 48 |
|
src/core/integrity.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Content-hash helpers for integrity / tamper verification.
|
| 2 |
+
|
| 3 |
+
A tiny, dependency-free module so the *same* hashing code backs two controls:
|
| 4 |
+
|
| 5 |
+
- **ADR-0011** (manual-upload safety gate) records ``sha256`` in an upload's
|
| 6 |
+
provenance at validation time.
|
| 7 |
+
- **ADR-0010** (dataset integrity on load) will reuse :func:`verify_sha256` to
|
| 8 |
+
refuse a tampered file at ``resolve_to_local_path`` time.
|
| 9 |
+
|
| 10 |
+
Streamed so a multi-GB h5ad never has to be read into memory at once.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import hashlib
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
# 4 MiB — matches the download chunk size used in src/core/data_io.py.
|
| 18 |
+
_DEFAULT_CHUNK = 4 * 1024 * 1024
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def compute_sha256(path: str | Path, chunk_size: int = _DEFAULT_CHUNK) -> str:
|
| 22 |
+
"""Return the hex SHA-256 of the file at ``path``, read in chunks.
|
| 23 |
+
|
| 24 |
+
Raises ``FileNotFoundError`` if the path does not exist (callers that stage
|
| 25 |
+
a file should have materialized it first).
|
| 26 |
+
"""
|
| 27 |
+
h = hashlib.sha256()
|
| 28 |
+
with open(path, "rb") as fh:
|
| 29 |
+
for chunk in iter(lambda: fh.read(chunk_size), b""):
|
| 30 |
+
h.update(chunk)
|
| 31 |
+
return h.hexdigest()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def verify_sha256(path: str | Path, expected: str, chunk_size: int = _DEFAULT_CHUNK) -> bool:
|
| 35 |
+
"""Return True iff the file at ``path`` hashes to ``expected`` (case-insensitive).
|
| 36 |
+
|
| 37 |
+
A missing/empty ``expected`` returns False — an absent baseline is not a
|
| 38 |
+
pass. (ADR-0010 handles "no baseline recorded" as a distinct, explicit case
|
| 39 |
+
at its call site; this helper only answers "does it match".)
|
| 40 |
+
"""
|
| 41 |
+
if not expected:
|
| 42 |
+
return False
|
| 43 |
+
return compute_sha256(path, chunk_size).lower() == str(expected).strip().lower()
|
src/uploads/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Safety gate for manual dataset uploads (ADR-0011).
|
| 2 |
+
|
| 3 |
+
Public, non-agent-facing API. An upload is quarantined-until-validated and never
|
| 4 |
+
executable, routed through the same manifest/validation machinery a registered
|
| 5 |
+
dataset passes, plus an admin approval:
|
| 6 |
+
|
| 7 |
+
stage_upload(...) → quarantine + type/size/manifest/attestation gate
|
| 8 |
+
validate_upload(rec) → manifest-schema + against-data validation
|
| 9 |
+
register_upload(rec) → admin-only promotion into the live registry
|
| 10 |
+
|
| 11 |
+
Only admins (UPLOAD_ADMIN_IDS) can register; researchers may stage + validate.
|
| 12 |
+
"""
|
| 13 |
+
from src.uploads.promotion import RegistrationRefused, register_upload
|
| 14 |
+
from src.uploads.records import (
|
| 15 |
+
STATUS_QUARANTINED,
|
| 16 |
+
STATUS_REGISTERED,
|
| 17 |
+
STATUS_REJECTED,
|
| 18 |
+
STATUS_VALIDATED,
|
| 19 |
+
UploadRecord,
|
| 20 |
+
is_admin,
|
| 21 |
+
)
|
| 22 |
+
from src.uploads.staging import UploadRejected, stage_upload
|
| 23 |
+
from src.uploads.validation import validate_upload
|
| 24 |
+
|
| 25 |
+
__all__ = [
|
| 26 |
+
"stage_upload",
|
| 27 |
+
"validate_upload",
|
| 28 |
+
"register_upload",
|
| 29 |
+
"UploadRecord",
|
| 30 |
+
"UploadRejected",
|
| 31 |
+
"RegistrationRefused",
|
| 32 |
+
"is_admin",
|
| 33 |
+
"STATUS_REJECTED",
|
| 34 |
+
"STATUS_QUARANTINED",
|
| 35 |
+
"STATUS_VALIDATED",
|
| 36 |
+
"STATUS_REGISTERED",
|
| 37 |
+
]
|
src/uploads/promotion.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin approval to register a validated upload (ADR-0011 item 7).
|
| 2 |
+
|
| 3 |
+
Researchers can stage and validate; only a named admin can make an upload
|
| 4 |
+
*available* to the pipeline. :func:`register_upload` is the promote step: it
|
| 5 |
+
refuses unless the caller is an admin (``UPLOAD_ADMIN_IDS``) AND the upload has
|
| 6 |
+
already reached ``validated``. On success it writes the manifest into a
|
| 7 |
+
registered-overlay directory and registers it with the live
|
| 8 |
+
:class:`DatasetRegistry`, so ``load_manifest(dataset_id)`` resolves thereafter.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import yaml
|
| 17 |
+
|
| 18 |
+
from src.uploads.records import (
|
| 19 |
+
STATUS_REGISTERED,
|
| 20 |
+
STATUS_VALIDATED,
|
| 21 |
+
UploadRecord,
|
| 22 |
+
is_admin,
|
| 23 |
+
persist_record,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Where promoted manifests land. Kept separate from the read-only biodata_registry
|
| 27 |
+
# package dir; the live registry is told about the file via .register(). Read at
|
| 28 |
+
# call time (not import) so env overrides / per-test isolation take effect.
|
| 29 |
+
def _registered_dir() -> str:
|
| 30 |
+
return os.environ.get("UPLOAD_REGISTERED_DIR", "./upload_registered")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class RegistrationRefused(Exception):
|
| 34 |
+
"""Raised when a promote request fails the admin / status precondition."""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def register_upload(
|
| 38 |
+
record: UploadRecord,
|
| 39 |
+
*,
|
| 40 |
+
admin: str,
|
| 41 |
+
manifest_dir: str | Path | None = None,
|
| 42 |
+
) -> UploadRecord:
|
| 43 |
+
"""Promote a validated upload into the active registry. Admins only.
|
| 44 |
+
|
| 45 |
+
Raises
|
| 46 |
+
------
|
| 47 |
+
RegistrationRefused
|
| 48 |
+
If ``admin`` is not in the admin allow-list, or the upload is not yet
|
| 49 |
+
``validated``. The record is left unchanged.
|
| 50 |
+
"""
|
| 51 |
+
# ── Admin gate (item 7) ──────────────────────────────────────────────────
|
| 52 |
+
if not is_admin(admin):
|
| 53 |
+
raise RegistrationRefused(
|
| 54 |
+
f"'{admin}' is not an authorized admin. Only accounts in "
|
| 55 |
+
"UPLOAD_ADMIN_IDS may register an upload."
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# ── Must have cleared validation first ───────────────────────────────────
|
| 59 |
+
if record.status != STATUS_VALIDATED:
|
| 60 |
+
raise RegistrationRefused(
|
| 61 |
+
f"Upload '{record.upload_id}' is '{record.status}', not 'validated' — "
|
| 62 |
+
"it must pass validate_upload before it can be registered."
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# ── Write the manifest into the registered overlay + register it ─────────
|
| 66 |
+
out_dir = Path(manifest_dir) if manifest_dir is not None else Path(_registered_dir())
|
| 67 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
manifest_path = out_dir / f"{record.dataset_id}.yaml"
|
| 69 |
+
with open(manifest_path, "w", encoding="utf-8") as fh:
|
| 70 |
+
yaml.safe_dump(dict(record.manifest), fh, sort_keys=False)
|
| 71 |
+
|
| 72 |
+
from src.datasets.registry import get_registry
|
| 73 |
+
|
| 74 |
+
get_registry().register(record.dataset_id, manifest_path)
|
| 75 |
+
|
| 76 |
+
record.status = STATUS_REGISTERED
|
| 77 |
+
record.registered_at = datetime.now().isoformat(timespec="seconds")
|
| 78 |
+
record.registered_manifest_path = str(manifest_path)
|
| 79 |
+
persist_record(record)
|
| 80 |
+
return record
|
src/uploads/records.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provenance record + admin allow-list for the upload safety gate (ADR-0011).
|
| 2 |
+
|
| 3 |
+
An :class:`UploadRecord` is the single, serialisable audit object that follows a
|
| 4 |
+
manual upload through its whole lifecycle:
|
| 5 |
+
|
| 6 |
+
rejected → (never staged; failed the door gate)
|
| 7 |
+
quarantined → staged + hashed, not yet validated
|
| 8 |
+
validated → cleared manifest schema + against-data checks
|
| 9 |
+
registered → admin-approved into the active registry
|
| 10 |
+
|
| 11 |
+
Every state transition calls :func:`persist_record`, which writes ``record.json``
|
| 12 |
+
into the quarantine directory AND pushes the same dict through the always-on
|
| 13 |
+
audit sink (ADR-0008) so the de-identification attestation and integrity hash
|
| 14 |
+
land in the durable audit trail.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
from dataclasses import dataclass, field
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any, Optional
|
| 23 |
+
|
| 24 |
+
# Lifecycle states (kept as plain strings so the record stays JSON-trivial).
|
| 25 |
+
STATUS_REJECTED = "rejected"
|
| 26 |
+
STATUS_QUARANTINED = "quarantined"
|
| 27 |
+
STATUS_VALIDATED = "validated"
|
| 28 |
+
STATUS_REGISTERED = "registered"
|
| 29 |
+
|
| 30 |
+
VALID_STATUSES = frozenset(
|
| 31 |
+
{STATUS_REJECTED, STATUS_QUARANTINED, STATUS_VALIDATED, STATUS_REGISTERED}
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class UploadRecord:
|
| 37 |
+
"""Audit + provenance record for one manual dataset upload."""
|
| 38 |
+
|
| 39 |
+
upload_id: str
|
| 40 |
+
dataset_id: str
|
| 41 |
+
uploader: str
|
| 42 |
+
status: str
|
| 43 |
+
created_at: str
|
| 44 |
+
|
| 45 |
+
original_filename: str = ""
|
| 46 |
+
staged_path: Optional[str] = None
|
| 47 |
+
size_bytes: Optional[int] = None
|
| 48 |
+
sha256: Optional[str] = None
|
| 49 |
+
deidentified_attestation: bool = False
|
| 50 |
+
|
| 51 |
+
validated_at: Optional[str] = None
|
| 52 |
+
registered_at: Optional[str] = None
|
| 53 |
+
registered_manifest_path: Optional[str] = None
|
| 54 |
+
|
| 55 |
+
errors: list[str] = field(default_factory=list)
|
| 56 |
+
manifest: dict = field(default_factory=dict)
|
| 57 |
+
|
| 58 |
+
def to_dict(self) -> dict[str, Any]:
|
| 59 |
+
return {
|
| 60 |
+
"upload_id": self.upload_id,
|
| 61 |
+
"dataset_id": self.dataset_id,
|
| 62 |
+
"uploader": self.uploader,
|
| 63 |
+
"status": self.status,
|
| 64 |
+
"created_at": self.created_at,
|
| 65 |
+
"original_filename": self.original_filename,
|
| 66 |
+
"staged_path": self.staged_path,
|
| 67 |
+
"size_bytes": self.size_bytes,
|
| 68 |
+
"sha256": self.sha256,
|
| 69 |
+
"deidentified_attestation": self.deidentified_attestation,
|
| 70 |
+
"validated_at": self.validated_at,
|
| 71 |
+
"registered_at": self.registered_at,
|
| 72 |
+
"registered_manifest_path": self.registered_manifest_path,
|
| 73 |
+
"errors": self.errors,
|
| 74 |
+
"manifest": self.manifest,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def persist_record(record: UploadRecord) -> None:
|
| 79 |
+
"""Write the record to disk (quarantine dir) and to the audit sink.
|
| 80 |
+
|
| 81 |
+
Best-effort: a persistence failure must never mask the gate decision. The
|
| 82 |
+
on-disk ``record.json`` is only written when the upload has a staged
|
| 83 |
+
directory (a door-rejected upload never gets one); the audit-sink push
|
| 84 |
+
always runs so even a rejection is traceable.
|
| 85 |
+
"""
|
| 86 |
+
# 1) On-disk copy next to the staged file (when there is one).
|
| 87 |
+
if record.staged_path:
|
| 88 |
+
try:
|
| 89 |
+
out_dir = Path(record.staged_path).parent
|
| 90 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 91 |
+
with open(out_dir / "record.json", "w", encoding="utf-8") as fh:
|
| 92 |
+
json.dump(record.to_dict(), fh, indent=2, default=str)
|
| 93 |
+
except OSError:
|
| 94 |
+
pass # disk copy is a convenience; the sink push below is the audit trail
|
| 95 |
+
|
| 96 |
+
# 2) Durable audit trail via the always-on log sink (ADR-0008/0009).
|
| 97 |
+
try:
|
| 98 |
+
from src.logging_sink import get_log_sink, persist_trace_safe
|
| 99 |
+
|
| 100 |
+
persist_trace_safe(get_log_sink(), f"upload_{record.upload_id}", record.to_dict())
|
| 101 |
+
except Exception: # noqa: BLE001 — auditing must never crash the gate
|
| 102 |
+
pass
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def is_admin(identity: Optional[str]) -> bool:
|
| 106 |
+
"""Whether ``identity`` may promote a validated upload into the registry.
|
| 107 |
+
|
| 108 |
+
The admin set is the ``UPLOAD_ADMIN_IDS`` env var — a comma-separated list of
|
| 109 |
+
the 1–2 named admin accounts (no shared accounts, Auth Q6). Empty/unset means
|
| 110 |
+
*no one* is an admin (fail closed): registration is impossible until an
|
| 111 |
+
operator names the admins, which is the correct default for a fresh deploy.
|
| 112 |
+
"""
|
| 113 |
+
if not identity:
|
| 114 |
+
return False
|
| 115 |
+
raw = os.environ.get("UPLOAD_ADMIN_IDS", "")
|
| 116 |
+
admins = {a.strip() for a in raw.split(",") if a.strip()}
|
| 117 |
+
return identity.strip() in admins
|
src/uploads/staging.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Quarantine-on-arrival staging + the door gate (ADR-0011 items 1, 2, 5, 6).
|
| 2 |
+
|
| 3 |
+
An uploaded file is untrusted input. :func:`stage_upload` is the *only* way a
|
| 4 |
+
file enters the system, and it clears four gates before the file is written
|
| 5 |
+
anywhere the pipeline can see it:
|
| 6 |
+
|
| 7 |
+
1. de-identification attestation present (item 6)
|
| 8 |
+
2. manifest supplied (item 2)
|
| 9 |
+
3. type allow-list (item 1)
|
| 10 |
+
4. size limit (item 1)
|
| 11 |
+
|
| 12 |
+
On success the file is copied into an isolated per-upload quarantine directory
|
| 13 |
+
(NOT the active data path), its SHA-256 is recorded (item 5), and an
|
| 14 |
+
``UploadRecord`` with ``status="quarantined"`` is returned. Nothing here loads,
|
| 15 |
+
parses, or executes the file — that is the validation step's job, and only via
|
| 16 |
+
the vetted loaders.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import shutil
|
| 22 |
+
import uuid
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import yaml
|
| 27 |
+
|
| 28 |
+
from src.core.integrity import compute_sha256
|
| 29 |
+
from src.uploads.records import (
|
| 30 |
+
STATUS_QUARANTINED,
|
| 31 |
+
STATUS_REJECTED,
|
| 32 |
+
UploadRecord,
|
| 33 |
+
persist_record,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# --------------------------------------------------------------------------- #
|
| 37 |
+
# Config (all env-overridable)
|
| 38 |
+
# --------------------------------------------------------------------------- #
|
| 39 |
+
|
| 40 |
+
# Expression matrices / h5ad / supported tabular only. A compound suffix like
|
| 41 |
+
# ``.csv.gz`` is matched against the last two suffixes as well (see _suffix_of).
|
| 42 |
+
ALLOWED_SUFFIXES = frozenset(
|
| 43 |
+
{".h5ad", ".csv", ".tsv", ".txt", ".csv.gz", ".tsv.gz", ".txt.gz"}
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Config is read at CALL time (not import time) so env overrides and per-test
|
| 48 |
+
# isolation actually take effect. Isolated staging area — deliberately NOT under
|
| 49 |
+
# any served data path, so a quarantined file is never resolvable by the loaders
|
| 50 |
+
# until it is registered.
|
| 51 |
+
def _staging_dir() -> str:
|
| 52 |
+
return os.environ.get("UPLOAD_STAGING_DIR", "./upload_staging")
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# Default 2 GiB ceiling; raise/lower via UPLOAD_MAX_BYTES for a given deployment.
|
| 56 |
+
def _max_upload_bytes() -> int:
|
| 57 |
+
return int(os.environ.get("UPLOAD_MAX_BYTES", str(2 * 1024 * 1024 * 1024)))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class UploadRejected(Exception):
|
| 61 |
+
"""Raised when an upload fails the door gate.
|
| 62 |
+
|
| 63 |
+
Carries the ``UploadRecord`` (``status="rejected"``, with the reason in
|
| 64 |
+
``.errors``) so a caller/UI has one structured failure path.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
def __init__(self, record: UploadRecord):
|
| 68 |
+
self.record = record
|
| 69 |
+
super().__init__("; ".join(record.errors) or "upload rejected")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _suffix_of(filename: str) -> str:
|
| 73 |
+
"""Return the allow-list suffix for ``filename`` (handles ``.csv.gz`` etc.).
|
| 74 |
+
|
| 75 |
+
Prefers the longest matching compound suffix so ``data.csv.gz`` maps to
|
| 76 |
+
``.csv.gz`` rather than ``.gz``.
|
| 77 |
+
"""
|
| 78 |
+
name = filename.lower()
|
| 79 |
+
parts = Path(name).suffixes # e.g. ['.csv', '.gz']
|
| 80 |
+
if len(parts) >= 2:
|
| 81 |
+
compound = "".join(parts[-2:])
|
| 82 |
+
if compound in ALLOWED_SUFFIXES:
|
| 83 |
+
return compound
|
| 84 |
+
return Path(name).suffix # single suffix, e.g. '.h5ad'
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def stage_upload(
|
| 88 |
+
src_path: str | Path,
|
| 89 |
+
*,
|
| 90 |
+
uploader: str,
|
| 91 |
+
dataset_id: str,
|
| 92 |
+
manifest: dict,
|
| 93 |
+
deidentified: bool,
|
| 94 |
+
staging_dir: str | Path | None = None,
|
| 95 |
+
) -> UploadRecord:
|
| 96 |
+
"""Gate an upload and quarantine it. Returns a ``quarantined`` record.
|
| 97 |
+
|
| 98 |
+
Parameters
|
| 99 |
+
----------
|
| 100 |
+
src_path:
|
| 101 |
+
Path to the file the researcher wants to upload (already on local disk).
|
| 102 |
+
uploader:
|
| 103 |
+
Identity of the person uploading (recorded in provenance).
|
| 104 |
+
dataset_id:
|
| 105 |
+
The dataset_id the upload will register as if it passes.
|
| 106 |
+
manifest:
|
| 107 |
+
The manifest dict for the upload — required (ADR item 2). Schema
|
| 108 |
+
validity is checked later in :func:`validate_upload`; here it must
|
| 109 |
+
merely be present and non-empty.
|
| 110 |
+
deidentified:
|
| 111 |
+
The uploader's attestation that the data carries no PHI/PSI (ADR item 6).
|
| 112 |
+
Must be ``True`` or the upload is rejected at the door.
|
| 113 |
+
|
| 114 |
+
Raises
|
| 115 |
+
------
|
| 116 |
+
UploadRejected
|
| 117 |
+
If any door gate fails. The attached record has ``status="rejected"``.
|
| 118 |
+
"""
|
| 119 |
+
src_path = Path(src_path)
|
| 120 |
+
now = datetime.now().isoformat(timespec="seconds")
|
| 121 |
+
upload_id = uuid.uuid4().hex[:12]
|
| 122 |
+
|
| 123 |
+
def _reject(reason: str) -> "UploadRejected":
|
| 124 |
+
rec = UploadRecord(
|
| 125 |
+
upload_id=upload_id,
|
| 126 |
+
dataset_id=dataset_id,
|
| 127 |
+
uploader=uploader,
|
| 128 |
+
status=STATUS_REJECTED,
|
| 129 |
+
created_at=now,
|
| 130 |
+
original_filename=src_path.name,
|
| 131 |
+
deidentified_attestation=bool(deidentified),
|
| 132 |
+
manifest=dict(manifest) if isinstance(manifest, dict) else {},
|
| 133 |
+
errors=[reason],
|
| 134 |
+
)
|
| 135 |
+
persist_record(rec)
|
| 136 |
+
return UploadRejected(rec)
|
| 137 |
+
|
| 138 |
+
# ── Gate 1: de-identification attestation (item 6) ───────────────────────
|
| 139 |
+
if deidentified is not True:
|
| 140 |
+
raise _reject(
|
| 141 |
+
"De-identification attestation required: the uploader must confirm "
|
| 142 |
+
"the data contains no PHI/PSI (Data-Classification Q1–Q3)."
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# ── Gate 2: manifest required (item 2) ───────────────────────────────────
|
| 146 |
+
if not isinstance(manifest, dict) or not manifest:
|
| 147 |
+
raise _reject(
|
| 148 |
+
"A manifest is required — an upload with no manifest is not "
|
| 149 |
+
"ingestible (ADR-0011 item 2)."
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# ── Gate 3: type allow-list (item 1) ─────────────────────────────────────
|
| 153 |
+
suffix = _suffix_of(src_path.name)
|
| 154 |
+
if suffix not in ALLOWED_SUFFIXES:
|
| 155 |
+
raise _reject(
|
| 156 |
+
f"File type '{src_path.suffix or src_path.name}' is not allowed. "
|
| 157 |
+
f"Permitted: {sorted(ALLOWED_SUFFIXES)}."
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# ── Gate 4: existence + size limit (item 1) ──────────────────────────────
|
| 161 |
+
if not src_path.is_file():
|
| 162 |
+
raise _reject(f"Source file not found: {src_path}")
|
| 163 |
+
size = src_path.stat().st_size
|
| 164 |
+
limit = _max_upload_bytes()
|
| 165 |
+
if size > limit:
|
| 166 |
+
raise _reject(
|
| 167 |
+
f"File is {size} bytes, exceeding the {limit}-byte upload limit "
|
| 168 |
+
f"(UPLOAD_MAX_BYTES)."
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# ── Quarantine: copy into an isolated per-upload dir (item 1) ────────────
|
| 172 |
+
base = Path(staging_dir) if staging_dir is not None else Path(_staging_dir())
|
| 173 |
+
quarantine = base / upload_id
|
| 174 |
+
quarantine.mkdir(parents=True, exist_ok=True)
|
| 175 |
+
staged_path = quarantine / f"data{suffix}"
|
| 176 |
+
shutil.copy2(src_path, staged_path)
|
| 177 |
+
|
| 178 |
+
# Manifest travels with the staged file.
|
| 179 |
+
with open(quarantine / "manifest.yaml", "w", encoding="utf-8") as fh:
|
| 180 |
+
yaml.safe_dump(dict(manifest), fh, sort_keys=False)
|
| 181 |
+
|
| 182 |
+
# ── Integrity record (item 5) ────────────────────────────────────────────
|
| 183 |
+
sha256 = compute_sha256(staged_path)
|
| 184 |
+
|
| 185 |
+
record = UploadRecord(
|
| 186 |
+
upload_id=upload_id,
|
| 187 |
+
dataset_id=dataset_id,
|
| 188 |
+
uploader=uploader,
|
| 189 |
+
status=STATUS_QUARANTINED,
|
| 190 |
+
created_at=now,
|
| 191 |
+
original_filename=src_path.name,
|
| 192 |
+
staged_path=str(staged_path),
|
| 193 |
+
size_bytes=size,
|
| 194 |
+
sha256=sha256,
|
| 195 |
+
deidentified_attestation=True,
|
| 196 |
+
manifest=dict(manifest),
|
| 197 |
+
)
|
| 198 |
+
persist_record(record)
|
| 199 |
+
return record
|
src/uploads/validation.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate-before-availability for a quarantined upload (ADR-0011 items 3, 4, 5).
|
| 2 |
+
|
| 3 |
+
A staged file stays quarantined until it clears the SAME grounding gate a
|
| 4 |
+
registered dataset clears:
|
| 5 |
+
|
| 6 |
+
1. manifest schema validation (src.datasets.manifest_schema.validate_manifest)
|
| 7 |
+
2. semantic against-data checks (src.workflows.manifest_data_validation)
|
| 8 |
+
|
| 9 |
+
Item 4 ("never execute uploaded content") is honoured structurally: the file is
|
| 10 |
+
opened ONLY by the vetted scanpy loader — the same loader registered data uses —
|
| 11 |
+
never ``exec``/``eval``/``pickle``. Passing both checks flips the record to
|
| 12 |
+
``validated``; failing keeps it quarantined with the errors surfaced.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
from src.uploads.records import (
|
| 21 |
+
STATUS_QUARANTINED,
|
| 22 |
+
STATUS_VALIDATED,
|
| 23 |
+
UploadRecord,
|
| 24 |
+
persist_record,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _report(status: str, errors: list[str], **extra: Any) -> dict:
|
| 29 |
+
return {"status": status, "errors": errors, **extra}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def validate_upload(record: UploadRecord) -> tuple[UploadRecord, dict]:
|
| 33 |
+
"""Run the manifest-schema + against-data gate on a quarantined upload.
|
| 34 |
+
|
| 35 |
+
Returns ``(record, report)``. The record's ``status`` becomes ``validated``
|
| 36 |
+
only when both gates pass; otherwise it stays ``quarantined`` and the reasons
|
| 37 |
+
are in ``record.errors`` / ``report['errors']``.
|
| 38 |
+
"""
|
| 39 |
+
if record.status != STATUS_QUARANTINED:
|
| 40 |
+
return record, _report(
|
| 41 |
+
"error",
|
| 42 |
+
[f"Upload is '{record.status}', not 'quarantined' — nothing to validate."],
|
| 43 |
+
)
|
| 44 |
+
if not record.staged_path or not Path(record.staged_path).is_file():
|
| 45 |
+
record.errors = [f"Staged file missing: {record.staged_path}"]
|
| 46 |
+
persist_record(record)
|
| 47 |
+
return record, _report("error", record.errors)
|
| 48 |
+
|
| 49 |
+
# ── Gate 1: manifest schema validity ─────────────────────────────────────
|
| 50 |
+
from src.datasets.manifest_schema import DatasetManifest, validate_manifest
|
| 51 |
+
|
| 52 |
+
schema_result = validate_manifest(record.manifest)
|
| 53 |
+
if not schema_result.valid:
|
| 54 |
+
record.errors = [f"manifest schema: {e}" for e in schema_result.errors]
|
| 55 |
+
persist_record(record)
|
| 56 |
+
return record, _report(
|
| 57 |
+
"error", record.errors, schema_warnings=schema_result.warnings
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
manifest_obj = DatasetManifest.from_dict(record.manifest)
|
| 61 |
+
|
| 62 |
+
# ── Gate 2: against-data validation via the VETTED loader only (item 4) ──
|
| 63 |
+
suffix = Path(record.staged_path).suffix.lower()
|
| 64 |
+
if suffix != ".h5ad":
|
| 65 |
+
# The gate accepts tabular for staging, but auto against-data validation
|
| 66 |
+
# is h5ad-only this phase (every served dataset is an h5ad). Stay
|
| 67 |
+
# quarantined and say so honestly rather than pretend it validated.
|
| 68 |
+
record.errors = [
|
| 69 |
+
f"Auto-validation for '{suffix}' uploads is not implemented yet — "
|
| 70 |
+
"convert to .h5ad or validate manually. File stays quarantined."
|
| 71 |
+
]
|
| 72 |
+
persist_record(record)
|
| 73 |
+
return record, _report("error", record.errors)
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
import numpy as np
|
| 77 |
+
import scanpy as sc
|
| 78 |
+
|
| 79 |
+
from src.workflows.manifest_data_validation import validate_manifest_against_data
|
| 80 |
+
|
| 81 |
+
adata = sc.read_h5ad(record.staged_path) # vetted loader — never exec
|
| 82 |
+
|
| 83 |
+
X = adata.X
|
| 84 |
+
if hasattr(X, "toarray"):
|
| 85 |
+
X = X.toarray()
|
| 86 |
+
X_flat = X.flatten()
|
| 87 |
+
if len(X_flat) > 50_000:
|
| 88 |
+
rng = np.random.default_rng(0)
|
| 89 |
+
X_flat = rng.choice(X_flat, size=50_000, replace=False)
|
| 90 |
+
|
| 91 |
+
data_report = validate_manifest_against_data(
|
| 92 |
+
X_flat, list(adata.var.index), adata.obs.copy(), manifest_obj
|
| 93 |
+
)
|
| 94 |
+
except Exception as exc: # noqa: BLE001 — a load/validate failure is a rejection
|
| 95 |
+
record.errors = [f"against-data validation failed to run: {exc}"]
|
| 96 |
+
persist_record(record)
|
| 97 |
+
return record, _report("error", record.errors)
|
| 98 |
+
|
| 99 |
+
if not data_report.get("overall_valid"):
|
| 100 |
+
record.errors = list(data_report.get("errors", [])) or [
|
| 101 |
+
"against-data validation reported the manifest inconsistent with the file."
|
| 102 |
+
]
|
| 103 |
+
persist_record(record)
|
| 104 |
+
return record, _report(
|
| 105 |
+
"error", record.errors, against_data=data_report,
|
| 106 |
+
schema_warnings=schema_result.warnings,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# ── Passed both gates ────────────────────────────────────────────────────
|
| 110 |
+
record.status = STATUS_VALIDATED
|
| 111 |
+
record.validated_at = datetime.now().isoformat(timespec="seconds")
|
| 112 |
+
record.errors = []
|
| 113 |
+
persist_record(record)
|
| 114 |
+
return record, _report(
|
| 115 |
+
"pass", [], against_data=data_report, schema_warnings=schema_result.warnings
|
| 116 |
+
)
|
tests/test_upload_gate.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end tests for the manual-upload safety gate (ADR-0011).
|
| 2 |
+
|
| 3 |
+
Synthetic fixtures only — a small AnnData written to a tmp .h5ad, a matching
|
| 4 |
+
manifest dict, no network. Exercises the whole lifecycle:
|
| 5 |
+
|
| 6 |
+
stage_upload → validate_upload → register_upload
|
| 7 |
+
|
| 8 |
+
plus every gate that can reject at each step.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
import anndata as ad
|
| 16 |
+
import numpy as np
|
| 17 |
+
import pandas as pd
|
| 18 |
+
import pytest
|
| 19 |
+
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 21 |
+
|
| 22 |
+
from src.core.integrity import compute_sha256, verify_sha256
|
| 23 |
+
from src.datasets.registry import get_registry, load_manifest
|
| 24 |
+
from src.uploads import (
|
| 25 |
+
RegistrationRefused,
|
| 26 |
+
UploadRejected,
|
| 27 |
+
register_upload,
|
| 28 |
+
stage_upload,
|
| 29 |
+
validate_upload,
|
| 30 |
+
)
|
| 31 |
+
from src.uploads.records import (
|
| 32 |
+
STATUS_QUARANTINED,
|
| 33 |
+
STATUS_REGISTERED,
|
| 34 |
+
STATUS_VALIDATED,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# --------------------------------------------------------------------------- #
|
| 39 |
+
# Fixtures / helpers
|
| 40 |
+
# --------------------------------------------------------------------------- #
|
| 41 |
+
|
| 42 |
+
def _write_h5ad(path: Path, *, data_level: str = "log", n_obs: int = 30, n_var: int = 60) -> Path:
|
| 43 |
+
"""Write a synthetic h5ad. data_level='log' → log2-ish; 'counts' → integers."""
|
| 44 |
+
rng = np.random.default_rng(0)
|
| 45 |
+
if data_level == "counts":
|
| 46 |
+
X = rng.poisson(200, size=(n_obs, n_var)).astype(np.float32)
|
| 47 |
+
else:
|
| 48 |
+
X = rng.uniform(2.0, 14.0, size=(n_obs, n_var)).astype(np.float32)
|
| 49 |
+
var = pd.DataFrame(index=[f"GENE{i:04d}" for i in range(n_var)])
|
| 50 |
+
cond = (["A"] * (n_obs // 2)) + (["B"] * (n_obs - n_obs // 2))
|
| 51 |
+
obs = pd.DataFrame({"condition": cond}, index=[f"S{i}" for i in range(n_obs)])
|
| 52 |
+
adata = ad.AnnData(X=X, obs=obs, var=var)
|
| 53 |
+
adata.write_h5ad(path)
|
| 54 |
+
return path
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _manifest(dataset_id: str, *, data_level: str = "log_expression") -> dict:
|
| 58 |
+
return {
|
| 59 |
+
"dataset_id": dataset_id,
|
| 60 |
+
"title": "Synthetic upload test dataset",
|
| 61 |
+
"accession": "UPLOAD-TEST",
|
| 62 |
+
"organism": "human",
|
| 63 |
+
"modality": "bulk_rnaseq",
|
| 64 |
+
"platform": "synthetic",
|
| 65 |
+
"data_level": data_level,
|
| 66 |
+
"feature_id_type": "gene_symbol",
|
| 67 |
+
"expression_source": {"type": "local"},
|
| 68 |
+
"metadata_source": {"type": "local", "embedded": True},
|
| 69 |
+
"group_columns": ["condition"],
|
| 70 |
+
"valid_workflows": ["activity_scoring"],
|
| 71 |
+
"limitations": ["synthetic dataset for tests"],
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@pytest.fixture
|
| 76 |
+
def good_h5ad(tmp_path: Path) -> Path:
|
| 77 |
+
return _write_h5ad(tmp_path / "source.h5ad")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@pytest.fixture(autouse=True)
|
| 81 |
+
def _isolated_env(tmp_path, monkeypatch):
|
| 82 |
+
"""Point staging/registered/audit dirs at tmp and default to no admins."""
|
| 83 |
+
monkeypatch.setenv("UPLOAD_STAGING_DIR", str(tmp_path / "staging"))
|
| 84 |
+
monkeypatch.setenv("UPLOAD_REGISTERED_DIR", str(tmp_path / "registered"))
|
| 85 |
+
monkeypatch.setenv("LOG_SINK", "local")
|
| 86 |
+
monkeypatch.setenv("LOG_SINK_LOCAL_DIR", str(tmp_path / "audit"))
|
| 87 |
+
monkeypatch.delenv("UPLOAD_ADMIN_IDS", raising=False)
|
| 88 |
+
monkeypatch.delenv("UPLOAD_MAX_BYTES", raising=False)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _stage(good_h5ad, dataset_id="up_test", **over):
|
| 92 |
+
kwargs = dict(
|
| 93 |
+
uploader="researcher@ohsu.edu",
|
| 94 |
+
dataset_id=dataset_id,
|
| 95 |
+
manifest=_manifest(dataset_id),
|
| 96 |
+
deidentified=True,
|
| 97 |
+
)
|
| 98 |
+
kwargs.update(over)
|
| 99 |
+
return stage_upload(good_h5ad, **kwargs)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# --------------------------------------------------------------------------- #
|
| 103 |
+
# Door gate
|
| 104 |
+
# --------------------------------------------------------------------------- #
|
| 105 |
+
|
| 106 |
+
@pytest.mark.parametrize("suffix", [".py", ".sh", ".pkl", ".bin", ".exe"])
|
| 107 |
+
def test_type_gate_rejects_disallowed(tmp_path, suffix):
|
| 108 |
+
bad = tmp_path / f"payload{suffix}"
|
| 109 |
+
bad.write_bytes(b"not data")
|
| 110 |
+
with pytest.raises(UploadRejected) as exc:
|
| 111 |
+
_stage(bad)
|
| 112 |
+
assert exc.value.record.status == "rejected"
|
| 113 |
+
assert "not allowed" in exc.value.record.errors[0]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@pytest.mark.parametrize("suffix", [".h5ad", ".csv", ".tsv", ".csv.gz"])
|
| 117 |
+
def test_type_gate_accepts_allowed_suffixes(tmp_path, suffix):
|
| 118 |
+
f = tmp_path / f"data{suffix}"
|
| 119 |
+
f.write_bytes(b"col1,col2\n1,2\n") # content irrelevant at the door
|
| 120 |
+
rec = _stage(f) # tabular is accepted for STAGING (validation is h5ad-only)
|
| 121 |
+
assert rec.status == STATUS_QUARANTINED
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_size_gate_rejects_oversize(good_h5ad, monkeypatch):
|
| 125 |
+
monkeypatch.setenv("UPLOAD_MAX_BYTES", "10") # 10 bytes — the h5ad is bigger
|
| 126 |
+
with pytest.raises(UploadRejected) as exc:
|
| 127 |
+
_stage(good_h5ad)
|
| 128 |
+
assert "upload limit" in exc.value.record.errors[0]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def test_manifest_required(good_h5ad):
|
| 132 |
+
with pytest.raises(UploadRejected) as exc:
|
| 133 |
+
_stage(good_h5ad, manifest={})
|
| 134 |
+
assert "manifest is required" in exc.value.record.errors[0].lower()
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_attestation_required(good_h5ad):
|
| 138 |
+
with pytest.raises(UploadRejected) as exc:
|
| 139 |
+
_stage(good_h5ad, deidentified=False)
|
| 140 |
+
assert "attestation" in exc.value.record.errors[0].lower()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_staged_record_carries_attestation_and_hash(good_h5ad):
|
| 144 |
+
rec = _stage(good_h5ad)
|
| 145 |
+
assert rec.status == STATUS_QUARANTINED
|
| 146 |
+
assert rec.deidentified_attestation is True
|
| 147 |
+
assert rec.sha256 and len(rec.sha256) == 64
|
| 148 |
+
assert Path(rec.staged_path).is_file()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# --------------------------------------------------------------------------- #
|
| 152 |
+
# Integrity helper
|
| 153 |
+
# --------------------------------------------------------------------------- #
|
| 154 |
+
|
| 155 |
+
def test_integrity_hash_recorded_and_verifiable(good_h5ad):
|
| 156 |
+
rec = _stage(good_h5ad)
|
| 157 |
+
assert rec.sha256 == compute_sha256(rec.staged_path)
|
| 158 |
+
assert verify_sha256(rec.staged_path, rec.sha256) is True
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def test_integrity_detects_tamper(good_h5ad):
|
| 162 |
+
rec = _stage(good_h5ad)
|
| 163 |
+
with open(rec.staged_path, "ab") as fh:
|
| 164 |
+
fh.write(b"tampered")
|
| 165 |
+
assert verify_sha256(rec.staged_path, rec.sha256) is False
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_verify_sha256_empty_baseline_is_false(good_h5ad):
|
| 169 |
+
rec = _stage(good_h5ad)
|
| 170 |
+
assert verify_sha256(rec.staged_path, "") is False
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# --------------------------------------------------------------------------- #
|
| 174 |
+
# Validation
|
| 175 |
+
# --------------------------------------------------------------------------- #
|
| 176 |
+
|
| 177 |
+
def test_validate_happy_path(good_h5ad):
|
| 178 |
+
rec = _stage(good_h5ad, dataset_id="up_valid")
|
| 179 |
+
rec, report = validate_upload(rec)
|
| 180 |
+
assert rec.status == STATUS_VALIDATED
|
| 181 |
+
assert report["status"] == "pass"
|
| 182 |
+
assert rec.validated_at
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_validate_mismatch_stays_quarantined(tmp_path):
|
| 186 |
+
# Manifest declares raw_counts, but the data is log-scaled → against-data error.
|
| 187 |
+
src = _write_h5ad(tmp_path / "log.h5ad", data_level="log")
|
| 188 |
+
m = _manifest("up_mismatch", data_level="raw_counts")
|
| 189 |
+
rec = stage_upload(
|
| 190 |
+
src, uploader="r@ohsu.edu", dataset_id="up_mismatch",
|
| 191 |
+
manifest=m, deidentified=True,
|
| 192 |
+
)
|
| 193 |
+
rec, report = validate_upload(rec)
|
| 194 |
+
assert rec.status == STATUS_QUARANTINED
|
| 195 |
+
assert report["status"] == "error"
|
| 196 |
+
assert rec.errors
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_validate_tabular_not_supported(tmp_path):
|
| 200 |
+
f = tmp_path / "matrix.csv"
|
| 201 |
+
f.write_bytes(b"gene,S1,S2\nGENE1,1,2\n")
|
| 202 |
+
rec = _stage(f, dataset_id="up_csv")
|
| 203 |
+
rec, report = validate_upload(rec)
|
| 204 |
+
assert rec.status == STATUS_QUARANTINED
|
| 205 |
+
assert "not implemented" in report["errors"][0].lower()
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# --------------------------------------------------------------------------- #
|
| 209 |
+
# Admin approval / promotion
|
| 210 |
+
# --------------------------------------------------------------------------- #
|
| 211 |
+
|
| 212 |
+
def test_register_refused_for_non_admin(good_h5ad, monkeypatch):
|
| 213 |
+
monkeypatch.setenv("UPLOAD_ADMIN_IDS", "alice@ohsu.edu")
|
| 214 |
+
rec = _stage(good_h5ad, dataset_id="up_nonadmin")
|
| 215 |
+
rec, _ = validate_upload(rec)
|
| 216 |
+
assert rec.status == STATUS_VALIDATED
|
| 217 |
+
with pytest.raises(RegistrationRefused, match="not an authorized admin"):
|
| 218 |
+
register_upload(rec, admin="bob@ohsu.edu")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def test_register_refused_before_validation(good_h5ad, monkeypatch):
|
| 222 |
+
monkeypatch.setenv("UPLOAD_ADMIN_IDS", "alice@ohsu.edu")
|
| 223 |
+
rec = _stage(good_h5ad, dataset_id="up_early") # still quarantined
|
| 224 |
+
with pytest.raises(RegistrationRefused, match="not 'validated'"):
|
| 225 |
+
register_upload(rec, admin="alice@ohsu.edu")
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def test_admin_register_promotes_into_registry(good_h5ad, monkeypatch):
|
| 229 |
+
monkeypatch.setenv("UPLOAD_ADMIN_IDS", "alice@ohsu.edu")
|
| 230 |
+
dsid = "up_registered_ds"
|
| 231 |
+
rec = _stage(good_h5ad, dataset_id=dsid)
|
| 232 |
+
rec, _ = validate_upload(rec)
|
| 233 |
+
rec = register_upload(rec, admin="alice@ohsu.edu")
|
| 234 |
+
assert rec.status == STATUS_REGISTERED
|
| 235 |
+
assert rec.registered_at
|
| 236 |
+
# The live registry now resolves the freshly-registered dataset.
|
| 237 |
+
assert get_registry().get(dsid) is not None
|
| 238 |
+
assert load_manifest(dsid).dataset_id == dsid
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# --------------------------------------------------------------------------- #
|
| 242 |
+
# Never-execute guarantee (ADR item 4)
|
| 243 |
+
# --------------------------------------------------------------------------- #
|
| 244 |
+
|
| 245 |
+
def test_uploads_package_has_no_exec_paths():
|
| 246 |
+
pkg = Path(__file__).parent.parent / "src" / "uploads"
|
| 247 |
+
forbidden = ("exec(", "eval(", "pickle.load", "__import__(")
|
| 248 |
+
for py in pkg.glob("*.py"):
|
| 249 |
+
text = py.read_text()
|
| 250 |
+
for token in forbidden:
|
| 251 |
+
assert token not in text, f"{py.name} contains forbidden call: {token}"
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# --------------------------------------------------------------------------- #
|
| 255 |
+
# Provenance / audit trail (ADR item 6)
|
| 256 |
+
# --------------------------------------------------------------------------- #
|
| 257 |
+
|
| 258 |
+
def test_attestation_persisted_to_audit_sink(good_h5ad, tmp_path):
|
| 259 |
+
rec = _stage(good_h5ad, dataset_id="up_audit")
|
| 260 |
+
rec, _ = validate_upload(rec)
|
| 261 |
+
audit = tmp_path / "audit" / f"upload_{rec.upload_id}_trace.json"
|
| 262 |
+
assert audit.is_file()
|
| 263 |
+
import json
|
| 264 |
+
|
| 265 |
+
blob = json.loads(audit.read_text())
|
| 266 |
+
assert blob["deidentified_attestation"] is True
|
| 267 |
+
assert blob["sha256"] == rec.sha256
|
| 268 |
+
assert blob["status"] == STATUS_VALIDATED
|