Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
# `tenant-scoped/create-tenant-scoped-wrapper` — SQL rewriter for row-level tenancy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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.