Tenant isolation: current guarantees and remaining boundary
A sales channel is a storefront inside one merchant workspace. It can override catalog visibility, locale, company and commerce settings, but shares that workspace's merchant membership and customers/orders. It is not a security boundary between unrelated businesses. Independent SaaS merchants need separate tenant IDs, memberships, settings and records. A person may intentionally be a member of both; an integration key remains bound to one workspace.
Actual enforcement
src/auth/middleware.rsstrips forged internal identity headers, resolves the personal session and current active membership, and derives the principal. Callerx-tenantselects a workspace; it cannot grant membership. Platform access requires a separate personal operator grant. Private stages inherit live-workspace membership and reject anonymous commerce/API/MCP access.- Object handlers scope records by tenant and object ID. Customers also need their own customer session; addresses bind tenant, customer and address ID. Cart/payment/download/handoff capabilities require the scoped secret token or the applicable customer ownership. Public storefront catalog and explicitly published assets are intentionally public; changing shops to read a published product is not an authorization failure.
- HTTP and MCP use the same native operations; MCP discovery and direct calls both enforce current grants. Apps cannot override tenant scope in arguments. Native app records use tenant, app, entity and record ID. Same IDs in different shops must remain independent. External service implementations are separately trusted operator-configured services, not automatically isolated by core RLS.
- Read caches carry tenant/channel/authentication context. Each new request rechecks authoritative membership and configuration versions. Semantic results require tenant/model filters and current PostgreSQL hydration, plus publication checks for public documents. Database and vector services must stay private.
038-tenant-referencesadds 22 tenant-aware foreign keys for order/cart, payment/reservation/refund, receipt/download/promotion, product parent, event/flow/app/knowledge, and live/staging/build relationships. Existing customer, address, product/category and document keys already carry tenant scope.
Reproduced gap and repair
On the pre-migration schema, an internal SQL write could associate a record under
shop A with shop B's cart. Only cart ID existence was checked. Adversarial API
probes did not demonstrate a remote bypass; the defect was missing relational
containment if an application query were wrong. Migration 038 makes PostgreSQL
reject these relationships with SQLSTATE 23503. Refunds additionally bind the
job and its exact payment attempt. Environment/build references bind the same
live workspace. Existing rows are validated atomically: migration failure leaves
the ledger/data unchanged and requires investigation, never silent reassignment.
The migration retains legacy keys and adds indexed unique target tuples. It scans existing rows and takes DDL locks; schedule it as a controlled migration on a large/live installation. It does not add per-request network trips or change canonical object IDs. Its write/index overhead is not yet benchmarked at SaaS scale.
Continuous real regression
QDRANT_URL=http://127.0.0.1:16333 python3 scripts/verify_integration.py \
--container vendune-postgres-1 --only tenant_isolation
The single integration registry runs this automatically in CI. It creates two
synthetic shops with real personal/customer sessions and actual PostgreSQL;
provider calls are zero. scripts/tenant_isolation.py tests swapped IDs and
headers in both directions across CRM/orders/receipts/assets/knowledge/stages/
builds/memberships, direct MCP/UCP calls, same-ID app data, scope injection,
current integration-key rights and revocation. It re-reads victim records to
ensure denied writes did not change them. Successful owner reads validate that
negative MCP probes did not merely call nonexistent tools.
Foreign integration-key deletion is also tested: its idempotent 200 response
must leave the other shop's key list unchanged and the original key usable.
scripts/security/tenant_schema.py tests each of the 22 new constraints with
both an accepted own-shop operation and a denied foreign-shop operation, checking
the exact constraint name. A deliberately introduced bad reference verifies that the schema guard detects
missing containment. The schema-wide guard rejects new ID-only foreign keys
between tenant tables unless a scoped counterpart exists. Managed app RLS is
executed under NOSUPERUSER NOBYPASSRLS: missing context reads nothing, context
A cannot see/write B, and transaction-local context disappears on connection
reuse. artifacts/tenant-isolation.json records grouped checks; this is not a
claim of exhaustive endpoint coverage or a penetration-test certificate.
Core RLS and strict runtime mode
Migration 044 adds forced row policies to current public core tenant tables.
Managed app tables retain their existing forced policy. tenant_scope.rs carries
server-derived task context; SQLx pool hooks bind fresh/reused connections and
clear them on release. The actual query uses that same borrowed connection.
Unknown tasks deny core rows. Strict startup requires DATABASE_RUNTIME_URL and
DB_RLS_REQUIRED=true and rejects superuser/BYPASSRLS roles, core owners/owner
membership, core TRUNCATE privileges and missing forced policies. A default
development superuser still bypasses RLS: installing migrations does not establish
strict enforcement on public hosting. PostgreSQL documents this bypass in its
row-security guide.
The production_foundations suite runs two actual server replicas under a
non-owner, NOSUPERUSER NOBYPASSRLS login, omits tenant WHERE filters in direct
SQL probes, tests foreign mutations, pooled reuse, registration and strict-runtime
checkout/CRM/tenant regressions. It also checks outbox-driven cache eviction and
shared daily quotas. Run it with the integration registry. The tenant-isolation
report now records whether strict runtime was enabled for that run.
Identity, provisioning, platform, staging/developer control and fleet workers have explicit trusted system scopes. They retain their existing tenant/object guards; this is not complete worker privilege separation or proof against SQL injection and process compromise. Dynamic app DDL ownership must be restricted to managed app tables. Direct PostgreSQL or session pooling is required; transaction/statement poolers would need transaction-local scopes instead of these connection hooks. See deployment, privilege setup and remaining risks. Object/customer ownership checks remain necessary even under RLS.
Public hosting additionally requires isolated origins/CSP, session and recovery
hardening, resource quotas, secure provider/service storage, backup/export/restore
boundaries and independent security review. The instance bootstrap token has
fleet-wide authority and must remain disabled (ALLOW_BOOTSTRAP_AUTH=false) in
SaaS deployment. It must never be handed to a merchant or external app.