Close the last tenant-isolation adversarial probe (mid-provisioning tenants)
Phase 4 task 8's verification plan named four adversarial probes; three were closed earlier this phase, the fourth (an evaluator tick, or any other caller, hitting a tenant that exists but hasn't reached the active+credentialed gate yet -- must be refused, not served) was still an explicitly-skipped stub in api/queryapi/tenant_isolation_gap_test.go. Investigating it found the two storage engines needed genuinely different treatment: - ClickHouse (enterprise/internal/chrunner) already had this property structurally, for free: Registry is built once at startup from rbacstore.ListProvisionedDataSources, which already filters to active+credentialed tenants only, so a mid-provisioning tenant is simply absent from the connection map. New test TestRegistryRefusesMidProvisioningTenant proves this without Docker -- an empty DataSource list never dials ClickHouse, so this genuinely runs in this environment, unlike every other test in that file. - Tantivy (search/src/registry.rs's IndexRegistry) was a real, different gap, not just an unverified assumption: it opens-or-creates an index for any syntactically-valid tenant_id on first request, because it's a separate process with no Postgres access and structurally can't know which tenants are actually provisioned. A query against a mid-provisioning tenant would have silently succeeded with zero results from a freshly-created empty index -- "ambient success" indistinguishable from "no matching logs," exactly the failure mode this item was worried about. Fixed the Tantivy gap with a new enterprise/internal/searchclient. TenantChecker interface (backed by a new rbacstore.TenantIsActive, implemented structurally, no new import edge needed), consulted before every gRPC call: Client.Search now refuses a non-active tenant before it ever reaches `search`. Dial's signature gained a required TenantChecker parameter; enterprise-api's main.go passes its existing rbacstore.Store (already satisfies the interface). Verified Docker-free via searchclient's existing real-in-process-gRPC-server test harness (TestSearchRefusesMidProvisioningTenant, plus TestSearchPropagatesTenantCheckerError for the fail-closed-on-error case) -- both genuinely run in this environment, same bar as the rest of the Tantivy isolation work. rbacstore.TenantIsActive itself has two new skip-gated live-Postgres tests (TestTenantIsActive, TestTenantIsActiveNonexistentTenant) -- disclosed as not run against a live database here, same gap as the rest of this phase's Postgres-backed pieces. api/queryapi/tenant_isolation_gap_test.go rewritten from a checklist with one skipped stub to a full accounting of all four now-closed probes. Docs updated in lockstep: CLAUDE.md, threat-model.md, phase-4-isolation-design.md (implementation note added after its original sign-off), phase-4-runbook.md (§9), enterprise/README.md.
This commit is contained in:
@@ -183,3 +183,38 @@ func TestRegistryTenantCannotReadOtherTenantEvenViaRawSQL(t *testing.T) {
|
||||
t.Fatal("tenant A's request was able to read tenant B's database by fully-qualified name -- isolation is broken")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegistryRefusesMidProvisioningTenant is Phase 4 task 8's item 4
|
||||
// adversarial probe (see /docs/phase-4-isolation-design.md's
|
||||
// verification plan and api/queryapi/tenant_isolation_gap_test.go):
|
||||
// a tenant row that exists in rbacstore but hasn't reached the
|
||||
// active+credentialed gate yet must be refused, not served via some
|
||||
// ambient connection. Unlike every other test in this file, this one
|
||||
// needs no live ClickHouse at all -- New never dials out for an empty
|
||||
// DataSource list, so an empty Registry (as if every tenant in
|
||||
// `tenants` were still mid-provisioning) is exactly what
|
||||
// enterprise-api's main.go would build from
|
||||
// rbacstore.ListProvisionedDataSources before any tenant clears that
|
||||
// filter. "Mid-provisioning" and "entirely unknown" collapse to the
|
||||
// identical code path here by construction: Registry has no concept of
|
||||
// "a tenant row exists," only of "a runner is in my map" -- the real
|
||||
// gate is ListProvisionedDataSources's SQL WHERE clause, already
|
||||
// covered by rbacstore_test.go's
|
||||
// TestListProvisionedDataSourcesExcludesUnprovisionedAndInactive. This
|
||||
// test is the Docker-free proof that RunSQL's refusal actually holds on
|
||||
// the empty-map end of that gate, complementing
|
||||
// TestRegistryRefusesUnknownTenant's live-ClickHouse proof on the
|
||||
// populated end.
|
||||
func TestRegistryRefusesMidProvisioningTenant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reg, err := New(ctx, "unused:9000", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer reg.Close()
|
||||
|
||||
reqCtx := authz.WithIdentity(ctx, authz.Identity{TenantID: "mid-provisioning-tenant", Role: authz.RoleViewer})
|
||||
if _, err := reg.RunSQL(reqCtx, "SELECT 1"); err == nil {
|
||||
t.Fatal("expected RunSQL to refuse a tenant that hasn't reached the active+credentialed gate, not silently serve it")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user