Files
skills/docs/api-stack/api/api-db-adapter/src/tenant-scoped/create-tenant-scoped-wrapper.md
2026-05-29 08:20:56 +01:00

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 = ? (or AND 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 and false, reject; if present with a conflicting value (literal or bound), reject.
  • Transactions: every tx.query and tx.execute inside 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 rewriters export 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.