fix(persistence): make the S3 tenant→prefix derivation injective and prefix-disjoint (#447) - #475
Open
mauripunzueta wants to merge 1 commit into
Open
fix(persistence): make the S3 tenant→prefix derivation injective and prefix-disjoint (#447)#475mauripunzueta wants to merge 1 commit into
mauripunzueta wants to merge 1 commit into
Conversation
…prefix-disjoint (#447) `S3Keyspace::with_tenant_prefix` derived a tenant's data-key prefix with `trim_matches('/')`, so `acme`, `/acme`, `acme/` and `//acme` all resolved to the prefix `acme` while the tenant registry — injective since #271 — held them as four separate, separately-registrable tenants. `purge_tenant_data` resolves its location through that same derivation, so `DELETE /admin/tenants/%2Facme?purge=true` deleted tenant `acme`'s resources *and* its version history, left `acme`'s registry record in place, and reported success. The end state was a tenant still registered, still listed, with all of its data gone. A second defect lives in the same derivation. `RESERVED_TENANT_IDS` is compared against the whole id, so `acme/resources` provisions cleanly — and then stores everything under `acme/resources/…`, which is exactly what tenant `acme` sweeps on purge and enumerates on list (`list_current_keys` selects on `/current.json` anywhere beneath the prefix). That is a cross-tenant delete *and* a cross-tenant read, reachable through the admin API alone. The derivation now escapes exactly the ids that are not already safe, via the `registry_object_id` helper #271 introduced, and is the identity for every id that is. That distinction is the whole design: S3 is the system of record, not a derived index, and has no `$reindex` equivalent — changing a computed prefix does not move objects, it makes them unreachable. So `acme`, `acme/research` and `__system__` keep their exact prefixes, and the only ids that move are those whose objects are currently commingled with another tenant's or sitting inside another tenant's purge radius, where there is no coherent single-tenant dataset at the old location to preserve. Deliberately does not touch any ingress validator: that is #385 / PR #450, which already rejects these ids at every entry point. This is the half #450 cannot cover — it guards only `register_tenant`, leaving `purge_tenant_data` and `deregister_tenant` unguarded by design so operators can clean up legacy tenants, and those construct a `TenantId` directly with no validator in the path. Tests: the two guarantees are asserted as properties over an adversarial id corpus, and the consequences are driven end to end through the mock S3 client. All 8 fail against the previous derivation. A backend-agnostic tenant-id fidelity suite runs the same id shapes through SQLite, PostgreSQL and MongoDB, whose scoping is exact-match on the raw id, so the "no derivation, therefore no defect" claim for those backends is checked rather than asserted in prose.
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 31, 2026 18:17
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 #447.
What was wrong
S3Keyspace::with_tenant_prefixderived a tenant's data-key prefix by trimming leading and trailing/. That mapping is many-to-one, while the tenant registry key has been injective since #271 — so the registry said N tenants and the keyspace said 1.The issue named one defect. There are two, in the same six lines.
D1 — slash padding collapses (the issue's subject).
acme,/acme,acme/,//acme→ prefixacme.D2 — a tenant can nest inside another's sweep radius (not previously named).
RESERVED_TENANT_IDSis compared against the whole id (admin_tenants.rs:123), soacme/resourcesprovisions cleanly. It then stores everything underacme/resources/…— which is exactlyacme'sresources_prefix(). That prefix is whatpurge_tenant_datasweeps and whatlist_current_keysenumerates (it selects on/current.jsonanywhere beneath). So tenantacmeboth deletes and reads tenantacme/resources's data. Reachable through the admin API alone, with no IdP involvement — arguably more reachable than D1.Injectivity alone does not fix D2, and single-segment prefixes alone do not fix D1. Both properties are needed.
Consequence
purge_tenant_dataresolves through the same derivation, so onPrefixPerTenant(the default, and the only modecrates/hfs/src/main.rsconstructs):deleted tenant
acme's resources and its version history, whilederegister_tenant("/acme")removed onlytenants/%2Facme.json. End state: a tenant still registered, still listed, with all of its data gone — reported to the caller as success.The fix
The derivation now escapes exactly the ids that are not already safe, reusing the
registry_object_idhelper #271 introduced, and is the identity for every id that is.acmeacmeacme— unchangedacme/researchacme/researchacme/research— unchanged__system____system____system__— unchanged/acmeacme⚠ shared%2Facmeacme/acme⚠ sharedacme%2F//acmeacme⚠ shared%2F%2Facmeacme/resourcesacme/resources⚠ insideacme's sweepacme%2FresourcesAn id is keyspace-safe iff it contains no
%,\, or space; has no empty segment; and has no segment after the first equal to a sweep root (resources,history,bulk). Safe → verbatim. Unsafe → one opaque escaped segment.Why the guarantees hold. Every structural defect implies the id contains
/, andregistry_object_idescapes/, so an escaped prefix always contains%— which no safe prefix may. The two ranges are disjoint and each mapping is injective on its own domain, giving injectivity. For prefix-disjointness, an escaped prefix contains no/at all and so cannot end in/{sweep-root}, while a safe prefix is barred from a non-first sweep-root segment by construction.tenantsand_system.user-settingsare deliberately not sweep roots: they live on the base keyspace, never under a tenant prefix, and are already protected structurally by the direct-child filter inlist_tenantsand the digest leaf inuser_settings_key(#271). Listing them would relocate a top-level tenant namedtenantsfor nothing.Why not simply escape the whole id
Option A in the issue was an unconditional escape. That was rejected, and the reason governs the whole design:
S3 is the system of record, not a derived index. There is no
$reindexthat can rebuild an object at a new key. Changing a computed prefix does not move objects — it makes the existing ones unreachable, and a laterpurge_tenant_datawill not reach them either. An unconditional escape would relocate every hierarchical id (a/b→a%2Fb), turning a bug that needs a slash-padded id nobody has into silent data loss for anyone using a documented id shape.A compatibility read path (the
legacy_tenant_registry_keytrick from #271) does not transfer either: a registry record is one leaf key you can try twice; a tenant prefix is the root of an unbounded listing. Dual-prefix reads would double every LIST, leave writes ambiguous, and give purge two radii.So the escape is conditional, and the condition is exactly "is this id currently unsafe". Every id whose prefix moves is one whose objects are already commingled with another tenant's or already inside another tenant's purge radius — there is no coherent single-tenant dataset at the old location, so relocating it is the remediation rather than a migration cost. No object belonging to a currently well-formed tenant moves.
Upgrade impact
None for any deployment whose tenant ids come from the header or URL-path routes — both reject
/outright ([A-Za-z0-9_-]), so every id they can produce is keyspace-safe and keeps its exact prefix.A deployment that provisioned a slash-padded or sweep-root-nested id through the admin API will find that tenant's prefix has moved. Its objects at the old location were never separable from the tenant it was colliding with; they stay where they are, now owned unambiguously by that tenant.
Relationship to #385 / PR #450
Complementary, and this is not merely defence in depth.
#450 is Option B done properly — one canonical
TenantId::parserejecting leading/trailing/repeated/, enforced at every ingress. It deliberately guards only the mint point (register_tenant), leavingderegister_tenant,purge_tenant_data, and every read path unguarded so operators can still clean up a non-canonical tenant that predates the validator.purge_tenant_databuilds itsTenantIddirectly from an operator-supplied path segment. No validator is in that path. So consequence 1 above survives #450 for any id already in a registry — this PR closes it.#450's own contract says as much: "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." This is that encoding.
Branches from
main; touches no ingress validator and noelasticsearch/file, so it rebases cleanly against both #450 and #446 in any merge order.Databases — all backends addressed
The defect is a derivation. Two backends derive a storage location from the tenant id; three use it as an opaque exact-match value.
to_lowercase)tenant_id TEXTin the composite PK, bound and compared with=(BINARY collation)tenant_id TEXTin the composite PK, bound and compared with="tenant_id": <id>exact BSON match, default case-sensitive collationFor the three "identity" rows that was a code reading — and a code reading is exactly what let this defect class sit undiscovered in both rows that derive. So it is now checked:
tests/multitenancy/tenant_id_fidelity_suite.rsdrives the id shapes S3 collapsed through each backend's real storage path and asserts nothing commingles,#[path]-shared into the SQLite, PostgreSQL and MongoDB test binaries (the arrangementtransactions/if_match_suite.rsestablished).Tests
Properties, over an adversarial id corpus (
s3/keyspace.rs) — a new id shape gets added to the corpus rather than given its own test:tenant_prefix_derivation_is_injective— pairwise, with and without a global prefixno_tenant_sweep_prefix_covers_another_tenants_keys— pairwise, every sweep prefix against every sample keysafe_tenant_ids_keep_their_existing_prefix/unsafe_tenant_ids_move_to_one_escaped_segmentan_escaped_prefix_cannot_be_forged_by_a_literal—%2Facmeas a literal must not land where/acmelandsthe_empty_tenant_id_does_not_become_the_base_keyspacekeyspace_safety_predicate_clausesConsequences, end to end through the mock S3 client (
s3/tests.rs) — what an operator actually experiences:purging_a_slash_padded_tenant_leaves_the_bare_tenant_intact— current resource and version historyslash_padded_tenants_do_not_share_resourcesa_tenant_named_after_a_sweep_root_survives_its_parents_purge— D2, covering read and deletesafe_tenant_ids_still_resolve_to_their_original_prefix— asserts the literal object keys, so a silent relocation fails the buildAll 8 of the behavioural tests fail against the previous derivation — verified by temporarily restoring
trim_matches('/')and re-running (8 failed / 83 passed), then restoring the fix (91 passed).Verification
cargo test -p helios-persistence --features s3,sqlite --lib— 849 passedmultitenancy_suite,s3_tests,crud_suite,versioning_suite— 150 passedrustfmtclean on every touched file;cargo clippy --all-targetsintroduces no new warning (the 4 ins3/tests.rsare pre-existing — confirmed by running clippy against the unmodified file)