diff --git a/AGENTS.md b/AGENTS.md index c28a04807..9f453133b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -473,7 +473,8 @@ TruffleHog, detect-secrets, large file check (max 1000KB), merge conflict marker - Trust-internal service key for trust-api / imaging-api / fl-client → imaging-api / data-access-api auth (per-trust, never leaves trust env). See **Trust-internal Service Authentication** below. - FL clients intentionally have no Central Hub credentials. - Cohort-query validation is three-layer and **deliberately asymmetric — do not "sync" the layers**. Only the trust-side `data_access_api.services.cohort.validate_query` is authoritative (single parse-validate-emit; length, single-statement, SELECT-only, no `INSERT`/`UPDATE`/`DELETE`/`MERGE` anywhere in the tree — a writable CTE parses as a top-level `Select`, so the shape check alone misses it — `omop`-schema pin, literal `LIMIT`/`OFFSET`; re-emits from the checked AST; backed by the read-only `data_analyst_reader` role — pass its return value to the engine, never the caller's raw string). The hub-side `flip_api.cohort_services.submit_cohort_query.validate_query` is a *fast-feedback validity pre-check only, not a security control*: it exists so a malformed query fails in-hand instead of after an async fan-out to every trust, and enforces only what every trust would reject anyway. The flip-ui cohort form validates required-field only. A trust must stay safe regardless of what the hub checked, so hub drift is safe by construction. **No layer uses a keyword denylist** — the removed one blocked legitimate `SUBSTRING()` while stopping nothing; blind extraction is defeated by the literal-`LIMIT` rule and DDL/DML by the read-only role. See [`trust/data-access-api/README.md`](trust/data-access-api/README.md#cohort-query-validation). -- Row-level cohort egress is gated on `COHORT_QUERY_THRESHOLD` at **both** row-level routes — `/cohort/dataframe` (FL training data) and `/cohort/accession-ids` (the accession list that decides whose imaging is pulled into XNAT) — sharing one fixed refusal string so a below-threshold cohort is indistinguishable from an empty one. The threshold is the trust's own disclosure floor (default 10, set per trust in its kit file), enforced trust-side rather than relying on the hub's staging guard. Both gates evaluate the **live** cohort on every call: FLIP stores the cohort only as a SQL string and re-runs it against OMOP at every stage, so a project can import cleanly and later start refusing (FLIP#857). +- Row-level cohort egress is gated on `COHORT_QUERY_THRESHOLD` at **both** row-level routes — `/cohort/dataframe` (FL training data) and `/cohort/accession-ids` (the accession list that decides whose imaging is pulled into XNAT) — sharing one fixed refusal string so a below-threshold cohort is indistinguishable from an empty one. The threshold is the trust's own disclosure floor (default 10, set per trust in its kit file), enforced trust-side rather than relying on the hub's staging guard. +- Both row-level routes serve **only the frozen approved-cohort snapshot** (FLIP#857): at approval a `PERSIST_COHORT` TrustTask makes each trust run the query of record ONCE and persist the dataframe (`data_access_api/services/cohort_snapshot.py` — parquet + meta per project UUID on the trust's `COHORT_SNAPSHOT_STORAGE_DIR` bind mount, atomic-rename writes, no TTL; the hub records aggregates in `cohort_snapshot_status` and warns on drift vs the approved count). The caller-supplied SQL on those routes is **ignored** — researcher FL code can no longer execute SQL under a project id, the cohort cannot grow after approval, and every training round fetches identical data. A project with no snapshot is refused (fail-closed); a snapshot without `accession_id` serves an empty accession list (tabular project — imaging no-ops); imaging-api resolves the XNAT project's `secondary_ID` (the hub id) before asking, so the ~10s status poll reads the frozen pointer set instead of re-running SQL. Re-approval atomically replaces the artefact — how OMOP-side removals/opt-outs propagate, never silently mid-training. Live OMOP is evaluated only by `/cohort` statistics (pre-approval) and `/cohort/snapshot` (once per approval); the threshold gate reads the frozen count but the live threshold value. The snapshot **write** routes (`/cohort/snapshot`, `/cohort/snapshot/delete`) that define/destroy this artefact carry a second auth gate beyond the shared trust-internal key — proof of possessing `AES_KEY_BASE64` (held by trust-api + data-access-api, not fl-client) — so researcher FL code, which holds the trust-internal key for its reads, cannot rewrite or delete a project's frozen cohort. See the **Trust-internal Service Authentication** section's "Cohort-admin gate". - Do not hardcode env values in Dockerfiles or compose files. - 72-hour supply-chain cooldown on Python/npm package installs — enforced by uv `exclude-newer` (`[tool.uv]` in every `pyproject.toml`) and npm `min-release-age` (`flip-ui/.npmrc`, requires npm >= 11.10 which Node 24 LTS ships), backstopped by a `uv lock --check` CI gate in `secret-scanning.yml`. See CONTRIBUTING.md ("Dependency cooldown"). @@ -487,14 +488,16 @@ TruffleHog, detect-secrets, large file check (max 1000KB), merge conflict marker **Generating keys.** The key is minted by `register_trust` (`make register-trusts`), which writes `TRUST_INTERNAL_SERVICE_KEY` into the trust's kit file. Re-register to rotate. +**Cohort-admin gate (write routes, FLIP#857).** The trust-internal key does not distinguish callers, and fl-client legitimately holds it (it reads the frozen cohort via `flip.get_dataframe` and pulls imaging). That is safe for the read routes, but data-access-api's cohort-**defining** writes — `POST /cohort/snapshot` (materialise/replace the frozen artefact everyone then trains on) and `POST /cohort/snapshot/delete` — must not be reachable by researcher training code. They therefore carry a **second** router-level gate on top of the trust-internal key: `authenticate_cohort_admin`, proof of possessing `AES_KEY_BASE64`. trust-api and data-access-api hold that key (they encrypt/decrypt hub payloads with it); fl-client deliberately does not (it is not in fl-client's compose env). The proof is the **SHA-256 of the key**, not the key itself, so it never travels the wire or lands in a log; receivers compare it with `hmac.compare_digest`. A caller with a valid trust-internal key but no proof gets **403** (authenticated, not authorised); the dependency order (trust-internal first) means a caller with neither still gets 401 first. No new secret is minted or distributed — the gate reuses the existing AES-key possession boundary, so kit files, `register_trust`, and compose are unchanged. Header name: `COHORT_ADMIN_KEY_HEADER` (default `X-Cohort-Admin-Key`), configured on both trust-api and data-access-api. + **Per-service code.** The auth check lives in each receiving service's `utils/internal_auth.py`: - `trust/imaging-api/imaging_api/utils/internal_auth.py` — applied at the router level on every imaging-api router except `/health`. -- `trust/data-access-api/data_access_api/utils/internal_auth.py` — applied at the router level on `/cohort` (covers `/cohort`, `/cohort/dataframe`, `/cohort/accession-ids`). +- `trust/data-access-api/data_access_api/utils/internal_auth.py` — `authenticate_internal_service` gates the read router (`/cohort` statistics, `/cohort/dataframe`, `/cohort/accession-ids`); the write router (`/cohort/snapshot`, `/cohort/snapshot/delete`) additionally requires `authenticate_cohort_admin` (the AES-possession proof above). The senders construct the header inline at call sites: -- `trust-api/trust_api/services/task_handlers.py::_trust_internal_headers()` — used on outbound imaging-api and data-access-api calls. +- `trust-api/trust_api/services/task_handlers.py::trust_internal_headers()` — used on outbound imaging-api and data-access-api calls; `cohort_admin_headers()` in the same module layers the cohort-admin proof on top for the snapshot write. - `imaging-api/imaging_api/services_external/data_access.py` — used on the outbound `/cohort/accession-ids` call. - The `flip` Python package — lives at [`flip-utils/flip/`](flip-utils/flip/) in this mono-repo, consumed by both the NVFLARE and Flower fl-client / fl-server images built from `fl-services/`. Wraps every fl-client call to imaging-api (`flip.get_by_accession_number`, etc.) and data-access-api (`flip.get_dataframe`). The package reads `TRUST_INTERNAL_SERVICE_KEY` from `os.environ` and forwards it on every request. **User-uploaded training code (`client_app.py`, `server_app.py`, anything under `tutorials/`) does not deal with the header directly** — it calls `flip.*` and the package handles transport-level auth. diff --git a/CLAUDE.md b/CLAUDE.md index 776bd7388..599bbc180 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -473,7 +473,8 @@ TruffleHog, detect-secrets, large file check (max 1000KB), merge conflict marker - Trust-internal service key for trust-api / imaging-api / fl-client → imaging-api / data-access-api auth (per-trust, never leaves trust env). See **Trust-internal Service Authentication** below. - FL clients intentionally have no Central Hub credentials. - Cohort-query validation is three-layer and **deliberately asymmetric — do not "sync" the layers**. Only the trust-side `data_access_api.services.cohort.validate_query` is authoritative (single parse-validate-emit; length, single-statement, SELECT-only, no `INSERT`/`UPDATE`/`DELETE`/`MERGE` anywhere in the tree — a writable CTE parses as a top-level `Select`, so the shape check alone misses it — `omop`-schema pin, literal `LIMIT`/`OFFSET`; re-emits from the checked AST; backed by the read-only `data_analyst_reader` role — pass its return value to the engine, never the caller's raw string). The hub-side `flip_api.cohort_services.submit_cohort_query.validate_query` is a *fast-feedback validity pre-check only, not a security control*: it exists so a malformed query fails in-hand instead of after an async fan-out to every trust, and enforces only what every trust would reject anyway. The flip-ui cohort form validates required-field only. A trust must stay safe regardless of what the hub checked, so hub drift is safe by construction. **No layer uses a keyword denylist** — the removed one blocked legitimate `SUBSTRING()` while stopping nothing; blind extraction is defeated by the literal-`LIMIT` rule and DDL/DML by the read-only role. See [`trust/data-access-api/README.md`](trust/data-access-api/README.md#cohort-query-validation). -- Row-level cohort egress is gated on `COHORT_QUERY_THRESHOLD` at **both** row-level routes — `/cohort/dataframe` (FL training data) and `/cohort/accession-ids` (the accession list that decides whose imaging is pulled into XNAT) — sharing one fixed refusal string so a below-threshold cohort is indistinguishable from an empty one. The threshold is the trust's own disclosure floor (default 10, set per trust in its kit file), enforced trust-side rather than relying on the hub's staging guard. Both gates evaluate the **live** cohort on every call: FLIP stores the cohort only as a SQL string and re-runs it against OMOP at every stage, so a project can import cleanly and later start refusing (FLIP#857). +- Row-level cohort egress is gated on `COHORT_QUERY_THRESHOLD` at **both** row-level routes — `/cohort/dataframe` (FL training data) and `/cohort/accession-ids` (the accession list that decides whose imaging is pulled into XNAT) — sharing one fixed refusal string so a below-threshold cohort is indistinguishable from an empty one. The threshold is the trust's own disclosure floor (default 10, set per trust in its kit file), enforced trust-side rather than relying on the hub's staging guard. +- Both row-level routes serve **only the frozen approved-cohort snapshot** (FLIP#857): at approval a `PERSIST_COHORT` TrustTask makes each trust run the query of record ONCE and persist the dataframe (`data_access_api/services/cohort_snapshot.py` — parquet + meta per project UUID on the trust's `COHORT_SNAPSHOT_STORAGE_DIR` bind mount, atomic-rename writes, no TTL; the hub records aggregates in `cohort_snapshot_status` and warns on drift vs the approved count). The caller-supplied SQL on those routes is **ignored** — researcher FL code can no longer execute SQL under a project id, the cohort cannot grow after approval, and every training round fetches identical data. A project with no snapshot is refused (fail-closed); a snapshot without `accession_id` serves an empty accession list (tabular project — imaging no-ops); imaging-api resolves the XNAT project's `secondary_ID` (the hub id) before asking, so the ~10s status poll reads the frozen pointer set instead of re-running SQL. Re-approval atomically replaces the artefact — how OMOP-side removals/opt-outs propagate, never silently mid-training. Live OMOP is evaluated only by `/cohort` statistics (pre-approval) and `/cohort/snapshot` (once per approval); the threshold gate reads the frozen count but the live threshold value. The snapshot **write** routes (`/cohort/snapshot`, `/cohort/snapshot/delete`) that define/destroy this artefact carry a second auth gate beyond the shared trust-internal key — proof of possessing `AES_KEY_BASE64` (held by trust-api + data-access-api, not fl-client) — so researcher FL code, which holds the trust-internal key for its reads, cannot rewrite or delete a project's frozen cohort. See the **Trust-internal Service Authentication** section's "Cohort-admin gate". - Do not hardcode env values in Dockerfiles or compose files. - 72-hour supply-chain cooldown on Python/npm package installs — enforced by uv `exclude-newer` (`[tool.uv]` in every `pyproject.toml`) and npm `min-release-age` (`flip-ui/.npmrc`, requires npm >= 11.10 which Node 24 LTS ships), backstopped by a `uv lock --check` CI gate in `secret-scanning.yml`. See CONTRIBUTING.md ("Dependency cooldown"). @@ -487,14 +488,16 @@ TruffleHog, detect-secrets, large file check (max 1000KB), merge conflict marker **Generating keys.** The key is minted by `register_trust` (`make register-trusts`), which writes `TRUST_INTERNAL_SERVICE_KEY` into the trust's kit file. Re-register to rotate. +**Cohort-admin gate (write routes, FLIP#857).** The trust-internal key does not distinguish callers, and fl-client legitimately holds it (it reads the frozen cohort via `flip.get_dataframe` and pulls imaging). That is safe for the read routes, but data-access-api's cohort-**defining** writes — `POST /cohort/snapshot` (materialise/replace the frozen artefact everyone then trains on) and `POST /cohort/snapshot/delete` — must not be reachable by researcher training code. They therefore carry a **second** router-level gate on top of the trust-internal key: `authenticate_cohort_admin`, proof of possessing `AES_KEY_BASE64`. trust-api and data-access-api hold that key (they encrypt/decrypt hub payloads with it); fl-client deliberately does not (it is not in fl-client's compose env). The proof is the **SHA-256 of the key**, not the key itself, so it never travels the wire or lands in a log; receivers compare it with `hmac.compare_digest`. A caller with a valid trust-internal key but no proof gets **403** (authenticated, not authorised); the dependency order (trust-internal first) means a caller with neither still gets 401 first. No new secret is minted or distributed — the gate reuses the existing AES-key possession boundary, so kit files, `register_trust`, and compose are unchanged. Header name: `COHORT_ADMIN_KEY_HEADER` (default `X-Cohort-Admin-Key`), configured on both trust-api and data-access-api. + **Per-service code.** The auth check lives in each receiving service's `utils/internal_auth.py`: - `trust/imaging-api/imaging_api/utils/internal_auth.py` — applied at the router level on every imaging-api router except `/health`. -- `trust/data-access-api/data_access_api/utils/internal_auth.py` — applied at the router level on `/cohort` (covers `/cohort`, `/cohort/dataframe`, `/cohort/accession-ids`). +- `trust/data-access-api/data_access_api/utils/internal_auth.py` — `authenticate_internal_service` gates the read router (`/cohort` statistics, `/cohort/dataframe`, `/cohort/accession-ids`); the write router (`/cohort/snapshot`, `/cohort/snapshot/delete`) additionally requires `authenticate_cohort_admin` (the AES-possession proof above). The senders construct the header inline at call sites: -- `trust-api/trust_api/services/task_handlers.py::_trust_internal_headers()` — used on outbound imaging-api and data-access-api calls. +- `trust-api/trust_api/services/task_handlers.py::trust_internal_headers()` — used on outbound imaging-api and data-access-api calls; `cohort_admin_headers()` in the same module layers the cohort-admin proof on top for the snapshot write. - `imaging-api/imaging_api/services_external/data_access.py` — used on the outbound `/cohort/accession-ids` call. - The `flip` Python package — lives at [`flip-utils/flip/`](flip-utils/flip/) in this mono-repo, consumed by both the NVFLARE and Flower fl-client / fl-server images built from `fl-services/`. Wraps every fl-client call to imaging-api (`flip.get_by_accession_number`, etc.) and data-access-api (`flip.get_dataframe`). The package reads `TRUST_INTERNAL_SERVICE_KEY` from `os.environ` and forwards it on every request. **User-uploaded training code (`client_app.py`, `server_app.py`, anything under `tutorials/`) does not deal with the header directly** — it calls `flip.*` and the package handles transport-level auth. diff --git a/deploy/providers/kubernetes/templates/data-access-api.yaml b/deploy/providers/kubernetes/templates/data-access-api.yaml index 7534ae353..e788208ab 100644 --- a/deploy/providers/kubernetes/templates/data-access-api.yaml +++ b/deploy/providers/kubernetes/templates/data-access-api.yaml @@ -34,6 +34,11 @@ data: DATA_ACCESS_POSTGRES_USER: {{ .Values.dataAccessApi.env.DATA_ACCESS_POSTGRES_USER | quote }} ENV: {{ .Values.environment | quote }} LOG_LEVEL: {{ .Values.logLevel | quote }} + {{- if .Values.dataAccessApi.snapshots.enabled }} + # Approved-cohort snapshot store (FLIP#857) — the PVC mount below. Row-level serving is + # fail-closed without it. + COHORT_SNAPSHOT_DIR: "/snapshots" + {{- end }} --- apiVersion: apps/v1 kind: Deployment @@ -57,6 +62,13 @@ spec: spec: enableServiceLinks: false serviceAccountName: {{ include "flip-trust.fullname" . }}-data-access-api + {{- if .Values.dataAccessApi.snapshots.enabled }} + securityContext: + # Makes the snapshot PVC group-writable for the image's non-root user — without it a + # fresh volume mounts root-owned, the store disables itself, and fail-closed serving + # refuses every project. + fsGroup: {{ .Values.dataAccessApi.snapshots.fsGroup }} + {{- end }} {{- if .Values.imagePullSecrets }} imagePullSecrets: {{ include "flip-trust.imagePullSecrets" . | nindent 8 }} @@ -101,6 +113,11 @@ spec: secretKeyRef: name: {{ if .Values.secrets.create }}{{ include "flip-trust.fullname" . }}-secrets{{ else }}{{ .Values.secrets.existingName }}{{ end }} key: trust-internal-service-key + {{- if .Values.dataAccessApi.snapshots.enabled }} + volumeMounts: + - name: cohort-snapshots + mountPath: /snapshots + {{- end }} resources: {{- toYaml .Values.dataAccessApi.resources | nindent 12 }} livenessProbe: @@ -117,6 +134,34 @@ spec: initialDelaySeconds: {{ .Values.dataAccessApi.probes.readiness.initialDelaySeconds }} periodSeconds: {{ .Values.dataAccessApi.probes.readiness.periodSeconds }} timeoutSeconds: {{ .Values.dataAccessApi.probes.readiness.timeoutSeconds }} + {{- if .Values.dataAccessApi.snapshots.enabled }} + volumes: + - name: cohort-snapshots + persistentVolumeClaim: + claimName: {{ include "flip-trust.fullname" . }}-cohort-snapshots + {{- end }} +{{- if .Values.dataAccessApi.snapshots.enabled }} +--- +# Approved-cohort snapshot store (FLIP#857): frozen cohort artefacts, one directory per +# hub project UUID. Row-level patient data — back this PV up with the OMOP volume. +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "flip-trust.fullname" . }}-cohort-snapshots + namespace: {{ include "flip-trust.namespace" . }} + labels: + {{- include "flip-trust.labels" . | nindent 4 }} + app.kubernetes.io/component: data-access-api +spec: + accessModes: + - {{ .Values.dataAccessApi.snapshots.accessMode }} + {{- if .Values.dataAccessApi.snapshots.storageClassName }} + storageClassName: {{ .Values.dataAccessApi.snapshots.storageClassName }} + {{- end }} + resources: + requests: + storage: {{ .Values.dataAccessApi.snapshots.size }} +{{- end }} --- apiVersion: v1 kind: Service diff --git a/deploy/providers/kubernetes/values.yaml b/deploy/providers/kubernetes/values.yaml index 12a9d7776..9d8c3e0c9 100644 --- a/deploy/providers/kubernetes/values.yaml +++ b/deploy/providers/kubernetes/values.yaml @@ -263,6 +263,21 @@ dataAccessApi: OMOP_DB_SERVICE_NAME: "omop-db" OMOP_POSTGRES_DB: "trustomopdb" DATA_ACCESS_POSTGRES_USER: "data_analyst_reader" + # Approved-cohort snapshot store (FLIP#857): the frozen artefacts the row-level routes + # serve. Holds row-level patient data — include the PV in the trust's backup practice. + # Serving is fail-closed: with this disabled (or the volume unwritable) every project is + # refused row-level data, so only disable it deliberately. accessMode RWO matches + # replicas: 1 — scaling data-access-api out needs a ReadWriteMany storage class so every + # replica reads the same store. + snapshots: + enabled: true + size: 5Gi + storageClassName: "" + accessMode: ReadWriteOnce + # The GHCR image runs non-root (uid 1000); fsGroup makes a fresh PVC group-writable + # for it. The chart otherwise avoids fsGroup (see README "Known Limitations") — this + # one is load-bearing, not cosmetic. + fsGroup: 1000 # --------------------------------------------------------------------------- # Shared images PVC — used by imaging-api and fl-client to share downloaded diff --git a/docs/source/security.rst b/docs/source/security.rst index 66613cade..3c970f047 100644 --- a/docs/source/security.rst +++ b/docs/source/security.rst @@ -130,6 +130,15 @@ trust receives a different value, limiting the effect of disclosure to one trust rotation is performed by issuing a new trust kit and restarting the trust-side services together so callers and receivers change atomically. +Because the FL client legitimately holds this key — it reads the approved cohort and pulls +imaging — the key alone cannot separate reading a project's cohort from *defining* it. The +two ``data-access-api`` routes that materialise or delete the frozen cohort therefore carry a +second gate on top of the shared key: proof of possessing the trust's payload-encryption key +(``AES_KEY_BASE64``), which ``trust-api`` and ``data-access-api`` hold but the FL client does +not. The proof is a one-way digest of the key, not the key itself, so it never appears on the +wire or in logs. A caller that presents a valid shared key but not this proof is refused. No +additional secret is provisioned; the control reuses a possession boundary that already exists. + ************************** The clinical data boundary ************************** @@ -151,9 +160,15 @@ independent controls would each have to fail before anything unintended could ex reveal that a handful of patients matched — the threshold is the trust's own disclosure floor (default 10), set by each trust in its deployment kit: trusts need not agree on a shared value, and the hub cannot lower it; -- cached results are scoped to the requesting project and expire in minutes, so no - project is served another's data and no result outlives a withdrawal of consent or a - correction to a record. +- row-level data is released only from the **cohort frozen at project approval**: each + Trust materialises the approved query's result once and serves that immutable, + project-scoped artefact from then on, ignoring any SQL supplied at request time. The + cohort a project trains on is therefore exactly the cohort that was approved — it cannot + silently grow as the live database grows — and training code cannot run queries of its + own. Withdrawals and record corrections propagate at explicit re-approval events, which + atomically replace the frozen artefact, and at project teardown, which deletes it — + never mid-training, where a silently shifting dataset would corrupt the model without + anyone approving the change. This is achieved **without restricting researchers to a fixed menu of queries** — arbitrary analytical SQL remains available. The constraint is on the shape and privilege diff --git a/flip-api/src/flip_api/db/migrations/versions/b3f1c857a001_persist_cohort_task_and_snapshot_status.py b/flip-api/src/flip_api/db/migrations/versions/b3f1c857a001_persist_cohort_task_and_snapshot_status.py new file mode 100644 index 000000000..8c6cabec3 --- /dev/null +++ b/flip-api/src/flip_api/db/migrations/versions/b3f1c857a001_persist_cohort_task_and_snapshot_status.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""persist_cohort task type and cohort_snapshot_status + +Approved-cohort snapshots (FLIP#857): adds the PERSIST_COHORT member to the native +``tasktype`` Postgres enum (the approval-time task that makes each trust freeze its +cohort) and the ``cohort_snapshot_status`` table — the hub's per-(project, trust) +audit record of what was frozen (aggregates only; the row-level cohort never leaves +the trust). ADD VALUE cannot run inside the migration transaction, hence the +autocommit block; it is appended last so migrated databases keep the same enum +order as fresh ones. + +Revision ID: b3f1c857a001 +Revises: 46edb903e4d1 +Create Date: 2026-08-26 18:20:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = 'b3f1c857a001' +down_revision: str | None = '46edb903e4d1' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Apply this revision.""" + with op.get_context().autocommit_block(): + op.execute("ALTER TYPE tasktype ADD VALUE IF NOT EXISTS 'PERSIST_COHORT'") + op.create_table( + 'cohort_snapshot_status', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('project_id', sa.Uuid(), nullable=True), + sa.Column('trust_id', sa.Uuid(), nullable=True), + sa.Column('query_id', sa.Uuid(), nullable=True), + sa.Column('row_count', sa.Integer(), nullable=False), + sa.Column('approved_record_count', sa.Integer(), nullable=True), + sa.Column('has_accessions', sa.Boolean(), nullable=False), + sa.Column('query_hash', sa.String(), nullable=True), + sa.Column('snapshot_at', sa.DateTime(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id']), + sa.ForeignKeyConstraint(['trust_id'], ['trust.id']), + sa.PrimaryKeyConstraint('id'), + ) + + +def downgrade() -> None: + """Revert this revision. + + Postgres cannot drop an enum value, so PERSIST_COHORT stays in the type on + downgrade — harmless, as pre-#857 code never writes it. + """ + op.drop_table('cohort_snapshot_status') diff --git a/flip-api/src/flip_api/db/models/main_models.py b/flip-api/src/flip_api/db/models/main_models.py index c9b53b623..8d1996252 100644 --- a/flip-api/src/flip_api/db/models/main_models.py +++ b/flip-api/src/flip_api/db/models/main_models.py @@ -403,3 +403,31 @@ class XNATProjectStatus(SQLModel, table=True): query_at_creation: UUID | None = Field(default=None) last_reimport: Annotated[datetime, Field(default_factory=lambda: datetime.now(timezone.utc))] reimport_count: int = Field(default=0) + + +class CohortSnapshotStatus(SQLModel, table=True): + """The hub's per-trust record of what cohort was frozen at approval (FLIP#857). + + Aggregates only — the hub never sees a row of the cohort. Written by the PERSIST_COHORT + task's post-processing from the trust's snapshot response; one row per (project, trust), + updated in place on re-approval (a re-snapshot replaces the trust-side artefact, so the + latest facts are the ones that describe what is being served). ``approved_record_count`` + is the count the project was staged/approved on (from the aggregated cohort statistics); + a mismatch with ``row_count`` means the live cohort drifted between submission and + approval, and is logged as a warning when the row is written — surfaced, never silently + adopted. + """ + + __tablename__ = "cohort_snapshot_status" # type: ignore + id: UUID = Field(default_factory=uuid4, primary_key=True) + project_id: UUID | None = Field(default=None, foreign_key="projects.id") + trust_id: UUID | None = Field(default=None, foreign_key="trust.id") + # Which Queries row was frozen — the missing link #857 calls out. + query_id: UUID | None = Field(default=None) + row_count: int = Field() + approved_record_count: int | None = Field(default=None) + # False = the frozen cohort has no accession_id column (tabular project; imaging no-ops). + has_accessions: bool = Field(default=False) + query_hash: str | None = Field(default=None) + snapshot_at: datetime = Field() + created_at: Annotated[datetime, Field(default_factory=lambda: datetime.now(timezone.utc))] diff --git a/flip-api/src/flip_api/domain/interfaces/project.py b/flip-api/src/flip_api/domain/interfaces/project.py index 9498ad5d3..e89e8c788 100644 --- a/flip-api/src/flip_api/domain/interfaces/project.py +++ b/flip-api/src/flip_api/domain/interfaces/project.py @@ -236,6 +236,27 @@ class IImagingImportStatus(BaseModel): ) +class ICohortSnapshot(BaseModel): + """One trust's frozen approved-cohort record (FLIP#857) — aggregates only. + + ``approvedRecordCount`` is the count the project was staged/approved on; when it + differs from ``rowCount`` the live cohort drifted between submission and approval + (the UI surfaces the diff — the snapshot is what the project trains on either way). + """ + + trust_id: UUID = Field(alias="trustId") + trust_name: str = Field(alias="trustName") + row_count: int = Field(alias="rowCount") + approved_record_count: int | None = Field(default=None, alias="approvedRecordCount") + has_accessions: bool = Field(alias="hasAccessions") + snapshot_at: datetime = Field(alias="snapshotAt") + query_id: UUID | None = Field(default=None, alias="queryId") + + model_config = ConfigDict( + populate_by_name=True, + ) + + class IImagingStatusResponse(BaseModel): project_creation_completed: bool = Field(alias="projectCreationCompleted") import_status: IImagingImportStatus | None = Field(default=None, alias="importStatus") diff --git a/flip-api/src/flip_api/domain/interfaces/trust.py b/flip-api/src/flip_api/domain/interfaces/trust.py index 970cfb33b..1225e4834 100644 --- a/flip-api/src/flip_api/domain/interfaces/trust.py +++ b/flip-api/src/flip_api/domain/interfaces/trust.py @@ -114,6 +114,22 @@ class ICreateImagingProject(BaseModel): dicom_to_nifti: bool = True +class IPersistCohort(BaseModel): + """Payload of the approval-time PERSIST_COHORT task (FLIP#857). + + Carries everything the trust needs to freeze the approved cohort: the query of record + and the hub project id both in the clear (for the trust's own logging/keying) and + encrypted (what the trust forwards to data-access-api, whose routes take the encrypted + form — mirroring the cohort-query task). + """ + + project_id: UUID # This is the central hub project ID + trust_id: UUID + encrypted_project_id: str + query: str + query_id: UUID | None = None + + class ICreatedImagingUser(BaseModel): """Represents a user created on XNAT. Used to be called IImageUser in the old repo.""" diff --git a/flip-api/src/flip_api/domain/schemas/status.py b/flip-api/src/flip_api/domain/schemas/status.py index dfc03ed9c..303bdd4a2 100644 --- a/flip-api/src/flip_api/domain/schemas/status.py +++ b/flip-api/src/flip_api/domain/schemas/status.py @@ -184,6 +184,11 @@ class TaskType(StrEnum): GET_IMAGING_STATUS = "get_imaging_status" REIMPORT_STUDIES = "reimport_studies" UPDATE_USER_PROFILE = "update_user_profile" + # Approval-time cohort freeze (FLIP#857): the trust materialises the approved cohort + # once and persists it; the row-level routes serve only that artefact from then on. + # Queued BEFORE the CREATE_IMAGING task so the frozen accession set exists by the time + # imaging retrieval asks for it. + PERSIST_COHORT = "persist_cohort" class XNATImageStatus(StrEnum): diff --git a/flip-api/src/flip_api/main.py b/flip-api/src/flip_api/main.py index 7df1acbd5..a86a89ae2 100644 --- a/flip-api/src/flip_api/main.py +++ b/flip-api/src/flip_api/main.py @@ -65,6 +65,7 @@ create_project, delete_project, edit_project, + get_cohort_snapshots, get_imaging_project_status, get_models, get_project, @@ -214,6 +215,7 @@ async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded) create_project.router, delete_project.router, edit_project.router, + get_cohort_snapshots.router, get_imaging_project_status.router, get_models.router, get_project_approved_trusts.router, diff --git a/flip-api/src/flip_api/private_services/snapshot_notifications.py b/flip-api/src/flip_api/private_services/snapshot_notifications.py new file mode 100644 index 000000000..c7aea5544 --- /dev/null +++ b/flip-api/src/flip_api/private_services/snapshot_notifications.py @@ -0,0 +1,106 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Post-processing of completed PERSIST_COHORT tasks (FLIP#857). + +Records the hub's audit row for the cohort a trust froze at approval — the answer to +"what cohort was this project approved for?" that #857 found missing — and surfaces +membership drift against the count the project was approved on. Aggregates only: the +row-level cohort never leaves the trust. +""" + +import json +from datetime import datetime +from uuid import UUID + +from sqlmodel import Session, select + +from flip_api.db.models.main_models import CohortSnapshotStatus, QueryStats, TrustTask +from flip_api.domain.schemas.private import AggregatedCohortStats +from flip_api.utils.logger import logger + + +def _approved_record_count(query_id: UUID | None, trust_id: UUID | None, db: Session) -> int | None: + """The per-trust cohort count the project was staged/approved on, if recorded. + + Read from the aggregated statistics blob captured at submission time + (``AggregatedCohortStats.trust_record_counts``). None when the stats row or the + trust's entry is missing — old data or an errored trust — which downgrades the drift + check to "not comparable", never blocks the audit row. + """ + if query_id is None or trust_id is None: + return None + stats_row = db.exec(select(QueryStats).where(QueryStats.query_id == query_id)).first() + if stats_row is None: + return None + try: + stats = AggregatedCohortStats.model_validate(json.loads(stats_row.stats)) + except Exception: + logger.warning(f"Could not parse QueryStats for query {query_id}; skipping drift comparison") + return None + return stats.trust_record_counts.get(str(trust_id)) + + +def handle_snapshot_task_completed(task: TrustTask, db: Session) -> None: + """Persist the frozen-cohort audit row for a successful PERSIST_COHORT task. + + Upserts one ``CohortSnapshotStatus`` row per (project, trust) — re-approval replaces + the trust-side artefact, so the newest snapshot's facts overwrite the row. Logs a + WARNING when the frozen row count differs from the count the project was approved on: + the live cohort drifted between submission and approval. The drift is surfaced, never + acted on — the snapshot IS the approved cohort from here on. + + Called after the task result has been committed to the database. + Any exceptions are expected to be caught by the caller. + + Args: + task (TrustTask): The completed PERSIST_COHORT task with result data. + db (Session): Database session. + + Raises: + ValueError: If the task has no result data. + """ + if not task.result: + raise ValueError(f"Task {task.id} has no result data") + snapshot = json.loads(task.result) + + payload = json.loads(task.payload) + project_id = UUID(payload["project_id"]) + query_id = UUID(payload["query_id"]) if payload.get("query_id") else None + + row_count = int(snapshot["row_count"]) + approved_count = _approved_record_count(query_id, task.trust_id, db) + if approved_count is not None and approved_count != row_count: + logger.warning( + f"Cohort membership drift for project {project_id}, trust {task.trust_id}: approved on " + f"{approved_count} records, frozen snapshot holds {row_count}. The live cohort changed " + "between submission and approval; the snapshot is what the project will train on." + ) + + existing = db.exec( + select(CohortSnapshotStatus) + .where(CohortSnapshotStatus.project_id == project_id) + .where(CohortSnapshotStatus.trust_id == task.trust_id) + ).first() + status_row = existing or CohortSnapshotStatus(project_id=project_id, trust_id=task.trust_id, row_count=row_count) + status_row.query_id = query_id + status_row.row_count = row_count + status_row.approved_record_count = approved_count + status_row.has_accessions = bool(snapshot.get("has_accessions", False)) + status_row.query_hash = snapshot.get("query_hash") + status_row.snapshot_at = datetime.fromisoformat(snapshot["snapshot_at"]) + db.add(status_row) + db.commit() + logger.info( + f"Recorded cohort snapshot for project {project_id}, trust {task.trust_id}: " + f"{row_count} rows, has_accessions={status_row.has_accessions}" + ) diff --git a/flip-api/src/flip_api/private_services/trust_tasks.py b/flip-api/src/flip_api/private_services/trust_tasks.py index 2fb7b042b..2027e2261 100644 --- a/flip-api/src/flip_api/private_services/trust_tasks.py +++ b/flip-api/src/flip_api/private_services/trust_tasks.py @@ -33,6 +33,7 @@ from flip_api.domain.schemas.private import TaskResultInput, TrustHeartbeatInput, TrustTaskResponse from flip_api.domain.schemas.status import TaskStatus, TaskType from flip_api.private_services.imaging_notifications import handle_imaging_task_completed +from flip_api.private_services.snapshot_notifications import handle_snapshot_task_completed from flip_api.utils.encryption import encrypt from flip_api.utils.logger import logger from flip_api.utils.rate_limiter import limiter @@ -157,7 +158,10 @@ def _submit_task_result( detail=f"Task {task_id} is not in progress (current status: {task.status})", ) - needs_post_processing = task_result.success and task.task_type == TaskType.CREATE_IMAGING + needs_post_processing = task_result.success and task.task_type in ( + TaskType.CREATE_IMAGING, + TaskType.PERSIST_COHORT, + ) task.status = TaskStatus.COMPLETED if task_result.success else TaskStatus.FAILED task.result = task_result.result @@ -165,15 +169,19 @@ def _submit_task_result( task.needs_post_processing = needs_post_processing db.commit() - # Post-process successful imaging project creation (persist status + send credential emails). + # Post-process by type: imaging creation persists status + sends credential emails; + # cohort snapshots record the frozen-cohort audit row and surface membership drift. if needs_post_processing: try: - handle_imaging_task_completed(task, db) + if task.task_type == TaskType.CREATE_IMAGING: + handle_imaging_task_completed(task, db) + else: + handle_snapshot_task_completed(task, db) task.needs_post_processing = False db.commit() except Exception as post_err: logger.error( - f"Failed post-processing for imaging task {task_id}: {post_err}. " + f"Failed post-processing for {task.task_type} task {task_id}: {post_err}. " "The stale task recovery job will retry this." ) diff --git a/flip-api/src/flip_api/project_services/get_cohort_snapshots.py b/flip-api/src/flip_api/project_services/get_cohort_snapshots.py new file mode 100644 index 000000000..8e5a28fe4 --- /dev/null +++ b/flip-api/src/flip_api/project_services/get_cohort_snapshots.py @@ -0,0 +1,95 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select + +from flip_api.auth.access_manager import can_access_project +from flip_api.auth.dependencies import verify_token +from flip_api.db.database import get_session +from flip_api.db.models.main_models import CohortSnapshotStatus, Trust +from flip_api.domain.interfaces.project import ICohortSnapshot +from flip_api.utils.logger import logger + +router = APIRouter(prefix="/projects", tags=["project_services"]) + + +@router.get( + "/{project_id}/cohort-snapshots", + summary="Get the per-trust frozen approved-cohort records for a project.", + response_model=list[ICohortSnapshot], + status_code=status.HTTP_200_OK, + responses={ + status.HTTP_200_OK: { + "model": list[ICohortSnapshot], + "description": "The per-trust cohort snapshot records (empty until trusts report snapshots).", + }, + status.HTTP_403_FORBIDDEN: { + "model": None, + "description": "You do not have permission to access this project.", + }, + }, +) +async def get_cohort_snapshots( + project_id: UUID, + session: Session = Depends(get_session), + user_id: UUID = Depends(verify_token), +) -> list[ICohortSnapshot]: + """ + Get the per-trust record of the cohort frozen at project approval (FLIP#857). + + Aggregates only — the row-level cohort never leaves each trust. One entry per trust + that has completed its PERSIST_COHORT task; a trust missing from the list has not + reported a snapshot (task pending/failed, or the project predates the feature), and + its row-level routes will refuse serving until it does. A ``rowCount`` differing from + ``approvedRecordCount`` means the live cohort drifted between submission and approval — + surfaced here so the drift is visible, never silently adopted. + + Args: + project_id (UUID): The ID of the project. + session (Session): The database session. + user_id (UUID): The ID of the user. + + Returns: + list[ICohortSnapshot]: One frozen-cohort record per reporting trust. + + Raises: + HTTPException: If the user does not have permission to access the project. + """ + logger.info(f"Getting cohort snapshots for project {project_id}") + + if not can_access_project(user_id, project_id, session): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to access this project.", + ) + + rows = session.exec( + select(CohortSnapshotStatus, Trust) + .join(Trust, Trust.id == CohortSnapshotStatus.trust_id) # type: ignore[arg-type] + .where(CohortSnapshotStatus.project_id == project_id) + ).all() + + return [ + ICohortSnapshot( # type: ignore[call-arg] # populate_by_name: field names are valid at runtime + trust_id=snapshot.trust_id, + trust_name=trust.name, + row_count=snapshot.row_count, + approved_record_count=snapshot.approved_record_count, + has_accessions=snapshot.has_accessions, + snapshot_at=snapshot.snapshot_at, + query_id=snapshot.query_id, + ) + for snapshot, trust in rows + ] diff --git a/flip-api/src/flip_api/trusts_services/start_project_imaging_creation.py b/flip-api/src/flip_api/trusts_services/start_project_imaging_creation.py index ce5199f28..1c63e9982 100644 --- a/flip-api/src/flip_api/trusts_services/start_project_imaging_creation.py +++ b/flip-api/src/flip_api/trusts_services/start_project_imaging_creation.py @@ -23,11 +23,13 @@ from flip_api.db.models.user_models import PermissionRef from flip_api.domain.interfaces.trust import ( ICreateImagingProject, + IPersistCohort, ITrust, ) from flip_api.domain.schemas.status import TaskType from flip_api.project_services.services.project_services import get_project, get_users_with_access from flip_api.utils.cognito_helpers import get_cognito_users, get_user_pool_id +from flip_api.utils.encryption import encrypt from flip_api.utils.logger import logger router = APIRouter(prefix="/trust", tags=["trusts_services"]) @@ -91,6 +93,36 @@ async def start_project_imaging_creation( # Get Cognito users cognito_users = get_cognito_users(params={"UserPoolId": user_pool_id}) + # Freeze the approved cohort BEFORE imaging creation (FLIP#857): the trust + # materialises the cohort once and the row-level routes serve only that artefact, + # so the frozen accession set must exist by the time imaging retrieval asks for + # it. Queued and committed first: pending-task dispatch orders by created_at and + # the trust poller processes sequentially, so the separate commit gives the + # snapshot task a strictly earlier timestamp than the imaging task below. + if project.query is not None: + persist_payload = IPersistCohort( + project_id=project_id, + trust_id=trust.id, + encrypted_project_id=encrypt(str(project_id)), + query=project.query.query, + query_id=project.query.id, + ) + persist_task = TrustTask( + trust_id=trust.id, + task_type=TaskType.PERSIST_COHORT, + # query_id on the task row records which Queries row was frozen (indexed). + query_id=project.query.id, + payload=json.dumps(persist_payload.model_dump(mode="json"), default=str), + ) + db.add(persist_task) + db.commit() + logger.info(f"Queued cohort snapshot task for trust {trust.name}, project {project_id}") + else: + logger.warning( + f"Project {project_id} has no cohort query — skipping the cohort snapshot task; " + "row-level routes will refuse this project at every trust" + ) + # Create request data for trust request_data = ICreateImagingProject( project_id=project_id, diff --git a/flip-api/tests/e2e_smoke.py b/flip-api/tests/e2e_smoke.py index 147d16cc9..a933a7c19 100644 --- a/flip-api/tests/e2e_smoke.py +++ b/flip-api/tests/e2e_smoke.py @@ -806,7 +806,10 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: help="Reuse an existing approved project: skip cohort submission and approval; jump straight " "to model creation + upload + training. Image-pull wait still runs (cheap when already at " "100%%, correct when a prior --abort-midway run left pulls in flight). Lets you iterate on " - "training code without re-creating the project for every retry.", + "training code without re-creating the project for every retry. The project must have been " + "approved AFTER the cohort-snapshot feature (FLIP#857): the trusts' row-level routes serve " + "only the cohort frozen at approval, so a pre-snapshot project is refused — re-approve it " + "(or create a fresh one) first.", ) parser.add_argument("--model-name", default=DEFAULT_MODEL_NAME) parser.add_argument( diff --git a/flip-api/tests/unit/private_services/test_snapshot_notifications.py b/flip-api/tests/unit/private_services/test_snapshot_notifications.py new file mode 100644 index 000000000..16d7185e0 --- /dev/null +++ b/flip-api/tests/unit/private_services/test_snapshot_notifications.py @@ -0,0 +1,123 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Unit tests for PERSIST_COHORT post-processing (FLIP#857 audit record + drift surfacing).""" + +import json +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest + +from flip_api.domain.schemas.private import AggregatedCohortStats +from flip_api.private_services.snapshot_notifications import handle_snapshot_task_completed + +TRUST_ID = uuid4() +PROJECT_ID = str(uuid4()) +QUERY_ID = str(uuid4()) + + +def _make_task(row_count=24, query_id=QUERY_ID, result_overrides=None): + task = MagicMock() + task.id = uuid4() + task.trust_id = TRUST_ID + task.payload = json.dumps( + { + "project_id": PROJECT_ID, + "trust_id": str(TRUST_ID), + "encrypted_project_id": "enc", + "query": "SELECT * FROM omop.image_occurrence", + "query_id": query_id, + } + ) + result = { + "row_count": row_count, + "columns": ["modality", "accession_id"], + "has_accessions": True, + "snapshot_at": "2026-08-26T00:00:00+00:00", + "query_hash": "abc123", + } + result.update(result_overrides or {}) + task.result = json.dumps(result) + return task + + +def _make_db(stats_row=None, existing_status=None): + """Session mock: first exec() resolves QueryStats, second the existing status row.""" + db = MagicMock() + stats_result = MagicMock() + stats_result.first.return_value = stats_row + status_result = MagicMock() + status_result.first.return_value = existing_status + db.exec.side_effect = [stats_result, status_result] + return db + + +def _stats_row(trust_record_counts): + stats = AggregatedCohortStats(record_count=sum(trust_record_counts.values()), trusts_results=[]) + stats.trust_record_counts = trust_record_counts + row = MagicMock() + row.stats = stats.model_dump_json() + return row + + +def test_records_the_frozen_cohort_audit_row(): + db = _make_db(stats_row=_stats_row({str(TRUST_ID): 24})) + handle_snapshot_task_completed(_make_task(), db) + + db.add.assert_called_once() + status_row = db.add.call_args[0][0] + assert status_row.row_count == 24 + assert status_row.approved_record_count == 24 + assert status_row.has_accessions is True + assert status_row.query_hash == "abc123" + assert str(status_row.query_id) == QUERY_ID + db.commit.assert_called_once() + + +def test_membership_drift_is_surfaced_not_swallowed(caplog): + """A frozen count that differs from the approved count logs a WARNING naming both.""" + db = _make_db(stats_row=_stats_row({str(TRUST_ID): 20})) + with caplog.at_level("WARNING"): + handle_snapshot_task_completed(_make_task(row_count=24), db) + + assert any("drift" in record.message and "20" in record.message for record in caplog.records) + status_row = db.add.call_args[0][0] + assert status_row.approved_record_count == 20 + assert status_row.row_count == 24 + + +def test_missing_query_stats_still_records_the_row(): + """No aggregated stats (old data, errored trust) downgrades drift to not-comparable.""" + db = _make_db(stats_row=None) + handle_snapshot_task_completed(_make_task(), db) + + status_row = db.add.call_args[0][0] + assert status_row.approved_record_count is None + assert status_row.row_count == 24 + + +def test_reapproval_updates_the_existing_row_in_place(): + existing = MagicMock() + db = _make_db(stats_row=_stats_row({str(TRUST_ID): 30}), existing_status=existing) + handle_snapshot_task_completed(_make_task(row_count=30), db) + + # The same row object is updated and re-added — no duplicate per (project, trust). + assert db.add.call_args[0][0] is existing + assert existing.row_count == 30 + + +def test_task_without_result_raises(): + task = _make_task() + task.result = None + with pytest.raises(ValueError, match="no result data"): + handle_snapshot_task_completed(task, MagicMock()) diff --git a/flip-api/tests/unit/project_services/test_get_cohort_snapshots.py b/flip-api/tests/unit/project_services/test_get_cohort_snapshots.py new file mode 100644 index 000000000..aad2fc6c3 --- /dev/null +++ b/flip-api/tests/unit/project_services/test_get_cohort_snapshots.py @@ -0,0 +1,108 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Unit tests for GET /projects/{id}/cohort-snapshots (FLIP#857 audit surfacing).""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from flip_api.auth.dependencies import verify_token +from flip_api.db.database import get_session +from flip_api.db.models.main_models import CohortSnapshotStatus, Trust +from flip_api.project_services.get_cohort_snapshots import router as get_cohort_snapshots_router + +MOCK_USER_ID = uuid4() +MOCK_PROJECT_ID = uuid4() +MOCK_TRUST_ID = uuid4() +MOCK_QUERY_ID = uuid4() +SNAPSHOT_AT = datetime(2026, 8, 26, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def app_fixture() -> FastAPI: + app = FastAPI() + app.include_router(get_cohort_snapshots_router, prefix="/api") + return app + + +@pytest.fixture +def client(app_fixture: FastAPI) -> TestClient: + return TestClient(app_fixture) + + +def _snapshot_row(row_count: int = 24, approved: int | None = 24) -> CohortSnapshotStatus: + return CohortSnapshotStatus( + project_id=MOCK_PROJECT_ID, + trust_id=MOCK_TRUST_ID, + query_id=MOCK_QUERY_ID, + row_count=row_count, + approved_record_count=approved, + has_accessions=True, + query_hash="abc123", + snapshot_at=SNAPSHOT_AT, + ) + + +def test_returns_per_trust_snapshot_records_with_trust_names(client: TestClient, app_fixture: FastAPI): + mock_db_session = MagicMock() + trust = Trust(id=MOCK_TRUST_ID, name="GSTT") + mock_db_session.exec.return_value.all.return_value = [(_snapshot_row(row_count=24, approved=20), trust)] + app_fixture.dependency_overrides[get_session] = lambda: mock_db_session + app_fixture.dependency_overrides[verify_token] = lambda: MOCK_USER_ID + + with patch("flip_api.project_services.get_cohort_snapshots.can_access_project", return_value=True) as mock_access: + response = client.get(f"/api/projects/{MOCK_PROJECT_ID}/cohort-snapshots") + + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert len(body) == 1 + # camelCase aliases on the wire; drift between frozen and approved counts is visible. + assert body[0]["trustName"] == "GSTT" + assert body[0]["rowCount"] == 24 + assert body[0]["approvedRecordCount"] == 20 + assert body[0]["hasAccessions"] is True + assert body[0]["queryId"] == str(MOCK_QUERY_ID) + mock_access.assert_called_once_with(MOCK_USER_ID, MOCK_PROJECT_ID, mock_db_session) + app_fixture.dependency_overrides.clear() + + +def test_no_snapshots_yet_returns_empty_list(client: TestClient, app_fixture: FastAPI): + """A project whose trusts have not reported (pending task / pre-feature) is an empty list, not 404.""" + mock_db_session = MagicMock() + mock_db_session.exec.return_value.all.return_value = [] + app_fixture.dependency_overrides[get_session] = lambda: mock_db_session + app_fixture.dependency_overrides[verify_token] = lambda: MOCK_USER_ID + + with patch("flip_api.project_services.get_cohort_snapshots.can_access_project", return_value=True): + response = client.get(f"/api/projects/{MOCK_PROJECT_ID}/cohort-snapshots") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + app_fixture.dependency_overrides.clear() + + +def test_forbidden_without_project_access(client: TestClient, app_fixture: FastAPI): + mock_db_session = MagicMock() + app_fixture.dependency_overrides[get_session] = lambda: mock_db_session + app_fixture.dependency_overrides[verify_token] = lambda: MOCK_USER_ID + + with patch("flip_api.project_services.get_cohort_snapshots.can_access_project", return_value=False): + response = client.get(f"/api/projects/{MOCK_PROJECT_ID}/cohort-snapshots") + + assert response.status_code == status.HTTP_403_FORBIDDEN + mock_db_session.exec.assert_not_called() + app_fixture.dependency_overrides.clear() diff --git a/flip-api/tests/unit/trusts_services/test_start_project_imaging_creation.py b/flip-api/tests/unit/trusts_services/test_start_project_imaging_creation.py index 9db72fb81..d4b96c2fc 100644 --- a/flip-api/tests/unit/trusts_services/test_start_project_imaging_creation.py +++ b/flip-api/tests/unit/trusts_services/test_start_project_imaging_creation.py @@ -10,6 +10,7 @@ # limitations under the License. # +import json import uuid from unittest import mock from unittest.mock import MagicMock @@ -19,6 +20,7 @@ from flip_api.domain.interfaces.project import IProjectQuery, IProjectResponse from flip_api.domain.interfaces.trust import ITrust +from flip_api.domain.schemas.status import TaskType from flip_api.domain.schemas.users import CognitoUser from flip_api.trusts_services.start_project_imaging_creation import start_project_imaging_creation @@ -173,9 +175,22 @@ async def test_successful_imaging_creation( ) assert response["success"] == "Imaging project creation task queued successfully" - # Verify task was added and committed - mock_get_session.add.assert_called_once() - mock_get_session.commit.assert_called_once() + # Two tasks, in this order: the cohort snapshot (FLIP#857) is queued and committed + # FIRST so its created_at strictly precedes the imaging task's — pending-task dispatch + # orders by created_at and the trust poller is sequential, so the frozen accession set + # exists by the time imaging retrieval asks for it. + assert mock_get_session.add.call_count == 2 + assert mock_get_session.commit.call_count == 2 + persist_task = mock_get_session.add.call_args_list[0][0][0] + imaging_task = mock_get_session.add.call_args_list[1][0][0] + assert persist_task.task_type == TaskType.PERSIST_COHORT + assert imaging_task.task_type == TaskType.CREATE_IMAGING + # The task row records which Queries row was frozen, and the payload carries both the + # encrypted id (forwarded to data-access-api) and the query of record. + assert persist_task.query_id is not None + persist_payload = json.loads(persist_task.payload) + assert persist_payload["encrypted_project_id"] + assert persist_payload["query"] == "SELECT * FROM table" # Test case for DB error during task creation @@ -229,8 +244,9 @@ async def test_dicom_to_nifti_false_forwarded_to_trust( user_id=user_id, ) - # Verify the task payload includes dicom_to_nifti=False - mock_get_session.add.assert_called_once() - task = mock_get_session.add.call_args[0][0] + # Verify the task payload includes dicom_to_nifti=False. + # The imaging task is queued second, after the cohort-snapshot task. + task = mock_get_session.add.call_args_list[-1][0][0] + assert task.task_type == TaskType.CREATE_IMAGING payload = json.loads(task.payload) assert payload["dicom_to_nifti"] is False diff --git a/flip-ui/src/partials/projects/CohortSnapshotSummary.vue b/flip-ui/src/partials/projects/CohortSnapshotSummary.vue new file mode 100644 index 000000000..77e01b83d --- /dev/null +++ b/flip-ui/src/partials/projects/CohortSnapshotSummary.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/flip-ui/src/partials/projects/ProjectStatus.vue b/flip-ui/src/partials/projects/ProjectStatus.vue index cc9769a8f..f42588c1a 100644 --- a/flip-ui/src/partials/projects/ProjectStatus.vue +++ b/flip-ui/src/partials/projects/ProjectStatus.vue @@ -29,6 +29,7 @@ +
@@ -307,6 +308,8 @@ import useErrorHandler from "@/composables/useErrorHandler"; import { getImagingProjectsStatus } from "@/services/project-service"; import { useSiteDetailsStore } from "@/store/siteDetailsStore"; +import CohortSnapshotSummary from "./CohortSnapshotSummary.vue"; + interface IImagingProjectStatusProps { canLoad: boolean; cohortSize?: number; diff --git a/flip-ui/src/partials/projects/__tests__/cohort-snapshot-summary.spec.ts b/flip-ui/src/partials/projects/__tests__/cohort-snapshot-summary.spec.ts new file mode 100644 index 000000000..50ec82b12 --- /dev/null +++ b/flip-ui/src/partials/projects/__tests__/cohort-snapshot-summary.spec.ts @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2026 Guy's and St Thomas' NHS Foundation Trust & King's College London + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mount } from "@vue/test-utils"; +import { reactive, ref } from "vue"; + +import { ICohortSnapshot } from "@/services/project-service"; + +import CohortSnapshotSummary from "../CohortSnapshotSummary.vue"; + +const mockRoute = reactive({ + name: "ProjectView", + fullPath: "/project/test-project-id", + path: "/project/test-project-id", + params: { projectId: "test-project-id" } as Record +}); + +vi.mock("vue-router", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useRoute: () => mockRoute + }; +}); + +const mockSwrvData = ref(undefined); +const mockSwrvError = ref(null); + +vi.mock("swrv", () => ({ + default: () => ({ + data: mockSwrvData, + mutate: vi.fn(), + error: mockSwrvError + }) +})); + +vi.mock("@/composables/useErrorHandler", () => ({ default: vi.fn() })); + +const frozenSnapshot = (overrides: Partial = {}): ICohortSnapshot => ({ + trustId: "trust-1", + trustName: "Alpha Trust", + rowCount: 300, + approvedRecordCount: 300, + hasAccessions: true, + snapshotAt: "2026-08-26T21:16:00+00:00", + queryId: "query-1", + ...overrides +}); + +const mountComponent = (canLoad = true) => + mount(CohortSnapshotSummary, { props: { canLoad } }); + +describe("CohortSnapshotSummary", () => { + beforeEach(() => { + mockSwrvData.value = undefined; + mockSwrvError.value = null; + }); + + it("renders nothing while there are no snapshot records", () => { + mockSwrvData.value = []; + const wrapper = mountComponent(); + + expect(wrapper.find("[data-test='cohort-snapshot-row']").exists()).toBe(false); + }); + + it("renders one row per trust with the frozen record count", () => { + mockSwrvData.value = [ + frozenSnapshot(), + frozenSnapshot({ + trustId: "trust-2", + trustName: "Beta Trust", + rowCount: 120 + }) + ]; + const wrapper = mountComponent(); + + const rows = wrapper.findAll("[data-test='cohort-snapshot-row']"); + expect(rows).toHaveLength(2); + expect(rows[0].text()).toContain("Alpha Trust"); + expect(rows[0].text()).toContain("300"); + expect(rows[1].text()).toContain("Beta Trust"); + expect(rows[1].text()).toContain("120"); + }); + + it("surfaces membership drift when the frozen count differs from the approved count", () => { + mockSwrvData.value = [frozenSnapshot({ + rowCount: 300, + approvedRecordCount: 280 + })]; + const wrapper = mountComponent(); + + const drift = wrapper.find("[data-test='cohort-snapshot-drift']"); + expect(drift.exists()).toBe(true); + expect(drift.text()).toContain("280"); + }); + + it("shows no drift badge when frozen equals approved or approved is unknown", () => { + mockSwrvData.value = [ + frozenSnapshot(), + frozenSnapshot({ + trustId: "trust-2", + trustName: "Beta Trust", + approvedRecordCount: null + }) + ]; + const wrapper = mountComponent(); + + expect(wrapper.find("[data-test='cohort-snapshot-drift']").exists()).toBe(false); + }); + + it("marks a tabular cohort (no accession column) so an empty imaging panel is explained", () => { + mockSwrvData.value = [frozenSnapshot({ hasAccessions: false })]; + const wrapper = mountComponent(); + + expect(wrapper.find("[data-test='cohort-snapshot-tabular']").exists()).toBe(true); + }); + + it("renders nothing when loading is gated off (project not approved yet)", () => { + mockSwrvData.value = [frozenSnapshot()]; + const wrapper = mountComponent(false); + + expect(wrapper.find("[data-test='cohort-snapshot-row']").exists()).toBe(false); + }); +}); diff --git a/flip-ui/src/partials/projects/__tests__/project-status.spec.ts b/flip-ui/src/partials/projects/__tests__/project-status.spec.ts index 5e5c169db..e0abcea60 100644 --- a/flip-ui/src/partials/projects/__tests__/project-status.spec.ts +++ b/flip-ui/src/partials/projects/__tests__/project-status.spec.ts @@ -51,6 +51,9 @@ vi.mock("swrv", () => ({ vi.mock("@/composables/useErrorHandler", () => ({ default: vi.fn() })); const stubs = { + // Has its own useSWRV call; stubbed so this spec's single-ref swrv mock stays + // scoped to the imaging-status fetch. Covered by cohort-snapshot-summary.spec.ts. + CohortSnapshotSummary: { template: "
" }, "icon-ph-check-bold": { template: "" }, "icon-ph-clock": { template: "" }, "icon-ph-warning-circle-fill": { template: "" }, diff --git a/flip-ui/src/services/project-service.ts b/flip-ui/src/services/project-service.ts index e269e938c..753ff09ef 100644 --- a/flip-ui/src/services/project-service.ts +++ b/flip-ui/src/services/project-service.ts @@ -112,6 +112,19 @@ export interface IImagingProjectStatus { reimportCount?: number, } +// One trust's frozen approved-cohort record (FLIP#857) — aggregates only. A rowCount +// differing from approvedRecordCount means the live cohort drifted between submission +// and approval; hasAccessions=false marks a tabular cohort with no imaging to pull. +export interface ICohortSnapshot { + trustId: string, + trustName: string, + rowCount: number, + approvedRecordCount: number | null, + hasAccessions: boolean, + snapshotAt: string, + queryId: string | null, +} + export async function getProject(url: string): Promise { const response = await _http.get(url); @@ -157,3 +170,9 @@ export async function getImagingProjectsStatus(url: string): Promise { + const response = await _http.get(url); + + return response.data; +} diff --git a/trust/.env.GSTT.development.example b/trust/.env.GSTT.development.example index 18165e5a2..19d0ff6cd 100644 --- a/trust/.env.GSTT.development.example +++ b/trust/.env.GSTT.development.example @@ -23,6 +23,8 @@ LOKI_PORT=3100 # DICOM lives under orthanc/orthanc-storage-trust. Decoupled from the CODE. ORTHANC_STORAGE_DIR=./orthanc/orthanc-storage-trust1 BASE_IMAGES_DOWNLOAD_DIR=./data/GSTT +# Approved-cohort snapshot store (FLIP#857): host dir bind-mounted into data-access-api. +COHORT_SNAPSHOT_STORAGE_DIR=./data-access-api/.snapshots/GSTT XNAT_DATA_DIR=./xnat-data-GSTT FL_KIT_DIR=/opt/flip/fl-kit AICENTRE_BUCKET_NAME=flipdev-aicentre diff --git a/trust/.env.KCH.development.example b/trust/.env.KCH.development.example index 154239b0a..19d10e3c7 100644 --- a/trust/.env.KCH.development.example +++ b/trust/.env.KCH.development.example @@ -23,6 +23,8 @@ LOKI_PORT=3101 # DICOM lives under orthanc/orthanc-storage-trust. Decoupled from the CODE. ORTHANC_STORAGE_DIR=./orthanc/orthanc-storage-trust2 BASE_IMAGES_DOWNLOAD_DIR=./data/KCH +# Approved-cohort snapshot store (FLIP#857): host dir bind-mounted into data-access-api. +COHORT_SNAPSHOT_STORAGE_DIR=./data-access-api/.snapshots/KCH XNAT_DATA_DIR=./xnat-data-KCH FL_KIT_DIR=/opt/flip/fl-kit AICENTRE_BUCKET_NAME=flipdev-aicentre diff --git a/trust/.env.example b/trust/.env.example index 21f69cc1b..59bfd27d4 100644 --- a/trust/.env.example +++ b/trust/.env.example @@ -33,6 +33,9 @@ LOKI_PORT=3100 # under orthanc/orthanc-storage-trust. Decoupled from the trust CODE. ORTHANC_STORAGE_DIR=./orthanc/orthanc-storage-trust1 BASE_IMAGES_DOWNLOAD_DIR=./data/trust +# Approved-cohort snapshot store (FLIP#857): host dir bind-mounted into data-access-api. +# Holds row-level patient data — include it in the trust's backup/retention practice. +COHORT_SNAPSHOT_STORAGE_DIR=./data-access-api/.snapshots/trust XNAT_DATA_DIR=./xnat-data # FL participant kit root on this host. The prod trust compose mounts # ${FL_KIT_DIR}/net-1/services/${FL_KIT_SLOT}/{local,startup,transfer} diff --git a/trust/.gitignore b/trust/.gitignore index 2d7d2fd4a..556303c1b 100644 --- a/trust/.gitignore +++ b/trust/.gitignore @@ -48,3 +48,6 @@ xnat/plugins/.s3-prefix # Image data folder data/**/*.dcm data/**/*.nii.gz + +# Approved-cohort snapshots (FLIP#857): row-level patient data, never committed +data-access-api/.snapshots/ diff --git a/trust/Makefile b/trust/Makefile index fa071f75f..ffeb7cd87 100644 --- a/trust/Makefile +++ b/trust/Makefile @@ -181,6 +181,7 @@ TRUST_VARS = \ TRUST_BUILD_VARS = \ BASE_IMAGES_DOWNLOAD_DIR=$(or $(BASE_IMAGES_DOWNLOAD_DIR),./data/trust) \ ORTHANC_STORAGE_DIR=$(or $(ORTHANC_STORAGE_DIR),./orthanc/orthanc-storage-trust1) \ + COHORT_SNAPSHOT_STORAGE_DIR=$(or $(COHORT_SNAPSHOT_STORAGE_DIR),./data-access-api/.snapshots/trust) \ FL_KIT_SLOT=$(or $(FL_KIT_SLOT),Trust_1) \ FL_KIT_SLOT_NUMBER=$(or $(FL_KIT_SLOT_NUMBER),1) @@ -267,6 +268,10 @@ up-trust: update-omop-data update-orthanc-data create-networks @echo "🧠 FL_BACKEND=$(FL_BACKEND) ($(FL_BACKEND_COMPOSE_FILE))" $(call check_omop_data_dir,$(or $(OMOP_DATA_DIR),./omop-db/volumes/Trust_$(TRUST_NUM)/db_data)) mkdir -p $(or $(BASE_IMAGES_DOWNLOAD_DIR),./data/$(KIT)) + # Pre-create the cohort-snapshot bind dir as the HOST user: if compose auto-created it, + # it would be root-owned and the non-root data-access-api would disable the store. + # The fallback must match the compose default (Trust_${TRUST_NUMBER}). + mkdir -p $(or $(COHORT_SNAPSHOT_STORAGE_DIR),./data-access-api/.snapshots/Trust_$(TRUST_NUM)) @echo "$(GPU_STATUS_MSG)" $(TRUST_VARS) DEBUG=${DEBUG} ${DOCKER_COMPOSE_CMD} $(GPU_OVERRIDE) $(TRUST_OVERRIDE) -p $(TRUST_PROJECT) up -d $(UP_PULL_FLAGS) @echo "🩻 Starting XNAT for $(KIT)..." @@ -302,6 +307,7 @@ up-trust-ec2: @echo "⚙️ Using kit file trust/$(KIT_FILE)" @echo "🧠 FL_BACKEND=$(FL_BACKEND) ($(FL_BACKEND_COMPOSE_FILE))" @echo "🖥️ EC2 trust runs CPU-only by design — GPU overlay omitted + NUM_AVAILABLE_GPUS forced to 0 (t3.xlarge has no GPU)." + mkdir -p $(or $(COHORT_SNAPSHOT_STORAGE_DIR),./data-access-api/.snapshots/Trust_$(TRUST_NUM)) $(TRUST_VARS) NUM_AVAILABLE_GPUS=0 DEBUG=${DEBUG} ${DOCKER_COMPOSE_CMD} -p $(TRUST_PROJECT) up -d --pull always @echo "🩻 Starting XNAT for $(KIT)..." $(MAKE) -C xnat up-xnat KIT=$(KIT) PROD=$(PROD) diff --git a/trust/data-access-api/AGENTS.md b/trust/data-access-api/AGENTS.md index c56f2f57d..7e23db915 100644 --- a/trust/data-access-api/AGENTS.md +++ b/trust/data-access-api/AGENTS.md @@ -8,11 +8,11 @@ FastAPI service for querying the OMOP Common Data Model database. Receives cohor - Connects to omop-db (PostgreSQL port 5432) on the trust network - Receives internal requests from trust-api (all `/cohort` endpoints) and from imaging-api (`/cohort/accession-ids`); not directly exposed -- Every internal caller authenticates with the per-trust `TRUST_INTERNAL_SERVICE_KEY` header (see the root `AGENTS.md` "Trust-internal Service Authentication" section). `/health` stays unauthenticated +- Every internal caller authenticates with the per-trust `TRUST_INTERNAL_SERVICE_KEY` header (see the root `AGENTS.md` "Trust-internal Service Authentication" section). `/health` stays unauthenticated. The cohort router is **split by privilege** (`utils/internal_auth.py`): the read router (`/cohort` statistics, `/cohort/dataframe`, `/cohort/accession-ids`) requires the trust-internal key; the write router (`/cohort/snapshot`, `/cohort/snapshot/delete`) requires that **plus** cohort-admin — `authenticate_cohort_admin`, proof of possessing `AES_KEY_BASE64` (SHA-256 of the key, constant-time compared). fl-client holds the trust-internal key (for its reads) but no AES key, so it cannot define/destroy a project's frozen cohort (FLIP#857). A valid trust-internal key without the proof → 403 - OMOP CDM query translation layer - `services/cohort.py::validate_query` is the **authority** on cohort-query safety: one parse-validate-emit pass that returns the query re-emitted from the AST it checked. Pass that return value to the engine, never the caller's raw string. The hub runs its own pre-check, but it is fast-feedback only and deliberately weaker — never relax a rule here on the assumption the hub filtered first, and do not mirror trust-local rules onto the hub. See [`README.md`](README.md#cohort-query-validation) - Both row-level routes — `/cohort/dataframe` (FL training data) and `/cohort/accession-ids` (the accession list that decides whose imaging is pulled into XNAT) — are gated on `COHORT_QUERY_THRESHOLD` and share one fixed refusal string, so a below-threshold cohort is indistinguishable from an empty one and the refusal cannot act as a row-count oracle. The trust enforces this itself; the hub's staging guard is not relied on -- The gates evaluate the **live** cohort on every call, not the cohort as approved. The cohort query is re-run against OMOP at every stage (the imaging status poll alone re-runs it roughly every 10s while a project page is open), so a project can import cleanly and later start refusing. There is no frozen approved-cohort artefact anywhere in FLIP — see FLIP#857 +- **Row-level routes serve ONLY the frozen approved-cohort snapshot (FLIP#857)** — the artefact `POST /cohort/snapshot` persists at project approval (`services/cohort_snapshot.py`: parquet + meta per project UUID under `COHORT_SNAPSHOT_DIR`, a dedicated bind mount; atomic-rename writes, no TTL). The caller-supplied SQL on those routes is **ignored** (hash-compared and logged on drift) — this closes the audit finding that researcher FL code could execute arbitrary SQL under a project id. A project with no snapshot is refused (fail-closed; fixed generic detail); a snapshot without an `accession_id` column serves an **empty** accession list (tabular project — imaging no-ops). Live OMOP is evaluated only by `/cohort` (statistics, pre-approval by definition) and `/cohort/snapshot` (once per approval; re-approval atomically replaces the artefact — that is how OMOP-side removals/opt-outs propagate, never silently mid-training). The threshold gate reads the **frozen** row count but the threshold value live, so raising a trust's floor bites already-approved projects. The two write routes (`/cohort/snapshot`, `/cohort/snapshot/delete`) that create/destroy the artefact are gated on cohort-admin (AES-possession) beyond the trust-internal key — researcher FL code cannot reach them. `/cohort/snapshot/delete` is the (not-yet-called) FLIP#997 teardown hook - `COHORT_QUERY_THRESHOLD` is the trust's own disclosure floor (default 10), set per trust in its kit file. It is a `PositiveInt`: `0` would disable both row-level gates (`len(df) < 0` is never true) *and* the statistics suppression at once, so a non-positive value is rejected at import and the service refuses to start rather than run with no floor. Its default lives in one place — the module-level `DEFAULT_COHORT_QUERY_THRESHOLD`, read by both the field default and the empty-string coercion validator; do not re-inline the literal. Requiring the value to be at least the shipped 10 rather than merely positive is FLIP#870. Any new non-`str` setting needs its own empty-string coercion validator: the service Makefile's `export $(shell sed 's/=.*//' $(KIT_ENV_FILE))` strips values from commented lines too, so a commented-out entry arrives as `""` and pydantic rejects it at import ## Commands diff --git a/trust/data-access-api/CLAUDE.md b/trust/data-access-api/CLAUDE.md index 196fd2cb2..9ce72981a 100644 --- a/trust/data-access-api/CLAUDE.md +++ b/trust/data-access-api/CLAUDE.md @@ -8,11 +8,11 @@ FastAPI service for querying the OMOP Common Data Model database. Receives cohor - Connects to omop-db (PostgreSQL port 5432) on the trust network - Receives internal requests from trust-api (all `/cohort` endpoints) and from imaging-api (`/cohort/accession-ids`); not directly exposed -- Every internal caller authenticates with the per-trust `TRUST_INTERNAL_SERVICE_KEY` header (see the root `CLAUDE.md` "Trust-internal Service Authentication" section). `/health` stays unauthenticated +- Every internal caller authenticates with the per-trust `TRUST_INTERNAL_SERVICE_KEY` header (see the root `CLAUDE.md` "Trust-internal Service Authentication" section). `/health` stays unauthenticated. The cohort router is **split by privilege** (`utils/internal_auth.py`): the read router (`/cohort` statistics, `/cohort/dataframe`, `/cohort/accession-ids`) requires the trust-internal key; the write router (`/cohort/snapshot`, `/cohort/snapshot/delete`) requires that **plus** cohort-admin — `authenticate_cohort_admin`, proof of possessing `AES_KEY_BASE64` (SHA-256 of the key, constant-time compared). fl-client holds the trust-internal key (for its reads) but no AES key, so it cannot define/destroy a project's frozen cohort (FLIP#857). A valid trust-internal key without the proof → 403 - OMOP CDM query translation layer - `services/cohort.py::validate_query` is the **authority** on cohort-query safety: one parse-validate-emit pass that returns the query re-emitted from the AST it checked. Pass that return value to the engine, never the caller's raw string. The hub runs its own pre-check, but it is fast-feedback only and deliberately weaker — never relax a rule here on the assumption the hub filtered first, and do not mirror trust-local rules onto the hub. See [`README.md`](README.md#cohort-query-validation) - Both row-level routes — `/cohort/dataframe` (FL training data) and `/cohort/accession-ids` (the accession list that decides whose imaging is pulled into XNAT) — are gated on `COHORT_QUERY_THRESHOLD` and share one fixed refusal string, so a below-threshold cohort is indistinguishable from an empty one and the refusal cannot act as a row-count oracle. The trust enforces this itself; the hub's staging guard is not relied on -- The gates evaluate the **live** cohort on every call, not the cohort as approved. The cohort query is re-run against OMOP at every stage (the imaging status poll alone re-runs it roughly every 10s while a project page is open), so a project can import cleanly and later start refusing. There is no frozen approved-cohort artefact anywhere in FLIP — see FLIP#857 +- **Row-level routes serve ONLY the frozen approved-cohort snapshot (FLIP#857)** — the artefact `POST /cohort/snapshot` persists at project approval (`services/cohort_snapshot.py`: parquet + meta per project UUID under `COHORT_SNAPSHOT_DIR`, a dedicated bind mount; atomic-rename writes, no TTL). The caller-supplied SQL on those routes is **ignored** (hash-compared and logged on drift) — this closes the audit finding that researcher FL code could execute arbitrary SQL under a project id. A project with no snapshot is refused (fail-closed; fixed generic detail); a snapshot without an `accession_id` column serves an **empty** accession list (tabular project — imaging no-ops). Live OMOP is evaluated only by `/cohort` (statistics, pre-approval by definition) and `/cohort/snapshot` (once per approval; re-approval atomically replaces the artefact — that is how OMOP-side removals/opt-outs propagate, never silently mid-training). The threshold gate reads the **frozen** row count but the threshold value live, so raising a trust's floor bites already-approved projects. The two write routes (`/cohort/snapshot`, `/cohort/snapshot/delete`) that create/destroy the artefact are gated on cohort-admin (AES-possession) beyond the trust-internal key — researcher FL code cannot reach them. `/cohort/snapshot/delete` is the (not-yet-called) FLIP#997 teardown hook - `COHORT_QUERY_THRESHOLD` is the trust's own disclosure floor (default 10), set per trust in its kit file. It is a `PositiveInt`: `0` would disable both row-level gates (`len(df) < 0` is never true) *and* the statistics suppression at once, so a non-positive value is rejected at import and the service refuses to start rather than run with no floor. Its default lives in one place — the module-level `DEFAULT_COHORT_QUERY_THRESHOLD`, read by both the field default and the empty-string coercion validator; do not re-inline the literal. Requiring the value to be at least the shipped 10 rather than merely positive is FLIP#870. Any new non-`str` setting needs its own empty-string coercion validator: the service Makefile's `export $(shell sed 's/=.*//' $(KIT_ENV_FILE))` strips values from commented lines too, so a commented-out entry arrives as `""` and pydantic rejects it at import ## Commands diff --git a/trust/data-access-api/README.md b/trust/data-access-api/README.md index 971febe55..343c0952d 100644 --- a/trust/data-access-api/README.md +++ b/trust/data-access-api/README.md @@ -67,6 +67,40 @@ Key environment variables (set in [`.env.development.example`](../../.env.develo | `AES_KEY_BASE64` | AES encryption key for decrypting project identifiers | | `TRUST_INTERNAL_SERVICE_KEY_HEADER` | Header name for trust-internal service auth (default `X-Trust-Internal-Service-Key`) | | `TRUST_INTERNAL_SERVICE_KEY` | Per-trust plaintext key. Required on every `/cohort` request. | +| `COHORT_SNAPSHOT_DIR` | Container path of the approved-cohort snapshot store (the compose files fix it to `/snapshots` and bind-mount the kit's host-side `COHORT_SNAPSHOT_STORAGE_DIR` there). Empty = store disabled, so row-level routes refuse every project — fail-closed. | +| `SNAPSHOT_MAX_BYTES` | Hard cap on one serialized snapshot (default 512 MiB). An over-cap cohort is refused, never truncated. | + +## Approved-cohort snapshots (FLIP#857) + +At project approval, trust-api forwards a `POST /cohort/snapshot` request: the approved query +of record is validated, executed **once**, and the resulting dataframe is persisted under +`COHORT_SNAPSHOT_DIR//` (`dataframe.parquet` + `meta.json`, written with +atomic directory renames so readers never see a half-written artefact). From then on the two +row-level routes — `/cohort/dataframe` and `/cohort/accession-ids` — serve **only** that +frozen artefact, keyed on the encrypted hub project id; the SQL the caller supplies is ignored +(hash-compared against the frozen query and logged when it differs). Consequences: + +- the cohort a project trains on is exactly the cohort that was approved — it cannot grow as + the live OMOP database grows, and every training-round fetch returns identical data; +- researcher FL code calling `flip.get_dataframe(...)` cannot execute SQL against OMOP at + all — the arbitrary-SQL exposure on `/cohort/dataframe` is closed; +- a project with no snapshot is refused outright (fixed generic detail); +- a frozen cohort with no `accession_id` column returns an **empty** accession list — a + tabular/OMOP-only project legitimately has no imaging to pull; +- re-approval calls `/cohort/snapshot` again and atomically replaces the artefact — this is + how OMOP-side record removals and opt-outs propagate into an approved project (at explicit + re-snapshot events, never silently mid-training); +- `POST /cohort/snapshot/delete` removes the artefact (the FLIP#997 teardown hook); +- both write routes (`/cohort/snapshot`, `/cohort/snapshot/delete`) require **cohort-admin** auth + (AES-key possession) on top of the trust-internal key, so researcher FL code cannot define or + destroy the frozen cohort — see [Authentication](#authentication). + +Live OMOP is evaluated only by `/cohort` (statistics — pre-approval by definition) and +`/cohort/snapshot`. The disclosure threshold below is enforced at snapshot creation (nothing +is persisted for a below-threshold cohort) and re-checked at serve time against the frozen +row count, using the live threshold value — so an operator raising their floor takes effect +on already-approved projects. The snapshot directory holds row-level patient data: include it +in the trust's backup/retention practice alongside the OMOP data volume. ## Authentication @@ -84,6 +118,18 @@ from the [`flip` Python package](https://github.com/londonaicentre/FLIP/tree/dev `TRUST_INTERNAL_SERVICE_KEY` from `os.environ` and adds the header to its HTTP request. Tutorials and user-uploaded `client_app.py` / `server_app.py` do not deal with the header directly. +**Cohort-admin gate on the write routes (FLIP#857).** Because fl-client legitimately holds the +trust-internal key, that key alone cannot separate "may read the approved cohort" from "may +DEFINE it". The cohort router is therefore split: `authenticate_internal_service` gates the read +routes (`/cohort`, `/cohort/dataframe`, `/cohort/accession-ids`), while the snapshot **write** +routes (`/cohort/snapshot`, `/cohort/snapshot/delete`) additionally require `authenticate_cohort_admin` +— proof of possessing `AES_KEY_BASE64`, sent as the **SHA-256 of the key** (never the key itself) +in `COHORT_ADMIN_KEY_HEADER` (default `X-Cohort-Admin-Key`) and constant-time compared. trust-api +holds the AES key and sends the proof when it forwards an approval-time snapshot; fl-client has no +AES key, so researcher FL code cannot rewrite or delete a project's frozen cohort. A caller with a +valid trust-internal key but no/invalid proof is refused **403**. No new secret is provisioned — the +gate reuses the existing AES-key possession boundary, so kit files and `register_trust` are unchanged. + Each trust has a distinct key. A trust's `TRUST_INTERNAL_SERVICE_KEY` is minted by `register_trust` (`make register-trusts`) and written into that trust's kit file (`trust/.env..`), which `trust/Makefile` `-include`s so every trust-internal container inherits it. diff --git a/trust/data-access-api/data_access_api/config.py b/trust/data-access-api/data_access_api/config.py index 8c3a4e687..4101ced3e 100644 --- a/trust/data-access-api/data_access_api/config.py +++ b/trust/data-access-api/data_access_api/config.py @@ -78,6 +78,25 @@ def coerce_empty_cohort_query_threshold(cls, v: object) -> object: CACHE_MAX_RESULT_ROWS: PositiveInt = 50_000 # Max rows per cached result; larger results skip caching CACHE_MAX_ENTRIES: PositiveInt = 64 # Max number of cached query results + # Approved-cohort snapshot store (FLIP#857). Directory where the cohort frozen at project + # approval is persisted, one sub-directory per hub project id — a dedicated bind mount in + # the compose files. The row-level routes serve ONLY these frozen artefacts (a project + # with no snapshot is refused), so an unset/unwritable directory means no row-level data + # can be released until the store is fixed — deliberately fail-closed. + COHORT_SNAPSHOT_DIR: str = "" + + # Hard cap on one serialized snapshot. Over the cap the snapshot is REFUSED, never + # truncated — a partial cohort would silently poison training data. + SNAPSHOT_MAX_BYTES: PositiveInt = 536_870_912 # 512 MiB + + @field_validator("SNAPSHOT_MAX_BYTES", mode="before") + @classmethod + def coerce_empty_snapshot_max_bytes(cls, v: object) -> object: + """Treat an empty-string cap as the default (same kit-file gotcha as the threshold).""" + if v is None or v == "": + return 536_870_912 + return v + # OMOP_DB_SERVICE_NAME: str = "omop-db" # The name of the OMOP database service in Docker Compose or Kubernetes OMOP_DB_PORT: int = 5432 # Calls from another container use port 5432 (e.g. http://omop-db:5432) @@ -96,6 +115,17 @@ def coerce_empty_cohort_query_threshold(cls, v: object) -> object: TRUST_INTERNAL_SERVICE_KEY_HEADER: str = "X-Trust-Internal-Service-Key" TRUST_INTERNAL_SERVICE_KEY: str = "" + # Cohort-write authorisation (FLIP#857). The snapshot create/delete routes carry a second + # gate on top of the trust-internal key: the caller must prove possession of AES_KEY_BASE64 + # by sending the SHA-256 of the key in this header. This separates the services trusted to + # DEFINE a project's approved cohort (trust-api, data-access-api — both hold the AES key) + # from fl-client, which runs researcher training code, holds the trust-internal key for its + # imaging reads, and deliberately has no AES key. The read routes are unaffected, so + # fl-client's ``get_dataframe`` keeps working; only the cohort-defining writes are locked + # down. The digest — not the raw key — is transmitted, so a captured header or a log line + # never reveals the encryption key. + COHORT_ADMIN_KEY_HEADER: str = "X-Cohort-Admin-Key" + # Define the database URL for the OMOP database @property def OMOP_DATABASE_URL(self) -> SecretStr: diff --git a/trust/data-access-api/data_access_api/main.py b/trust/data-access-api/data_access_api/main.py index 102412382..6df094c2b 100644 --- a/trust/data-access-api/data_access_api/main.py +++ b/trust/data-access-api/data_access_api/main.py @@ -10,14 +10,30 @@ # limitations under the License. # +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + from fastapi import FastAPI from log_config import LoggingMiddleware # Ensure structured logging is configured on import import data_access_api.utils.logger # noqa: F401 from data_access_api.config import get_settings -from data_access_api.routers.cohort import router as cohort_router +from data_access_api.routers.cohort import read_router as cohort_read_router +from data_access_api.routers.cohort import write_router as cohort_write_router from data_access_api.routers.health import router as health_router +from data_access_api.services.cohort_snapshot import ensure_store + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncIterator[None]: + # Boot-time check of the approved-cohort snapshot store (FLIP#857): creates the + # directory, sweeps stale write debris, probes writability. Never raises — a broken + # store must not take the service down; the row-level routes then refuse projects + # whose artefact cannot be read (fail-closed) while statistics keep serving. + ensure_store() + yield + # Disable Swagger / OpenAPI / ReDoc in production. Data-access-api executes SQL # against OMOP under a service account; leaking its route + schema map to anyone @@ -32,9 +48,11 @@ docs_url="/docs" if _docs_enabled else None, openapi_url="/openapi.json" if _docs_enabled else None, redoc_url="/redoc" if _docs_enabled else None, + lifespan=lifespan, ) app.add_middleware(LoggingMiddleware) -app.include_router(cohort_router) +app.include_router(cohort_read_router) +app.include_router(cohort_write_router) app.include_router(health_router) diff --git a/trust/data-access-api/data_access_api/routers/cohort.py b/trust/data-access-api/data_access_api/routers/cohort.py index 8df5cb4db..c76ed354a 100644 --- a/trust/data-access-api/data_access_api/routers/cohort.py +++ b/trust/data-access-api/data_access_api/routers/cohort.py @@ -20,11 +20,22 @@ AccessionIdsResponse, CohortQueryInput, DataframeQuery, + SnapshotDeleteRequest, + SnapshotResponse, StatisticsResponse, ) from data_access_api.services.cohort import get_records, get_statistics, validate_query +from data_access_api.services.cohort_snapshot import ( + Snapshot, + SnapshotTooLarge, + delete_snapshot, + get_snapshot, + normalised_query_hash, + save_snapshot, + snapshot_enabled, +) from data_access_api.utils.encryption import decrypt -from data_access_api.utils.internal_auth import authenticate_internal_service +from data_access_api.utils.internal_auth import authenticate_cohort_admin, authenticate_internal_service from data_access_api.utils.logger import logger # Returned instead of row-level data when a cohort is smaller than @@ -34,16 +45,92 @@ # becomes a one-row oracle for probing the database. _BELOW_THRESHOLD_DETAIL = "Cohort is too small for row-level data to be released." +# Returned by the row-level routes for a project with no frozen cohort artefact. +# Row-level data is released ONLY from the snapshot persisted at approval +# (FLIP#857); there is no live-SQL serving path. Deliberately generic: it must +# not reveal whether the project exists. +_NO_SNAPSHOT_DETAIL = "No approved cohort snapshot exists for this project." + + +# Two routers under the same /cohort prefix, split by privilege (FLIP#857). +# +# read_router — statistics + the two row-level serve routes. Gated on the trust-internal key +# alone: every trust-internal service (trust-api, imaging-api, fl-client) legitimately reads +# here, and the routes serve only the frozen, project-scoped, threshold-gated snapshot. +# +# write_router — the routes that DEFINE/destroy the frozen artefact the row-level routes then +# serve. Gated on the trust-internal key AND cohort-admin (proof of possessing AES_KEY_BASE64), +# so a caller must be both a trust-internal service and one of the two trusted to define cohorts. +# fl-client holds the trust-internal key but no AES key, so it can read its cohort but never +# rewrite or delete it. The dependency order matters: trust-internal runs first, so a caller +# without it gets 401 before the cohort-admin check ever runs (which returns 403). +read_router = APIRouter(prefix="/cohort", tags=["Cohort"], dependencies=[Depends(authenticate_internal_service)]) +write_router = APIRouter( + prefix="/cohort", + tags=["Cohort"], + dependencies=[Depends(authenticate_internal_service), Depends(authenticate_cohort_admin)], +) + + +def _require_snapshot(project_id: str) -> Snapshot: + """The project's frozen cohort, or the fixed 403 when there is none. + + The snapshot persisted at approval is the ONLY source of row-level data: a project + that was never approved (or whose snapshot was purged, or whose trust has the store + unconfigured/unwritable) is refused. Fail-closed by construction — there is no + live-SQL fallback for these routes. + """ + snapshot = get_snapshot(project_id) + if snapshot is None: + logger.warning(f"Refusing row-level data for project {project_id}: no approved cohort snapshot") + raise HTTPException(status_code=403, detail=_NO_SNAPSHOT_DETAIL) + return snapshot + + +def _check_frozen_threshold(project_id: str, snapshot: Snapshot) -> None: + """Apply ``COHORT_QUERY_THRESHOLD`` to the FROZEN row count. + + The gate reads the snapshot, not live OMOP: the frozen cohort is immutable, so a project + that cleared the threshold at approval keeps serving even while the live database drifts + underneath (pre-FLIP#857, the SQL was re-run on every call and the answer could change). + The refusal reuses the fixed below-threshold text so a zero-row snapshot and a + below-threshold one stay indistinguishable. The threshold itself is still read live, so + an operator RAISING their disclosure floor takes effect on already-approved projects. + """ + if snapshot.meta.row_count < get_settings().COHORT_QUERY_THRESHOLD: + logger.warning( + f"Withholding frozen cohort for project {project_id}: snapshot of " + f"{snapshot.meta.row_count} rows is below the minimum size of " + f"{get_settings().COHORT_QUERY_THRESHOLD}" + ) + raise HTTPException(status_code=403, detail=_BELOW_THRESHOLD_DETAIL) + + +def _log_ignored_client_query(project_id: str, snapshot: Snapshot, client_query: str) -> None: + """Record that the caller-supplied SQL was ignored in favour of the frozen cohort. -# Create Router -router = APIRouter(prefix="/cohort", tags=["Cohort"], dependencies=[Depends(authenticate_internal_service)]) + Serving the snapshot regardless of the submitted SQL is what closes the arbitrary-SQL + exposure on these routes (see FLIP#857's audit note): the only row-level data obtainable + under a project's id is the cohort that was approved. The hash comparison exists purely + so a mismatch is visible in the trust's logs; the ``query`` field stays in the request + schema because the FL client library sends it. + """ + if normalised_query_hash(client_query) != snapshot.meta.query_hash: + logger.warning( + f"Client-supplied query for project {project_id} differs from the frozen cohort " + "query — ignored; serving the approved snapshot." + ) -@router.post("", response_model=StatisticsResponse) +@read_router.post("", response_model=StatisticsResponse) def receive_cohort_query(query_input: CohortQueryInput) -> StatisticsResponse: """ Receives a cohort query and returns the aggregated statistics. + This is the one route that always evaluates LIVE OMOP: it runs pre-approval by + definition (it is how a proposed cohort is sized in the first place), and it + releases aggregates only. + Below-threshold results are privacy-suppressed: any count below the threshold — including a genuine zero — comes back as a normal ``StatisticsResponse`` with ``record_count=0``, empty ``data`` and ``suppressed=True``, *not* an HTTP error. @@ -95,154 +182,206 @@ def receive_cohort_query(query_input: CohortQueryInput) -> StatisticsResponse: return results -@router.post("/dataframe") +@read_router.post("/dataframe") def get_dataframe(query_input: DataframeQuery) -> dict[str, list[Any]]: """ - Retrieves query results in a DataFrame-like structure (column-oriented dictionary). - - This is the training-data path: user-supplied FL code inside the trust's - fl-client reaches it through ``flip.get_dataframe(...)``, so it necessarily - returns row-level records — a model trains on rows. The data stays inside - the trust; only model updates leave it. - - Because it is row-level, the cohort must clear ``COHORT_QUERY_THRESHOLD`` - before anything is released, mirroring the suppression the ``/cohort`` - statistics route already applies. A cohort below the threshold is refused - outright rather than returned truncated: there is no training value in it, - and releasing a handful of identifiable rows is exactly the disclosure the - threshold exists to prevent. - - Note there is deliberately no column allowlist here. ``accession_id`` is - load-bearing — it is how the returned rows join to the imaging studies - pulled into XNAT — and shipped tutorials legitimately select ``*``, so a - column filter would break every FL app on the platform while a caller could - trivially alias around it. Column-level minimisation belongs in the cohort - query the project submits and in project approval, not here. + Serves the project's FROZEN cohort in a DataFrame-like structure (column-oriented dict). + + This is the training-data path: user-supplied FL code inside the trust's fl-client + reaches it through ``flip.get_dataframe(...)``, so it necessarily returns row-level + records — a model trains on rows. The data stays inside the trust; only model updates + leave it. + + What it serves is the cohort snapshot persisted at project approval (FLIP#857), keyed + on the project id — **the caller-supplied SQL is ignored** (logged when it differs from + the frozen query). Consequences, all deliberate: the cohort cannot grow after approval; + every training round fetches an identical frame; and researcher code cannot execute + arbitrary SQL under an approved project's id. A project with no snapshot is refused — + there is no live-SQL serving path on this route. + + Because it is row-level, the frozen cohort must clear ``COHORT_QUERY_THRESHOLD`` + before anything is released, mirroring the suppression the ``/cohort`` statistics + route applies. The threshold is read live, so an operator raising their disclosure + floor takes effect on already-approved projects. Args: - query_input (DataframeQuery): The input data for the DataFrame query. + query_input (DataframeQuery): The encrypted project id (and the advisory query). Returns: - dict[str, list[Any]]: The query results in a DataFrame-like structure. + dict[str, list[Any]]: The frozen cohort in a DataFrame-like structure. Raises: - HTTPException: 400 if the query is invalid, 403 if the cohort is below - the disclosure threshold, 500 if the query fails to execute. + HTTPException: 403 if the project has no cohort snapshot or the frozen cohort is + below the disclosure threshold. """ project_id = decrypt(query_input.encrypted_project_id) logger.info(f"Received DataFrame query for project {project_id}") - safe_query = validate_query(query_input.query) - - try: - df = get_records(safe_query) - except HTTPException: - # get_records already converts driver errors into category-only - # HTTPExceptions; re-wrapping them below would discard that work and - # turn every categorised 400 into an opaque 500. - raise - except SQLAlchemyError: - logger.exception("DataFrame query failed with a database error") - raise HTTPException(status_code=500, detail="Query execution failed.") - except Exception: - # Detail is category-only: the trust forwards it to the hub, which shows - # it to every project member, and raw exception text can carry row - # values and connection internals. - logger.exception("DataFrame query failed unexpectedly") - raise HTTPException(status_code=500, detail="Query execution failed.") - - minimum_cohort_size = get_settings().COHORT_QUERY_THRESHOLD - if len(df) < minimum_cohort_size: - logger.warning( - f"Withholding row-level data for project {project_id}: " - f"cohort below the minimum size of {minimum_cohort_size}" - ) - raise HTTPException(status_code=403, detail=_BELOW_THRESHOLD_DETAIL) - - return df.to_dict(orient="list") + snapshot = _require_snapshot(project_id) + _log_ignored_client_query(project_id, snapshot, query_input.query) + _check_frozen_threshold(project_id, snapshot) + logger.info(f"Serving frozen cohort snapshot for project {project_id}: {snapshot.meta.row_count} rows") + return snapshot.df.to_dict(orient="list") -@router.post("/accession-ids", response_model=AccessionIdsResponse) +@read_router.post("/accession-ids", response_model=AccessionIdsResponse) def get_accession_ids(query_input: DataframeQuery) -> AccessionIdsResponse: """ - Returns only the ``accession_id`` column of the cohort, projected server-side. + Returns only the ``accession_id`` column of the project's FROZEN cohort. - The caller's query is wrapped as ``SELECT accession_id FROM () sub`` so - no other columns ever cross the trust boundary. This is the minimal-disclosure - endpoint used by imaging-api to fetch the accession numbers it needs to import - studies from PACS — it does not expose row-level patient attributes. + This is the minimal-disclosure endpoint used by imaging-api to fetch the accession + numbers it needs to import studies from PACS — it does not expose row-level patient + attributes. Like ``/cohort/dataframe`` it serves the snapshot persisted at approval and + **ignores the caller-supplied SQL** (FLIP#857): the imaging status poll (roughly every + 10 s while a project page is open) and reimport read a stable pointer set instead of + re-running the cohort SQL against a live OMOP that changes underneath. Accession IDs are still row-level identifiers, and they are the pointer set into the imaging data: they decide whose studies get pulled into XNAT, where project - members view them. So the cohort must clear ``COHORT_QUERY_THRESHOLD`` here just - as it must on ``/cohort/dataframe``, and the refusal reuses that route's fixed - text so a zero-row cohort and a below-threshold one are indistinguishable. - - The trust applies this itself rather than relying on the hub's staging guard - (``flip_api.project_services.stage_project``, which refuses to stage a trust whose - cohort came back empty or suppressed). The hub is a separate administrative domain; - a trust must stay safe regardless of what the hub checked, and the hub's own - ``start_project_imaging_creation`` endpoint does not re-check staging. - - **This is evaluated against the live cohort on every call, not once at approval.** - The endpoint is re-invoked by the imaging status poll roughly every 10 s while a - user has the project page open, and again on reimport — each time re-running the - cohort SQL against OMOP, which changes underneath. A project can therefore pull - cleanly at approval and later start refusing if its cohort shrinks below the - threshold. FLIP has no frozen approved-cohort artefact; see FLIP#857. + members view them. So the frozen cohort must clear ``COHORT_QUERY_THRESHOLD`` here + just as it must on ``/cohort/dataframe``, and the refusal reuses that route's fixed + text so a zero-row cohort and a below-threshold one are indistinguishable. The trust + applies this itself rather than relying on the hub's staging guard — the hub is a + separate administrative domain, and a trust must stay safe regardless of what the hub + checked. + + A frozen cohort with no ``accession_id`` column returns an EMPTY list rather than an + error: a tabular/OMOP-only project legitimately has no imaging to pull. Args: - query_input (DataframeQuery): The cohort query. + query_input (DataframeQuery): The encrypted project id (and the advisory query). Returns: - AccessionIdsResponse: The accession IDs returned by the cohort query. + AccessionIdsResponse: The frozen cohort's accession IDs. Raises: - HTTPException: 400 if the query is invalid or does not select an - ``accession_id`` column, 403 if the cohort is below the disclosure - threshold, 500 if the query fails to execute. + HTTPException: 403 if the project has no cohort snapshot or the frozen cohort is + below the disclosure threshold. """ project_id = decrypt(query_input.encrypted_project_id) logger.info(f"Received accession-ids query for project {project_id}") - # validate_query returns the caller's SQL re-emitted from its parsed AST, - # which breaks any injection taint chain and strips trailing semicolons so - # the inner query composes cleanly inside the outer SELECT subquery. - safe_inner = validate_query(query_input.query) - wrapped_query = f"SELECT accession_id FROM ({safe_inner}) AS cohort_subquery" + snapshot = _require_snapshot(project_id) + _log_ignored_client_query(project_id, snapshot, query_input.query) + # Threshold before the column check: nothing about the cohort's shape is revealed for a + # below-threshold snapshot. + _check_frozen_threshold(project_id, snapshot) + if not snapshot.meta.has_accessions: + logger.info(f"Frozen cohort for project {project_id} has no accession_id column (tabular project)") + return AccessionIdsResponse(accession_ids=[]) + frozen_ids = [str(value) for value in snapshot.df["accession_id"].tolist()] + logger.info(f"Serving {len(frozen_ids)} frozen accession ids for project {project_id}") + return AccessionIdsResponse(accession_ids=frozen_ids) + + +@write_router.post("/snapshot", response_model=SnapshotResponse) +def create_snapshot(query_input: DataframeQuery) -> SnapshotResponse: + """ + Materialises the cohort ONCE and persists it as this project's frozen artefact (FLIP#857). + + Called by trust-api when the hub approves a project. It defines the artefact everyone + then trains on, so it sits on the ``write_router`` and requires cohort-admin authorisation + (proof of possessing ``AES_KEY_BASE64``) in addition to the trust-internal key — fl-client + holds the latter but not the former, so researcher code cannot reach here (FLIP#857). From + that point the two row-level routes serve the persisted dataframe keyed on the project id + and ignore caller SQL, so the cohort a project trains on is exactly the cohort that was + approved — it cannot grow with the live database, and it is identical on every fetch. + Re-approval calls this again and atomically replaces the artefact (which is also how + OMOP-side removals and opt-outs propagate into an approved project: at explicit re-snapshot + events, never silently mid-training). + + This route (with the statistics route) is where live OMOP is evaluated, exactly once + per approval; ``validate_query`` remains the authority on the SQL executed here. The + disclosure threshold is enforced BEFORE anything is persisted: a below-threshold cohort + leaves no artefact and returns the same fixed refusal as the row-level routes. The + response carries aggregates only (count, column names, timestamps) — the row-level data + stays on this trust's disk. + + Args: + query_input (DataframeQuery): The approved cohort query and encrypted project id. + + Returns: + SnapshotResponse: What was frozen. + + Raises: + HTTPException: 400 if the query is invalid or the project id is not a UUID, 403 if + the cohort is below the disclosure threshold, 413 if the serialized snapshot + exceeds ``SNAPSHOT_MAX_BYTES``, 500 if the query fails to execute, 503 if the + snapshot store is not configured. + """ + if not snapshot_enabled(): + raise HTTPException(status_code=503, detail="Cohort snapshot store is not configured on this trust.") + + project_id = decrypt(query_input.encrypted_project_id) + logger.info(f"Received cohort snapshot request for project {project_id}") + safe_query = validate_query(query_input.query) try: - df = get_records(wrapped_query) + # use_cache=False: the artefact must freeze LIVE OMOP at this moment. The statistics + # run at submission caches this same SQL for CACHE_TTL_DAYS, and on re-approval — + # the event that propagates OMOP-side removals/opt-outs into the snapshot — a cached + # read would silently re-freeze the pre-removal cohort. + df = get_records(safe_query, use_cache=False) except HTTPException: # get_records already converts driver errors into category-only # HTTPExceptions; re-wrapping them below would discard that work and # turn every categorised 400 into an opaque 500. raise except SQLAlchemyError: - logger.exception("Accession-ids query failed with a database error") + logger.exception("Snapshot cohort query failed with a database error") raise HTTPException(status_code=500, detail="Query execution failed.") except Exception: - # Detail is category-only: the trust forwards it to the hub, which shows - # it to every project member, and raw exception text can carry row - # values and connection internals. - logger.exception("Accession-ids query failed unexpectedly") + # Detail is category-only, as on the other routes: it travels back to the hub. + logger.exception("Snapshot cohort query failed unexpectedly") raise HTTPException(status_code=500, detail="Query execution failed.") - minimum_cohort_size = get_settings().COHORT_QUERY_THRESHOLD - if len(df) < minimum_cohort_size: + if len(df) < get_settings().COHORT_QUERY_THRESHOLD: logger.warning( - f"Withholding accession IDs for project {project_id}: " - f"cohort below the minimum size of {minimum_cohort_size}" + f"Refusing to snapshot project {project_id}: cohort below the minimum size of " + f"{get_settings().COHORT_QUERY_THRESHOLD}" ) raise HTTPException(status_code=403, detail=_BELOW_THRESHOLD_DETAIL) - # No "did the DataFrame come back with accession_id?" guard here: it could never fire. - # The wrapper above selects the column explicitly, so a cohort that does not project it - # fails inside get_records with UndefinedColumn — surfacing as a category 400 through the - # `except HTTPException: raise` branch, before any DataFrame exists. Pinned by - # tests/integration/test_cohort_endpoint.py::test_accession_ids_missing_column_surfaces_get_records_400. - accession_ids = [str(value) for value in df["accession_id"].tolist()] - logger.info(f"accession-ids query for project {project_id} returned {len(accession_ids)} ids") - return AccessionIdsResponse(accession_ids=accession_ids) + try: + # The hash is of the RAW submitted SQL (not the validator's re-emission) so serving + # can compare it against the raw query the hub injects into FL job configs. + meta = save_snapshot(project_id, df, query_hash=normalised_query_hash(query_input.query)) + except ValueError: + raise HTTPException(status_code=400, detail="Project id is not a valid UUID.") + except SnapshotTooLarge as err: + logger.error(str(err)) + raise HTTPException(status_code=413, detail="Cohort snapshot exceeds the configured size limit.") + except OSError: + logger.exception("Cohort snapshot store write failed") + raise HTTPException(status_code=500, detail="Snapshot persistence failed.") + + return SnapshotResponse( + row_count=meta.row_count, + columns=meta.columns, + has_accessions=meta.has_accessions, + snapshot_at=meta.created_at, + query_hash=meta.query_hash, + ) + + +@write_router.post("/snapshot/delete") +def remove_snapshot(query_input: SnapshotDeleteRequest) -> dict[str, bool]: + """ + Removes a project's frozen cohort artefact. Idempotent. + + The teardown hook for the project purge path (FLIP#997 — which has no hub-side caller + yet, so nothing invokes this in the current lifecycle). After deletion the project's + row-level routes refuse until a re-approval creates a fresh snapshot. + + Args: + query_input (SnapshotDeleteRequest): The encrypted project id. + + Returns: + dict[str, bool]: ``{"deleted": bool}`` — False when no snapshot existed. + """ + project_id = decrypt(query_input.encrypted_project_id) + deleted = delete_snapshot(project_id) + logger.info(f"Snapshot delete for project {project_id}: {'removed' if deleted else 'nothing to remove'}") + return {"deleted": deleted} diff --git a/trust/data-access-api/data_access_api/routers/schema.py b/trust/data-access-api/data_access_api/routers/schema.py index 42c256363..add5a7771 100644 --- a/trust/data-access-api/data_access_api/routers/schema.py +++ b/trust/data-access-api/data_access_api/routers/schema.py @@ -82,3 +82,29 @@ class AccessionIdsResponse(BaseModel): ..., description="The accession IDs of the cohort, in query order.", ) + + +class SnapshotResponse(BaseModel): + """What ``POST /cohort/snapshot`` persisted — aggregates only, no row-level data.""" + + row_count: int = Field(..., description="Number of cohort rows frozen in the snapshot") + columns: list[str] = Field(..., description="Ordered column names of the frozen dataframe") + has_accessions: bool = Field( + ..., + description="Whether the frozen cohort carries an accession_id column (i.e. pulls imaging)", + ) + snapshot_at: str = Field(..., description="ISO-8601 UTC timestamp of snapshot creation") + query_hash: str = Field( + ..., + description="SHA-256 of the normalised SQL the snapshot froze (drift detection, not a control)", + ) + + +class SnapshotDeleteRequest(BaseModel): + """Input for ``POST /cohort/snapshot/delete``.""" + + encrypted_project_id: str = Field( + ..., + description="The encrypted identifier for the central hub project", + json_schema_extra={"example": "encrypted_12345"}, + ) diff --git a/trust/data-access-api/data_access_api/services/cohort.py b/trust/data-access-api/data_access_api/services/cohort.py index 13855d211..bf25efdec 100644 --- a/trust/data-access-api/data_access_api/services/cohort.py +++ b/trust/data-access-api/data_access_api/services/cohort.py @@ -430,6 +430,7 @@ def _validate_query_ast(query: str) -> str: def get_records( query: str | TextClause, params: Mapping[str, Any] | None = None, + use_cache: bool = True, ) -> pd.DataFrame: """ Executes a SQL query and returns results. @@ -439,6 +440,12 @@ def get_records( parameters when the query is parameterized. params (Mapping[str, Any] | None): Optional mapping of bind parameter names to values for parameterized queries. + use_cache (bool): Whether a cached result may be served. Cohort SNAPSHOT creation + passes False: the statistics run at submission caches the same SQL for + ``CACHE_TTL_DAYS``, so a cached read would freeze a stale frame — and on + RE-approval, which is precisely the event that must propagate OMOP-side + removals/opt-outs into the artefact (FLIP#857), it would silently re-freeze the + pre-removal cohort. A fresh read still updates the cache afterwards. Returns: pd.DataFrame: The results of the query as a DataFrame. @@ -448,9 +455,10 @@ def get_records( """ logger.info("Executing SQL query") - cached = get_cached_result(query, params) - if cached is not None: - return cached + if use_cache: + cached = get_cached_result(query, params) + if cached is not None: + return cached try: # TODO: Trace the query filtering to understand what the final user can see. diff --git a/trust/data-access-api/data_access_api/services/cohort_snapshot.py b/trust/data-access-api/data_access_api/services/cohort_snapshot.py new file mode 100644 index 000000000..b5b8a70cc --- /dev/null +++ b/trust/data-access-api/data_access_api/services/cohort_snapshot.py @@ -0,0 +1,296 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Durable, project-keyed store for the approved-cohort snapshot (FLIP#857). + +At project approval the trust materialises its cohort dataframe once; this module persists +that artefact on the local filesystem (``COHORT_SNAPSHOT_DIR``, a dedicated bind mount) so +the row-level routes can serve a frozen, immutable cohort instead of re-running the SQL +against live OMOP on every call. Deliberately a file store: data-access-api keeps zero +write access to any database, and researcher SQL (pinned to the ``omop`` schema by +``validate_query``) cannot reach a filesystem at all. + +Layout, one directory per hub project id:: + + // + dataframe.parquet # the frozen cohort, dtype-faithful (parquet, no index) + meta.json # row_count / columns / query_hash / created_at / format_version + +Writes are atomic at directory granularity: everything lands in a ``.tmp-*`` sibling first +and is activated with ``os.replace`` renames, so a reader never observes a half-written +snapshot and a crash mid-write leaves (at worst) a stale temp directory that the boot-time +sweep removes. There is deliberately NO TTL and no in-place mutation — a snapshot is +immutable until it is overwritten by a re-approval or deleted; an approved cohort must not +silently vanish or drift mid-training (contrast ``services/query_cache.py``, the volatile +per-process cache this module's API is modelled on). +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import shutil +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path + +import pandas as pd + +from data_access_api.config import get_settings +from data_access_api.utils.logger import logger + +# Bumped when the on-disk layout changes; a snapshot with an unknown version is treated as +# absent (legacy live-SQL fall-through) rather than mis-read. +_FORMAT_VERSION = 1 +_DATA_FILENAME = "dataframe.parquet" +_META_FILENAME = "meta.json" +# Work-in-progress / superseded directories. Never valid snapshots; swept at startup. +_TMP_PREFIX = ".tmp-" +_OLD_PREFIX = ".old-" + + +class SnapshotStoreDisabled(Exception): + """Raised on writes when ``COHORT_SNAPSHOT_DIR`` is not configured.""" + + +class SnapshotTooLarge(Exception): + """Raised when the serialized snapshot exceeds ``SNAPSHOT_MAX_BYTES`` (never truncated).""" + + +@dataclass(frozen=True) +class SnapshotMeta: + """The snapshot's serving-relevant facts, readable without deserialising the frame.""" + + row_count: int + columns: list[str] + query_hash: str + created_at: str # ISO-8601 UTC + format_version: int = _FORMAT_VERSION + + @property + def has_accessions(self) -> bool: + return "accession_id" in self.columns + + +@dataclass(frozen=True) +class Snapshot: + df: pd.DataFrame + meta: SnapshotMeta + + +def normalised_query_hash(query: str) -> str: + """SHA-256 of the whitespace-normalised, lowercased SQL text. + + Used only to *detect and log* when a caller-supplied query differs from the one the + snapshot froze — never as a security control (the frozen artefact is served either + way; that is the point). Hashes the raw submitted text, not the validator's re-emitted + form, so the hub-side string of record compares equal across submission and serving. + """ + normalised = " ".join(query.strip().lower().split()) + return hashlib.sha256(normalised.encode()).hexdigest() + + +def snapshot_enabled() -> bool: + """Whether a snapshot directory is configured (empty ``COHORT_SNAPSHOT_DIR`` = disabled).""" + return bool(get_settings().COHORT_SNAPSHOT_DIR) + + +def _store_dir() -> Path: + configured = get_settings().COHORT_SNAPSHOT_DIR + if not configured: + raise SnapshotStoreDisabled("COHORT_SNAPSHOT_DIR is not configured") + return Path(configured) + + +def _canonical_project_id(project_id: str) -> str | None: + """The project id as a canonical UUID string, or None when it is not a UUID. + + The id becomes a directory name, so only a parsed-and-re-emitted UUID is ever used as + a path component — nothing else reaches the filesystem (no traversal surface, even + though every caller is already authenticated and the id is hub-encrypted). + """ + try: + return str(uuid.UUID(str(project_id))) + except (ValueError, AttributeError, TypeError): + return None + + +def ensure_store() -> None: + """Boot-time store check: create the directory, sweep stale temp dirs, probe writability. + + Never raises — a missing or unwritable store must not take the service (and the + statistics route) down. Failures log at ERROR with the remediation; every subsequent + write fails loudly per-call and every read returns None, which the row-level routes + refuse (fail-closed). + """ + if not snapshot_enabled(): + logger.error( + "Cohort snapshot store DISABLED (COHORT_SNAPSHOT_DIR unset): snapshots cannot be " + "created and the row-level routes will refuse every project (fail-closed). Set " + "COHORT_SNAPSHOT_DIR / mount the snapshot volume." + ) + return + + base = _store_dir() + try: + base.mkdir(parents=True, exist_ok=True) + # Sweep leftovers from crashed writes: only this service writes here, and no write + # can be in flight during startup. + for stale in base.iterdir(): + if stale.name.startswith((_TMP_PREFIX, _OLD_PREFIX)): + shutil.rmtree(stale, ignore_errors=True) + logger.warning(f"Removed stale snapshot work directory {stale.name}") + probe = base / f"{_TMP_PREFIX}write-probe" + probe.mkdir(exist_ok=True) + probe.rmdir() + except OSError: + logger.exception( + f"Cohort snapshot store at {base} is not writable — snapshots cannot be created and " + "the row-level routes will refuse projects whose artefact cannot be read (fail-closed). " + f"Remediation: create the directory on the host and chown it to this service's uid " + f"(uid {os.getuid()})." + ) + return + + logger.info(f"Cohort snapshot store ready at {base}") + + +def save_snapshot(project_id: str, df: pd.DataFrame, query_hash: str) -> SnapshotMeta: + """Persist the cohort dataframe for ``project_id``, atomically replacing any predecessor. + + Args: + project_id (str): The decrypted hub project id (must be a UUID). + df (pd.DataFrame): The cohort exactly as ``get_records`` returned it. + query_hash (str): ``normalised_query_hash`` of the raw SQL that produced ``df``. + + Returns: + SnapshotMeta: What was written. + + Raises: + SnapshotStoreDisabled: When no store directory is configured. + SnapshotTooLarge: When the serialized frame exceeds ``SNAPSHOT_MAX_BYTES``. + ValueError: When ``project_id`` is not a UUID. + OSError: When the store directory is not writable. + """ + base = _store_dir() + canonical = _canonical_project_id(project_id) + if canonical is None: + raise ValueError("project_id must be a UUID to key a cohort snapshot") + + # Parquet keeps pandas dtypes (datetimes, nullable ints) so every training-time fetch of + # the frozen cohort deserialises to an identical frame. The index is dropped — the serve + # routes emit to_dict(orient="list"), which never includes it. + buffer = io.BytesIO() + df.to_parquet(buffer, engine="pyarrow", index=False) + payload = buffer.getvalue() + + max_bytes = get_settings().SNAPSHOT_MAX_BYTES + if len(payload) > max_bytes: + # Refuse rather than truncate: a partial cohort silently poisons training data. + raise SnapshotTooLarge( + f"Serialized cohort snapshot is {len(payload)} bytes, over the {max_bytes}-byte limit " + "(SNAPSHOT_MAX_BYTES). Narrow the cohort query's columns, or raise the limit." + ) + + meta = SnapshotMeta( + row_count=len(df), + columns=[str(column) for column in df.columns], + query_hash=query_hash, + created_at=datetime.now(UTC).isoformat(), + ) + + base.mkdir(parents=True, exist_ok=True) + nonce = uuid.uuid4().hex[:8] + workdir = base / f"{_TMP_PREFIX}{canonical}-{nonce}" + final = base / canonical + superseded = base / f"{_OLD_PREFIX}{canonical}-{nonce}" + try: + workdir.mkdir() + (workdir / _DATA_FILENAME).write_bytes(payload) + (workdir / _META_FILENAME).write_text(json.dumps(meta.__dict__)) + + # Two atomic renames. A crash between them leaves no active snapshot (readers fall + # back to live SQL and the boot sweep clears the debris) — never a half-written one. + if final.exists(): + os.replace(final, superseded) + os.replace(workdir, final) + finally: + shutil.rmtree(workdir, ignore_errors=True) + shutil.rmtree(superseded, ignore_errors=True) + + logger.info( + f"Cohort snapshot saved for project {canonical}: {meta.row_count} rows, " + f"{len(meta.columns)} columns, {len(payload)} bytes" + ) + return meta + + +def get_snapshot(project_id: str) -> Snapshot | None: + """The frozen cohort for ``project_id``, or None when there is none to serve. + + None covers every no-artefact case — store disabled, non-UUID project id, no snapshot + yet, unreadable/corrupt artefact (logged at ERROR). The row-level routes refuse on + None (fail-closed); a corrupt artefact is never partially served. + """ + if not snapshot_enabled(): + return None + canonical = _canonical_project_id(project_id) + if canonical is None: + logger.debug(f"Project id {project_id!r} is not a UUID; no snapshot lookup") + return None + + snapshot_dir = _store_dir() / canonical + meta_path = snapshot_dir / _META_FILENAME + if not meta_path.exists(): + return None + + try: + raw_meta = json.loads(meta_path.read_text()) + meta = SnapshotMeta(**raw_meta) + if meta.format_version != _FORMAT_VERSION: + logger.error( + f"Cohort snapshot for project {canonical} has format_version " + f"{meta.format_version} (expected {_FORMAT_VERSION}) — treating as absent" + ) + return None + df = pd.read_parquet(snapshot_dir / _DATA_FILENAME, engine="pyarrow") + except Exception: + logger.exception( + f"Cohort snapshot for project {canonical} is unreadable — treating as absent " + "(row-level routes refuse the project rather than serve a partial artefact)" + ) + return None + + return Snapshot(df=df, meta=meta) + + +def delete_snapshot(project_id: str) -> bool: + """Remove the snapshot for ``project_id``. Idempotent; True if one existed.""" + if not snapshot_enabled(): + return False + canonical = _canonical_project_id(project_id) + if canonical is None: + return False + + snapshot_dir = _store_dir() / canonical + if not snapshot_dir.exists(): + return False + # Move aside first so a concurrent reader sees either the intact snapshot or none — + # never a directory whose files are vanishing under it mid-read. + tomb = _store_dir() / f"{_OLD_PREFIX}{canonical}-{uuid.uuid4().hex[:8]}" + os.replace(snapshot_dir, tomb) + shutil.rmtree(tomb, ignore_errors=True) + logger.info(f"Cohort snapshot deleted for project {canonical}") + return True diff --git a/trust/data-access-api/data_access_api/utils/internal_auth.py b/trust/data-access-api/data_access_api/utils/internal_auth.py index 13302d60c..d95691b6b 100644 --- a/trust/data-access-api/data_access_api/utils/internal_auth.py +++ b/trust/data-access-api/data_access_api/utils/internal_auth.py @@ -23,8 +23,20 @@ The key is held in plaintext by every trust-internal service (sender or receiver). See ``imaging_api/utils/internal_auth.py`` for the rationale — the same module-level docstring applies here. + +The trust-internal key gates every ``/cohort`` route, but it does not +distinguish callers: fl-client holds it (legitimately — it reads the frozen +cohort via ``get_dataframe`` and pulls imaging), so it alone cannot separate +"may read the approved cohort" from "may DEFINE the approved cohort". The +snapshot create/delete routes — which materialise and destroy the artefact +every training round then trains on — therefore carry a second gate, +``authenticate_cohort_admin``: proof of possessing ``AES_KEY_BASE64``. trust-api +and data-access-api hold that key (they encrypt/decrypt hub payloads with it); +fl-client deliberately does not. The proof is the SHA-256 of the key, never the +key itself (FLIP#857). """ +import hashlib import hmac from fastapi import HTTPException, Security, status @@ -40,6 +52,11 @@ auto_error=False, ) +cohort_admin_header_scheme = APIKeyHeader( + name=_settings.COHORT_ADMIN_KEY_HEADER, + auto_error=False, +) + def authenticate_internal_service(api_key: str | None = Security(internal_key_header_scheme)) -> None: """Authenticate a trust-internal caller (trust-api, imaging-api, fl-client). @@ -73,3 +90,54 @@ def authenticate_internal_service(api_key: str | None = Security(internal_key_he status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid trust-internal service key.", ) + + +# Returned when a caller is trust-internal-authenticated but is not a cohort admin. Fixed and +# generic — it must not reveal whether the proof was absent or merely wrong, nor confirm the +# project exists. +_NOT_COHORT_ADMIN_DETAIL = "Not authorised to define the cohort snapshot." + + +def cohort_admin_proof() -> str: + """The credential the cohort-write routes require: SHA-256 of the trust's AES key. + + Possession of ``AES_KEY_BASE64`` is what separates the services trusted to define a + project's approved cohort (trust-api, data-access-api) from those that only consume it + (fl-client, which runs researcher code and holds no AES key). Sending the digest rather + than the key keeps the encryption key off the wire and out of logs — a captured header is + a replayable token scoped to two routes, never the key itself. + + Returns: + str: The hex SHA-256 digest of ``AES_KEY_BASE64``. + """ + return hashlib.sha256(get_settings().AES_KEY_BASE64.encode()).hexdigest() + + +def authenticate_cohort_admin(proof: str | None = Security(cohort_admin_header_scheme)) -> None: + """Authorise a cohort-defining write (snapshot create/delete) on top of trust-internal auth. + + Layered under ``authenticate_internal_service`` on the write router, so it only runs for + callers that already passed the trust-internal key check — hence 403 (authenticated, not + authorised) rather than 401. fl-client passes the first gate and fails this one; trust-api + passes both. + + Args: + proof (str | None): The SHA-256-of-AES-key digest from the request header. + + Raises: + HTTPException: 403 if the proof is missing, invalid, or the AES key is unconfigured. + """ + aes_key = get_settings().AES_KEY_BASE64 + if not aes_key: + # Fail closed. AES_KEY_BASE64 is a required setting, so this is defensive: an empty + # value would make sha256("") a universally-guessable proof. Refuse rather than admit it. + logger.warning("Cohort-admin authorization failed: AES_KEY_BASE64 not configured.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_NOT_COHORT_ADMIN_DETAIL) + + if not proof: + logger.warning("Cohort-admin authorization failed: proof missing from request.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_NOT_COHORT_ADMIN_DETAIL) + + if not hmac.compare_digest(proof, cohort_admin_proof()): + logger.warning("Cohort-admin authorization failed: invalid proof.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_NOT_COHORT_ADMIN_DETAIL) diff --git a/trust/data-access-api/pyproject.toml b/trust/data-access-api/pyproject.toml index 6cc509479..185995235 100644 --- a/trust/data-access-api/pyproject.toml +++ b/trust/data-access-api/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "idna>=3.15", "pandas>=2.2.3", "psycopg2-binary>=2.9.10", + # Parquet (de)serialisation of the approved-cohort snapshots (FLIP#857). + "pyarrow>=19.0.0", "pydantic-settings>=2.9.1", "python-multipart>=0.0.26", "sqlalchemy>=2.0.39", diff --git a/trust/data-access-api/tests/conftest.py b/trust/data-access-api/tests/conftest.py index e961ef56e..dc4f69aa3 100644 --- a/trust/data-access-api/tests/conftest.py +++ b/trust/data-access-api/tests/conftest.py @@ -10,6 +10,7 @@ # limitations under the License. # +import hashlib import os # Plaintext test key used by tests to authenticate against the /cohort router. @@ -18,6 +19,17 @@ TEST_TRUST_INTERNAL_SERVICE_KEY = "test-trust-internal-key" AUTH_HEADERS = {"X-Trust-Internal-Service-Key": TEST_TRUST_INTERNAL_SERVICE_KEY} +# 32-byte AES key (base64), single source for both the env default below and the +# cohort-admin proof the snapshot WRITE routes require (FLIP#857). +TEST_AES_KEY_BASE64 = "QgZ+TBA0lUxcuCiRPLneFe/JjMaUEUJWHACHHGz2gGA=" # pragma: allowlist secret + +# Cohort-admin authorisation for the snapshot create/delete routes: proof of possessing +# AES_KEY_BASE64, sent as the SHA-256 of the key. The read routes never need this header. +COHORT_ADMIN_PROOF = hashlib.sha256(TEST_AES_KEY_BASE64.encode()).hexdigest() +COHORT_ADMIN_HEADERS = {"X-Cohort-Admin-Key": COHORT_ADMIN_PROOF} +# The write routes require BOTH the trust-internal key AND the cohort-admin proof. +WRITE_AUTH_HEADERS = {**AUTH_HEADERS, **COHORT_ADMIN_HEADERS} + # Set dummy environment variables required by Settings() before any app code # is imported. These are only used in tests; real values come from Docker # Compose environment or .env files in deployed environments. @@ -25,7 +37,7 @@ "DATA_ACCESS_POSTGRES_USER": "test_user", "DATA_ACCESS_POSTGRES_PASSWORD": "test_password", "OMOP_POSTGRES_DB": "test_omop_db", - "AES_KEY_BASE64": "QgZ+TBA0lUxcuCiRPLneFe/JjMaUEUJWHACHHGz2gGA=", # 32-byte key, base64 + "AES_KEY_BASE64": TEST_AES_KEY_BASE64, "TRUST_INTERNAL_SERVICE_KEY": TEST_TRUST_INTERNAL_SERVICE_KEY, } diff --git a/trust/data-access-api/tests/integration/conftest.py b/trust/data-access-api/tests/integration/conftest.py index c7b59c364..dc4787d78 100644 --- a/trust/data-access-api/tests/integration/conftest.py +++ b/trust/data-access-api/tests/integration/conftest.py @@ -24,6 +24,7 @@ process and the container always agree on the key the compose stack uses. """ +import hashlib import os # Pin the AES key BEFORE any data_access_api module loads its Settings singleton. @@ -50,6 +51,11 @@ TRUST_INTERNAL_KEY = "test-trust-internal-service-key" # pragma: allowlist secret AUTH_HEADERS = {"X-Trust-Internal-Service-Key": TRUST_INTERNAL_KEY} +# Cohort-admin proof for the snapshot WRITE routes (FLIP#857): SHA-256 of the AES key the +# container runs with (pinned above / baked into compose.test.yml). The read routes never +# need it. Merged per-request onto the write calls, on top of the client's AUTH_HEADERS. +COHORT_ADMIN_HEADERS = {"X-Cohort-Admin-Key": hashlib.sha256(os.environ["AES_KEY_BASE64"].encode()).hexdigest()} + @pytest.fixture(scope="session") def compose_stack() -> Generator[DockerCompose, None, None]: @@ -58,6 +64,10 @@ def compose_stack() -> Generator[DockerCompose, None, None]: context=str(_COMPOSE_DIR), compose_file_name=_COMPOSE_FILE, wait=True, + # Rebuild the from-source service on every session: without it, `docker compose up` + # reuses a previously built image, so a dependency change (pyproject/uv.lock) never + # reaches the stack and the suite green-lights code that cannot run in a fresh build. + build=True, ) as compose: yield compose diff --git a/trust/data-access-api/tests/integration/test_cohort_endpoint.py b/trust/data-access-api/tests/integration/test_cohort_endpoint.py index 69d938dfd..40b5c786e 100644 --- a/trust/data-access-api/tests/integration/test_cohort_endpoint.py +++ b/trust/data-access-api/tests/integration/test_cohort_endpoint.py @@ -21,7 +21,7 @@ import httpx import pytest -from tests.integration.conftest import AUTH_HEADERS +from tests.integration.conftest import AUTH_HEADERS, COHORT_ADMIN_HEADERS @pytest.fixture @@ -41,17 +41,23 @@ def _cohort_payload(query: str) -> dict: } -def _dataframe_payload(query: str) -> dict: - """Payload for the two row-level routes, which take ``DataframeQuery`` (two fields only). +# Row-level routes key everything on the hub project id, which must be a UUID (it becomes a +# directory name in the snapshot store). +_PROJECT_A = "0b91a3f2-30c3-4bd5-9a1e-2f24c7f5a111" +_PROJECT_B = "4d5e6f70-8192-4a3b-bc4d-5e6f70819222" + + +def _dataframe_payload(query: str, project_id: str = _PROJECT_A) -> dict: + """Payload for the row-level + snapshot routes, which take ``DataframeQuery``. ``encrypted_project_id`` is encrypted with the same AES key the container is configured - with, so the decrypt path stays real rather than mocked. The import is function-local for - the same reason it is in ``test_dataframe_endpoint_returns_seeded_columns``: the conftest - pins ``AES_KEY_BASE64`` before any ``data_access_api`` module builds its Settings singleton. + with, so the decrypt path stays real rather than mocked. The import is function-local: + the conftest pins ``AES_KEY_BASE64`` before any ``data_access_api`` module builds its + Settings singleton. """ from data_access_api.utils.encryption import encrypt - return {"encrypted_project_id": encrypt("integration-project-1"), "query": query} + return {"encrypted_project_id": encrypt(project_id), "query": query} def test_cohort_endpoint_returns_aggregates_for_image_occurrences(http_client): @@ -132,112 +138,168 @@ def test_cohort_endpoint_requires_auth_header(data_access_api_url): assert response.status_code == 401 -def test_dataframe_endpoint_returns_seeded_columns(http_client): - """``/cohort/dataframe`` returns column-oriented data straight from Postgres. +# --------------------------------------------------------------------------- +# Snapshot lifecycle: the row-level routes serve ONLY the frozen approved cohort +# (FLIP#857). Each test uses its own project UUID so the per-session store never +# couples tests; snapshots are cleaned up where a later test would collide. +# --------------------------------------------------------------------------- - The endpoint decrypts ``encrypted_project_id`` with the shared AES key, so the test - encrypts a real project id with the same key the container is configured with. This - keeps the encryption path real instead of mocking ``decrypt``. - """ +_SEED_COHORT_QUERY = ( + "SELECT c.concept_code AS modality, io.accession_id " + "FROM omop.image_occurrence io " + "LEFT JOIN omop.concept c ON c.concept_id = io.modality_concept_id" +) + + +def _create_snapshot(http_client, query: str, project_id: str) -> httpx.Response: + # The write route needs the cohort-admin proof on top of the client's trust-internal key. + return http_client.post( + "/cohort/snapshot", json=_dataframe_payload(query, project_id), headers=COHORT_ADMIN_HEADERS + ) + + +def test_row_level_routes_refuse_without_a_snapshot(http_client): + """Fail-closed: no approved snapshot ⇒ no row-level data, on both routes.""" + payload = _dataframe_payload("SELECT * FROM omop.image_occurrence", "97fca5ab-0000-4000-8000-000000000001") + for path in ("/cohort/dataframe", "/cohort/accession-ids"): + response = http_client.post(path, json=payload) + assert response.status_code == 403, response.text + assert response.json()["detail"] == "No approved cohort snapshot exists for this project." + + +def test_snapshot_write_routes_reject_trust_internal_key_without_cohort_admin_proof(http_client): + """End-to-end: the container refuses a cohort-defining write from a caller that holds only the + shared trust-internal key (what fl-client has) — the AES-possession gate closes that path + (FLIP#857). ``http_client`` carries AUTH_HEADERS but not COHORT_ADMIN_HEADERS.""" from data_access_api.utils.encryption import encrypt - payload = { - "encrypted_project_id": encrypt("integration-project-1"), - "query": ( - "SELECT c.concept_code AS modality, io.accession_id " - "FROM omop.image_occurrence io " - "LEFT JOIN omop.concept c ON c.concept_id = io.modality_concept_id" - ), - } - response = http_client.post("/cohort/dataframe", json=payload) - assert response.status_code == 200, response.text + create = http_client.post("/cohort/snapshot", json=_dataframe_payload(_SEED_COHORT_QUERY, _PROJECT_B)) + assert create.status_code == 403, create.text + delete = http_client.post("/cohort/snapshot/delete", json={"encrypted_project_id": encrypt(_PROJECT_B)}) + assert delete.status_code == 403, delete.text - body = response.json() - assert set(body.keys()) == {"modality", "accession_id"} - assert len(body["modality"]) == 24 - # Sanity: counts in the dataframe should match the seed totals. - assert body["modality"].count("CT") == 12 - assert body["modality"].count("MR") == 8 - assert body["modality"].count("XR") == 4 - # accession_id is unique per row in the seed. - assert len(set(body["accession_id"])) == 24 - - -def test_accession_ids_returns_seeded_ids(http_client): - """``/cohort/accession-ids`` projects the id column out of the caller's cohort. - - The route wraps the caller's (re-emitted) SQL as ``SELECT accession_id FROM (...) AS - cohort_subquery``, so only that one column ever crosses the trust boundary regardless of - what the inner query selected. - """ + +def test_snapshot_then_dataframe_serves_the_frozen_cohort(http_client): + """The full approval-time flow: freeze once, then serve — ignoring the client's SQL.""" + created = _create_snapshot(http_client, _SEED_COHORT_QUERY, _PROJECT_A) + assert created.status_code == 200, created.text + body = created.json() + assert body["row_count"] == 24 + assert body["has_accessions"] is True + assert set(body["columns"]) == {"modality", "accession_id"} + + # The served query is hostile-looking and never executed: the frozen frame comes back. response = http_client.post( - "/cohort/accession-ids", - json=_dataframe_payload("SELECT person_id, accession_id FROM omop.image_occurrence"), + "/cohort/dataframe", + json=_dataframe_payload("SELECT * FROM omop.person", _PROJECT_A), ) assert response.status_code == 200, response.text - - accession_ids = response.json()["accession_ids"] + frame = response.json() + assert set(frame.keys()) == {"modality", "accession_id"} + assert len(frame["modality"]) == 24 + assert frame["modality"].count("CT") == 12 + assert frame["modality"].count("MR") == 8 + assert frame["modality"].count("XR") == 4 + assert len(set(frame["accession_id"])) == 24 + + # And the frozen accession pointer set serves from the same artefact. + ids_response = http_client.post( + "/cohort/accession-ids", + json=_dataframe_payload("SELECT 1 AS nothing FROM omop.person", _PROJECT_A), + ) + assert ids_response.status_code == 200, ids_response.text + accession_ids = ids_response.json()["accession_ids"] assert len(accession_ids) == 24 - # accession_id is unique per row in the seed (ACC-1001 … ACC-1024). - assert len(set(accession_ids)) == 24 assert "ACC-1001" in accession_ids -def test_accession_ids_missing_column_surfaces_get_records_400(http_client): - """A cohort that does not project ``accession_id`` fails inside ``get_records``, not after it. +def test_tabular_snapshot_serves_empty_accession_list(http_client): + """A frozen cohort without accession_id is a tabular project: imaging no-ops, no error. - Pins which 400 actually reaches the caller. Because the route wraps the inner query in - ``SELECT accession_id FROM (...)``, Postgres raises ``UndefinedColumn`` while the query is - executing — so ``get_records`` converts it to a category 400 and the router's - ``except HTTPException: raise`` branch re-raises it before any DataFrame is returned. A - router-level "did the DataFrame come back with the column?" guard could never fire, which - is why there isn't one; this test is the standing proof of that. The cohort here is 16 - rows — comfortably over the stack's threshold — so the refusal is about the column, not - the cohort size. + (Pre-snapshot, a cohort not projecting accession_id produced an UndefinedColumn 400 out + of the live wrapped query; with frozen serving the column's absence is a legitimate + project shape, recorded in the snapshot's metadata.) """ + created = _create_snapshot(http_client, "SELECT person_id FROM omop.person", _PROJECT_B) + assert created.status_code == 200, created.text + assert created.json()["has_accessions"] is False + response = http_client.post( "/cohort/accession-ids", - json=_dataframe_payload("SELECT person_id FROM omop.person"), + json=_dataframe_payload("SELECT person_id FROM omop.person", _PROJECT_B), ) - assert response.status_code == 400, response.text - assert response.json()["detail"] == "The column 'accession_id' does not exist." + assert response.status_code == 200, response.text + assert response.json()["accession_ids"] == [] -def test_accession_ids_below_threshold_is_refused(http_client): - """A below-threshold cohort is refused outright — no partial list, no count. +def test_below_threshold_snapshot_is_refused_and_persists_nothing(http_client): + """The disclosure floor is enforced at freeze time; a refused freeze leaves no artefact. modality_concept_id 4013632 = 'XR'; the seed has 4 such rows, under the stack's - COHORT_QUERY_THRESHOLD of 5. + COHORT_QUERY_THRESHOLD of 5. The refusal text matches the row-level routes' fixed + below-threshold string, and a zero-row cohort refuses byte-identically so the snapshot + route cannot act as a row-count oracle either. """ + project_id = "97fca5ab-0000-4000-8000-000000000002" + below = _create_snapshot( + http_client, + "SELECT accession_id FROM omop.image_occurrence WHERE modality_concept_id = 4013632", + project_id, + ) + zero = _create_snapshot( + http_client, + "SELECT accession_id FROM omop.image_occurrence WHERE accession_id = 'NONEXISTENT'", + project_id, + ) + assert below.status_code == zero.status_code == 403 + assert below.text == zero.text + assert below.json()["detail"] == "Cohort is too small for row-level data to be released." + + # Nothing was persisted: the project still refuses row-level serving outright. response = http_client.post( - "/cohort/accession-ids", - json=_dataframe_payload( - "SELECT accession_id FROM omop.image_occurrence WHERE modality_concept_id = 4013632" - ), + "/cohort/dataframe", json=_dataframe_payload("SELECT 1 AS one FROM omop.person", project_id) ) - assert response.status_code == 403, response.text - assert response.json()["detail"] == "Cohort is too small for row-level data to be released." + assert response.status_code == 403 + assert response.json()["detail"] == "No approved cohort snapshot exists for this project." -def test_accession_ids_zero_rows_indistinguishable_from_below_threshold(http_client): - """Privacy regression, against real Postgres rather than a mocked DataFrame. +def test_snapshot_route_rejects_unsafe_sql(http_client): + """validate_query remains the authority on the one query the snapshot route executes.""" + response = _create_snapshot( + http_client, + "INSERT INTO omop.person (person_id) VALUES (999)", + "97fca5ab-0000-4000-8000-000000000003", + ) + assert response.status_code == 400, response.text - A cohort matching nothing and a cohort of 1-4 rows must produce byte-identical refusals, - or the response itself becomes a row-count oracle: a caller could binary-search a - predicate and learn whether *any* patient matches it, one bit at a time. - """ - below_threshold = http_client.post( - "/cohort/accession-ids", - json=_dataframe_payload( - "SELECT accession_id FROM omop.image_occurrence WHERE modality_concept_id = 4013632" - ), + +def test_reapproval_replaces_the_snapshot_and_delete_removes_it(http_client): + """Overwrite-on-reapproval and the FLIP#997 teardown hook, end to end.""" + from data_access_api.utils.encryption import encrypt + + project_id = "97fca5ab-0000-4000-8000-000000000004" + first = _create_snapshot(http_client, _SEED_COHORT_QUERY, project_id) + assert first.status_code == 200, first.text + + second = _create_snapshot(http_client, "SELECT person_id, accession_id FROM omop.image_occurrence", project_id) + assert second.status_code == 200, second.text + + served = http_client.post( + "/cohort/dataframe", json=_dataframe_payload("SELECT 1 AS one FROM omop.person", project_id) ) - genuine_zero = http_client.post( - "/cohort/accession-ids", - json=_dataframe_payload( - "SELECT accession_id FROM omop.image_occurrence WHERE accession_id = 'NONEXISTENT'" - ), + assert set(served.json().keys()) == {"person_id", "accession_id"} + + deleted = http_client.post( + "/cohort/snapshot/delete", json={"encrypted_project_id": encrypt(project_id)}, headers=COHORT_ADMIN_HEADERS ) + assert deleted.status_code == 200 + assert deleted.json() == {"deleted": True} + again = http_client.post( + "/cohort/snapshot/delete", json={"encrypted_project_id": encrypt(project_id)}, headers=COHORT_ADMIN_HEADERS + ) + assert again.json() == {"deleted": False} - assert below_threshold.status_code == genuine_zero.status_code == 403 - assert below_threshold.text == genuine_zero.text + refused = http_client.post( + "/cohort/dataframe", json=_dataframe_payload("SELECT 1 AS one FROM omop.person", project_id) + ) + assert refused.status_code == 403 diff --git a/trust/data-access-api/tests/routers/test_cohort.py b/trust/data-access-api/tests/routers/test_cohort.py index e6bf2280b..805ce6254 100644 --- a/trust/data-access-api/tests/routers/test_cohort.py +++ b/trust/data-access-api/tests/routers/test_cohort.py @@ -16,7 +16,6 @@ import pytest from fastapi import HTTPException from fastapi.testclient import TestClient -from sqlalchemy.exc import SQLAlchemyError from data_access_api.main import app from data_access_api.routers.schema import StatisticsResponse @@ -187,318 +186,11 @@ def test_receive_cohort_query_execution_error(mock_validate_query, mock_get_sett "query": "SELECT age, gender FROM dummy_table", } -# Expected output -sample_df_dict = { - "age": [25, 30, 40], - "gender": ["M", "F", "M"], -} - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -@patch("data_access_api.routers.cohort.validate_query") -def test_get_dataframe_success(mock_validate_query, mock_get_records, mock_decrypt, mock_get_settings): - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 2 - mock_decrypt.return_value = "decrypted-id" - mock_get_records.return_value = pd.DataFrame(sample_df_dict) - - response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 200 - assert response.json() == sample_df_dict - mock_decrypt.assert_called_once_with("encrypted-id") - mock_validate_query.assert_called_once_with(sample_dataframe_query["query"]) - # The engine receives what validate_query emitted from the checked AST, - # never the caller's raw string. - mock_get_records.assert_called_once_with(mock_validate_query.return_value) - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.validate_query") -def test_get_dataframe_invalid_query(mock_validate_query, mock_decrypt): - mock_decrypt.return_value = "decrypted-id" - mock_validate_query.side_effect = HTTPException(status_code=400, detail="Invalid query syntax") - - response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 400 - assert response.json()["detail"] == "Invalid query syntax" - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_dataframe_sqlalchemy_error(mock_get_records, mock_decrypt): - """Driver text must not reach the caller — the hub relays this detail to the UI.""" - mock_decrypt.return_value = "decrypted-id" - mock_get_records.side_effect = SQLAlchemyError("relation omop.person has 42 rows for patient Bob") - - response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 500 - assert "Bob" not in response.json()["detail"] - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_dataframe_generic_error(mock_get_records, mock_decrypt): - """Unexpected exception text must not reach the caller either.""" - mock_decrypt.return_value = "decrypted-id" - mock_get_records.side_effect = RuntimeError("connection string postgres://user:hunter2@omop-db") - - response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 500 - assert "hunter2" not in response.json()["detail"] - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_dataframe_preserves_http_exception_from_get_records(mock_get_records, mock_decrypt): - """A categorised 400 from get_records must not be re-wrapped as an opaque 500. - - ``get_records`` deliberately converts driver errors into category-only - HTTPExceptions. ``except Exception`` also catches HTTPException, so the - route used to swallow that work and re-raise every one of them as a 500 - whose detail was the *repr of the HTTPException*. - """ - mock_decrypt.return_value = "decrypted-id" - mock_get_records.side_effect = HTTPException(status_code=400, detail="Column 'nope' does not exist") - - response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 400 - assert response.json()["detail"] == "Column 'nope' does not exist" - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_dataframe_rejects_cohort_below_threshold(mock_get_records, mock_decrypt, mock_get_settings): - """Row-level data is withheld for cohorts smaller than COHORT_QUERY_THRESHOLD.""" - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 - mock_decrypt.return_value = "decrypted-id" - mock_get_records.return_value = pd.DataFrame({"age": range(9)}) - - response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 403 - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_dataframe_below_threshold_does_not_disclose_row_count( - mock_get_records, mock_decrypt, mock_get_settings -): - """The refusal must not reveal how many rows matched — 0 and 9 look identical.""" - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 - mock_decrypt.return_value = "decrypted-id" - - details = [] - for row_count in (0, 9): - mock_get_records.return_value = pd.DataFrame({"age": range(row_count)}) - response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) - details.append(response.json()["detail"]) - - assert details[0] == details[1] - assert "9" not in details[1] - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_dataframe_allows_cohort_at_threshold(mock_get_records, mock_decrypt, mock_get_settings): - """A cohort exactly at the threshold is released — the gate is not off-by-one.""" - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 - mock_decrypt.return_value = "decrypted-id" - mock_get_records.return_value = pd.DataFrame({"age": range(10)}) - - response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 200 - assert len(response.json()["age"]) == 10 - - -# --------------------------------------------------------------------------- -# /cohort/accession-ids -# --------------------------------------------------------------------------- - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_success(mock_get_records, mock_decrypt, mock_get_settings): - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 2 - mock_decrypt.return_value = "decrypted-id" - mock_get_records.return_value = pd.DataFrame({"accession_id": ["ACC1", "ACC2", "ACC3"]}) - - response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 200 - assert response.json() == {"accession_ids": ["ACC1", "ACC2", "ACC3"]} - mock_decrypt.assert_called_once_with("encrypted-id") - # The caller's query must be wrapped server-side so only accession_id is projected. - called_query = mock_get_records.call_args[0][0] - assert called_query.startswith("SELECT accession_id FROM (") - # What gets wrapped is validate_query's *emitted* SQL, not the caller's raw string — so the - # unqualified `dummy_table` arrives pinned to the omop schema. - assert "SELECT age, gender FROM omop.dummy_table" in called_query - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_strips_trailing_semicolon(mock_get_records, mock_decrypt, mock_get_settings): - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 1 - mock_decrypt.return_value = "decrypted-id" - mock_get_records.return_value = pd.DataFrame({"accession_id": ["ACC1"]}) - - response = client.post( - "/cohort/accession-ids", - json={"encrypted_project_id": "encrypted-id", "query": "SELECT * FROM cohort; "}, - headers=AUTH_HEADERS, - ) - - assert response.status_code == 200 - called_query = mock_get_records.call_args[0][0] - # No bare semicolon should leak into the wrapped subquery. The table arrives schema-pinned - # because the wrapper wraps the emitted SQL, not the caller's raw string. - assert "SELECT * FROM omop.cohort)" in called_query - assert ";" not in called_query - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.validate_query") -def test_get_accession_ids_invalid_query(mock_validate_query, mock_decrypt): - mock_decrypt.return_value = "decrypted-id" - mock_validate_query.side_effect = HTTPException(status_code=400, detail="Invalid query syntax") - - response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 400 - assert response.json()["detail"] == "Invalid query syntax" - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_missing_column_propagates_400(mock_get_records, mock_decrypt): - """A cohort that does not project ``accession_id`` is refused by ``get_records``, not the router. - - The route wraps the inner query as ``SELECT accession_id FROM (...)``, so Postgres raises - ``UndefinedColumn`` during execution and ``get_records`` turns it into a category 400 — - there is no DataFrame to inspect afterwards, which is why the router carries no - column-presence guard. This mocks the conversion ``get_records`` performs; the real - Postgres behaviour it stands in for is pinned by - ``tests/integration/test_cohort_endpoint.py::test_accession_ids_missing_column_surfaces_get_records_400``. - """ - mock_decrypt.return_value = "decrypted-id" - mock_get_records.side_effect = HTTPException( - status_code=400, detail="The column 'accession_id' does not exist." - ) - - response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 400 - assert response.json()["detail"] == "The column 'accession_id' does not exist." - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_propagates_http_exception(mock_get_records, mock_decrypt): - """``get_records`` raises HTTPException for things like undefined tables/columns; - the wrapping ``except`` clauses must not swallow that into a 500.""" - mock_decrypt.return_value = "decrypted-id" - mock_get_records.side_effect = HTTPException( - status_code=400, detail="The table 'omop.bogus' does not exist." - ) - - response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 400 - assert response.json()["detail"] == "The table 'omop.bogus' does not exist." - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_sqlalchemy_error_does_not_leak(mock_get_records, mock_decrypt): - """Driver text must not reach the caller: the trust forwards this detail to the hub, - which shows it to every project member (FLIP-PT-016).""" - mock_decrypt.return_value = "decrypted-id" - mock_get_records.side_effect = SQLAlchemyError("relation omop.secret_table row 42") - - response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 500 - assert response.json()["detail"] == "Query execution failed." - assert "secret_table" not in response.text - - -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_generic_error_does_not_leak(mock_get_records, mock_decrypt): - mock_decrypt.return_value = "decrypted-id" - mock_get_records.side_effect = RuntimeError("connection to 10.0.0.5 failed for user svc_omop") - - response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 500 - assert response.json()["detail"] == "Query execution failed." - assert "svc_omop" not in response.text - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_rejects_cohort_below_threshold(mock_get_records, mock_decrypt, mock_get_settings): - """Accession IDs are row-level identifiers and decide whose imaging is pulled into XNAT, - so a below-threshold cohort is refused just as it is on /cohort/dataframe.""" - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 - mock_decrypt.return_value = "decrypted-id" - mock_get_records.return_value = pd.DataFrame({"accession_id": [f"ACC{i}" for i in range(9)]}) - - response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 403 - assert response.json()["detail"] == "Cohort is too small for row-level data to be released." - # No identifier may appear in the refusal. - assert "ACC" not in response.text - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_below_threshold_is_indistinguishable_from_zero( - mock_get_records, mock_decrypt, mock_get_settings -): - """A zero-row cohort and a below-threshold one must return byte-identical responses, - or the refusal itself becomes a row-count oracle.""" - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 - mock_decrypt.return_value = "decrypted-id" - - mock_get_records.return_value = pd.DataFrame({"accession_id": []}) - zero_response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - mock_get_records.return_value = pd.DataFrame({"accession_id": [f"ACC{i}" for i in range(9)]}) - below_response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert zero_response.status_code == below_response.status_code == 403 - assert zero_response.text == below_response.text - - -@patch("data_access_api.routers.cohort.get_settings") -@patch("data_access_api.routers.cohort.decrypt") -@patch("data_access_api.routers.cohort.get_records") -def test_get_accession_ids_allows_cohort_at_threshold(mock_get_records, mock_decrypt, mock_get_settings): - """Exactly at the threshold is allowed — the gate is `<`, not `<=`.""" - mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 - mock_decrypt.return_value = "decrypted-id" - mock_get_records.return_value = pd.DataFrame({"accession_id": [f"ACC{i}" for i in range(10)]}) - - response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) - - assert response.status_code == 200 - assert len(response.json()["accession_ids"]) == 10 +# The /cohort/dataframe and /cohort/accession-ids behavioural tests live in +# tests/routers/test_cohort_snapshot.py: both routes serve ONLY the frozen +# approved-cohort snapshot (FLIP#857) — the live-SQL serving path they used to +# have (and the tests that pinned it) is gone. Live SQL now runs only on +# /cohort (statistics) above and /cohort/snapshot (creation). # --------------------------------------------------------------------------- @@ -514,6 +206,8 @@ def test_get_accession_ids_allows_cohort_at_threshold(mock_get_records, mock_dec ("/cohort", sample_query_input), ("/cohort/dataframe", sample_dataframe_query), ("/cohort/accession-ids", sample_dataframe_query), + ("/cohort/snapshot", sample_dataframe_query), + ("/cohort/snapshot/delete", {"encrypted_project_id": "encrypted-id"}), ], ) def test_cohort_route_rejects_missing_key(path, payload): @@ -528,6 +222,8 @@ def test_cohort_route_rejects_missing_key(path, payload): ("/cohort", sample_query_input), ("/cohort/dataframe", sample_dataframe_query), ("/cohort/accession-ids", sample_dataframe_query), + ("/cohort/snapshot", sample_dataframe_query), + ("/cohort/snapshot/delete", {"encrypted_project_id": "encrypted-id"}), ], ) def test_cohort_route_rejects_wrong_key(path, payload): @@ -544,6 +240,53 @@ def test_health_does_not_require_auth(): assert response.status_code == 200 +# --------------------------------------------------------------------------- +# Cohort-admin auth — the snapshot WRITE routes require proof of possessing +# AES_KEY_BASE64 on top of the trust-internal key (FLIP#857), so a caller that +# holds only the shared trust-internal key (fl-client) cannot DEFINE or destroy a +# project's frozen cohort. The read routes must stay reachable with the +# trust-internal key alone. +# --------------------------------------------------------------------------- + +_WRITE_ROUTES = [ + ("/cohort/snapshot", sample_dataframe_query), + ("/cohort/snapshot/delete", {"encrypted_project_id": "encrypted-id"}), +] + + +@pytest.mark.parametrize(("path", "payload"), _WRITE_ROUTES) +def test_write_routes_reject_trust_internal_key_without_cohort_admin_proof(path, payload): + """A valid trust-internal key alone (what fl-client holds) is refused with 403 — + authenticated but not authorised to define the cohort.""" + response = client.post(path, json=payload, headers=AUTH_HEADERS) + assert response.status_code == 403 + assert "authorised" in response.json()["detail"].lower() + + +@pytest.mark.parametrize(("path", "payload"), _WRITE_ROUTES) +def test_write_routes_reject_wrong_cohort_admin_proof(path, payload): + """A wrong AES-possession proof is refused with the same fixed 403 as a missing one, + so the refusal never reveals whether the proof was absent or merely invalid.""" + headers = {**AUTH_HEADERS, "X-Cohort-Admin-Key": "not-the-real-proof"} + response = client.post(path, json=payload, headers=headers) + assert response.status_code == 403 + assert "authorised" in response.json()["detail"].lower() + + +@pytest.mark.parametrize("path", ["/cohort/dataframe", "/cohort/accession-ids"]) +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.get_snapshot") +def test_read_routes_do_not_require_cohort_admin_proof(mock_get_snapshot, mock_decrypt, path): + """The read routes must NOT gain the cohort-admin gate: the trust-internal key alone must + get past auth into the handler (a cohort-admin 403 here would mean fl-client's get_dataframe + broke). With no snapshot the handler reaches its own fail-closed 403 — distinct text — which + proves auth let the caller through rather than blocking on cohort-admin.""" + mock_decrypt.return_value = "my_project" + mock_get_snapshot.return_value = None + response = client.post(path, json=sample_dataframe_query, headers=AUTH_HEADERS) + assert "authorised" not in response.json().get("detail", "").lower() + + # --------------------------------------------------------------------------- # Parse-then-emit tests # diff --git a/trust/data-access-api/tests/routers/test_cohort_snapshot.py b/trust/data-access-api/tests/routers/test_cohort_snapshot.py new file mode 100644 index 000000000..187d8ceb1 --- /dev/null +++ b/trust/data-access-api/tests/routers/test_cohort_snapshot.py @@ -0,0 +1,304 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Route-level tests for approved-cohort snapshot serving and creation (FLIP#857).""" + +from datetime import UTC, datetime +from unittest.mock import patch + +import pandas as pd +import pytest +from fastapi.testclient import TestClient + +from data_access_api.main import app +from data_access_api.routers.cohort import _BELOW_THRESHOLD_DETAIL, _NO_SNAPSHOT_DETAIL +from data_access_api.services.cohort_snapshot import ( + Snapshot, + SnapshotMeta, + SnapshotTooLarge, + normalised_query_hash, +) +from tests.conftest import AUTH_HEADERS, WRITE_AUTH_HEADERS + +client = TestClient(app) + +FROZEN_QUERY = "SELECT * FROM omop.person" + +sample_dataframe_query = { + "encrypted_project_id": "encrypted_my_project", + "query": FROZEN_QUERY, +} + + +def _snapshot(df: pd.DataFrame, query: str = FROZEN_QUERY) -> Snapshot: + return Snapshot( + df=df, + meta=SnapshotMeta( + row_count=len(df), + columns=[str(column) for column in df.columns], + query_hash=normalised_query_hash(query), + created_at=datetime.now(UTC).isoformat(), + ), + ) + + +# --------------------------------------------------------------------------- +# Frozen serving on /cohort/dataframe +# --------------------------------------------------------------------------- + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.get_snapshot") +@patch("data_access_api.routers.cohort.validate_query") +@patch("data_access_api.routers.cohort.get_records") +def test_dataframe_serves_frozen_snapshot_and_ignores_client_sql( + mock_get_records, mock_validate_query, mock_get_snapshot, mock_decrypt, mock_get_settings +): + """With a snapshot present, even hostile SQL is never validated or executed.""" + mock_decrypt.return_value = "my_project" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 2 + frozen = pd.DataFrame({"accession_id": ["A1", "A2", "A3"], "label": [0, 1, 0]}) + mock_get_snapshot.return_value = _snapshot(frozen) + + body = {**sample_dataframe_query, "query": "SELECT * FROM omop.person; DROP TABLE omop.person"} + response = client.post("/cohort/dataframe", json=body, headers=AUTH_HEADERS) + + assert response.status_code == 200 + assert response.json() == frozen.to_dict(orient="list") + mock_validate_query.assert_not_called() + mock_get_records.assert_not_called() + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.get_snapshot") +def test_dataframe_frozen_below_threshold_uses_the_fixed_refusal( + mock_get_snapshot, mock_decrypt, mock_get_settings +): + """The frozen count is gated with the same fixed text as the live path — the threshold + is read live, so an operator raising their floor bites already-approved projects.""" + mock_decrypt.return_value = "my_project" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 + mock_get_snapshot.return_value = _snapshot(pd.DataFrame({"accession_id": ["A1"]})) + + response = client.post("/cohort/dataframe", json=sample_dataframe_query, headers=AUTH_HEADERS) + + assert response.status_code == 403 + assert response.json()["detail"] == _BELOW_THRESHOLD_DETAIL + + +@pytest.mark.parametrize("path", ["/cohort/dataframe", "/cohort/accession-ids"]) +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.get_snapshot") +@patch("data_access_api.routers.cohort.validate_query") +@patch("data_access_api.routers.cohort.get_records") +def test_row_level_routes_refuse_projects_without_a_snapshot( + mock_get_records, mock_validate_query, mock_get_snapshot, mock_decrypt, path +): + """No snapshot ⇒ no row-level data, fail-closed: there is no live-SQL serving path.""" + mock_decrypt.return_value = "my_project" + mock_get_snapshot.return_value = None + + response = client.post(path, json=sample_dataframe_query, headers=AUTH_HEADERS) + + assert response.status_code == 403 + assert response.json()["detail"] == _NO_SNAPSHOT_DETAIL + mock_validate_query.assert_not_called() + mock_get_records.assert_not_called() + + +# --------------------------------------------------------------------------- +# Frozen serving on /cohort/accession-ids +# --------------------------------------------------------------------------- + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.get_snapshot") +@patch("data_access_api.routers.cohort.get_records") +def test_accession_ids_serves_frozen_pointer_set(mock_get_records, mock_get_snapshot, mock_decrypt, mock_get_settings): + mock_decrypt.return_value = "my_project" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 2 + mock_get_snapshot.return_value = _snapshot(pd.DataFrame({"accession_id": [101, 102], "label": [0, 1]})) + + response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) + + assert response.status_code == 200 + assert response.json() == {"accession_ids": ["101", "102"]} + mock_get_records.assert_not_called() + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.get_snapshot") +def test_accession_ids_tabular_snapshot_returns_empty_list_not_an_error( + mock_get_snapshot, mock_decrypt, mock_get_settings +): + """A frozen cohort with no accession_id column is a tabular project: imaging no-ops.""" + mock_decrypt.return_value = "my_project" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 2 + mock_get_snapshot.return_value = _snapshot(pd.DataFrame({"person_id": [1, 2, 3], "label": [0, 1, 0]})) + + response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) + + assert response.status_code == 200 + assert response.json() == {"accession_ids": []} + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.get_snapshot") +def test_accession_ids_frozen_below_threshold_is_indistinguishable_from_zero( + mock_get_snapshot, mock_decrypt, mock_get_settings +): + mock_decrypt.return_value = "my_project" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 + + details = [] + for rows in (0, 9): + frozen = pd.DataFrame({"accession_id": [f"A{i}" for i in range(rows)]}) + mock_get_snapshot.return_value = _snapshot(frozen) + response = client.post("/cohort/accession-ids", json=sample_dataframe_query, headers=AUTH_HEADERS) + assert response.status_code == 403 + details.append(response.json()["detail"]) + + assert details[0] == details[1] == _BELOW_THRESHOLD_DETAIL + + +# --------------------------------------------------------------------------- +# POST /cohort/snapshot (creation) and /cohort/snapshot/delete +# --------------------------------------------------------------------------- + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.save_snapshot") +@patch("data_access_api.routers.cohort.get_records") +@patch("data_access_api.routers.cohort.validate_query") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.snapshot_enabled") +def test_create_snapshot_freezes_the_validated_query_result( + mock_snapshot_enabled, mock_decrypt, mock_validate_query, mock_get_records, mock_save_snapshot, mock_get_settings +): + mock_snapshot_enabled.return_value = True + mock_decrypt.return_value = "8b2e9d6e-5a53-4f2e-9c37-2c8f4f0f2d11" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 2 + frozen = pd.DataFrame({"accession_id": ["A1", "A2", "A3"]}) + mock_get_records.return_value = frozen + mock_save_snapshot.return_value = SnapshotMeta( + row_count=3, + columns=["accession_id"], + query_hash=normalised_query_hash(FROZEN_QUERY), + created_at="2026-08-26T00:00:00+00:00", + ) + + response = client.post("/cohort/snapshot", json=sample_dataframe_query, headers=WRITE_AUTH_HEADERS) + + assert response.status_code == 200 + payload = response.json() + assert payload["row_count"] == 3 + assert payload["has_accessions"] is True + assert payload["query_hash"] == normalised_query_hash(FROZEN_QUERY) + # The frame is what validate_query's emission produced, read FRESH (use_cache=False — + # a re-approval must not re-freeze the stale frame the statistics run cached); + # the hash is of the RAW query. + mock_get_records.assert_called_once_with(mock_validate_query.return_value, use_cache=False) + mock_save_snapshot.assert_called_once() + assert mock_save_snapshot.call_args.kwargs["query_hash"] == normalised_query_hash(FROZEN_QUERY) + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.save_snapshot") +@patch("data_access_api.routers.cohort.get_records") +@patch("data_access_api.routers.cohort.validate_query") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.snapshot_enabled") +def test_create_snapshot_below_threshold_persists_nothing( + mock_snapshot_enabled, mock_decrypt, mock_validate_query, mock_get_records, mock_save_snapshot, mock_get_settings +): + mock_snapshot_enabled.return_value = True + mock_decrypt.return_value = "8b2e9d6e-5a53-4f2e-9c37-2c8f4f0f2d11" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 10 + mock_get_records.return_value = pd.DataFrame({"accession_id": ["A1"]}) + + response = client.post("/cohort/snapshot", json=sample_dataframe_query, headers=WRITE_AUTH_HEADERS) + + assert response.status_code == 403 + assert response.json()["detail"] == _BELOW_THRESHOLD_DETAIL + mock_save_snapshot.assert_not_called() + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.save_snapshot") +@patch("data_access_api.routers.cohort.get_records") +@patch("data_access_api.routers.cohort.validate_query") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.snapshot_enabled") +def test_create_snapshot_oversize_returns_413_without_detail_leakage( + mock_snapshot_enabled, mock_decrypt, mock_validate_query, mock_get_records, mock_save_snapshot, mock_get_settings +): + mock_snapshot_enabled.return_value = True + mock_decrypt.return_value = "8b2e9d6e-5a53-4f2e-9c37-2c8f4f0f2d11" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 2 + mock_get_records.return_value = pd.DataFrame({"accession_id": ["A1", "A2", "A3"]}) + mock_save_snapshot.side_effect = SnapshotTooLarge("snapshot is 999 bytes, over the 10-byte limit") + + response = client.post("/cohort/snapshot", json=sample_dataframe_query, headers=WRITE_AUTH_HEADERS) + + assert response.status_code == 413 + assert "byte" not in response.json()["detail"] # category-only, no internals + + +@patch("data_access_api.routers.cohort.snapshot_enabled") +def test_create_snapshot_store_disabled_returns_503(mock_snapshot_enabled): + mock_snapshot_enabled.return_value = False + response = client.post("/cohort/snapshot", json=sample_dataframe_query, headers=WRITE_AUTH_HEADERS) + assert response.status_code == 503 + + +@patch("data_access_api.routers.cohort.get_settings") +@patch("data_access_api.routers.cohort.save_snapshot") +@patch("data_access_api.routers.cohort.get_records") +@patch("data_access_api.routers.cohort.validate_query") +@patch("data_access_api.routers.cohort.decrypt") +@patch("data_access_api.routers.cohort.snapshot_enabled") +def test_create_snapshot_non_uuid_project_id_returns_400( + mock_snapshot_enabled, mock_decrypt, mock_validate_query, mock_get_records, mock_save_snapshot, mock_get_settings +): + mock_snapshot_enabled.return_value = True + mock_decrypt.return_value = "not-a-uuid" + mock_get_settings.return_value.COHORT_QUERY_THRESHOLD = 2 + mock_get_records.return_value = pd.DataFrame({"accession_id": ["A1", "A2", "A3"]}) + mock_save_snapshot.side_effect = ValueError("project_id must be a UUID") + + response = client.post("/cohort/snapshot", json=sample_dataframe_query, headers=WRITE_AUTH_HEADERS) + + assert response.status_code == 400 + + +@patch("data_access_api.routers.cohort.delete_snapshot") +@patch("data_access_api.routers.cohort.decrypt") +def test_delete_snapshot_route_is_idempotent(mock_decrypt, mock_delete_snapshot): + mock_decrypt.return_value = "8b2e9d6e-5a53-4f2e-9c37-2c8f4f0f2d11" + mock_delete_snapshot.side_effect = [True, False] + + first = client.post("/cohort/snapshot/delete", json={"encrypted_project_id": "enc"}, headers=WRITE_AUTH_HEADERS) + second = client.post("/cohort/snapshot/delete", json={"encrypted_project_id": "enc"}, headers=WRITE_AUTH_HEADERS) + + assert first.json() == {"deleted": True} + assert second.json() == {"deleted": False} + + +# (Auth coverage for the snapshot routes lives in test_cohort.py: the parametrised +# missing-key / wrong-key tests cover the trust-internal gate alongside every other +# /cohort route, and the cohort-admin tests cover the extra AES-possession gate the +# WRITE routes carry — a valid trust-internal key without the proof is refused 403.) diff --git a/trust/data-access-api/tests/services/test_cohort.py b/trust/data-access-api/tests/services/test_cohort.py index fe83147de..66cb78940 100644 --- a/trust/data-access-api/tests/services/test_cohort.py +++ b/trust/data-access-api/tests/services/test_cohort.py @@ -171,6 +171,47 @@ def test_get_statistics_genuine_zero_is_suppressed(mock_read_sql): assert stats.suppressed is True +@patch("pandas.read_sql") +def test_get_records_use_cache_false_bypasses_a_poisoned_cache(mock_read_sql): + """Snapshot creation must freeze LIVE OMOP: a cached frame (e.g. from the statistics + run at submission) is skipped when use_cache=False, and the fresh read replaces it.""" + import pandas as pd + + from data_access_api.services.cohort import get_records + from data_access_api.services.query_cache import get_cached_result, set_cached_result + + query = "SELECT person_id FROM omop.person" + stale = pd.DataFrame({"person_id": [1, 2]}) + fresh = pd.DataFrame({"person_id": [1]}) # a person was removed from OMOP since + set_cached_result(query, stale) + mock_read_sql.return_value = fresh + + result = get_records(query, use_cache=False) + + pd.testing.assert_frame_equal(result, fresh) + mock_read_sql.assert_called_once() + # The fresh read updates the cache, so subsequent cached reads see the new state too. + pd.testing.assert_frame_equal(get_cached_result(query), fresh) + + +@patch("pandas.read_sql") +def test_get_records_serves_cache_by_default(mock_read_sql): + """Default behaviour is unchanged: a cached frame short-circuits the database read.""" + import pandas as pd + + from data_access_api.services.cohort import get_records + from data_access_api.services.query_cache import set_cached_result + + query = "SELECT person_id FROM omop.person" + cached = pd.DataFrame({"person_id": [1, 2]}) + set_cached_result(query, cached) + + result = get_records(query) + + pd.testing.assert_frame_equal(result, cached) + mock_read_sql.assert_not_called() + + @patch("pandas.read_sql") def test_get_records_undefined_table_error(mock_read_sql): """ diff --git a/trust/data-access-api/tests/services/test_cohort_snapshot.py b/trust/data-access-api/tests/services/test_cohort_snapshot.py new file mode 100644 index 000000000..fc3899a96 --- /dev/null +++ b/trust/data-access-api/tests/services/test_cohort_snapshot.py @@ -0,0 +1,174 @@ +# Copyright (c) Guy's and St Thomas' NHS Foundation Trust & King's College London +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Unit tests for the approved-cohort snapshot file store (FLIP#857).""" + +import json +import uuid +from unittest.mock import patch + +import pandas as pd +import pytest + +from data_access_api.services import cohort_snapshot +from data_access_api.services.cohort_snapshot import ( + SnapshotStoreDisabled, + SnapshotTooLarge, + delete_snapshot, + ensure_store, + get_snapshot, + normalised_query_hash, + save_snapshot, + snapshot_enabled, +) + +PROJECT_ID = "8b2e9d6e-5a53-4f2e-9c37-2c8f4f0f2d11" +QUERY_HASH = normalised_query_hash("SELECT * FROM omop.person") + + +@pytest.fixture +def store(tmp_path): + """A configured, writable snapshot store rooted in a per-test temp directory.""" + with patch("data_access_api.services.cohort_snapshot.get_settings") as mock_settings: + mock_settings.return_value.COHORT_SNAPSHOT_DIR = str(tmp_path) + mock_settings.return_value.SNAPSHOT_MAX_BYTES = 536_870_912 + yield tmp_path + + +def _sample_df() -> pd.DataFrame: + return pd.DataFrame( + { + "accession_id": ["A1", "A2", "A3"], + "age": pd.array([40, None, 61], dtype="Int64"), + "measured_at": pd.to_datetime(["2021-01-01", "2022-06-30", "2023-12-31"]), + } + ) + + +def test_save_then_get_round_trips_the_frame_dtype_faithfully(store): + df = _sample_df() + meta = save_snapshot(PROJECT_ID, df, query_hash=QUERY_HASH) + + snapshot = get_snapshot(PROJECT_ID) + assert snapshot is not None + pd.testing.assert_frame_equal(snapshot.df, df) + assert snapshot.meta.row_count == 3 + assert snapshot.meta.columns == ["accession_id", "age", "measured_at"] + assert snapshot.meta.query_hash == QUERY_HASH + assert snapshot.meta.has_accessions is True + assert meta.row_count == 3 + + +def test_save_overwrites_atomically_on_reapproval(store): + save_snapshot(PROJECT_ID, _sample_df(), query_hash=QUERY_HASH) + replacement = pd.DataFrame({"person_id": [1, 2]}) + save_snapshot(PROJECT_ID, replacement, query_hash=normalised_query_hash("SELECT person_id FROM omop.person")) + + snapshot = get_snapshot(PROJECT_ID) + assert snapshot is not None + pd.testing.assert_frame_equal(snapshot.df, replacement) + assert snapshot.meta.has_accessions is False + # No write debris left behind after the swap. + leftovers = [p.name for p in store.iterdir() if p.name.startswith((".tmp-", ".old-"))] + assert leftovers == [] + + +def test_get_returns_none_for_unknown_project_and_non_uuid_ids(store): + assert get_snapshot(str(uuid.uuid4())) is None + # A non-UUID id must never touch the filesystem — it is a path component. + assert get_snapshot("../../etc/passwd") is None + assert get_snapshot("my_project") is None + + +def test_save_rejects_non_uuid_project_id(store): + with pytest.raises(ValueError, match="UUID"): + save_snapshot("../escape", _sample_df(), query_hash=QUERY_HASH) + assert list(store.iterdir()) == [] + + +def test_save_refuses_oversized_snapshot_without_truncating(store): + with patch("data_access_api.services.cohort_snapshot.get_settings") as mock_settings: + mock_settings.return_value.COHORT_SNAPSHOT_DIR = str(store) + mock_settings.return_value.SNAPSHOT_MAX_BYTES = 10 + with pytest.raises(SnapshotTooLarge, match="SNAPSHOT_MAX_BYTES"): + save_snapshot(PROJECT_ID, _sample_df(), query_hash=QUERY_HASH) + # Refused means nothing persisted — no partial artefact to poison training data. + assert get_snapshot(PROJECT_ID) is None + + +def test_disabled_store_reads_none_and_refuses_writes(): + with patch("data_access_api.services.cohort_snapshot.get_settings") as mock_settings: + mock_settings.return_value.COHORT_SNAPSHOT_DIR = "" + assert snapshot_enabled() is False + assert get_snapshot(PROJECT_ID) is None + assert delete_snapshot(PROJECT_ID) is False + with pytest.raises(SnapshotStoreDisabled): + save_snapshot(PROJECT_ID, _sample_df(), query_hash=QUERY_HASH) + + +def test_corrupt_meta_is_treated_as_absent(store): + save_snapshot(PROJECT_ID, _sample_df(), query_hash=QUERY_HASH) + (store / PROJECT_ID / "meta.json").write_text("{not json") + assert get_snapshot(PROJECT_ID) is None + + +def test_unknown_format_version_is_treated_as_absent(store): + save_snapshot(PROJECT_ID, _sample_df(), query_hash=QUERY_HASH) + meta_path = store / PROJECT_ID / "meta.json" + meta = json.loads(meta_path.read_text()) + meta["format_version"] = 999 + meta_path.write_text(json.dumps(meta)) + assert get_snapshot(PROJECT_ID) is None + + +def test_delete_is_idempotent(store): + save_snapshot(PROJECT_ID, _sample_df(), query_hash=QUERY_HASH) + assert delete_snapshot(PROJECT_ID) is True + assert get_snapshot(PROJECT_ID) is None + assert delete_snapshot(PROJECT_ID) is False + + +def test_ensure_store_sweeps_stale_write_debris(store): + save_snapshot(PROJECT_ID, _sample_df(), query_hash=QUERY_HASH) + (store / ".tmp-crashed-write").mkdir() + (store / ".old-crashed-swap").mkdir() + + ensure_store() + + survivors = sorted(p.name for p in store.iterdir()) + assert survivors == [PROJECT_ID] + assert get_snapshot(PROJECT_ID) is not None + + +def test_ensure_store_survives_unwritable_directory(tmp_path): + target = tmp_path / "readonly" + target.mkdir() + target.chmod(0o500) + try: + with patch("data_access_api.services.cohort_snapshot.get_settings") as mock_settings: + mock_settings.return_value.COHORT_SNAPSHOT_DIR = str(target) + # Must log and return, never raise: a broken store cannot take OMOP serving down. + ensure_store() + finally: + target.chmod(0o700) + + +def test_normalised_query_hash_ignores_case_and_whitespace_only(): + base = normalised_query_hash("SELECT * FROM omop.person") + assert normalised_query_hash(" select *\n FROM omop.person ") == base + assert normalised_query_hash("SELECT person_id FROM omop.person") != base + + +def test_hash_key_matches_module_constant_shape(): + # cohort_snapshot deliberately does not import query_cache: pin that its normalisation + # stays self-contained and deterministic. + assert len(cohort_snapshot.normalised_query_hash("x")) == 64 diff --git a/trust/data-access-api/uv.lock b/trust/data-access-api/uv.lock index 106973c06..3067cc170 100644 --- a/trust/data-access-api/uv.lock +++ b/trust/data-access-api/uv.lock @@ -282,6 +282,7 @@ dependencies = [ { name = "idna" }, { name = "pandas" }, { name = "psycopg2-binary" }, + { name = "pyarrow" }, { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "sqlalchemy" }, @@ -307,6 +308,7 @@ requires-dist = [ { name = "idna", specifier = ">=3.15" }, { name = "pandas", specifier = ">=2.2.3" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "pyarrow", specifier = ">=19.0.0" }, { name = "pydantic-settings", specifier = ">=2.9.1" }, { name = "python-multipart", specifier = ">=0.0.26" }, { name = "sqlalchemy", specifier = ">=2.0.39" }, @@ -877,6 +879,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, ] +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, +] + [[package]] name = "pycparser" version = "3.0" diff --git a/trust/deploy/compose.test.yml b/trust/deploy/compose.test.yml index a5dec7f1e..18ad78f31 100644 --- a/trust/deploy/compose.test.yml +++ b/trust/deploy/compose.test.yml @@ -82,6 +82,9 @@ services: # sex/age distribution assertions test the real per-bucket counts rather # than always rolling into the "Other" bucket. COHORT_QUERY_THRESHOLD: "5" + # Approved-cohort snapshot store (FLIP#857). /tmp is writable by the container + # user and ephemeral per run — each pytest session gets a clean store. + COHORT_SNAPSHOT_DIR: /tmp/snapshots ports: - "8000" volumes: diff --git a/trust/deploy/compose_trust.development.yml b/trust/deploy/compose_trust.development.yml index dac7ecaa9..887fe3392 100644 --- a/trust/deploy/compose_trust.development.yml +++ b/trust/deploy/compose_trust.development.yml @@ -175,6 +175,9 @@ services: # kit file to release less. The `:-10` keeps an unset variable from arriving as an # empty string, which pydantic would reject against `int`. COHORT_QUERY_THRESHOLD: ${COHORT_QUERY_THRESHOLD:-10} + # Approved-cohort snapshot store (FLIP#857): the container-side path of the bind + # mount below. Fixed here; the HOST side is the kit's COHORT_SNAPSHOT_STORAGE_DIR. + COHORT_SNAPSHOT_DIR: /snapshots # Dev-on-pull (see `user:` above): import via PYTHONPATH, skip the editable # install (UV_NO_SYNC), keep uv's cache + HOME writable. HOME: /tmp @@ -185,6 +188,11 @@ services: - ./data-access-api/data_access_api:/app/data_access_api - ./data-access-api/tests:/app/tests - ./observability/log_config:/app/log_config:ro + # Approved-cohort snapshots: per-trust host dir from the kit's Host-local profile + # (pre-created by `make up-trust` so it is host-user-owned, not docker-root-owned — + # the service runs non-root and disables the store if it cannot write). Holds + # row-level patient data; gitignored, never committed. + - ${COHORT_SNAPSHOT_STORAGE_DIR:-./data-access-api/.snapshots/Trust_${TRUST_NUMBER}}:/snapshots trust-api: # Pull from GHCR by default; local trust_api/ bind-mount overlays it for live diff --git a/trust/deploy/compose_trust.production.yml b/trust/deploy/compose_trust.production.yml index 1a62ac6a8..2c32577a6 100644 --- a/trust/deploy/compose_trust.production.yml +++ b/trust/deploy/compose_trust.production.yml @@ -139,6 +139,15 @@ services: # kit file to release less. The `:-10` keeps an unset variable from arriving as an # empty string, which pydantic would reject against `int`. COHORT_QUERY_THRESHOLD: ${COHORT_QUERY_THRESHOLD:-10} + # Approved-cohort snapshot store (FLIP#857): the container-side path of the bind + # mount below. Fixed here; the HOST side is the kit's COHORT_SNAPSHOT_STORAGE_DIR. + COHORT_SNAPSHOT_DIR: /snapshots + volumes: + # Approved-cohort snapshots: host dir from the kit's Host-local profile, pre-created + # host-user-owned by `make up-trust` (the service runs non-root and disables the + # store if it cannot write). Holds row-level patient data — include it in the + # trust's backup/retention practice alongside the OMOP data dir. + - ${COHORT_SNAPSHOT_STORAGE_DIR:-./data-access-api/.snapshots/trust}:/snapshots trust-api: image: ghcr.io/londonaicentre/trust-api:${DOCKER_TAG} diff --git a/trust/imaging-api/imaging_api/services/retrieval.py b/trust/imaging-api/imaging_api/services/retrieval.py index 586178d76..96ad483d2 100644 --- a/trust/imaging-api/imaging_api/services/retrieval.py +++ b/trust/imaging-api/imaging_api/services/retrieval.py @@ -79,16 +79,17 @@ async def retrieve_images_for_project(project_id: str, query: str, headers: XNAT """ # Check if project exists try: - get_project(project_id, headers) + project = get_project(project_id, headers) except NotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - # Get accession IDs from data access API. The endpoint projects the cohort - # query to the accession_id column server-side, so no other columns are - # transmitted across the trust boundary. - encrypted_project_id = encrypt(project_id) + # Get accession IDs from data access API — the frozen approved-cohort pointer set + # (FLIP#857), keyed on the HUB project id. XNAT projects mint their own uuid and stash + # the hub id in secondary_ID, so that is what data-access-api's snapshot store is + # keyed on; sending the XNAT id would never match a snapshot. + encrypted_project_id = encrypt(project.secondary_ID) try: accession_ids: list[str] = await get_accession_ids(encrypted_project_id, query) except CohortBelowThresholdError: @@ -185,17 +186,21 @@ async def get_import_status(project_id: str, query: str, headers: XNATAuthHeader ImportStatus: An object containing the status of study imports. Raises: - HTTPException: 403 if the cohort has fallen below the trust's ``COHORT_QUERY_THRESHOLD`` - so its accession IDs cannot be released — note the cohort query is re-run against - live OMOP on every status check, so a project that imported cleanly can start - refusing later if its cohort shrinks (see FLIP#857). Otherwise, if the request - cannot be processed. + HTTPException: 403 if the frozen cohort is below the trust's ``COHORT_QUERY_THRESHOLD`` + (or the project has no approved-cohort snapshot) so its accession IDs cannot be + released. Otherwise, if the request cannot be processed. """ - # Encrypt project ID to send to the data access API - encrypted_project_id = encrypt(project_id) + # Resolve the HUB project id: data-access-api serves the frozen approved-cohort + # snapshot (FLIP#857) keyed on it, and the XNAT project stores it as secondary_ID. + try: + project = get_project(project_id, headers) + except NotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + encrypted_project_id = encrypt(project.secondary_ID) - # Get accession IDs from data access API (server-side projection — no other - # cohort columns leave the trust). + # Get accession IDs from data access API — the frozen pointer set; the query travels + # only as an advisory field (the snapshot serve ignores it), so the status poll no + # longer re-runs cohort SQL against live OMOP. try: accession_ids: list[str] = await get_accession_ids(encrypted_project_id, query) except CohortBelowThresholdError as e: diff --git a/trust/imaging-api/imaging_api/services_external/data_access.py b/trust/imaging-api/imaging_api/services_external/data_access.py index 1607070dd..f0941433c 100644 --- a/trust/imaging-api/imaging_api/services_external/data_access.py +++ b/trust/imaging-api/imaging_api/services_external/data_access.py @@ -72,13 +72,18 @@ async def get_accession_ids(encrypted_project_id: str, query: str) -> list[str]: except httpx.HTTPStatusError as exc: if exc.response.status_code == httpx.codes.FORBIDDEN: - # A deliberate refusal, not a failure: the cohort is below the trust's disclosure - # threshold. Typed separately so callers can report it as a settled outcome rather - # than as a transport error they might retry. - message = ( - "get_accession_ids: the Data Access API refused to release accession IDs — " - "the cohort is below the trust's minimum size." - ) + # A deliberate refusal, not a failure — typed separately so callers can report it + # as a settled outcome rather than as a transport error they might retry. Two + # policy refusals arrive as 403: the frozen cohort is below the trust's disclosure + # threshold, or the project has no approved-cohort snapshot yet (FLIP#857 — + # e.g. imaging creation raced ahead of the snapshot task). Both mean "no + # identifiers releasable right now"; relay data-access-api's own detail so the + # two stay distinguishable in status reporting. + try: + detail = exc.response.json().get("detail", "") + except ValueError: + detail = "" + message = f"get_accession_ids: the Data Access API refused to release accession IDs — {detail}" logger.warning(message) raise CohortBelowThresholdError(message) from exc error_message = f"get_accession_ids: HTTP error occurred while calling the Data Access API: {exc}" diff --git a/trust/imaging-api/tests/services/test_retrieval.py b/trust/imaging-api/tests/services/test_retrieval.py index 75c8dc9f4..cbf220637 100644 --- a/trust/imaging-api/tests/services/test_retrieval.py +++ b/trust/imaging-api/tests/services/test_retrieval.py @@ -133,10 +133,13 @@ async def test_retrieve_images_below_threshold_queues_nothing( mock_queue.assert_not_called() +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") -async def test_get_import_status_below_threshold_raises_403(mock_encrypt, mock_get_accession_ids, headers): +async def test_get_import_status_below_threshold_raises_403( + mock_encrypt, mock_get_accession_ids, mock_get_project, headers +): """The status path has a caller waiting, so the refusal must reach it as a 403 with a readable reason — trust-api relays this detail to the hub for the per-trust status.""" mock_encrypt.return_value = "encrypted_id" @@ -256,12 +259,13 @@ async def fake_session(): ) +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_all_successful( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): mock_encrypt.return_value = "encrypted_id" mock_get_accession_ids.return_value = ["ACC1", "ACC2"] @@ -282,12 +286,13 @@ async def test_get_import_status_all_successful( assert status.queue_failed == [] +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_mixed( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): mock_encrypt.return_value = "encrypted_id" mock_get_accession_ids.return_value = ["ACC_OK", "ACC_EXEC", "ACC_QUEUED", "ACC_UNKNOWN"] @@ -313,12 +318,13 @@ async def test_get_import_status_mixed( assert status.queue_failed == ["ACC_UNKNOWN"] +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_no_experiments( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): mock_encrypt.return_value = "encrypted_id" mock_get_accession_ids.return_value = ["ACC1"] @@ -345,12 +351,13 @@ def _direct_archive(accession_number: str, status: str) -> DirectArchiveSession: ) +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_executed_failed_is_failed( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): """A PACS retrieval that errored (executed status FAILED) is `failed`, not `processing`.""" mock_encrypt.return_value = "encrypted_id" @@ -366,12 +373,13 @@ async def test_get_import_status_executed_failed_is_failed( assert status.queue_failed == [] +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_direct_archive_error_is_failed( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): """A directArchive build that errored (status ERROR) is `failed`, not `queue_failed`/`processing`.""" mock_encrypt.return_value = "encrypted_id" @@ -389,12 +397,13 @@ async def test_get_import_status_direct_archive_error_is_failed( assert status.queue_failed == [] +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_direct_archive_error_overrides_received( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): """A study whose transfer RECEIVED but whose directArchive build then errored is `failed`. @@ -415,12 +424,13 @@ async def test_get_import_status_direct_archive_error_overrides_received( assert status.processing == [] +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_direct_archive_receiving_is_processing( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): """A directArchive build still in progress (RECEIVING) is `processing`, not `failed`/`queue_failed`.""" mock_encrypt.return_value = "encrypted_id" @@ -438,12 +448,13 @@ async def test_get_import_status_direct_archive_receiving_is_processing( assert status.queue_failed == [] +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_direct_archive_error_without_name_falls_back_to_folder_name( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): """A directArchive ERROR whose `name` is NULL is still attributed via its folder_name. @@ -467,12 +478,13 @@ async def test_get_import_status_direct_archive_error_without_name_falls_back_to assert status.queue_failed == [] +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_failed_precedes_queued( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): """A terminal-failed accession that also has a (re-)queued row is reported `failed`, not `queued`.""" mock_encrypt.return_value = "encrypted_id" @@ -490,12 +502,13 @@ async def test_get_import_status_failed_precedes_queued( assert status.queued == [] +@patch("imaging_api.services.retrieval.get_project") @pytest.mark.asyncio @patch("imaging_api.services.retrieval.get_experiments") @patch("imaging_api.services.retrieval.get_accession_ids", new_callable=AsyncMock) @patch("imaging_api.services.retrieval.encrypt") async def test_get_import_status_experiment_present_beats_stale_direct_archive_error( - mock_encrypt, mock_get_accession_ids, mock_get_experiments, headers, + mock_encrypt, mock_get_accession_ids, mock_get_experiments, mock_get_project, headers, ): """`successful` (archived as an experiment) wins over a stale directArchive ERROR for the same accession.""" mock_encrypt.return_value = "encrypted_id" diff --git a/trust/imaging-api/tests/services_external/test_data_access.py b/trust/imaging-api/tests/services_external/test_data_access.py index 26737899c..765c1cb7e 100644 --- a/trust/imaging-api/tests/services_external/test_data_access.py +++ b/trust/imaging-api/tests/services_external/test_data_access.py @@ -80,10 +80,10 @@ async def test_http_error_raises_runtime_error(self, mock_client_cls): await get_accession_ids("encrypted-proj-id", "SELECT * FROM cohort") @staticmethod - def _client_returning_status(mock_client_cls, status_code: int): + def _client_returning_status(mock_client_cls, status_code: int, json_body: dict | None = None): """Wires the mocked client so ``raise_for_status`` raises for ``status_code``.""" request = httpx.Request("POST", "http://data-access-api/cohort/accession-ids") - response = httpx.Response(status_code, request=request) + response = httpx.Response(status_code, request=request, json=json_body) mock_response = MagicMock() mock_response.raise_for_status = MagicMock( @@ -98,15 +98,28 @@ def _client_returning_status(mock_client_cls, status_code: int): @pytest.mark.asyncio @patch("imaging_api.services_external.data_access.httpx.AsyncClient") - async def test_403_raises_cohort_below_threshold(self, mock_client_cls): - """A 403 is the trust refusing to release identifiers for a too-small cohort. + async def test_403_raises_cohort_below_threshold_with_relayed_detail(self, mock_client_cls): + """A 403 is the trust refusing to release identifiers — a policy decision, not a failure. It must be typed distinctly from a transport failure: callers report it as a settled - outcome rather than an error to retry, and retrying cannot change the answer. + outcome rather than an error to retry, and retrying cannot change the answer. Two + refusals arrive this way (below-threshold cohort, or no approved-cohort snapshot — + FLIP#857), so data-access-api's own detail is relayed to keep them distinguishable. """ + self._client_returning_status( + mock_client_cls, 403, json_body={"detail": "Cohort is too small for row-level data to be released."} + ) + + with pytest.raises(CohortBelowThresholdError, match="Cohort is too small"): + await get_accession_ids("encrypted-proj-id", "SELECT * FROM cohort") + + @pytest.mark.asyncio + @patch("imaging_api.services_external.data_access.httpx.AsyncClient") + async def test_403_without_json_body_still_raises_typed_refusal(self, mock_client_cls): + """A body-less 403 must not crash the detail relay — the typed refusal still surfaces.""" self._client_returning_status(mock_client_cls, 403) - with pytest.raises(CohortBelowThresholdError, match="below the trust's minimum size"): + with pytest.raises(CohortBelowThresholdError, match="refused to release accession IDs"): await get_accession_ids("encrypted-proj-id", "SELECT * FROM cohort") @pytest.mark.asyncio diff --git a/trust/trust-api/tests/integration/conftest.py b/trust/trust-api/tests/integration/conftest.py index c10d7465a..235121189 100644 --- a/trust/trust-api/tests/integration/conftest.py +++ b/trust/trust-api/tests/integration/conftest.py @@ -71,6 +71,10 @@ def compose_stack() -> Generator[DockerCompose, None, None]: # Wait for the compose-level healthchecks rather than `wait_for_logs` — # logs are noisy and fragile across image versions. wait=True, + # Rebuild the from-source service on every session: without it, `docker compose up` + # reuses a previously built image, so a dependency change (pyproject/uv.lock) never + # reaches the stack and the suite green-lights code that cannot run in a fresh build. + build=True, ) as compose: yield compose diff --git a/trust/trust-api/tests/services/test_task_handlers.py b/trust/trust-api/tests/services/test_task_handlers.py index 1a00d785d..da2129235 100644 --- a/trust/trust-api/tests/services/test_task_handlers.py +++ b/trust/trust-api/tests/services/test_task_handlers.py @@ -10,12 +10,16 @@ # limitations under the License. # +import hashlib +import json from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest from trust_api.services.task_handlers import ( + AES_KEY_BASE64, + COHORT_ADMIN_KEY_HEADER, TASK_HANDLERS, TRUST_INTERNAL_SERVICE_KEY, TRUST_INTERNAL_SERVICE_KEY_HEADER, @@ -23,6 +27,7 @@ handle_create_imaging, handle_delete_imaging, handle_get_imaging_status, + handle_persist_cohort, handle_reimport_studies, handle_update_user_profile, ) @@ -35,6 +40,16 @@ def _assert_trust_internal_auth_header(call_args) -> None: assert headers.get(TRUST_INTERNAL_SERVICE_KEY_HEADER) == TRUST_INTERNAL_SERVICE_KEY +def _assert_cohort_admin_header(call_args) -> None: + """A cohort-DEFINING write (snapshot create/delete) must additionally carry the + cohort-admin proof: the SHA-256 of AES_KEY_BASE64, never the key itself (FLIP#857).""" + headers = call_args.kwargs.get("headers") or {} + expected = hashlib.sha256(AES_KEY_BASE64.encode()).hexdigest() + assert headers.get(COHORT_ADMIN_KEY_HEADER) == expected + # Defence in depth: the raw key must never travel as a header value. + assert AES_KEY_BASE64 not in headers.values() + + @pytest.fixture def mock_make_request(): with patch("trust_api.services.task_handlers.make_request", new_callable=AsyncMock) as mock: @@ -53,6 +68,7 @@ def test_task_handlers_registry(): "get_imaging_status", "reimport_studies", "update_user_profile", + "persist_cohort", } assert set(TASK_HANDLERS.keys()) == expected_types @@ -327,3 +343,67 @@ async def test_handle_update_user_profile_error(mock_make_request): assert result["success"] is False assert "Service unavailable" in result["error"] + + +# ---- Persist cohort handler (FLIP#857) ---- + + +@pytest.mark.asyncio +async def test_handle_persist_cohort_freezes_via_data_access_and_returns_facts(mock_make_request): + """Forwards the snapshot request to data-access-api and returns its response as the result.""" + snapshot_facts = { + "row_count": 24, + "columns": ["modality", "accession_id"], + "has_accessions": True, + "snapshot_at": "2026-08-26T00:00:00+00:00", + "query_hash": "abc123", + } + mock_make_request.return_value = snapshot_facts + + payload = { + "project_id": str(uuid4()), + "trust_id": str(uuid4()), + "encrypted_project_id": "enc123", + "query": "SELECT * FROM omop.image_occurrence", + "query_id": str(uuid4()), + } + result = await handle_persist_cohort(payload) + + assert result["success"] is True + assert json.loads(result["result"]) == snapshot_facts + + call = mock_make_request.call_args_list[0] + assert call.kwargs["method"] == "POST" + assert call.kwargs["url"].endswith("/cohort/snapshot") + # Only the encrypted id + query go to data-access-api (its DataframeQuery schema). + assert call.kwargs["json_body"] == {"encrypted_project_id": "enc123", "query": payload["query"]} + _assert_trust_internal_auth_header(call) + # Snapshot creation is a cohort-DEFINING write: it must also carry the cohort-admin proof + # (FLIP#857), which fl-client cannot produce. + _assert_cohort_admin_header(call) + + +@pytest.mark.asyncio +async def test_handle_persist_cohort_invalid_payload(): + """A malformed task payload fails validation before any request is made.""" + result = await handle_persist_cohort({"query": "SELECT 1"}) + + assert result["success"] is False + assert "validation error" in result["error"].lower() + + +@pytest.mark.asyncio +async def test_handle_persist_cohort_reports_data_access_failure(mock_make_request): + """A refused snapshot (e.g. below-threshold 403) marks the task FAILED at the hub.""" + mock_make_request.side_effect = Exception("403: Cohort is too small for row-level data to be released.") + + payload = { + "project_id": str(uuid4()), + "trust_id": str(uuid4()), + "encrypted_project_id": "enc123", + "query": "SELECT * FROM omop.image_occurrence", + } + result = await handle_persist_cohort(payload) + + assert result["success"] is False + assert "too small" in result["error"] diff --git a/trust/trust-api/trust_api/config.py b/trust/trust-api/trust_api/config.py index d457e0995..5804db158 100644 --- a/trust/trust-api/trust_api/config.py +++ b/trust/trust-api/trust_api/config.py @@ -58,6 +58,14 @@ def coerce_empty_env(cls, v: str) -> str: TRUST_INTERNAL_SERVICE_KEY: str = "" TRUST_INTERNAL_SERVICE_KEY_HEADER: str = "X-Trust-Internal-Service-Key" + # Cohort-write authorisation (FLIP#857). data-access-api's snapshot create/delete routes + # require proof of possessing AES_KEY_BASE64 in addition to the trust-internal key. trust-api + # sends the SHA-256 of the key in this header when it forwards an approval-time snapshot + # request; fl-client cannot (it holds no AES key), which is what keeps researcher code from + # rewriting or deleting a project's frozen cohort. Header name must match data-access-api's + # COHORT_ADMIN_KEY_HEADER. + COHORT_ADMIN_KEY_HEADER: str = "X-Cohort-Admin-Key" + # Polling configuration POLL_INTERVAL_SECONDS: int = 5 # How often to poll the hub for tasks (seconds) diff --git a/trust/trust-api/trust_api/routers/schemas.py b/trust/trust-api/trust_api/routers/schemas.py index fd3b76f09..a8ef1f609 100644 --- a/trust/trust-api/trust_api/routers/schemas.py +++ b/trust/trust-api/trust_api/routers/schemas.py @@ -29,6 +29,20 @@ class CohortQueryInput(BaseModel): trust_id: str = Field(..., description="The unique identifier for the trust") +class PersistCohortInput(BaseModel): + """Payload of the approval-time PERSIST_COHORT task (FLIP#857). + + Mirrors flip-api's ``IPersistCohort``: the approved cohort query of record plus the hub + project id, encrypted for forwarding to data-access-api and in the clear for logging. + """ + + project_id: str = Field(..., description="The central hub project id (clear, for logging)") + trust_id: str = Field(..., description="The unique identifier for the trust") + encrypted_project_id: str = Field(..., description="The hub project id, encrypted for data-access-api") + query: str = Field(..., description="The approved cohort SQL to freeze") + query_id: str | None = Field(default=None, description="The hub Queries row being frozen") + + # ######################### # Imaging # ######################### diff --git a/trust/trust-api/trust_api/services/task_handlers.py b/trust/trust-api/trust_api/services/task_handlers.py index b7a558348..09dfafd83 100644 --- a/trust/trust-api/trust_api/services/task_handlers.py +++ b/trust/trust-api/trust_api/services/task_handlers.py @@ -17,6 +17,7 @@ """ import datetime +import hashlib import json from typing import Any @@ -26,6 +27,7 @@ CohortQueryInput, DeleteImagingInput, GetImagingStatusInput, + PersistCohortInput, ReimportStudiesInput, UpdateProfileRequest, ) @@ -39,6 +41,8 @@ TRUST_API_KEY_HEADER = get_settings().TRUST_API_KEY_HEADER TRUST_INTERNAL_SERVICE_KEY = get_settings().TRUST_INTERNAL_SERVICE_KEY TRUST_INTERNAL_SERVICE_KEY_HEADER = get_settings().TRUST_INTERNAL_SERVICE_KEY_HEADER +AES_KEY_BASE64 = get_settings().AES_KEY_BASE64 +COHORT_ADMIN_KEY_HEADER = get_settings().COHORT_ADMIN_KEY_HEADER def trust_internal_headers() -> dict[str, str]: @@ -54,6 +58,22 @@ def trust_internal_headers() -> dict[str, str]: return {TRUST_INTERNAL_SERVICE_KEY_HEADER: TRUST_INTERNAL_SERVICE_KEY} +def cohort_admin_headers() -> dict[str, str]: + """Headers for data-access-api's cohort-DEFINING write routes (snapshot create/delete). + + Those routes require the trust-internal key AND proof of possessing ``AES_KEY_BASE64`` + (FLIP#857) — the second gate is what stops fl-client's researcher code from rewriting or + deleting a project's frozen cohort, since fl-client holds no AES key. The proof is the + SHA-256 of the key, never the key itself, so it stays off the wire and out of logs. Layers + the cohort-admin header on top of the trust-internal one. + + Returns: + dict[str, str]: The trust-internal header plus the cohort-admin proof header. + """ + proof = hashlib.sha256(AES_KEY_BASE64.encode()).hexdigest() + return {**trust_internal_headers(), COHORT_ADMIN_KEY_HEADER: proof} + + # Task type constants — must match TaskType enum in flip-api/src/flip_api/domain/schemas/status.py TASK_COHORT_QUERY = "cohort_query" TASK_CREATE_IMAGING = "create_imaging" @@ -61,6 +81,55 @@ def trust_internal_headers() -> dict[str, str]: TASK_GET_IMAGING_STATUS = "get_imaging_status" TASK_REIMPORT_STUDIES = "reimport_studies" TASK_UPDATE_USER_PROFILE = "update_user_profile" +TASK_PERSIST_COHORT = "persist_cohort" + + +async def handle_persist_cohort(payload: dict[str, Any]) -> dict[str, Any]: + """ + Freeze the approved cohort on this trust (FLIP#857). + + Forwards the approval-time snapshot request to the local data-access-api, which runs + the approved query ONCE and persists the resulting dataframe as the project's frozen + artefact — the only thing the row-level routes serve from then on. The snapshot + response (aggregates only: row count, column names, timestamps) is returned as the + task result verbatim, so the hub can record its frozen-cohort audit row from it. + + Failure (including a below-threshold cohort, which data-access-api refuses with 403) + marks the task FAILED at the hub with the category-only detail — nothing is persisted + trust-side in that case, and the project's row-level routes keep refusing. + + Args: + payload: Task payload matching ``PersistCohortInput``. + + Returns: + dict with success status, the snapshot facts as ``result``, or error details. + """ + logger.info(f"Processing cohort snapshot task: project_id={payload.get('project_id')}") + + try: + request = PersistCohortInput(**payload) + response = await make_request( + method="POST", + url=f"{DATA_ACCESS_API_URL}/cohort/snapshot", + json_body={ + "encrypted_project_id": request.encrypted_project_id, + "query": request.query, + }, + # Snapshot creation is a cohort-DEFINING write: it needs the cohort-admin proof on + # top of the trust-internal key (FLIP#857). + headers=cohort_admin_headers(), + # Snapshot creation runs the full cohort query, so it inherits the cohort + # query's timeout rather than the default request timeout. + timeout_seconds=get_settings().COHORT_QUERY_TIMEOUT_SECONDS, + ) + logger.info( + f"Cohort snapshot persisted for project {request.project_id}: " + f"{response.get('row_count')} rows" # type: ignore[union-attr] + ) + return {"success": True, "result": json.dumps(response)} + except Exception as e: + logger.error(f"Error persisting cohort snapshot: {e}") + return {"success": False, "error": str(e)} async def handle_cohort_query(payload: dict[str, Any]) -> dict[str, Any]: @@ -311,4 +380,5 @@ async def handle_update_user_profile(payload: dict[str, Any]) -> dict[str, Any]: TASK_GET_IMAGING_STATUS: handle_get_imaging_status, TASK_REIMPORT_STUDIES: handle_reimport_studies, TASK_UPDATE_USER_PROFILE: handle_update_user_profile, + TASK_PERSIST_COHORT: handle_persist_cohort, }