fix(persistence): make the Elasticsearch tenant→index derivation injective (#384) - #446
Open
mauripunzueta wants to merge 1 commit into
Open
fix(persistence): make the Elasticsearch tenant→index derivation injective (#384)#446mauripunzueta wants to merge 1 commit into
mauripunzueta wants to merge 1 commit into
Conversation
…ctive
`ElasticsearchBackend::index_name` was
`format!("{prefix}_{tenant.to_lowercase()}_{type}")`. `to_lowercase()` is not
injective, so tenants `ACME` and `acme` resolved to the same index. Combined
with a `document_id` of `{type}_{id}`, they also shared a document address —
and every `_id`-addressed operation (`create`, `update`, `delete`, `purge`,
`write_search_entries`, contained-doc indexing, and the `create_or_update`
existence probe) reads, overwrites, and deletes across that boundary, because
`GET/PUT/DELETE /{index}/_doc/{id}` admits no query filter.
The derivation was also *partial*: a tenant id containing `/` produced a string
Elasticsearch rejects, so every write for such a tenant 500'd. Same expression,
same fix.
Fixes #384.
## The encoding
New `backends/elasticsearch/naming.rs` holds the single derivation. Bytes in
`{a-z, 0-9, '-', '_', '.'}` pass through; every other byte becomes `+` plus two
lowercase hex digits. `+` is not in the safe set, so it self-escapes and can
never be confused with an introducer.
Injective by left inverse (`decode_tenant_segment`, asserted by round-trip over
an adversarial corpus). Legal by construction: the output alphabet excludes
every character Elasticsearch forbids and contains no uppercase byte; the
leading-character and `.`/`..` rules are discharged by the index prefix, which
is now validated at construction (`validate_config`) — that is what the
legality proof rests on.
`+` rather than `%`, deliberately diverging from the S3 prior art in #271: no
HFS tenant-routing surface accepts `+`, so it is only ever produced here; and
`%` in a URL path is the classic double-decode hazard, where an intermediary
that decodes once turns `a%252Fb` into `a%2Fb` and Elasticsearch then reads a
`/`. A spurious decode of `%2B` merely yields `+`.
## Identity on already-safe ids — why there is no migration for most deployments
For any tenant id drawn from `[a-z0-9._-]*` the encoding is the identity, so the
index name is byte-identical to what the old derivation produced. Deployments
whose tenant ids are already lowercase see no rename, no reindex, and no change
of any kind. The ids whose names *do* change are exactly those already broken:
mixed-case (colliding), `/`-bearing (500 on every write), and exotic ids that can
only arrive through the unvalidated JWT tenant claim (#385).
Pinned by
`already_safe_tenant_ids_are_unchanged_so_conforming_deployments_do_not_migrate`
— if that test ever fails, every conforming deployment on earth needs a reindex.
## Five derivation sites collapsed to one
`index_name` plus four hand-rolled `format!("{prefix}_{tenant.to_lowercase()}_*")`
globs (`delete_contained_docs`, `count`, `purge_tenant_data`,
`tenant_index_pattern`) all now route through `naming`. This is not tidying: had
the exact name been escaped while a glob was not, the glob would stop matching
the indices that exist and `purge_tenant_data` would delete nothing while
reporting success — a compliance-grade failure introduced by the fix itself.
## `document_id` deliberately unchanged
The issue proposes adding a tenant component. Rejected. An Elasticsearch `_id`
is unique only within its index, so an injective `index_name` already means no
two tenants can contend for one. It would also be harmful: changing `_id`
changes the address of every document in every deployment including conforming
ones, and since `delete` removes only the new `_id`, pre-upgrade documents would
persist as permanently-undeletable duplicate search hits.
`document_id_carries_no_tenant_component` is the alarm if someone adds one.
## Also in this commit
- `create_or_update`'s existence probe now checks `_source.tenant_id`, mirroring
`read`. Redundant once the derivation is injective, but it is the one place
this backend reads foreign state to make a write decision. A mismatch is
treated as *absent*, not an error: an upgraded index can still hold documents
a colliding tenant left behind, and erroring would brick the rightful owner on
those ids permanently with no operator remedy.
- Startup diagnostic: one aggregation over `{prefix}_*` warns when an index holds
documents whose `tenant_id` does not re-encode to that index's tenant segment.
A name-shape check would have been useless here — the old derivation
lowercased, so `ACME` wrote to the well-formed `{prefix}_acme_patient`; what is
wrong is the contents, not the name. Best effort, never fails startup.
- Removed `schema::delete_index` — dead (`#[allow(dead_code)]`), and an
unreachable whole-index DELETE is a footgun whose safety argument the first
caller would not re-derive. Tenant offboarding is already served, document-level
and term-filtered, by `purge_tenant_data`/`purge_all`.
- Corrected the over-match comment on `tenant_index_pattern`: it claimed tenant
`a` matches tenant `ab`, which is false (the pattern requires the literal
separator). The real case is `a` vs `a_b`.
## Tests
Replaces two tests that pinned the defect as intended behaviour
(`backend.rs` asserted `index_name("ACME", …) == "hfs_acme_observation"`;
`elasticsearch_tests.rs` exercised only inputs the lossy step preserved).
- `naming.rs`: injectivity over a corpus with a pair for each way a derivation
can lose information — ASCII case, separator ambiguity, the classes a lossy
sanitiser collapses, escape forgery, leading/trailing separators, Unicode
normalisation (U+FB01 vs "fi", U+00C5 vs U+212B), and truncation; plus
round-trip, Elasticsearch legality, template-glob coverage, and glob/name
agreement in both directions.
- `elasticsearch_tests.rs`: two live-cluster tests. The isolation one uses
`Acme`/`acme` and walks the probe, update, delete, and both directions of
`count` — the existing isolation tests used `tenant-a`/`tenant-b`, which the
lossy derivation preserved, which is why they were green throughout. The other
is the oracle for names a real cluster must accept.
- `sqlite_tests.rs`: a case-variant collision guard. SQLite is already correct
(`tenant_id TEXT` under BINARY collation); this keeps it that way, and the risk
is concrete — `COLLATE NOCASE` is used liberally in this backend's search SQL.
## Verification
No C linker in this environment, so nothing was compiled or run locally; CI is
the gate. `cargo fmt` clean. The encoder's injectivity, identity-on-safe-ids, and
length properties were additionally checked against an independent model of the
same corpus. Elasticsearch's index-name rules and the client's path-escaping
table (`elasticsearch-8.15.0-alpha.1/src/http/request.rs:26-31`) were verified
against the vendored source and upstream documentation rather than assumed.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
mauripunzueta
marked this pull request as ready for review
July 30, 2026 14:53
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
This was referenced Jul 30, 2026
Open
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 #384.
Makes the Elasticsearch tenant → index derivation injective, so that every
_id-addressed operation is confined to exactly one tenant's index. Designsettled by a five-persona council (multi-tenancy architect, healthcare
compliance auditor, migration/ops steward, scope steward, regression-prevention
engineer), each position adversarially stress-tested; the disagreements they
did not resolve among themselves are adjudicated below.
The defect
ElasticsearchBackend::index_namewasformat!("{prefix}_{tenant.to_lowercase()}_{type}").to_lowercase()is notinjective, so tenants
ACMEandacmeresolved to the same index, and with adocument_idof{type}_{id}they shared a document address outright.GET/PUT/DELETE /{index}/_doc/{id}admit no query filter, so the eight_id-addressed paths had nothing else holding the boundary.The same expression was also partial: a tenant id containing
/produced astring Elasticsearch rejects, so every write for such a tenant 500'd.
Severity — narrower than the issue implies in one direction, wider in another
Narrower. There is no cross-tenant content disclosure through the HFS API.
There are exactly two
_id-addressed reads:readre-checks_source.tenant_id(storage.rs:774-781, as the issue says), and thecreate_or_updateprobe reads onlyversion_id. Search and count areunconditionally filtered by a
term tenant_idclause on akeywordfield.purge_allandpurge_tenant_dataare term-filtered too, so no path could wipea colliding tenant wholesale. This is an integrity and availability defect,
not a confidentiality one — with one narrow exception: the probe is a
cross-tenant existence-and-edit-frequency oracle, and "is patient X a patient of
clinic Y" is itself protected. Now closed.
Wider. While verifying the encoding I found the client's path-escaping table
(
elasticsearch-8.15.0-alpha.1/src/http/request.rs:26-31):,and*reach Elasticsearch unescaped.JwtTenantExtractor(
crates/rest/src/tenant/resolver.rs:150-156) passes an unvalidated claimstraight into
TenantId::new, so a JWT withtenant_id: "acme,other"made theindex string
hfs_acme,other_patient— which Elasticsearch parses as atwo-index list — and
tenant_id: "*"producedhfs_*_patient, a wildcardacross every tenant's index. Search, count, get and delete-by-query all resolve
those. Until now that was held back only by the
termfilter in the requestbody, and the probe carried no such filter at all.
This is why an allowlist encoding was chosen over the issue's option (2),
"reject non-canonical tenant ids at the ES boundary": a blocklist has already
failed once here (case-folding is the bug), and it would not have caught
,/*either, because those are dangerous for a reason living in the client's
escaping table rather than in Elasticsearch's index-name rules.
Also worth stating plainly: this is not a fringe configuration.
README.md:182and
:184list SQLite+Elasticsearch and PostgreSQL+Elasticsearch under"Production deployments needing robust search."
Why option (1), and why not option (2)
Option (2) is worse than it looks. Elasticsearch is never standalone —
StorageBackendMode(crates/rest/src/config.rs:78-114) only ever pairs it as asearch secondary — so a backend-local validity domain means, in a
postgres-elasticsearchdeployment, that a write to tenantACMEsucceeds atthe primary and fails at the secondary: the resource exists and is unfindable.
It also makes a search index the de-facto tenant-admission authority for the
whole server, overruling the REST validators that already admitted the request.
And it offers no remedy — there is no tenant-rename operation, so an existing
ACMEdeployment would 500 forever.The encoding
New
backends/elasticsearch/naming.rsis the single derivation. Bytes in{a-z, 0-9, '-', '_', '.'}pass through; every other byte becomes+plus twolowercase hex digits.
+is not in the safe set, so it self-escapes.Injective by left inverse (
decode_tenant_segment; round-trip asserted over thecorpus). Legal by construction — the output alphabet excludes every forbidden
character and contains no uppercase byte. The leading-character and
./..rules are discharged by the index prefix, which is now validated at construction;
that validation is what the legality proof actually rests on, so it is part of
the fix rather than a nicety.
+rather than%, deliberately diverging from #271's S3 percent form: no HFStenant-routing surface accepts
+, so it is only ever produced by us; and%ina URL path is the double-decode hazard, where an intermediary that decodes once
turns
a%252Fbintoa%2Fband Elasticsearch then reads a/. A spuriousdecode of
%2Bmerely yields+.Migration: none, for any deployment that is not already broken
The encoding is the identity on
[a-z0-9._-]*, so for an already-lowercasetenant id the index name is byte-identical to what shipped. Those deployments
see no rename, no reindex, nothing.
hfs_acme_patientbefore and after.$reindex. Its lowercase counterpart is unaffected./-bearing tenant idThis is why the property is pinned by a named test
(
already_safe_tenant_ids_are_unchanged_so_conforming_deployments_do_not_migrate)rather than left as a remark: if it ever fails, every conforming deployment on
earth needs a reindex.
No backfill tool, no dual-write, no alias flip. Elasticsearch is a derived
artifact and
$reindexis online, async, per-tenant and already wired into thecomposite. Rollback is safe for the same reason: nothing touches the primary.
Operator runbook (only for deployments with a non-lowercase tenant id)
/. Empty list →nothing to do.
WARNnaming any index that holds misplaceddocuments (see below).
POST /$reindexper affected tenant — needs thesystem/reindexscope, and$reindex-statusonly answers on the node that accepted the kickoff.tenant_id.$reindex --clearExistingwill not do this, and neither willthat tenant's future
$purge: both scope to the new index pattern, so thepre-upgrade documents survive a tenant purge silently. This step is the one
with a compliance consequence.
Detection, for operators who want to check before upgrading
tenant_idis mappedkeyword, so this is exact:Empty result = no co-mingling. Substitute your
HFS_ES_INDEX_PREFIX.Without cluster access:
GET /{tenant}/Patient?_summary=countis served by theprimary while
GET /{tenant}/Patient?_count=0'stotalcomes fromElasticsearch. On an affected deployment they diverge.
Five derivation sites collapsed to one
index_nameplus four hand-rolledformat!("{prefix}_{tenant.to_lowercase()}_*")globs (
delete_contained_docs,count,purge_tenant_data,tenant_index_pattern) now all route throughnaming. Not tidying: had theexact name been escaped while one glob was not, the glob would stop matching the
indices that exist, and
purge_tenant_datawould delete nothing whilereporting success. The most likely way this PR could have gone wrong is
missing one of the five, which is what the live
count-in-both-directionsassertion exists to catch.
document_iddeliberately unchangedThe issue proposes making it injective too. Rejected, unanimously by the three
council members who examined it. An Elasticsearch
_idis unique only withinits index, so an injective
index_namealready guarantees no two tenants cancontend for one — the tenant component buys nothing. It would cost a great deal:
it changes the address of every document in every deployment including the
healthy ones, and because
deleteremoves only the new_id, every pre-upgradedocument would linger as a permanently-undeletable duplicate search hit. That
converts a rare integrity bug into a universal, silent, non-self-healing one.
document_id_carries_no_tenant_componentis the alarm if someone adds it later.Also in this PR
create_or_update's existence probe now checks_source.tenant_id,mirroring
read. Redundant once the derivation is injective, but it is the oneplace this backend reads foreign state to make a write decision. A mismatch
is treated as absent, not an error: an upgraded index can still hold
documents a colliding tenant left behind, and erroring would brick the rightful
owner on exactly those ids, permanently, with no operator remedy. Treating them
as absent is self-healing and correct on the merits.
{prefix}_*warns when an indexholds documents whose
tenant_iddoes not re-encode to that index's tenantsegment. Note the shape: a name-based check would have been useless, because
the old derivation lowercased —
ACMEwrote to{prefix}_acme_patient, aperfectly well-formed name for
acme. What is wrong is the contents. Besteffort; never fails startup, since misplaced documents are inert and refusing
to boot would turn a search-completeness problem into an outage.
schema::delete_index— dead code, and an unreachable whole-indexDELETEis a footgun whose safety argument (it is only correct becauseindex_nameis injective) the first caller would not re-derive. Its plausibleuse is already served, document-level and term-filtered, by
purge_tenant_data/purge_all.tenant_index_pattern: it claimed tenantamatches tenant
ab. It does not — the pattern requires the literal separator.The real over-match is
avsa_b, and it is deliberate (keeping_in thesafe set is what spares
my_tenant-shaped ids from renaming); thetermfilter is what isolates.
"Address this for all DB"
The other backends are already injective on the tenant, so the honest
discharge is proof plus a guard, not change — changing correct code to satisfy a
rule is its own failure mode.
tenant_id TEXT NOT NULL, default BINARY collation, noCOLLATE NOCASEtenant_id TEXT NOT NULL, noCOLLATE/citexttenant_iddocument field, no collation override anywhereregistry_object_id(#271)The SQLite guard is a real behavioural test, not a tautology, and it is free
(in-memory, runs on every PR). It is placed there specifically because
COLLATE NOCASEis used liberally in that backend's search SQL, so the idiom isone copy-paste from a tenant predicate. PostgreSQL/MongoDB are asserted by
reading the schema; no test guards a future
citextmigration, and saying so ispart of the claim.
Discovered, deliberately not fixed here
S3Keyspace::with_tenant_prefixis non-injective (keyspace.rs:36-43):tenant_id.trim_matches('/')means tenantsa,/a,a/and//ashare onedata prefix, while
registry_object_idgives each a distinct registry record.admin_tenants.rs:131-139accepts/anywhere, so this is reachable. Samedefect class as #384.
Not fixed in this PR, and the reason is not scope-tidiness: S3 is the system of
record. Re-prefixing moves live objects with no
$reindex-equivalent rebuildpath, whereas the Elasticsearch fix is safe precisely because its index is
derived. Bundling a safe change with an unsafe one in a single security PR is
how the unsafe one ships unexamined.
Filed as #447 with the full reachability analysis. Summary of the calibration
there: it is a genuine data-isolation defect, but not reachable by an ordinary
authenticated user — the header and URL-path tenant routes both reject
/outright, so it needs either an operator provisioning a slash-padded id or an IdP
emitting one. The realistic failure is
DELETE /admin/tenants/%2Fa?purge=truedestroying tenanta's resources andhistory while deregistering only
/a.Also for #385, whose severity this raises: the
,/*index-injection vectorabove is mitigated here at the Elasticsearch boundary but not at any other
backend, and it exists because
JwtTenantExtractorvalidates nothing.Deliberately out of scope
&str, with itsproof self-contained, so this fix never depends on a validator that does not
yet exist (~198
TenantId::newcall sites). The two ship independently, andlanding this does not make Tenant ids have no canonical validator: divergent charsets and an infallible TenantId::new #385 less necessary.
crates/persistence/src/backends/local_fs/mod.rs:42—root.join(tenant_id)has the same defect class on case-insensitive filesystems (macOS, Windows).
Different subsystem, not a database, and no victim on the Linux container
runtime HFS ships. Noted here rather than filed.
Verification
No C linker in this environment, so nothing was compiled or run locally — CI is
the gate.
cargo fmtclean. What was verified by hand instead:client's path-escaping table against the vendored crate source, rather than
assumed.
safe ids, and the resulting index-name lengths, cross-checked against an
independent model of the same specification.
tree.
The two live-cluster tests run on an ordinary PR:
elasticsearch_tests.rsisgated only on
#![cfg(feature = "elasticsearch")],ci.ymlruns--all-features, andci.yml:26-30hard-fails the job whenDOCKER_HOSTisabsent — so they cannot skip vacuously (cf. #390). The isolation test fails on
maintoday.Residual risk
to an overwrite are gone; this fix stops the bleeding and makes the state
visible, nothing more.
$purgecompleteness depends on the operatorperforming runbook step 4.
countandlist_resource_typesstill depend on a human remembering theterm tenant_idfilter; that invariant is review discipline, not construction.cluster is unreachable at boot.