@nebutra/tenant 0.1.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/dist/context.d.ts +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/isolation.d.ts +6 -4
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +53 -46
- package/dist/middleware.d.ts +1 -1
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +3 -3
- package/dist/react.d.ts +1 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +1 -1
- package/dist/resolvers/from-auth-session.d.ts +1 -1
- package/dist/resolvers/from-auth-session.d.ts.map +1 -1
- package/dist/resolvers.d.ts +1 -1
- package/dist/resolvers.d.ts.map +1 -1
- package/dist/rls-session.d.ts +110 -0
- package/dist/rls-session.d.ts.map +1 -0
- package/dist/rls-session.js +162 -0
- package/package.json +13 -2
package/README.md
CHANGED
|
@@ -372,6 +372,22 @@ interface TenantConfig {
|
|
|
372
372
|
|
|
373
373
|
- `withRls(prisma, tenantId)` — Apply RLS extension to Prisma
|
|
374
374
|
- `generateRlsPolicySql(options)` — Generate deterministic PostgreSQL RLS policy SQL
|
|
375
|
+
- `applyTenantSession(tx, tenantId, options?)` / `tenantSessionOperations(prisma, tenantId, options?)` —
|
|
376
|
+
The tenant session core: the transaction-local `SET LOCAL ROLE` (from `APP_DB_ROLE`) and
|
|
377
|
+
`set_config('app.current_tenant_id', …, true)` statements. `withRls` runs it as a batch
|
|
378
|
+
transaction; `withTenantContext` in `@nebutra/db/rls` runs it inside an interactive
|
|
379
|
+
transaction. One implementation, two shapes — new wrappers run the core rather than
|
|
380
|
+
restating the statements. (`getTenantDb` in `@nebutra/db` still carries its own copy
|
|
381
|
+
until the P1.2 follow-up routes it through `tenantSessionOperations`.)
|
|
382
|
+
- `resolveRlsRole(env?)` / `isValidDbRole(role)` — `APP_DB_ROLE` resolved as a bare SQL identifier
|
|
383
|
+
(permissive: `null` on invalid)
|
|
384
|
+
- `resolveRlsRoleOrThrow(env?)` — the same resolution, but throws `TenantIsolationError` when
|
|
385
|
+
`APP_DB_ROLE` is set to something that is not a bare SQL identifier, instead of returning
|
|
386
|
+
`null`. `applyTenantSession`/`tenantSessionOperations` resolve the role through this when no
|
|
387
|
+
explicit `role` option is given, and so does `withRls` (closure P1.3 — an unusable
|
|
388
|
+
`APP_DB_ROLE` refuses to run rather than silently dropping the role switch)
|
|
389
|
+
- `TENANT_SESSION_SETTING` / `TENANT_SESSION_EXPRESSION` — the setting key the policies read and
|
|
390
|
+
the wrappers write
|
|
375
391
|
- `getTenantSchema(tenantId)` — Get schema name for schema-per-tenant
|
|
376
392
|
- `getTenantDatabaseUrl(tenantId, baseUrl?)` — Get DB URL for database-per-tenant
|
|
377
393
|
- `TenantAwarePrismaClient` — Wrapper class for tenant isolation
|
package/dist/context.d.ts
CHANGED
package/dist/context.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAWhD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,OAAO,EAAE,aAAa,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAS7F;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,CAWhD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,eAAe,IAAI,aAAa,GAAG,IAAI,CAEtD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,aAAa,GAAG,IAAI,GAAG,SAAS,EACxC,OAAO,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,IAAI,aAAa,CASjC;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,IAAI,MAAM,CAE3C;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAEjD"}
|
package/dist/context.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { logger } from "@nebutra/logger";
|
|
3
|
-
import { TenantRequiredError } from "./types";
|
|
3
|
+
import { TenantRequiredError } from "./types.js";
|
|
4
4
|
/**
|
|
5
5
|
* AsyncLocalStorage-based tenant context — request-scoped, zero-copy across async boundaries.
|
|
6
6
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export { getCurrentTenant, getCurrentTenantId, getTenantIdOrNull, getTenantOrNull, requireTenant, runWithTenant, } from "./context";
|
|
2
|
-
export {
|
|
3
|
-
export
|
|
4
|
-
export {
|
|
5
|
-
export type { IsolationStrategy, PlanTier, TenantConfig, TenantContext, TenantInfo, TenantResolver, } from "./types";
|
|
6
|
-
export { TenantConfigSchema, TenantContextSchema, TenantInfoSchema, TenantIsolationError, TenantRequiredError, } from "./types";
|
|
7
|
-
export type { RlsPolicyCommand, RlsPolicySqlOptions } from "./isolation";
|
|
8
|
-
export { createTenantPrismaProxy, generateRlsPolicySql, getTenantDatabaseUrl, getTenantSchema, TenantAwarePrismaClient, withRls, } from "./isolation";
|
|
1
|
+
export { getCurrentTenant, getCurrentTenantId, getTenantIdOrNull, getTenantOrNull, requireTenant, runWithTenant, } from "./context.js";
|
|
2
|
+
export type { AuthSessionLike, SessionGetter } from "./resolvers/from-auth-session.js";
|
|
3
|
+
export { fromAuthSession } from "./resolvers/from-auth-session.js";
|
|
4
|
+
export { compose, fromApiKey, fromHeader, fromJwtClaim, fromPath, fromSubdomain, } from "./resolvers.js";
|
|
5
|
+
export type { IsolationStrategy, PlanTier, TenantConfig, TenantContext, TenantInfo, TenantResolver, } from "./types.js";
|
|
6
|
+
export { TenantConfigSchema, TenantContextSchema, TenantInfoSchema, TenantIsolationError, TenantRequiredError, } from "./types.js";
|
|
7
|
+
export type { RlsPolicyCommand, RlsPolicySqlOptions, TenantSessionExecutor, TenantSessionOptions, } from "./isolation.js";
|
|
8
|
+
export { applyTenantSession, createTenantPrismaProxy, generateRlsPolicySql, getTenantDatabaseUrl, getTenantSchema, isValidDbRole, resolveRlsRole, TENANT_SESSION_EXPRESSION, TENANT_SESSION_SETTING, TenantAwarePrismaClient, tenantSessionOperations, withRls, } from "./isolation.js";
|
|
9
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EACf,aAAa,EACb,aAAa,GACd,MAAM,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,iBAAiB,EACjB,eAAe,EACf,aAAa,EACb,aAAa,GACd,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAC;AACvF,OAAO,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAEnE,OAAO,EACL,OAAO,EACP,UAAU,EACV,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,aAAa,GACd,MAAM,gBAAgB,CAAC;AAExB,YAAY,EACV,iBAAiB,EACjB,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,UAAU,EACV,cAAc,GACf,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,YAAY,CAAC;AAKpB,YAAY,EACV,gBAAgB,EAChB,mBAAmB,EACnB,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EACL,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,oBAAoB,EACpB,eAAe,EACf,aAAa,EACb,cAAc,EACd,yBAAyB,EACzB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,OAAO,GACR,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// @nebutra/tenant — Multi-tenancy context and isolation
|
|
3
3
|
// =============================================================================
|
|
4
4
|
// Re-export context functions
|
|
5
|
-
export { getCurrentTenant, getCurrentTenantId, getTenantIdOrNull, getTenantOrNull, requireTenant, runWithTenant, } from "./context";
|
|
5
|
+
export { getCurrentTenant, getCurrentTenantId, getTenantIdOrNull, getTenantOrNull, requireTenant, runWithTenant, } from "./context.js";
|
|
6
|
+
export { fromAuthSession } from "./resolvers/from-auth-session.js";
|
|
6
7
|
// Re-export resolvers
|
|
7
|
-
export { compose, fromApiKey, fromHeader, fromJwtClaim, fromPath, fromSubdomain, } from "./resolvers";
|
|
8
|
-
export {
|
|
9
|
-
export
|
|
10
|
-
//
|
|
11
|
-
export { createTenantPrismaProxy, generateRlsPolicySql, getTenantDatabaseUrl, getTenantSchema, TenantAwarePrismaClient, withRls, } from "./isolation";
|
|
8
|
+
export { compose, fromApiKey, fromHeader, fromJwtClaim, fromPath, fromSubdomain, } from "./resolvers.js";
|
|
9
|
+
export { TenantConfigSchema, TenantContextSchema, TenantInfoSchema, TenantIsolationError, TenantRequiredError, } from "./types.js";
|
|
10
|
+
// Re-export isolation helpers — including the tenant session core shared with
|
|
11
|
+
// `@nebutra/db/rls` (closure P1.2: one implementation behind both wrappers).
|
|
12
|
+
export { applyTenantSession, createTenantPrismaProxy, generateRlsPolicySql, getTenantDatabaseUrl, getTenantSchema, isValidDbRole, resolveRlsRole, TENANT_SESSION_EXPRESSION, TENANT_SESSION_SETTING, TenantAwarePrismaClient, tenantSessionOperations, withRls, } from "./isolation.js";
|
|
12
13
|
// Re-export React hooks (as subpath export ./react)
|
|
13
14
|
// These are exported via package.json "exports" for tree-shaking
|
package/dist/isolation.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { IsolationStrategy } from "./types";
|
|
1
|
+
import type { IsolationStrategy } from "./types.js";
|
|
2
|
+
export { applyTenantSession, isValidDbRole, resolveRlsRole, resolveRlsRoleOrThrow, TENANT_SESSION_EXPRESSION, TENANT_SESSION_SETTING, type TenantSessionExecutor, type TenantSessionOptions, tenantSessionOperations, } from "./rls-session.js";
|
|
2
3
|
/** Minimal Prisma-like client interface for RLS extension support. */
|
|
3
4
|
interface PrismaLikeClient {
|
|
4
5
|
$extends?: (extension: unknown) => unknown;
|
|
@@ -37,8 +38,10 @@ export declare function generateRlsPolicySql(options: RlsPolicySqlOptions): stri
|
|
|
37
38
|
* Works with PostgreSQL RLS policies that check `app.current_tenant_id`.
|
|
38
39
|
* This is the standard pattern for shared-schema multi-tenancy.
|
|
39
40
|
*
|
|
40
|
-
* The Prisma client
|
|
41
|
-
* application-level variable before executing.
|
|
41
|
+
* The Prisma client extension intercepts all queries and sets the
|
|
42
|
+
* application-level variable before executing. Missing `$extends`,
|
|
43
|
+
* `$transaction`, or `$executeRaw` throws `TenantIsolationError` instead
|
|
44
|
+
* of running an unisolated query. Production also requires `APP_DB_ROLE`.
|
|
42
45
|
*
|
|
43
46
|
* @param prisma The Prisma client to extend
|
|
44
47
|
* @param tenantId The tenant ID to set in RLS context
|
|
@@ -162,5 +165,4 @@ export declare class TenantAwarePrismaClient {
|
|
|
162
165
|
* ```
|
|
163
166
|
*/
|
|
164
167
|
export declare function createTenantPrismaProxy(prisma: PrismaLikeClient, tenantId: string, strategy?: IsolationStrategy): PrismaLikeClient;
|
|
165
|
-
export {};
|
|
166
168
|
//# sourceMappingURL=isolation.d.ts.map
|
package/dist/isolation.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"isolation.d.ts","sourceRoot":"","sources":["../src/isolation.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"isolation.d.ts","sourceRoot":"","sources":["../src/isolation.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAMpD,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,cAAc,EACd,qBAAqB,EACrB,yBAAyB,EACzB,sBAAsB,EACtB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,uBAAuB,GACxB,MAAM,kBAAkB,CAAC;AAM1B,sEAAsE;AACtE,UAAU,gBAAgB;IACxB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,OAAO,CAAC;IAC3C,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,oBAAoB,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACrF,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,oBAAoB,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IACtF,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC7D,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACxD;AAED,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjF,MAAM,WAAW,mBAAmB;IAClC,4FAA4F;IAC5F,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,sEAAsE;IACtE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wCAAwC;IACxC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yFAAyF;IACzF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,OAAO,CAAC,EAAE,gBAAgB,CAAC;IAC3B,oDAAoD;IACpD,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA2CD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CA2DzE;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,OAAO,CAAC,CAAC,SAAS,gBAAgB,EAAE,MAAM,EAAE,CAAC,EAAE,QAAQ,EAAE,MAAM,GAAG,CAAC,CAwElF;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAmBxD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CA6B/E;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,uBAAuB;IAEhC,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,QAAQ;gBADR,MAAM,EAAE,gBAAgB,EACxB,QAAQ,EAAE,MAAM;IAK1B;;OAEG;IACH,IAAI,MAAM,qBAET;IAED;;OAEG;IACH,SAAS,IAAI,MAAM;IAInB;;OAEG;IACH,cAAc,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM;IAIxC;;;;;;OAMG;IACG,UAAU,CAAC,KAAK,EAAE,oBAAoB,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAuBpF;;;;;OAKG;IACG,QAAQ,CAAC,KAAK,EAAE,oBAAoB,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;CAmBtF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,gBAAgB,EACxB,QAAQ,EAAE,MAAM,EAChB,QAAQ,GAAE,iBAAmC,GAC5C,gBAAgB,CAmBlB"}
|
package/dist/isolation.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { logger } from "@nebutra/logger";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
const r = process.env.APP_DB_ROLE;
|
|
9
|
-
return r && /^[a-z_][a-z0-9_]*$/.test(r) ? r : null;
|
|
10
|
-
})();
|
|
2
|
+
import { resolveRlsRoleOrThrow, TENANT_SESSION_EXPRESSION, tenantSessionOperations, } from "./rls-session.js";
|
|
3
|
+
import { TenantIsolationError } from "./types.js";
|
|
4
|
+
// The tenant session core is the single implementation behind `withRls` here
|
|
5
|
+
// and `withTenantContext` in `@nebutra/db/rls`. Re-exported so consumers of
|
|
6
|
+
// `@nebutra/tenant/isolation` reach it without a second entry point.
|
|
7
|
+
export { applyTenantSession, isValidDbRole, resolveRlsRole, resolveRlsRoleOrThrow, TENANT_SESSION_EXPRESSION, TENANT_SESSION_SETTING, tenantSessionOperations, } from "./rls-session.js";
|
|
11
8
|
const DEFAULT_TENANT_COLUMN = "tenant_id";
|
|
12
9
|
const DEFAULT_POLICY_PREFIX = "tenant_isolation";
|
|
13
|
-
|
|
10
|
+
// Same key the session core writes with set_config — policies and wrappers
|
|
11
|
+
// cannot disagree about which setting carries the tenant.
|
|
12
|
+
const DEFAULT_TENANT_EXPRESSION = TENANT_SESSION_EXPRESSION;
|
|
14
13
|
function assertNonEmptyIdentifier(value, label) {
|
|
15
14
|
if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {
|
|
16
15
|
throw new TenantIsolationError(`Invalid ${label} for RLS policy generation`, "shared_schema");
|
|
@@ -87,8 +86,10 @@ export function generateRlsPolicySql(options) {
|
|
|
87
86
|
* Works with PostgreSQL RLS policies that check `app.current_tenant_id`.
|
|
88
87
|
* This is the standard pattern for shared-schema multi-tenancy.
|
|
89
88
|
*
|
|
90
|
-
* The Prisma client
|
|
91
|
-
* application-level variable before executing.
|
|
89
|
+
* The Prisma client extension intercepts all queries and sets the
|
|
90
|
+
* application-level variable before executing. Missing `$extends`,
|
|
91
|
+
* `$transaction`, or `$executeRaw` throws `TenantIsolationError` instead
|
|
92
|
+
* of running an unisolated query. Production also requires `APP_DB_ROLE`.
|
|
92
93
|
*
|
|
93
94
|
* @param prisma The Prisma client to extend
|
|
94
95
|
* @param tenantId The tenant ID to set in RLS context
|
|
@@ -112,45 +113,51 @@ export function generateRlsPolicySql(options) {
|
|
|
112
113
|
* ```
|
|
113
114
|
*/
|
|
114
115
|
export function withRls(prisma, tenantId) {
|
|
116
|
+
const extendClient = prisma.$extends;
|
|
117
|
+
const runTransaction = prisma.$transaction;
|
|
118
|
+
const executeRaw = prisma.$executeRaw;
|
|
119
|
+
if (typeof extendClient !== "function") {
|
|
120
|
+
throw new TenantIsolationError("Prisma client does not support $extends; refusing unisolated queries", "shared_schema");
|
|
121
|
+
}
|
|
122
|
+
if (typeof runTransaction !== "function" || typeof executeRaw !== "function") {
|
|
123
|
+
throw new TenantIsolationError("Prisma client cannot apply transaction-local RLS; refusing unisolated queries", "shared_schema");
|
|
124
|
+
}
|
|
125
|
+
// Closure P1.3: an unusable APP_DB_ROLE (not a bare SQL identifier) throws
|
|
126
|
+
// here instead of resolving to null and silently running without RLS.
|
|
127
|
+
const rlsRole = resolveRlsRoleOrThrow();
|
|
128
|
+
if (process.env.NODE_ENV === "production" && !rlsRole) {
|
|
129
|
+
throw new TenantIsolationError("APP_DB_ROLE is required in production so withRls cannot run as a BYPASSRLS owner", "shared_schema");
|
|
130
|
+
}
|
|
131
|
+
// Prisma's `$executeRaw*` and `$transaction` are prototype methods that read
|
|
132
|
+
// `this`, so the session core is handed bound adapters rather than the
|
|
133
|
+
// detached functions captured above for the capability checks.
|
|
134
|
+
const executeRawUnsafe = prisma.$executeRawUnsafe;
|
|
135
|
+
const executor = {
|
|
136
|
+
$executeRaw: (query, ...values) => executeRaw.call(prisma, query, ...values),
|
|
137
|
+
$executeRawUnsafe: typeof executeRawUnsafe === "function"
|
|
138
|
+
? (query) => executeRawUnsafe.call(prisma, query)
|
|
139
|
+
: undefined,
|
|
140
|
+
};
|
|
115
141
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// outside a transaction does not guarantee the query lands on the
|
|
127
|
-
// same connection at all.
|
|
128
|
-
if (typeof prisma.$transaction === "function" &&
|
|
129
|
-
typeof prisma.$executeRaw === "function") {
|
|
130
|
-
const ops = [];
|
|
131
|
-
// Switch to the non-bypassrls role first (when configured) so RLS
|
|
132
|
-
// policies apply; role names can't be bound, hence validated raw.
|
|
133
|
-
if (RLS_ROLE && typeof prisma.$executeRawUnsafe === "function") {
|
|
134
|
-
ops.push(prisma.$executeRawUnsafe(`SET LOCAL ROLE "${RLS_ROLE}"`));
|
|
135
|
-
}
|
|
136
|
-
ops.push(prisma.$executeRaw `SELECT set_config('app.current_tenant_id', ${tenantId}, true)`);
|
|
137
|
-
ops.push(query(args));
|
|
138
|
-
const results = (await prisma.$transaction(ops));
|
|
139
|
-
logger.debug("RLS context set", { tenantId });
|
|
140
|
-
return results[results.length - 1];
|
|
141
|
-
}
|
|
142
|
-
return query(args);
|
|
143
|
-
},
|
|
142
|
+
const extended = extendClient.call(prisma, {
|
|
143
|
+
query: {
|
|
144
|
+
async $allOperations({ args, query, }) {
|
|
145
|
+
// One batch transaction: role switch (if configured) → tenant
|
|
146
|
+
// setting → the model query, all on the same connection.
|
|
147
|
+
const ops = tenantSessionOperations(executor, tenantId, { role: rlsRole });
|
|
148
|
+
ops.push(query(args));
|
|
149
|
+
const results = (await runTransaction.call(prisma, ops));
|
|
150
|
+
logger.debug("RLS context set", { tenantId });
|
|
151
|
+
return results[results.length - 1];
|
|
144
152
|
},
|
|
145
|
-
}
|
|
146
|
-
return extended;
|
|
147
|
-
}
|
|
148
|
-
logger.debug("withRls: Prisma client does not support $extends, returning original client", {
|
|
149
|
-
tenantId,
|
|
153
|
+
},
|
|
150
154
|
});
|
|
151
|
-
return
|
|
155
|
+
return extended;
|
|
152
156
|
}
|
|
153
157
|
catch (err) {
|
|
158
|
+
if (err instanceof TenantIsolationError) {
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
154
161
|
logger.error("Failed to apply RLS extension", err, { tenantId });
|
|
155
162
|
throw new TenantIsolationError(`Failed to apply RLS isolation for tenant ${tenantId}`, "shared_schema");
|
|
156
163
|
}
|
package/dist/middleware.d.ts
CHANGED
package/dist/middleware.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAkB,MAAM,YAAY,CAAC;AAO/D,iFAAiF;AACjF,UAAU,eAAe;IACvB,GAAG,EAAE;QACH,GAAG,EAAE;YAAE,OAAO,EAAE,OAAO,CAAA;SAAE,CAAC;QAC1B,GAAG,EAAE,MAAM,CAAC;QACZ,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;KAC9C,CAAC;IACF,IAAI,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,KAAK,QAAQ,CAAC;CACnD;AAYD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,GAAE,OAAO,CAAC,YAAY,CAAM,IAuBnD,GAAG,eAAe,EAAE,MAAM,MAAM,OAAO,CAAC,QAAQ,CAAC,uBAyDhE;AAMD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS,GAAG,EAAE,EAAE,CAAC,EAC3C,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EACvC,MAAM,GAAE,OAAO,CAAC,YAAY,CAAM,IAQpB,GAAG,MAAM,CAAC,KAAG,OAAO,CAAC,CAAC,CAAC,CAsDtC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,GAAG,EAAE,EAAE,CAAC,EACjD,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,EACvC,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,MAAM,GAAG,IAAI,IAE3C,GAAG,MAAM,CAAC,KAAG,OAAO,CAAC,CAAC,CAAC,CAiBtC"}
|
package/dist/middleware.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { logger } from "@nebutra/logger";
|
|
2
|
-
import { runWithTenant } from "./context";
|
|
3
|
-
import { fromHeader, fromJwtClaim, fromPath, fromSubdomain } from "./resolvers";
|
|
4
|
-
import { TenantRequiredError } from "./types";
|
|
2
|
+
import { runWithTenant } from "./context.js";
|
|
3
|
+
import { fromHeader, fromJwtClaim, fromPath, fromSubdomain } from "./resolvers.js";
|
|
4
|
+
import { TenantRequiredError } from "./types.js";
|
|
5
5
|
// =============================================================================
|
|
6
6
|
// Hono Middleware for Multi-Tenant Context
|
|
7
7
|
// =============================================================================
|
package/dist/react.d.ts
CHANGED
package/dist/react.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAEA,OAAO,KAAoC,MAAM,OAAO,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAEA,OAAO,KAAoC,MAAM,OAAO,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAehD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,cAAc,CAAC,EAC7B,QAAQ,EACR,KAAK,GACN,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,KAAK,EAAE,aAAa,CAAC;CACtB;;;;;;;WAMA;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,SAAS,IAAI,aAAa,CAUzC;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,IAAI,aAAa,GAAG,IAAI,CAEtD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAGpC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAEjD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,8CAG5B;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAGzD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAG3F;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAAC,CAAC,SAAS,MAAM,EAC9C,SAAS,EAAE,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC,EACjC,aAAa,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAAE,KAAK,EAAE,KAAK,CAAA;CAAE,CAAC;YAEpB,CAAC;eAFW,KAAK;;;EAqBnD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,EAC7B,QAAQ,EACR,QAAQ,GACT,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B,uDAYA"}
|
package/dist/react.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import React, { createContext, useContext } from "react";
|
|
3
|
-
import { TenantRequiredError } from "./types";
|
|
3
|
+
import { TenantRequiredError } from "./types.js";
|
|
4
4
|
// =============================================================================
|
|
5
5
|
// React Context for Tenant — Client-side multi-tenancy
|
|
6
6
|
// =============================================================================
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"from-auth-session.d.ts","sourceRoot":"","sources":["../../src/resolvers/from-auth-session.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"from-auth-session.d.ts","sourceRoot":"","sources":["../../src/resolvers/from-auth-session.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAMlD;;;;;;;;GAQG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,CAC1B,GAAG,EAAE,UAAU,CAAC,cAAc,CAAC,CAAC,CAAC,CAAC,KAC/B,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,GAAG,eAAe,GAAG,IAAI,CAAC;AAE9D;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,aAAa,GAAG,cAAc,CAgBzE"}
|
package/dist/resolvers.d.ts
CHANGED
package/dist/resolvers.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolvers.d.ts","sourceRoot":"","sources":["../src/resolvers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"resolvers.d.ts","sourceRoot":"","sources":["../src/resolvers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAsDjD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,UAAU,CAAC,UAAU,GAAE,MAAsB,GAAG,cAAc,CAW7E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,cAAc,CAsB7D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,cAAc,CAwBvD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,cAAc,CAsB9D;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,cAAc,CAiB/F;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,OAAO,CAAC,GAAG,SAAS,EAAE,cAAc,EAAE,GAAG,cAAc,CAiBtE"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant session core — how a PostgreSQL transaction is scoped to a tenant for
|
|
3
|
+
* row-level security, shared by both public wrappers:
|
|
4
|
+
*
|
|
5
|
+
* - `withRls(prisma, tenantId)` (`@nebutra/tenant/isolation`)
|
|
6
|
+
* - `withTenantContext(prisma, tenantId, cb)` (`@nebutra/db/rls`)
|
|
7
|
+
*
|
|
8
|
+
* Both issue exactly the statements this module produces. The RLS policies
|
|
9
|
+
* (`generateRlsPolicySql`, migration `20260313000000_enable_rls`) read
|
|
10
|
+
* `current_setting('app.current_tenant_id', true)`; the wrappers write the same
|
|
11
|
+
* key through `set_config(..., true)`, so the value is transaction-local and
|
|
12
|
+
* cannot leak across pooled connections. Keeping the key and the statements
|
|
13
|
+
* here — rather than once per wrapper — is closure item P1.2: two copies of a
|
|
14
|
+
* security invariant drift, one copy cannot.
|
|
15
|
+
*
|
|
16
|
+
* Closure P1.3: an `APP_DB_ROLE` that is configured but unusable must refuse
|
|
17
|
+
* to run rather than quietly drop the role switch and execute as the
|
|
18
|
+
* connection's own (possibly BYPASSRLS) role. Two shapes of "unusable" are
|
|
19
|
+
* handled here — `resolveSessionRole` refuses a value that fails
|
|
20
|
+
* `isValidDbRole`, and `planTenantSession` refuses when the executor cannot
|
|
21
|
+
* run `$executeRawUnsafe` at all, so the role switch has nowhere to go.
|
|
22
|
+
* Neither case matters to `getTenantDb` in `@nebutra/db` (`src/client.ts`):
|
|
23
|
+
* it still carries its own copy of the RLS statements, closes the same gap
|
|
24
|
+
* with its own verification (`rls-role.ts`), and is unaffected either way.
|
|
25
|
+
*
|
|
26
|
+
* Not yet routed through here: `getTenantDb` in `@nebutra/db` (`src/client.ts`)
|
|
27
|
+
* still carries its own copy of these statements, with a
|
|
28
|
+
* `SET LOCAL statement_timeout` between the role switch and `set_config`. The
|
|
29
|
+
* P1.2 follow-up moves it onto `tenantSessionOperations`; until then that copy
|
|
30
|
+
* is the one other place these statements exist, and it must not gain siblings.
|
|
31
|
+
*
|
|
32
|
+
* This module imports nothing outside `@nebutra/tenant` (only `./types`, whose
|
|
33
|
+
* sole dependency is zod): `@nebutra/db` consumes it, and it must stay usable
|
|
34
|
+
* from any Prisma-like executor (interactive transaction, batch transaction, or
|
|
35
|
+
* a client extension).
|
|
36
|
+
*/
|
|
37
|
+
/** PostgreSQL session setting the RLS policies compare `tenant_id` against. */
|
|
38
|
+
export declare const TENANT_SESSION_SETTING = "app.current_tenant_id";
|
|
39
|
+
/**
|
|
40
|
+
* SQL expression the generated RLS policies use to read the tenant. The second
|
|
41
|
+
* argument (`missing_ok = true`) makes an unset session yield NULL — which the
|
|
42
|
+
* policy predicate then rejects — instead of raising.
|
|
43
|
+
*/
|
|
44
|
+
export declare const TENANT_SESSION_EXPRESSION = "current_setting('app.current_tenant_id', true)";
|
|
45
|
+
/** True when `role` is a bare SQL identifier safe to interpolate into `SET LOCAL ROLE`. */
|
|
46
|
+
export declare function isValidDbRole(role: unknown): role is string;
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the optional non-BYPASSRLS role tenant-scoped transactions assume —
|
|
49
|
+
* e.g. `app_user` on Supabase, whose `postgres` connection role bypasses RLS.
|
|
50
|
+
*
|
|
51
|
+
* Resolved at call time (not module load) so every wrapper sees the same
|
|
52
|
+
* environment and tests can exercise both shapes.
|
|
53
|
+
*
|
|
54
|
+
* @returns the validated role, or `null` when unset or not a bare identifier
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveRlsRole(env?: {
|
|
57
|
+
APP_DB_ROLE?: string | undefined;
|
|
58
|
+
}): string | null;
|
|
59
|
+
/**
|
|
60
|
+
* Resolve `APP_DB_ROLE` the way `resolveRlsRole` does, but fail closed:
|
|
61
|
+
* when it is set to something that is not a bare SQL identifier, throw
|
|
62
|
+
* `TenantIsolationError` instead of silently returning `null`.
|
|
63
|
+
*
|
|
64
|
+
* Closure P1.3 — `resolveRlsRole`'s null-on-invalid contract is what let an
|
|
65
|
+
* unusable `APP_DB_ROLE` disable RLS silently: every caller that treated
|
|
66
|
+
* `null` as "no role configured" ran the query as the connection's own
|
|
67
|
+
* (possibly BYPASSRLS) role instead of refusing. `resolveRlsRole` keeps that
|
|
68
|
+
* permissive contract for callers that genuinely want it (diagnostics,
|
|
69
|
+
* tooling); every tenant-scoped code path resolves the role through this
|
|
70
|
+
* function instead.
|
|
71
|
+
*
|
|
72
|
+
* @throws TenantIsolationError when `APP_DB_ROLE` is set but not a bare SQL
|
|
73
|
+
* identifier.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveRlsRoleOrThrow(env?: {
|
|
76
|
+
APP_DB_ROLE?: string | undefined;
|
|
77
|
+
}): string | null;
|
|
78
|
+
/**
|
|
79
|
+
* The subset of a Prisma client (or interactive-transaction client) the tenant
|
|
80
|
+
* session needs. `$executeRaw` is tagged-template only so `tenantId` is always
|
|
81
|
+
* bound as a parameter; `$executeRawUnsafe` carries the role switch, whose
|
|
82
|
+
* identifier is validated by `isValidDbRole` before it is interpolated.
|
|
83
|
+
*/
|
|
84
|
+
export interface TenantSessionExecutor {
|
|
85
|
+
$executeRaw: (query: TemplateStringsArray, ...values: unknown[]) => PromiseLike<number>;
|
|
86
|
+
$executeRawUnsafe?: ((query: string) => PromiseLike<number>) | undefined;
|
|
87
|
+
}
|
|
88
|
+
export interface TenantSessionOptions {
|
|
89
|
+
/**
|
|
90
|
+
* Role to assume before scoping the transaction.
|
|
91
|
+
*
|
|
92
|
+
* - `undefined` (default): resolve from `APP_DB_ROLE` via `resolveRlsRoleOrThrow()`
|
|
93
|
+
* - `null`: never switch role (admin / migration paths that must run as owner)
|
|
94
|
+
* - a string: use this validated identifier
|
|
95
|
+
*/
|
|
96
|
+
role?: string | null | undefined;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Issue the tenant-session statements through `executor` and return them in
|
|
100
|
+
* order, without awaiting. Prisma promises are lazy, so the returned array can
|
|
101
|
+
* be spread into a batch `$transaction([...ops, query])` and will run inside
|
|
102
|
+
* that transaction, in order, ahead of the query.
|
|
103
|
+
*/
|
|
104
|
+
export declare function tenantSessionOperations(executor: TenantSessionExecutor, tenantId: string, options?: TenantSessionOptions): PromiseLike<number>[];
|
|
105
|
+
/**
|
|
106
|
+
* Run the tenant-session statements sequentially on `executor` — an interactive
|
|
107
|
+
* transaction client — and resolve once the transaction is scoped to `tenantId`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function applyTenantSession(executor: TenantSessionExecutor, tenantId: string, options?: TenantSessionOptions): Promise<void>;
|
|
110
|
+
//# sourceMappingURL=rls-session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rls-session.d.ts","sourceRoot":"","sources":["../src/rls-session.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAIH,+EAA+E;AAC/E,eAAO,MAAM,sBAAsB,0BAA0B,CAAC;AAE9D;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,mDAAuD,CAAC;AAQ9F,2FAA2F;AAC3F,wBAAgB,aAAa,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,MAAM,CAE3D;AAED;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,GAAG,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAgB,GACtD,MAAM,GAAG,IAAI,CAGf;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACnC,GAAG,GAAE;IAAE,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAgB,GACtD,MAAM,GAAG,IAAI,CAaf;AAED;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC,WAAW,EAAE,CAAC,KAAK,EAAE,oBAAoB,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,KAAK,WAAW,CAAC,MAAM,CAAC,CAAC;IACxF,iBAAiB,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,WAAW,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;CAC1E;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CAClC;AAoED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,qBAAqB,EAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,oBAAyB,GACjC,WAAW,CAAC,MAAM,CAAC,EAAE,CAGvB;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,qBAAqB,EAC/B,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,IAAI,CAAC,CAKf"}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant session core — how a PostgreSQL transaction is scoped to a tenant for
|
|
3
|
+
* row-level security, shared by both public wrappers:
|
|
4
|
+
*
|
|
5
|
+
* - `withRls(prisma, tenantId)` (`@nebutra/tenant/isolation`)
|
|
6
|
+
* - `withTenantContext(prisma, tenantId, cb)` (`@nebutra/db/rls`)
|
|
7
|
+
*
|
|
8
|
+
* Both issue exactly the statements this module produces. The RLS policies
|
|
9
|
+
* (`generateRlsPolicySql`, migration `20260313000000_enable_rls`) read
|
|
10
|
+
* `current_setting('app.current_tenant_id', true)`; the wrappers write the same
|
|
11
|
+
* key through `set_config(..., true)`, so the value is transaction-local and
|
|
12
|
+
* cannot leak across pooled connections. Keeping the key and the statements
|
|
13
|
+
* here — rather than once per wrapper — is closure item P1.2: two copies of a
|
|
14
|
+
* security invariant drift, one copy cannot.
|
|
15
|
+
*
|
|
16
|
+
* Closure P1.3: an `APP_DB_ROLE` that is configured but unusable must refuse
|
|
17
|
+
* to run rather than quietly drop the role switch and execute as the
|
|
18
|
+
* connection's own (possibly BYPASSRLS) role. Two shapes of "unusable" are
|
|
19
|
+
* handled here — `resolveSessionRole` refuses a value that fails
|
|
20
|
+
* `isValidDbRole`, and `planTenantSession` refuses when the executor cannot
|
|
21
|
+
* run `$executeRawUnsafe` at all, so the role switch has nowhere to go.
|
|
22
|
+
* Neither case matters to `getTenantDb` in `@nebutra/db` (`src/client.ts`):
|
|
23
|
+
* it still carries its own copy of the RLS statements, closes the same gap
|
|
24
|
+
* with its own verification (`rls-role.ts`), and is unaffected either way.
|
|
25
|
+
*
|
|
26
|
+
* Not yet routed through here: `getTenantDb` in `@nebutra/db` (`src/client.ts`)
|
|
27
|
+
* still carries its own copy of these statements, with a
|
|
28
|
+
* `SET LOCAL statement_timeout` between the role switch and `set_config`. The
|
|
29
|
+
* P1.2 follow-up moves it onto `tenantSessionOperations`; until then that copy
|
|
30
|
+
* is the one other place these statements exist, and it must not gain siblings.
|
|
31
|
+
*
|
|
32
|
+
* This module imports nothing outside `@nebutra/tenant` (only `./types`, whose
|
|
33
|
+
* sole dependency is zod): `@nebutra/db` consumes it, and it must stay usable
|
|
34
|
+
* from any Prisma-like executor (interactive transaction, batch transaction, or
|
|
35
|
+
* a client extension).
|
|
36
|
+
*/
|
|
37
|
+
import { TenantIsolationError } from "./types.js";
|
|
38
|
+
/** PostgreSQL session setting the RLS policies compare `tenant_id` against. */
|
|
39
|
+
export const TENANT_SESSION_SETTING = "app.current_tenant_id";
|
|
40
|
+
/**
|
|
41
|
+
* SQL expression the generated RLS policies use to read the tenant. The second
|
|
42
|
+
* argument (`missing_ok = true`) makes an unset session yield NULL — which the
|
|
43
|
+
* policy predicate then rejects — instead of raising.
|
|
44
|
+
*/
|
|
45
|
+
export const TENANT_SESSION_EXPRESSION = `current_setting('${TENANT_SESSION_SETTING}', true)`;
|
|
46
|
+
/**
|
|
47
|
+
* A bare SQL identifier: the only shape `APP_DB_ROLE` may take, because
|
|
48
|
+
* `SET LOCAL ROLE` cannot be bind-parameterized and the role is interpolated.
|
|
49
|
+
*/
|
|
50
|
+
const DB_ROLE_PATTERN = /^[a-z_][a-z0-9_]*$/;
|
|
51
|
+
/** True when `role` is a bare SQL identifier safe to interpolate into `SET LOCAL ROLE`. */
|
|
52
|
+
export function isValidDbRole(role) {
|
|
53
|
+
return typeof role === "string" && DB_ROLE_PATTERN.test(role);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the optional non-BYPASSRLS role tenant-scoped transactions assume —
|
|
57
|
+
* e.g. `app_user` on Supabase, whose `postgres` connection role bypasses RLS.
|
|
58
|
+
*
|
|
59
|
+
* Resolved at call time (not module load) so every wrapper sees the same
|
|
60
|
+
* environment and tests can exercise both shapes.
|
|
61
|
+
*
|
|
62
|
+
* @returns the validated role, or `null` when unset or not a bare identifier
|
|
63
|
+
*/
|
|
64
|
+
export function resolveRlsRole(env = process.env) {
|
|
65
|
+
const role = env.APP_DB_ROLE;
|
|
66
|
+
return isValidDbRole(role) ? role : null;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve `APP_DB_ROLE` the way `resolveRlsRole` does, but fail closed:
|
|
70
|
+
* when it is set to something that is not a bare SQL identifier, throw
|
|
71
|
+
* `TenantIsolationError` instead of silently returning `null`.
|
|
72
|
+
*
|
|
73
|
+
* Closure P1.3 — `resolveRlsRole`'s null-on-invalid contract is what let an
|
|
74
|
+
* unusable `APP_DB_ROLE` disable RLS silently: every caller that treated
|
|
75
|
+
* `null` as "no role configured" ran the query as the connection's own
|
|
76
|
+
* (possibly BYPASSRLS) role instead of refusing. `resolveRlsRole` keeps that
|
|
77
|
+
* permissive contract for callers that genuinely want it (diagnostics,
|
|
78
|
+
* tooling); every tenant-scoped code path resolves the role through this
|
|
79
|
+
* function instead.
|
|
80
|
+
*
|
|
81
|
+
* @throws TenantIsolationError when `APP_DB_ROLE` is set but not a bare SQL
|
|
82
|
+
* identifier.
|
|
83
|
+
*/
|
|
84
|
+
export function resolveRlsRoleOrThrow(env = process.env) {
|
|
85
|
+
const role = env.APP_DB_ROLE;
|
|
86
|
+
if (role === undefined || role === "")
|
|
87
|
+
return null;
|
|
88
|
+
if (!isValidDbRole(role)) {
|
|
89
|
+
throw new TenantIsolationError(`APP_DB_ROLE is set to ${JSON.stringify(role)}, which is not a bare SQL identifier ` +
|
|
90
|
+
"(expected /^[a-z_][a-z0-9_]*$/). Refusing to run tenant-scoped queries: an invalid " +
|
|
91
|
+
"role would otherwise be skipped silently, running the query as the connection's own " +
|
|
92
|
+
"(possibly BYPASSRLS) role instead of under row-level security.", "shared_schema");
|
|
93
|
+
}
|
|
94
|
+
return role;
|
|
95
|
+
}
|
|
96
|
+
function resolveSessionRole(options) {
|
|
97
|
+
if (options.role === undefined) {
|
|
98
|
+
// Closure P1.3: fail closed on an unusable APP_DB_ROLE instead of the
|
|
99
|
+
// permissive `resolveRlsRole()` silently treating it as unset.
|
|
100
|
+
return resolveRlsRoleOrThrow();
|
|
101
|
+
}
|
|
102
|
+
if (options.role === null) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (!isValidDbRole(options.role)) {
|
|
106
|
+
throw new TenantIsolationError(`Tenant session role must be a bare SQL identifier (got ${JSON.stringify(options.role)})`, "shared_schema");
|
|
107
|
+
}
|
|
108
|
+
return options.role;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The ordered statement plan for scoping one transaction to `tenantId`:
|
|
112
|
+
*
|
|
113
|
+
* 1. `SET LOCAL ROLE "<role>"` — only when a role is configured, so the
|
|
114
|
+
* tenant setting below (and every query after it) runs as the
|
|
115
|
+
* non-BYPASSRLS role rather than the connection owner.
|
|
116
|
+
* 2. `SELECT set_config('app.current_tenant_id', $1, true)` — transaction-local
|
|
117
|
+
* (`true`), so it is cleared when the transaction commits or rolls back.
|
|
118
|
+
*
|
|
119
|
+
* A role is refused, not skipped, when the executor cannot run
|
|
120
|
+
* `$executeRawUnsafe`: closure P1.3 turned the pre-merge `withRls` behaviour
|
|
121
|
+
* (silently skip the role switch) into a refusal, since skipping it here
|
|
122
|
+
* means the query after it runs as the connection's own role instead of the
|
|
123
|
+
* one `APP_DB_ROLE` configured.
|
|
124
|
+
*/
|
|
125
|
+
function planTenantSession(executor, tenantId, role) {
|
|
126
|
+
const plan = [];
|
|
127
|
+
if (role) {
|
|
128
|
+
const switchRole = executor.$executeRawUnsafe;
|
|
129
|
+
if (typeof switchRole !== "function") {
|
|
130
|
+
throw new TenantIsolationError(`APP_DB_ROLE is set to ${JSON.stringify(role)}, but this executor cannot run ` +
|
|
131
|
+
"$executeRawUnsafe to SET LOCAL ROLE. Refusing to run tenant-scoped queries: " +
|
|
132
|
+
"skipping the role switch would run them as the connection's own (possibly " +
|
|
133
|
+
"BYPASSRLS) role instead of under row-level security.", "shared_schema");
|
|
134
|
+
}
|
|
135
|
+
// `role` matched DB_ROLE_PATTERN, so it is safe to interpolate — SET LOCAL
|
|
136
|
+
// ROLE cannot take a bind parameter.
|
|
137
|
+
plan.push(() => switchRole.call(executor, `SET LOCAL ROLE "${role}"`));
|
|
138
|
+
}
|
|
139
|
+
// transaction-local: cleared automatically when the transaction ends.
|
|
140
|
+
plan.push(() => executor.$executeRaw `SELECT set_config('app.current_tenant_id', ${tenantId}, true)`);
|
|
141
|
+
return plan;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Issue the tenant-session statements through `executor` and return them in
|
|
145
|
+
* order, without awaiting. Prisma promises are lazy, so the returned array can
|
|
146
|
+
* be spread into a batch `$transaction([...ops, query])` and will run inside
|
|
147
|
+
* that transaction, in order, ahead of the query.
|
|
148
|
+
*/
|
|
149
|
+
export function tenantSessionOperations(executor, tenantId, options = {}) {
|
|
150
|
+
const role = resolveSessionRole(options);
|
|
151
|
+
return planTenantSession(executor, tenantId, role).map((statement) => statement());
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Run the tenant-session statements sequentially on `executor` — an interactive
|
|
155
|
+
* transaction client — and resolve once the transaction is scoped to `tenantId`.
|
|
156
|
+
*/
|
|
157
|
+
export async function applyTenantSession(executor, tenantId, options = {}) {
|
|
158
|
+
const role = resolveSessionRole(options);
|
|
159
|
+
for (const statement of planTenantSession(executor, tenantId, role)) {
|
|
160
|
+
await statement();
|
|
161
|
+
}
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebutra/tenant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"nebutra": {
|
|
8
|
+
"graph": "core",
|
|
8
9
|
"status": "foundation",
|
|
9
10
|
"productionReady": false,
|
|
10
11
|
"requires": [
|
|
@@ -44,9 +45,19 @@
|
|
|
44
45
|
],
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"zod": "^4.3.6",
|
|
47
|
-
"@nebutra/logger": "0.
|
|
48
|
+
"@nebutra/logger": "2.0.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"react": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
48
57
|
},
|
|
49
58
|
"devDependencies": {
|
|
59
|
+
"@types/react": "^19.0.0",
|
|
60
|
+
"react": "^19.0.0",
|
|
50
61
|
"vitest": "^4.1.4",
|
|
51
62
|
"typescript": "^5.9.3"
|
|
52
63
|
},
|