first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user