@nebutra/tenant 0.1.2 → 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.
@@ -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.1.2",
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.1.1"
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
  },