fix(tenant): one canonical tenant-id validator, enforced at every ingress (#385) - #450
Open
mauripunzueta wants to merge 4 commits into
Open
fix(tenant): one canonical tenant-id validator, enforced at every ingress (#385)#450mauripunzueta wants to merge 4 commits into
mauripunzueta wants to merge 4 commits into
Conversation
…ress (#385) Tenant ids had no single definition. Four surfaces validated them and all four disagreed: | surface | charset | max | on invalid | |--------------------------------|--------------------|-----|------------| | tenant::resolver (header, URL) | [A-Za-z0-9_-] | 64 | ignored | | middleware::tenant_prefix | [A-Za-z0-9_-] | 64 | not a tenant | | handlers::admin_tenants | [A-Za-z0-9_.-/] | 128 | 400 | | JWT tenant claim | anything | - | - | and `TenantId::new` imposed nothing beneath them, so each backend had to defend individually — which is how the Elasticsearch case-collision (#384) became reachable. `TenantId::parse` is now the one definition; `TenantId::new` stays as the unchecked constructor for trusted reconstruction (rebuilding from a stored `tenant_id`), and `FromStr` routes through `parse`. Every ingress uses it: header, URL prefix, JWT claim, admin body. `ResourceStorage::register_tenant` re-checks it on all five implementations as a backstop, so no future ingress can mint an id that skipped validation. Two defects this closes that the issue did not name: * The header validator failed *open*. `HeaderTenantExtractor` filtered an invalid value to `None`, so the resolver fell through to the **default tenant** and served the request — a `200` reading and writing another tenant's data because of a typo. `TenantSourceExtractor::extract` now has an `Err` arm; a header that asserts an invalid tenant is a `400`, and an invalid JWT claim is a `403` (the token is authenticated; it is the authorization context that is unusable). * Reserved names were compared against the whole id, so `acme/resources` passed. On S3 its data lands at `acme/resources/resources/…`, inside the `acme/resources/` prefix that tenant `acme` lists — and that `purge_tenant_data("acme")` deletes. Reserved segments are now rejected in any position. Charset: `[A-Za-z0-9._-]` plus `/` for hierarchy, 1..=64 bytes, no leading / trailing / repeated `/`, no reserved segment. Case is preserved — folding it would silently merge tenants that every backend keeps apart, and #384 fixes the Elasticsearch collision by making that derivation injective rather than by narrowing the charset here. Databases: SQLite, PostgreSQL, and MongoDB key tenants by an exact-match, case-sensitive column/field and have no derivation to protect, so they need no change beyond the shared `register_tenant` backstop. S3's `/`-structured keyspace is what the per-segment reserved list protects. Elasticsearch is left entirely to #384/PR #446, which makes its index derivation injective; this change supplies the 64-byte bound that PR's index-length proof rests on. Deliberate tightening: ids of 65..=128 bytes and hierarchical ids containing a reserved segment are refused where the admin API used to accept them. Both were reachable only through the JWT claim, the ingress that validated nothing — the header and URL validators already capped at 64. Deliberate widening: `.` is accepted on every ingress. The admin API always allowed it, so a tenant provisioned as `tenant.example` was registered and unroutable. That required reserving `.well-known`, which would otherwise parse as a tenant and turn SMART discovery into a 404 under url_path/both routing; a regression test pins it.
It is only used by tests, so importing it at module scope tripped `-D unused-imports` in the lib build.
The canonical validator refuses ids the old surfaces accepted, so a
deployment may hold data under an id no ingress can now reach. Nothing
told an operator which ones. Each row gains `canonical`, and the body a
`non_canonical_count`, so the stranded set is one request away.
Only reporting — `DELETE /admin/tenants/{id}` still accepts a
non-canonical id, deliberately, so they remain cleanable.
…cter check
`is_valid_tenant_id` asserted `tenant/path` was invalid while delegating
wholly to `TenantId::parse`, which accepts a hierarchical id — so the test
failed. The assertion was wrong, not the code: the "a tenant prefix is one
segment" constraint lives in the caller, which passes
`path.split('/').next()`, so a `/` never reaches this function.
Adding a local `/` check would guard nothing reachable while reintroducing
a second charset alongside the canonical one. Fixed the assertion, said why
in the doc comment, and added the hierarchical case to the
agreement test so a future divergence fails loudly.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
mauripunzueta
marked this pull request as ready for review
July 30, 2026 18:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #385.
What was wrong
Tenant ids had no single definition. Four surfaces validated them and all four disagreed, and
TenantId::newimposed nothing beneath them:tenant::resolver(header + URL)[A-Za-z0-9_-]middleware::tenant_prefix(URL strip)[A-Za-z0-9_-]handlers::admin_tenants(provisioning)[A-Za-z0-9_.-/]400TenantId::newSo each backend defended individually, which is how the Elasticsearch case-collision (#384) became reachable.
Two defects the issue did not name
1. The header validator failed open.
HeaderTenantExtractor::extractused.filter(…is_valid_tenant_id), so an invalid value becameNoneandTenantResolver::resolvefell through to the default tenant.X-Tenant-ID: acme.corp— a., which the old header charset rejected but the admin API always accepted — silently read and wrote the default tenant's data and returned200. A typo produced a cross-tenant read/write.2. Reserved names were compared against the whole id.
acme/resourcespassed.S3Keyspace::with_tenant_prefixdoes not escape, andresources_prefix()isjoin(["resources/"]), so:acmescans and purgesacme/resources/acme/resourcesstores atacme/resources/resources/…andacme/resources/history/…S3Backend::purge_tenant_data("acme")deletes the other tenant's data.keyspace.rsnow carries a test asserting both that the prefixes really do overlap and that the id is unconstructible.The change
TenantId::parseis the one definition.TenantId::newstays as the unchecked constructor for trusted reconstruction (rebuilding from a storedtenant_id— 213 call sites, where a fallible constructor would only addunwrap()s), andFromStrroutes throughparsesincestr::parseis where a reader expects validation. The two privateis_valid_tenant_idcopies are gone.Charset:
[A-Za-z0-9._-]plus/for hierarchy. 1..=64 bytes. No leading, trailing, or repeated/. No segment equal to__system__,tenants,resources,history,bulk,_system.user-settings,., or... Case preserved.Enforced at every ingress, with the failure semantics distinguished:
TenantSourceExtractor::extractgains anErrarm.Ok(None)still means "this source asserted nothing";Errmeans "asserted, and invalid".400.403. The token is authenticated and the request well-formed; it is the authorization context that is unusable, matching the existing claimless-token path.UrlPathTenantExtractornever returnsErr— for it "not a valid tenant id" genuinely means "not a tenant prefix" (/Patient/123). A naive fallible refactor would 400 every untenanted request.Databases — all backends addressed
register_tenantbackstoptenant_idcolumn, BINARY collation — no derivation to protectregister_tenantbackstopTEXTregister_tenantbackstopregister_tenantbackstop/-structured keyspace is what the per-segment reserved list protectsregister_tenantbackstopThe backstop is
ResourceStorage::ensure_canonical_tenant_id, a provided trait method each implementation calls first. It is deliberately not applied toderegister_tenant,purge_tenant_data, or any read path — those are how an operator cleans up a non-canonical tenant that predates this validator, and guarding them would make bad data permanently undeletable. Only the mint point is guarded.crates/persistence/src/tenant/mod.rsnow documents what a backend may rely on, and — as importantly — what it may not: the contract bounds the input, it does not make a lossy mapping safe. A backend with a narrower keyspace still owes an injective encoding of its own.Relationship to #384 / PR #446
Complementary and independent; this branches from
mainand touches noelasticsearch/file.#446 makes the tenant→index derivation injective for any id, so it does not need this charset. This change supplies the 64-byte bound that #446's index-name legality proof rests on ("≤255 bytes … for every id the validated routing accepts" — a
+xxescape triples worst case, so 64→192 fits and 128→384 would not). Whichever merges second rebases cleanly.Compatibility
Fixed, not broken: a malformed
X-Tenant-IDreturns400instead of silently serving the default tenant.Deliberate tightening — the JWT claim is the big one, and it deserves stating plainly rather than as a footnote. That claim previously accepted any string; it now accepts only the canonical charset. For an auth-enabled deployment this is the largest behavioural change here. A claim shaped like
urn:oid:1.2.3oracme@corp— anything carrying:,@, whitespace, or non-ASCII — now returns403where it used to resolve and read/write data under that literal string.Such a tenant was already unusable through the header and URL routes (both charsets excluded those characters, as did the admin API), so it could only ever be reached through this one unvalidated ingress. But if an IdP issues claims in that shape, this PR breaks that deployment, and the remedy is to change the claim — not to widen the validator, which would re-open the class the issue is about.
Two further shapes, both previously accepted only by the admin API:
acme/resourcesclass above.Deliberate widening:
.is accepted on every ingress, so a tenant provisioned astenant.exampleis now routable instead of registered-and-unreachable. This required reserving.well-known: without it,/.well-known/smart-configurationwould parse.well-knownas a tenant and rewrite the path to/smart-configuration, turning SMART discovery into a404underurl_path/bothrouting. Caught during review;smart_discovery_is_not_parsed_as_a_tenant_prefixpins it.Finding what breaks. Ids already stored are untouched — they round-trip through the unchecked
new, so no data moves and nothing is rewritten. But an id that is no longer reachable would otherwise be invisible, soGET /admin/tenantsnow reportscanonicalper row andnon_canonical_countin the body. Rows withcanonical: falseand a non-zeroresourcescount are exactly the stranded set.DELETE /admin/tenants/{id}still accepts a non-canonical id, deliberately, so they stay cleanable — the backstop guards only the mint point.Not fixed here
strip_tenant_prefix(routing/fhir_routes.rs:164) runs before route matching underurl_path/both, andRESERVED_SYSTEM_PATHSdoes not list every registered top-level route.export-status,export-file,bulk-submit-file,bulk-submit-status, andwsall parse as tenant ids today and are rewritten out of existence under those routing modes. That predates this change (their segments were valid under the old charset too) and is a route-reservation defect rather than a tenant-id one, so it is left alone rather than folded in — fixing it properly means auditing every registered route..well-knownis fixed here only because this change is what would have broken it.Verification
A user-local C toolchain was set up for this task (no root; gcc-13 extracted from
.debs), so this branch is verified locally now — earlier notes in this PR saying otherwise are superseded.Locally green:
cargo fmt --all --check;cargo test --workspace --lib(all crates);helios-persistencelib 813 passed (includes the tenant validator and the S3 keyspace tests);helios-restlib 466 passed;helios-rest --test admin_tenants31 passed;--test tenant_resolution41 passed. Clippy is clean over the changed files (local clippy 1.96 flags two pre-existing lints elsewhere —crates/serde-supportdocs andcrates/rest/src/error.rs:473— that CI's older stable does not; untouched deliberately).Running the suite locally caught a real defect in this PR that review had not:
is_valid_tenant_idassertedtenant/pathwas invalid while delegating wholly toTenantId::parse, which accepts hierarchical ids. The assertion was wrong, not the code — the single-segment constraint lives in the caller, which passespath.split('/').next(). Fixed, explained in the doc comment, and the hierarchical case added to the agreement test so a future divergence fails loudly.Still CI-only: anything needing Docker (testcontainers). CI's
Test Rusthas not yet produced a verdict on this branch for an unrelated reason — the shared Docker host is out of disk (disk quota exceededon containerd's metadata store), which fails ~148 testcontainers cases inhelios-htsand, becausecargo test --workspacefails fast, preventshelios-persistenceandhelios-restfrom running at all. Every branch in the repo is affected; two reruns reproduced it identically.LintingandSecurity Auditare green.Test coverage added: 12 cases on
TenantId::parse(including a "shapes accepted before this validator must stay valid" guard against orphaning data), the header fail-closed regression at both extractor and resolver level, header/URL classifier agreement (load-bearing —authz_middlewareand the resolver must agree on which tenant a path is for), JWT-claim rejection at the extractor, per-segment reserved names and the 64-byte cap through the admin API, the S3 prefix-overlap demonstration, the.well-knownregression, and the admin listing's canonicality reporting.