fix(persistence): correct tenancy capability advertisements (#369) - #379
Merged
Merged
Conversation
The PostgreSQL backend advertised `BackendCapability::SchemaPerTenant` and `DatabasePerTenant` in both `supports()` and `capabilities()`, and implements neither. It is shared-schema only: one `resources` table keyed by `(tenant_id, resource_type, id)` with a `tenant_id` discriminator that every query filters on. No schema or database switching exists in any code path, and no configuration selects one. Design discussion #28 chose shared-schema deliberately, so this was a false claim rather than an unimplemented plan. `capabilities()` is the surface a caller uses to ask whether a backend gives them a given tenant-isolation guarantee. Answering "yes" for isolation that does not exist is the dangerous direction to be wrong in for a healthcare server, so the claim is retracted outright rather than deprecated. S3 had the same defect from a subtler cause. Its `declared_capabilities()` was a static associated fn, so it answered identically regardless of the configured `S3TenancyMode` — while `PrefixPerTenant` shares one bucket across tenants and `BucketPerTenant` resolves a genuinely dedicated bucket per tenant. It therefore claimed both `SharedSchema` and `DatabasePerTenant` unconditionally and was false in one direction whichever mode it ran. It is now derived from the instance's mode, declaring exactly one. `DatabasePerTenant` is kept for `BucketPerTenant` because it is true there; dropping it to mirror Postgres would have erased a real, implemented isolation mode. This pins the semantics the trait already implied: `supports(&self)` and `capabilities(&self)` take `&self`, so a capability describes the running instance as configured — never what the backend type could be configured to do with more code. That reading is documented on `BackendCapability`, on the three tenancy variants (now defined, and declared mutually exclusive), and on both trait methods. The "could be configured to" reading is what let ~2,100 lines of never-executed `strategy/` scaffolding become a Postgres capability claim. The mechanical cause was duplication: every backend hand-maintained two capability lists, one in `supports()` and one in `capabilities()`, so the false claim had to be written twice and either copy could drift. All five backends now derive both from a single constructor-free `declared_capabilities()`. That is also what makes the fix testable: `PostgresBackend::new` eagerly verifies connectivity, so an instance-based assertion for the backend this issue is about could only run against a live database. Enforcement is a new `tests/backend_capability_contract.rs`, which needs no database, Docker, MinIO, AWS credentials, or network. Its central invariant is that each backend declares exactly one tenant-placement topology — re-adding a second claim cannot be made green by adding to a list, only by also removing the existing one, which is a loud reviewable claim rather than a quiet addition. A `CapabilityMatrix` under `tests/common/` looks like it already covered this. It cannot: no test target declares `mod common;`, so cargo never compiles that directory. It is also unfit in principle — it marks S3 `DatabasePerTenant` as `Implemented`, so it would have passed the S3 over-claim, and `SupportLevel:: Partial` has no representation in a capability vector. Its two Postgres tenancy cells are corrected and its status recorded, but it is labelled as documentation carrying no enforcement value. Its fate stays with #306/#361. Also corrects the same false claim in prose, which is what an operator reads when choosing a backend: the crate-level rustdoc, the PostgreSQL blurb and feature bullets, and the capability matrix row marking SQLite as implementing database-per-tenant (it has no per-tenant file or ATTACH logic). `src/strategy/` is deliberately untouched — its fate is #370. The README Phase 3 checklist is annotated rather than unchecked for the same reason: stating those generators are unwired is a fact, deciding whether they should be is not this issue's call. Refs #369
Contributor
Author
|
Cross-PR note for reviewers of either: #380 now implements the #370 decision (delete Overlap and resolution, so neither review is confusing:
This PR should merge first. #380 branches from |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…tenant search leak Second-pass corrections on top of the #369 tenancy-advertisement fix. Capability re-audit (all backends). Backend::capabilities() under-declared implemented, live-wired behaviour. Declarations now match what a deployment using the backend can actually do, derived from the single per-backend declared_capabilities() so supports()/capabilities() cannot drift: - SQLite +QuantitySearch, ChainedSearch, ReverseChaining, FullTextSearch, CursorPagination, InDbSofRunner - Postgres +QuantitySearch, ChainedSearch, ReverseChaining, InDbSofRunner - MongoDB +QuantitySearch, ChainedSearch, ReverseChaining, Include, Revinclude, BulkExport, InDbSofRunner - Elasticsearch +ChainedSearch, ReverseChaining Chained/_has and _include/_revinclude are resolved by the persistence layer's backend-agnostic resolver over each backend's search(), so every search-capable backend that honours _id declares them; S3 (no search) does not. InDbSofRunner covers in-DB SQL (SQLite/Postgres) and aggregation-pipeline (MongoDB) runners. Enum docs and golden lists updated to match. SQLite cross-tenant search leak. The _text/_content FTS, _filter, and :identifier reference sub-selects scanned every tenant's rows and returned a bare id set the tenant-filtered outer query then intersected, so tenant A's row came back whenever tenant B's same-id row matched. Added tenant_id = ?1 to each (no new bindings; ?1 is the tenant in every param layout this builder produces). New symmetric regression tests: the owning tenant matches, the other does not. S3 BucketPerTenant validation. Reject a tenant_bucket_map with duplicate buckets (or a mapped bucket colliding with default_system_bucket for a non-system tenant), so the DatabasePerTenant capability is true by construction rather than by operator discipline. Deterministic, sorted error message. Test hole. Instance-level supports()/capabilities() agreement tests for SQLite/MongoDB/Elasticsearch (no infra) and a live-Postgres extension catch a re-hand-rolled matches! ladder that the constructor-free golden tests cannot. Added the missing S3 BucketPerTenant golden list. Docs. README multitenancy matrix reconciled (Postgres schema/db-per-tenant o->x; Cassandra/Neo4j -> not-present; S3 mode-dependent); corrected the false "resolve chains natively in-backend" claim; de-marketed src/strategy/ rustdoc and marked it unwired (see #370); tenant/mod.rs and core/backend.rs tenancy docs aligned to shared-schema-everywhere + S3 bucket-per-tenant.
This was referenced Jul 24, 2026
Open
…atch
CI caught the new symmetric regression test failing on its *positive* half:
the owning tenant resolved nothing through `subject:identifier=…`.
Root cause is older than the tenancy fix and is the reason the sub-select
looked leaky: the condition was a correlated `EXISTS` whose correlation
never worked. It is embedded in `SELECT … FROM search_index`, and the
sub-select is `search_index si2`, so the unqualified `value_reference` in
`si2.resource_id = SUBSTR(value_reference, …)` bound to si2's own column
(innermost scope wins), not to the referencing row's. si2 rows are
`identifier` rows, whose `value_reference` is NULL, so `SUBSTR(NULL, …)`
made the `EXISTS` false for every row: `:identifier` matched nothing at
all, in any tenant. That also corrects the previous commit's claim — the
identifier path could not leak across tenants because it never matched;
the `_text`/`_content` FTS and `_filter` leaks it fixed are real.
Rewritten as an uncorrelated `IN` over the target's `Type/id`, the shape
`chain_builder.rs` already uses, so the referencing row's
`value_reference` is compared from the outer scope and cannot be shadowed
regardless of the enclosing alias. `si2.tenant_id = ?1` is kept and is now
load-bearing for real. Matching is version-agnostic (`Patient/p1/_history/2`
normalizes to `Patient/p1`), consistent with `build_reference_condition`;
absolute-URL references are still not resolved.
Tests: the unit test asserted only `contains("EXISTS")` and `contains
("identifier")` — both true of the broken SQL — so it now pins the
`Type/id` comparison, the tenant predicate, and the absence of
`value_reference` inside the sub-select. The integration test gains a
versioned-reference case.
…ncy-capability-advertisement
smunini
approved these changes
Jul 27, 2026
mauripunzueta
added a commit
that referenced
this pull request
Jul 27, 2026
Main landed #369 (PR #379), which added interim "not wired into any backend — see #370" warnings to the strategy module and its docs. This branch removes that module outright, so the deletions win: all of main's changes to strategy/{mod,shared_schema,schema_per_tenant, database_per_tenant}.rs were doc-comment-only. Doc conflicts resolved by keeping main's more detailed tenant-placement prose (per-backend table, S3 BucketPerTenant as the sole per-tenant container, no-RLS statement) minus every paragraph describing the now removed generators, and by folding in this branch's discussion-#28 provenance. Also updated two capability-matrix notes that merged cleanly but still pointed at src/strategy/.
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 #369.
Corrects a false capability advertisement, and — in a second pass — re-audits
every backend's non-tenancy capability claims, fixes a SQLite cross-tenant
search leak, and hardens S3 bucket-per-tenant validation. The tenancy
advertisement correction removes no isolation mode and places no data
differently; the second-pass search-leak and S3-validation fixes do change
runtime behaviour and are called out under "Round 2" below.
The claim, and why it's wrong
crates/persistence/src/backends/postgres/backend.rslistedBackendCapability::SchemaPerTenantandDatabasePerTenantin bothsupports()andcapabilities(). PostgreSQL implements neither. It isshared-schema only: one
resourcestable keyed by(tenant_id, resource_type, id)with atenant_iddiscriminator every queryfilters on. There is no
SET search_path, noCREATE SCHEMA, noCREATE DATABASE, and no per-tenant pool anywhere undersrc/backends/, andno configuration knob selects a tenancy strategy for the PostgreSQL backend
(S3's
S3TenancyModeis exactly such a knob — see below — but no SQL backendhas one). Design discussion #28 chose shared-schema as the pragmatic approach
and did not commit to schema- or database-per-tenant — so this was a false
claim, not an unimplemented plan.
capabilities()is the surface a caller uses to ask whether a backend givesthem a particular tenant-isolation guarantee. For a healthcare server,
answering "yes" for isolation that does not exist is the dangerous direction to
be wrong in.
Behaviour change — security relevant
PostgreSQL. Every release up to and including
v0.2.1reportedSchemaPerTenantandDatabasePerTenantfor the PostgreSQL backend; this isfixed in the next release, not in
0.2.1as published (the branch does not bumpthe version). It has never implemented either. All PostgreSQL deployments are,
and always have been, shared-schema.
No runtime behaviour changed and no data was ever placed differently — only the
advertisement was wrong. If you gated a deployment, procurement, or
data-segregation attestation on
supports(BackendCapability::DatabasePerTenant)against the PostgreSQL backend, please re-assess it.
S3 — a correction in both directions, per mode:
PrefixPerTenantinstances stop claimingDatabasePerTenant(was false —all tenants share one bucket).
BucketPerTenantinstances stop claimingSharedSchema(was false) and keepDatabasePerTenant(was, and remains, true).Source break:
S3Backend::declared_capabilities()is removed andreplaced by
S3Backend::declared_capabilities_for(&S3TenancyMode). It was astatic associated fn, so it could not answer a question whose truth depends on
the instance's configuration. It is renamed rather than silently redefined so
the change is loud at every call site — silently changing what a same-named
public function returns is the exact drift mechanism this PR exists to remove.
Release ask: this should not ship in a patch release. A public inherent fn
was removed and advertised values changed, so under 0.x conventions it belongs
in the minor slot (0.3.0). No version bump here —
0.2.1isshared-versionacross 19 crates and
cargo releaseowns it.Classification: a documentation/advertisement defect with security-decision
consequences. Not a vulnerability, no CVE, no RUSTSEC advisory — nothing is
exploitable, nothing regressed, and no isolation was ever weakened.
S3 was the same defect from a subtler cause
Not in the original issue; found while checking every backend.
S3TenancyModehas two genuinely different modes —
tenant_location()resolves a dedicatedper-tenant bucket under
BucketPerTenant(and errorsInvalidTenantfor anunmapped tenant), while
PrefixPerTenantshares one bucket. Because thedeclaration was static it claimed both
SharedSchemaandDatabasePerTenantunconditionally, and was false in one direction whichever mode it ran.
It now derives from
self.config.tenancy_modeand declares exactly one.DatabasePerTenantis kept forBucketPerTenant— dropping it to mirrorthe Postgres fix would have erased a real, implemented, operator-paid-for
isolation mode. The
matchis exhaustive with no wildcard arm, so a third modeis a compile error rather than a stale claim.
The semantics this pins down
The trait already implied the answer:
supports(&self)andcapabilities(&self)both take
&self, and S3 — the only backend that discarded it — was the onlyother backend that was wrong. A capability now explicitly means "this running
instance, as configured, does X", never "this backend type could be
configured to." The alternative reading is degenerate: with enough code any
backend could do anything, which is how ~2,100 lines of never-executed
strategy/scaffolding became a Postgres capability claim.Documented on
BackendCapability, on the three tenancy variants (now defined,and declared mutually exclusive), and on both trait methods. Configuration may
only refine which member of a mutually exclusive group is reported — it may
never motivate a new variant.
Why this drifted, and what stops it recurring
Every backend hand-maintained two capability lists — one
matches!ladderin
supports(), onevec![]incapabilities()— so the false claim had to bewritten twice and either copy could drift. All five backends now derive both
from a single constructor-free
declared_capabilities().That is also what makes the fix testable at all:
PostgresBackend::newis asyncand eagerly verifies connectivity, so an instance-based assertion for the very
backend this issue is about could only run against a live database — i.e. never
on an ordinary PR.
New
crates/persistence/tests/backend_capability_contract.rsneeds no database,Docker, MinIO, AWS credentials, or network. Its central invariant is that each
backend declares exactly one tenant-placement topology: re-adding
SchemaPerTenantto Postgres cannot be made green by adding to a list — theonly passing edit is to also remove
SharedSchema, which is a loud,reviewable semantic claim rather than a quiet addition.
Deliberately not added: a
supports(c) == capabilities().contains(&c)sweep(tautological after the delegation, and needs live instances), and a
source-scanning test for
SET search_path(that SQL already exists insrc/strategy/, so such a test would dictate #370's file layout and red-buildcorrect future work).
On
tests/common/capabilities.rsThe issue asks to reconcile this matrix. Worth stating plainly: cargo never
compiles it. No test target declares
mod common;(recorded in-tree attests/backend_error_handling.rs:44), so it has never executed — which isexactly why it and the backends were free to drift.
It is also unfit as a gate in principle: it marks S3
DatabasePerTenantasImplemented, so a matrix-agreement test would have passed the S3over-claim;
SupportLevel::Partialhas no representation in a capabilityvector (Postgres
FullTextSearchis Partial-and-declared,ChainedSearchisPartial-and-undeclared); and it is keyed by
BackendKind, so it structurallycannot express a mode-dependent S3.
So the two Postgres tenancy cells are corrected and the S3 mode-dependence noted,
but the file is labelled documentation-only with no enforcement value. Its fate,
and the dead
should_run_test/backend_test!harness around it, stays with#306/#361.
Prose corrections
The same false claim appeared where an operator actually reads it: the
crate-level rustdoc (
lib.rs), the PostgreSQL blurb and two feature bullets,and — not in the issue — the capability matrix row marking SQLite
✓forDatabase-per-Tenant, under a table that promises "actual implementation status,
not aspirational goals." SQLite has no per-tenant file or
ATTACHlogic anddeclares only
SharedSchema.Scope
src/strategy/is untouched — no deletion, no wiring, no un-exporting. Itsfate is #370. The re-export in
lib.rsgains a rustdoc note only (these are SQLgenerators that configure nothing); leaving the export in place is deliberate,
not an oversight. The README "Phase 3" checklist is annotated rather than
unchecked for the same reason: saying those generators are unwired is a fact;
deciding whether they should be wired is #370's call.
Deliberately out of scope, to be filed as follow-ups:
index_name()gives each tenant itsown index (
{prefix}_{tenant}_{type}), which looks like a per-tenanttopology. It keeps
SharedSchemahere — every document also carries atenant_idfield that queries filter on, and the split is a naming conventioninside one cluster and one credential, with no policy or storage boundary. The
adjudication is recorded in a doc comment. Reclassifying would strengthen an
isolation claim, which is not what this PR should do. It also exposes a real
taxonomy gap: the enum cannot express "namespace-per-tenant within one
service", which is what both ES indices and S3 buckets actually are.
BackendCapability::InDbSofRunnerandRawSqlQueryare declared by nobackend, though
RawSqlQuery's own doc says "(Postgres, SQLite only)" —under-claiming, the safe direction.
composite/config.rs:205keeps a parallel, config-declared capability listindependent of the backends' own.
CursorPagination: Plannedwhile keyset paging is implemented;PessimisticLockingabsent) —carried with the matrix's fate.
The first pass deliberately did not re-audit non-tenancy capability claims;
Round 2 (below) does. The
InDbSofRunner/CursorPaginationunder-claimsnoted above are now fixed.
Verification
There is no C linker in this environment, so nothing here was verified by a
local
cargo check/test— CI is the gate.cargo fmtwas run. Every symbol,import, feature name, public path, and intra-doc link target was checked by hand
against the tree.
Design settled by a five-persona council (multi-tenancy architect, healthcare
compliance auditor, Rust API/semver steward, regression-prevention engineer,
scope steward), each position adversarially stress-tested. The S3 defect, the
SQLite README row, the uncompiled-matrix finding, and the clippy interaction
that rules out
#[deprecated]all came out of that process.Round 2 — capability re-audit, SQLite leak fix, and test-hole closure
A second five-persona council reviewed the first pass, then a per-backend audit
(one auditor per backend) checked every
BackendCapabilityvariant againstthe actual implementation. Three things came out of it.
1. Non-tenancy capability re-audit (all backends)
Backend::capabilities()under-declared implemented, live-wired behaviour. It isconsumed by the shipped
config-advisorand by the README matrix, sounder-declaration misinforms operators. Declarations now match what a deployment
using the backend can actually do, still derived from the single per-backend
declared_capabilities():QuantitySearchChainedSearch,ReverseChainingFullTextSearchCursorPaginationInclude,RevincludeBulkExportInDbSofRunnerChained/
_hasand_include/_revincludeare resolved by the persistencelayer's backend-agnostic resolver (
helios_persistence::search::resolve_chains)over each backend's
search()— which rewrites the chain into an_idfilterthe backend then executes — so every search-capable backend that honours
_iddeclares them. S3 (no
search()) does not. The native per-backendChainedSearchProvideris dead code on the live path; the capability reflectsthe deployment, not that provider.
InDbSofRunnercovers an in-DB SQL runner(SQLite/Postgres) and an aggregation-pipeline runner (MongoDB); S3's in-process
runner and ES (no runner) do not declare it.
No over-claims were found. S3
OptimisticLockingwas scrutinised and is agenuine
If-Match/If-None-Matchconditional write (ETag CAS → 412 →VersionConflict), not a read-then-write. The enum docs and the golden lists inbackend_capability_contract.rswere updated to match; each backend'sdeclaration is cross-checked against its golden list as an exact set.
2. SQLite cross-tenant search leak (runtime fix)
Three SQLite search sub-selects omitted the
tenant_iddiscriminator thatBackendCapability::SharedSchemapromises every query carries — the_text/_contentFTS (query_builder.rs),_filter(filter_parser.rs), and:identifierreference (reference.rs) paths. Each scanned every tenant'srows and returned a bare
resource_id/EXISTSresult that the tenant-filteredouter query then intersected, so tenant A's
Patient/123was returned whenevertenant B's
Patient/123matched — a cross-tenant match oracle and plainly wrongresults. Postgres already scoped all three correctly.
Fixed by adding
tenant_id = ?1to each sub-select (no new bindings —?1isthe tenant in every param layout this builder produces). New symmetric
regression tests in
sqlite_tests.rsrun one query against two tenants holdingthe same resource id: the owning tenant must match, the other must not.
3. S3
BucketPerTenantvalidation (runtime fix)declared_capabilities_forreturnsDatabasePerTenantfor anyBucketPerTenantconfig, but nothing checked the per-tenant buckets were distinct — two tenants
mapped to one bucket wrote byte-identical keys (silent cross-tenant overwrite),
making the claim false.
S3BackendConfig::validate()now rejects duplicatebuckets, and a mapped bucket colliding with
default_system_bucketfor anon-system tenant, with a deterministic sorted error naming the collision. The
capability is now true by construction.
4. Delegation test hole
supports()andcapabilities()both delegate todeclared_capabilities(), sothey agree today — but a regression that re-hand-rolls a
matches!ladder insupports()would pass every constructor-free golden test. Added instance-levelagreement tests for SQLite/MongoDB/Elasticsearch (their constructors open no
connection — no infra needed) and extended the live-Postgres
postgres_integration_capabilitiestest with the tenancy negatives and asupports↔capabilitiesagreement loop. Added the missing S3BucketPerTenantgolden list.
Breaking changes
S3Backend::declared_capabilities(), replacedby
S3Backend::declared_capabilities_for(&S3TenancyMode). No in-repo callerexisted; impact is limited to external crates.io consumers of
helios-persistence 0.2.x.Backend::supports()return values changed for existing deployments:PostgreSQL now answers
falseforSchemaPerTenant/DatabasePerTenant, andseveral backends now answer
truefor the re-audited capabilities above. Anexternal caller branching on these takes a different branch. Together with the
removed fn, this is why the release belongs in the
0.3.0minor slot, not apatch.
Deliberately deferred to follow-ups
Confirmed but out of scope for an advertisement/correctness PR, now filed:
index_name()lowercases the tenant id, so tenantsACMEandacmeshare an index and document_id— a real cross-tenantread/overwrite/delete on every
_id-addressed write path.TenantIdcharset validation (the root cause of Elasticsearch: tenants differing only in case share an index and document _id (cross-tenant overwrite/delete) #384;each backend defends inconsistently).
resource_fts, so purged content stayssearchable (SQLite-only; Postgres deletes it).
◐/○featurerows) were not reconciled with the re-audited boolean capability vector.
Verification (Round 2)
Same as above: no C linker locally, CI is the gate.
cargo fmtclean. Eachbackend's declaration was cross-checked against its golden list as an exact set;
every new symbol, import, feature gate, and enum variant was hand-verified
against the tree. New tests were written to compile under
--all-features(theCI config) with each behind its backend's feature gate.