anne-voigt commited on
Commit
74d4e89
·
verified ·
1 Parent(s): 05fca82

feat(uploads): ADR-0011 manual-upload safety gate (Now slice)

Browse files

Standalone, 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 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-01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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