3.7 KiB
tenant-scoped/create-tenant-scoped-wrapper — SQL rewriter for row-level tenancy
(no submodules)
Purpose
This module is the load-bearing safety boundary between a plugin route handler
and the underlying admin database surface. Every SELECT/UPDATE/DELETE that the
route handler issues is rewritten in flight to carry a WHERE tenant_id = ?
predicate; every INSERT either carries the tenant column or is rejected (or
auto-injected, configurable). The contract is enforced at the wrapper, so a
route handler literally cannot read another tenant's rows even if the plugin
author is malicious.
The wrapper does not perform full SQL parsing; it performs a fast,
case-insensitive lexical pass that handles the shapes a route handler
realistically writes (simple FROM table, optional WHERE, ORDER BY, LIMIT,
RETURNING). Anything beyond — multi-row INSERT, INSERT...SELECT, DDL, named
params — is rejected with TENANT_SCOPE_VIOLATION, forcing the plugin author
to either move the statement into a migration or declare the
db.unsafe-queries capability.
Features
- Auto-injects
WHERE tenant_id = ?(orAND tenant_id = ?when WHERE already exists) on SELECT/UPDATE/DELETE. Tail clauses (ORDER BY, LIMIT, RETURNING, GROUP BY) are preserved. - INSERT handling: detect tenant column; if absent and
autoInjectInserts: true, rewrite to include it; if absent andfalse, reject; if present with a conflicting value (literal or bound), reject. - Transactions: every
tx.queryandtx.executeinside the transaction callback is rewritten identically to top-level calls. - Prepared statements: rewriting happens at execute time (so the bound params are visible), falling back to a non-prepared execute on the admin surface.
- Pure-function
rewritersexport for direct testing.
Usage: Basic
import { createTenantScopedWrapper } from "@suaveplan/api-db-adapter/tenant-scoped";
const scoped = createTenantScopedWrapper(admin, "acme" as TenantId);
const rows = await scoped.query<User[]>("SELECT * FROM users");
// executed SQL: SELECT * FROM users WHERE tenant_id = ?
// bound params: ["acme"]
Usage: Advanced
import { createTenantScopedWrapper } from "@suaveplan/api-db-adapter/tenant-scoped";
const strict = createTenantScopedWrapper(admin, tenantId, {
tenantColumn: "org_id",
autoInjectInserts: false,
});
// INSERT must explicitly carry org_id matching the resolved tenant
await strict.execute(
"INSERT INTO invoices (id, org_id, amount) VALUES (?, ?, ?)",
["inv_1", tenantId, 100]
);
// rejected: literal doesn't match scope
await strict
.execute("INSERT INTO invoices (org_id) VALUES ('other')")
.catch((e) => e.code === "DB_ADAPTER.TENANT_SCOPE_VIOLATION");
API Reference
createTenantScopedWrapper(admin, tenantId, options?): TenantScopedDatabaseLike.TenantScopedWrapperOptions—{ tenantColumn?, autoInjectInserts? }.rewriters.injectWhere(sql, tenantColumn, tenantId, params)— appends a WHERE/AND predicate.rewriters.handleInsert(sql, tenantColumn, tenantId, params, autoInject)— verifies or auto-injects.
Implementation Notes
The rewriter uses positional ? parameters because they are accepted by
SQLite, MySQL, and (with driver-side rebinding) Postgres. Engines requiring
$N placeholders are expected to rebind at the driver layer. Record-shaped
params and multi-row INSERTs are rejected explicitly rather than guessed;
that pushes complex SQL into either a migration (which runs on the admin
surface) or db.unsafe.query (capability-gated), keeping the rewriter
narrow and auditable. Transaction callbacks receive a wrapped DbTransaction
so the rewrite contract holds across the whole tx body.