Skip to content

fix(rest): refuse system-level search with 501 and stop advertising search-system (#1338) - #1425

Open
smunini wants to merge 3 commits into
mainfrom
fix/1338-reject-system-level-search
Open

smunini wants to merge 3 commits into
mainfrom
fix/1338-reject-system-level-search

Conversation

@smunini

@smunini smunini commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements the decision recorded on #1338: system-level search is refused explicitly for now. GET [base]?… (with or without _type) and POST [base]/_search answer 501 Not Implemented with an OperationOutcome (error / not-supported) that points the client at type-level search, and the CapabilityStatement no longer lists search-system. Implementing system-level search stays deferred.

Fixes #1338

Observed pre-fix behavior (running hfs, SQLite, origin/main 8d12b3a)

Request headless, header_only UI mounted url_path / both
GET /, GET /?_type=Patient, GET /?_type=Patient,Observation&_id=1, GET /?_lastUpdated=gt2020 bare 405, no body 307 -> /ui (query ignored) bare 405 (also under /acme/)
POST /_search (form), GET /_search 404 OperationOutcome "'_search' is not a resource type…" (the #989 gate - not the bare 404 the issue describes) same /acme/_search: same 404. Unprefixed POST /_search: treated as POST / (batch) for a tenant named _search
POST / form body 400 invalid "Invalid JSON" same same
POST / Bundle (batch, transaction) 200 200 200
/metadata rest.interaction transaction, batch, history-system, search-system

The url_path/both row is a real defect, not just an unhelpful status: _search parses as a tenant id, so the prefix stripper rewrote /_search to /. A transaction Bundle POSTed to /_search returned 200 and the Patient was then readable at GET /_search/Patient?family=Leak (total: 1).

Root cause

create_fhir_router registered only post on / and nothing on /_search, while capabilities.rs pushed search-system as an unconditional literal. handlers::search::search_system_handler exists but was never mounted (and cannot be as-is: it needs MultiTypeSearchProvider, which only SQLite and PostgreSQL implement, while the router is generic over every backend).

Fix

  • handlers/search.rs: new search_system_not_supported_handler -> RestError::NotImplemented (501 / not-supported), i.e. the server's normal error rendering. It takes TenantExtractor, so a bad tenant is refused as on every other route. The unrouted search_system_handler is kept as the starting point for the deferred work and documented as not routed, with what it lacks.
  • routing/fhir_routes.rs: GET / and GET|POST /_search route to it. POST / is unchanged (batch/transaction whatever the Content-Type - the spec's POST form of this search is /_search).
  • handlers/capabilities.rs: search-system removed. transaction (capability-driven), batch and history-system untouched; history-system is truthful (GET /_history is routed and the router requires SystemHistoryProvider).
  • middleware/tenant_prefix.rs (outside my stated ownership, one entry): _search added to RESERVED_SYSTEM_PATHS so it is never taken for a URL-path tenant. Without it the new /_search route is unreachable unprefixed under url_path/both, and the Bundle-into-tenant-_search behavior above remains.
  • crates/ui/src/lib.rs + crates/ui/Cargo.toml (outside my stated ownership, minimal): the root redirect (hfs: redirect GET / to /ui so the bare host lands on the web UI (mirroring HTS) #896) now applies to the bare root only; a GET / that carries a query is handed to the FHIR router (tower::ServiceExt::oneshot; tower was already a dev-dependency of helios-ui, Cargo.lock is unchanged). Without this, GET /?_type=Patient keeps redirecting a FHIR client to an HTML page whenever the UI is mounted (the default).
  • Docs: MultiTypeSearchProvider doc note (REST does not route it, System-level search is not routed: GET /?_type=… returns 405, POST /_search returns 404 #1338), crates/rest README / crate docs, run-hfs-server skill endpoint table, search-spec-assessment.md _type row.

Behavior changes clients can observe

  • GET [base] / GET [base]?… : bare 405 -> 501 + OperationOutcome (headless or with a query). With the UI mounted, bare GET / and GET /? still 307 -> /ui; GET /?<anything> is now 501 instead of a redirect.
  • GET|POST [base]/_search: 404 "not a resource type" -> 501 + OperationOutcome.
  • url_path/both: _search can no longer be addressed as a URL-path tenant; POST /_search with a Bundle is refused (501) instead of being committed into tenant _search.
  • /metadata: rest.interaction no longer contains search-system.
  • Unauthenticated requests still get 401 first (auth layer is outside the router) - covered by a test.
  • Unchanged: POST / batch/transaction, form-encoded POST / (400 invalid), type-level search GET/POST, /health, /_history, /ui.

Verification

Ran and passed:

  • cargo test -p helios-rest - 1725 passed, 0 failed (includes the new tests/system_search_not_supported.rs, 9 tests: every rejected form x header_only/url_path/both x default/header/URL-prefix tenant; Bundle to /_search not committed; batch + transaction on POST / in every mode with a positive-control search; /metadata; type-level search; 401-before-501).
  • cargo test -p helios-ui - 726 passed, 0 failed (new root_get_with_a_query_reaches_the_fhir_app; existing root_redirects_to_ui and root_post_still_reaches_the_fhir_batch_handler unchanged).
  • Manual re-run of the probe against the rebuilt hfs (UI mounted / url_path): results as in the table above, plus CORS headers present on the 501.
  • cargo +1.98.1 clippy -p helios-rest -p helios-ui -p helios-persistence --all-targets -- -D warnings <CI allow-list> - clean.
  • cargo +1.98.1 test --workspace --all-features --no-run --exclude pysof - compiles (pre-merge state; the later merge of origin/main brought only persistence seeder/CI changes).
  • cargo fmt -p helios-rest -p helios-ui -p helios-persistence -- --check - clean.
  • After merging origin/main: cargo test -p helios-rest (1725 passed), cargo test -p helios-ui --test router_http (177 passed), cargo check -p helios-hfs -p helios-ui -p helios-hts-ui - all pass.

Attempted, did not complete: running the new test file under --all-features (the disk filled during that rebuild; the all-features compile above had already succeeded). No code in this PR is cfg-gated.

Not run: Playwright/UI e2e (grepped crates/ui/e2e for expectations about GET / / search-system: none), non-SQLite backends (no backend code touched).

Found, not fixed

  • Every RestError is rendered as application/json, ignoring Accept: application/fhir+xml / _format (server-wide; the new 501 behaves like every other error).
  • A batch entry GET ?_type=Patient (system search inside a Bundle) answers 400 value "Entry request.url is empty" - refused, but with a misleading message (handlers/batch.rs, not touched).
  • _user is not a reserved first segment either, so under url_path an unprefixed /_user/settings is read as tenant _user (-> 404). Same class as _search; left alone.
  • crates/persistence/docs/search-spec-assessment.md has other stale rows (_list, _contained); only the _type row was corrected.

Stacking

Branched from origin/main; no stacking. Does not touch handlers/compartment.rs (#1423).

System-level search (`GET [base]?params`, `POST [base]/_search`) was never
routed, yet the CapabilityStatement listed `search-system` unconditionally.
Nothing in `create_fhir_router` claimed `GET /` or `/_search`, so:

- `GET /?_type=Patient` fell through as a bare `405` with no body;
- `[base]/_search` was caught by the resource-type gate and answered
  "'_search' is not a resource type" (404);
- under `url_path`/`both` tenant routing an unprefixed `POST /_search` was
  worse: `_search` parses as a tenant id, so the prefix stripper rewrote it
  to `POST /` - the batch endpoint - for a tenant named `_search`, and a
  transaction Bundle sent there was committed into that tenant;
- with the web UI mounted, `GET /?_type=Patient` was redirected to `/ui`,
  because the root redirect ignored the query.

Per the decision on #1338, system-level search is refused explicitly for now:

- `GET /` and `GET|POST /_search` are routed to
  `search_system_not_supported_handler`, which returns
  `RestError::NotImplemented` (501, `not-supported`) through the normal error
  rendering, with a message pointing at type-level search. The tenant is
  resolved first, as on every other route; auth still answers 401 first.
- `_search` is a reserved first path segment, so it is never taken for a
  URL-path tenant.
- The UI's root redirect applies to the bare root only; a `GET /` carrying a
  query is handed to the FHIR router.
- `/metadata` no longer lists `search-system`. `transaction`/`batch`/
  `history-system` are unchanged.
- `POST /` stays the batch/transaction endpoint whatever its Content-Type.

The unrouted `search_system_handler` is kept as the starting point for the
deferred implementation and documented as not routed; it needs
`MultiTypeSearchProvider`, which only SQLite and PostgreSQL implement.

Fixes #1338
RestError::NotImplemented renders "Feature '<feature>' is not implemented.",
so the feature text names the interaction and the alternative rather than
being a sentence of its own.

Refs #1338
@codecov

codecov Bot commented Sep 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

System-level search is not routed: GET /?_type=… returns 405, POST /_search returns 404

1 participant