@btravstack/prisma 0.32.0 → 0.34.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 CHANGED
@@ -17,6 +17,14 @@ copy of each. The `prisma` CLI is yours, as a dev dependency: the client is
17
17
  built from _your_ emitted contract, so there is none for this package to ship.
18
18
  Node `>=22.12`.
19
19
 
20
+ **Production packaging:** the pinned Postgres facade requires
21
+ `@prisma/orm-toolchain`, including its CLI-engine peer and build-tool
22
+ closure. A `/runtime` import and `pnpm deploy --prod` do not remove those
23
+ transitive dependencies. Keep contract emission in the build stage and
24
+ migrations in a deployment Job, with strict peers enabled; a toolchain-free
25
+ runtime install remains [blocked upstream](https://btravstack.github.io/btravstack/reference/prisma#production-dependency-closure).
26
+ That section documents the manifest split and the tracked dependency.
27
+
20
28
  **This is Prisma 8**, which is a different package family rather than a version
21
29
  bump — `@prisma/orm-postgres` replaces `@prisma/client` and `@prisma/adapter-pg`
22
30
  both, and there is no `@prisma/instrumentation`. For Prisma 7 use
@@ -162,9 +170,19 @@ nor a superuser — the boot refuses either with `RowSecurityBypassed`. The
162
170
  [reference page](https://btravstack.github.io/btravstack/reference/prisma)
163
171
  carries the DDL and the smaller lines that fail the same way.
164
172
 
173
+ ## Tenant discovery for scheduled work
174
+
175
+ `tenantListing` on `/rls` declares a tenant-ID-only query (100 IDs per page,
176
+ resuming after the last ID) and a
177
+ checked Prisma migration installing its function, owner policy and execution
178
+ grant. It works under forced row-level security while ordinary application
179
+ reads remain tenant-scoped. Shared data does not require a privileged
180
+ application connection. See
181
+ [tenant listing](https://btravstack.github.io/btravstack/reference/prisma#list-tenants-without-cross-tenant-row-access).
182
+
165
183
  ## What it does not
166
184
 
167
- **Migrations.** A deployment runs `prisma db migrate` against the same database
185
+ **Running migrations.** A deployment runs `prisma db migrate` against the same database
168
186
  _before the process starts_ — and, where row security is on, as its **owner**
169
187
  rather than the role `DATABASE_URL` carries. An application that migrates itself
170
188
  at boot races every other replica.
@@ -184,13 +202,15 @@ package's.
184
202
 
185
203
  ## Options
186
204
 
187
- | Option | Where | What it is |
188
- | -------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
189
- | `name` | `prismaDatabase(name)` | the port's id and the health check's name — required |
190
- | `client` | `prismaDatabase(name)({ client })` | builds your client from the URL and middleware this package bound — required |
191
- | `DATABASE_URL` | environment, read by `prismaDatabase(name)` | the connection string — required, validated at graph build; blank is an error, exit `78` |
192
- | `setting` | `tenantPinned(db, tenant, work, { setting })` | the PostgreSQL run-time setting the policy reads — default `app.tenant_id` |
193
- | `raises` | `tryQuery(run, { raises })`, `tenantPinned(…, { raises })` | SQLSTATEs modeled as `RaisedException` rather than defects — default none |
205
+ | Option | Where | What it is |
206
+ | --------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
207
+ | `name` | `prismaDatabase(name)` | the port's id and the health check's name — required |
208
+ | `client` | `prismaDatabase(name)({ client })` | builds your client from the URL and middleware this package bound — required |
209
+ | `DATABASE_URL` | environment, read by `prismaDatabase(name)` | the connection string — required, validated at graph build; blank is an error, exit `78` |
210
+ | `setting` | `tenantPinned(db, tenant, work, { setting })` | the PostgreSQL run-time setting the policy reads — default `app.tenant_id` |
211
+ | `raises` | `tryQuery(run, { raises })`, `tenantPinned(…, { raises })` | SQLSTATEs modeled as `RaisedException` rather than defects — default none |
212
+ | `schema`, `table`, `tenantColumn` | `tenantListing(options)` | the physical table and tenant column for a narrow listing |
213
+ | `name`, `owner`, `role` | `tenantListing(options)` | the listing function, its trusted owner and application caller |
194
214
 
195
215
  There is **no `instrumented` flag**: observation is a set port every call is
196
216
  handed to, so a graph composing no observability pays one inert call and no
package/dist/rls.cjs CHANGED
@@ -1,5 +1,176 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_result = require("./result.cjs");
3
+ let unthrown = require("unthrown");
4
+ //#region src/tenant-listing.ts
5
+ /** A tenant-listing declaration names an invalid identifier or shares its privileged owner with its caller. */
6
+ var TenantListingInvalid = class extends (0, unthrown.TaggedError)("TenantListingInvalid") {};
7
+ const identifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
8
+ const literal = (value) => `E'${value.replaceAll("\\", "\\\\").replaceAll("'", "''")}'`;
9
+ const statement = (parts) => Object.assign(parts, { raw: [...parts] });
10
+ /**
11
+ * Declare a paged SECURITY DEFINER function returning distinct, non-null
12
+ * tenant IDs as text, and a typed query calling only that function. Each page
13
+ * holds at most 100 IDs in bytewise order; resume after its last ID until empty.
14
+ *
15
+ * Pass `migration` to Prisma 8's `rawSql` inside a reviewed migration. Its
16
+ * transaction installs an owner-only SELECT policy, grants the owner access
17
+ * to the tenant column, fixes the function's search path, revokes PUBLIC
18
+ * execution and grants execution to `role`. Existing table policies and FORCE
19
+ * ROW LEVEL SECURITY are left intact. No application table privilege is added.
20
+ *
21
+ * Roles and the table must already exist. The migrator must be able to SET ROLE
22
+ * to the owner; provision that membership for an ordinary migration role.
23
+ * The migration refuses a privileged application or function-owner role, caller membership in the owner, schema
24
+ * CREATE for the caller, owner payload SELECT, or any same-name function overload. Its postcheck verifies the
25
+ * function, policy and execution grants before Prisma records it as applied.
26
+ * Apply the operation transactionally; the function is initially private only
27
+ * once the REVOKE runs. Do not grant the application the owner role afterwards.
28
+ */
29
+ const tenantListing = (options) => {
30
+ const names = {
31
+ schema: options.schema,
32
+ table: options.table,
33
+ tenantColumn: options.tenantColumn ?? "tenantId",
34
+ name: options.name,
35
+ owner: options.owner,
36
+ role: options.role
37
+ };
38
+ for (const [field, value] of Object.entries(names)) if (typeof value !== "string" || value.length === 0 || value.includes("\0") || Buffer.from(value, "utf8").toString("utf8") !== value || Buffer.byteLength(value, "utf8") > 63) return (0, unthrown.Err)(new TenantListingInvalid({
39
+ field,
40
+ reason: "invalid-identifier"
41
+ }));
42
+ if (names.owner === names.role) return (0, unthrown.Err)(new TenantListingInvalid({
43
+ field: "role",
44
+ reason: "shared-role"
45
+ }));
46
+ const schema = identifier(names.schema);
47
+ const table = `${schema}.${identifier(names.table)}`;
48
+ const fn = `${schema}.${identifier(names.name)}`;
49
+ const owner = identifier(names.owner);
50
+ const role = identifier(names.role);
51
+ const column = identifier(names.tenantColumn);
52
+ const functionRef = `pg_catalog.to_regprocedure(${literal(`${fn}(pg_catalog.text)`)})`;
53
+ const tableRef = `pg_catalog.to_regclass(${literal(table)})`;
54
+ const ownerRef = `(SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(names.owner)})`;
55
+ const roleRef = `(SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(names.role)})`;
56
+ const body = `SELECT DISTINCT ${column}::pg_catalog.text COLLATE pg_catalog."C" AS "tenantId" FROM ${table} WHERE ${column} IS NOT NULL AND ($1 IS NULL OR ${column}::pg_catalog.text COLLATE pg_catalog."C" > $1 COLLATE pg_catalog."C") ORDER BY "tenantId" LIMIT 100`;
57
+ const policy = `CURRENT_USER = ${literal(names.owner)}::pg_catalog.name`;
58
+ const assumption = "CASE WHEN pg_catalog.current_setting('server_version_num')::pg_catalog.int4 >= 160000 THEN 'SET' ELSE 'MEMBER' END";
59
+ const requirements = `EXISTS (
60
+ SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
61
+ JOIN pg_catalog.pg_roles o ON o.rolname = ${literal(names.owner)}
62
+ JOIN pg_catalog.pg_roles r ON r.rolname = ${literal(names.role)}
63
+ WHERE c.oid = ${tableRef} AND c.relrowsecurity AND a.attname = ${literal(names.tenantColumn)} AND a.attnum > 0 AND NOT a.attisdropped
64
+ AND NOT o.rolsuper AND NOT o.rolbypassrls AND NOT r.rolsuper AND NOT r.rolbypassrls
65
+ AND NOT pg_catalog.pg_has_role(r.oid, o.oid, 'MEMBER')
66
+ AND NOT EXISTS (
67
+ SELECT 1 FROM pg_catalog.pg_attribute payload
68
+ WHERE payload.attrelid = c.oid AND payload.attnum > 0 AND NOT payload.attisdropped
69
+ AND payload.attname <> ${literal(names.tenantColumn)}
70
+ AND pg_catalog.has_column_privilege(o.oid, c.oid, payload.attname, 'SELECT')
71
+ )
72
+ AND pg_catalog.has_schema_privilege(o.oid, ${literal(names.schema)}, 'CREATE')
73
+ AND NOT EXISTS (
74
+ SELECT 1 FROM pg_catalog.pg_roles creator
75
+ WHERE pg_catalog.pg_has_role(r.oid, creator.oid, ${assumption})
76
+ AND pg_catalog.has_schema_privilege(creator.oid, ${literal(names.schema)}, 'CREATE')
77
+ )
78
+ AND NOT EXISTS (
79
+ SELECT 1 FROM pg_catalog.pg_policy blocked WHERE blocked.polrelid = c.oid
80
+ AND NOT blocked.polpermissive AND blocked.polcmd IN ('r', '*')
81
+ AND EXISTS (SELECT 1 FROM pg_catalog.unnest(blocked.polroles) inherited(role)
82
+ WHERE CASE WHEN inherited.role = 0 THEN true ELSE pg_catalog.pg_has_role(o.oid, inherited.role, 'USAGE') END)
83
+ )
84
+ )`;
85
+ const functions = `SELECT p.oid FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = ${literal(names.schema)} AND p.proname = ${literal(names.name)}`;
86
+ const ready = `SELECT ${requirements} AND (SELECT count(*) FROM (${functions}) candidates) = 1 AND EXISTS (
87
+ SELECT 1 FROM pg_catalog.pg_proc p
88
+ WHERE p.oid = ${functionRef}
89
+ AND p.prosecdef AND NOT p.proisstrict AND p.proretset AND p.pronargs = 1 AND p.pronargdefaults = 1 AND pg_catalog.pg_get_expr(p.proargdefaults, 0) IN ('NULL::text', 'NULL::pg_catalog.text') AND p.provolatile = 's'
90
+ AND p.prolang = (SELECT oid FROM pg_catalog.pg_language WHERE lanname = 'sql')
91
+ AND p.proowner = ${ownerRef} AND p.prosrc = ${literal(body)}
92
+ AND p.proallargtypes = ARRAY['pg_catalog.text'::pg_catalog.regtype, 'pg_catalog.text'::pg_catalog.regtype]::pg_catalog.oid[]
93
+ AND p.proargnames = ARRAY['after', 'tenantId']::pg_catalog.text[] AND p.proargmodes = ARRAY['i', 't']::pg_catalog."char"[]
94
+ AND p.proconfig = ARRAY['search_path=pg_catalog, pg_temp', 'row_security=on']::pg_catalog.text[]
95
+ AND pg_catalog.has_function_privilege(${roleRef}, p.oid, 'EXECUTE')
96
+ AND pg_catalog.has_schema_privilege(${roleRef}, ${literal(names.schema)}, 'USAGE')
97
+ AND pg_catalog.has_schema_privilege(${ownerRef}, ${literal(names.schema)}, 'USAGE')
98
+ AND pg_catalog.has_column_privilege(${ownerRef}, ${tableRef}, ${literal(names.tenantColumn)}, 'SELECT')
99
+ AND NOT EXISTS (
100
+ SELECT 1 FROM pg_catalog.aclexplode(COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner))) a
101
+ WHERE a.privilege_type = 'EXECUTE' AND (a.grantee NOT IN (${ownerRef}, ${roleRef}) OR (a.grantee = ${roleRef} AND a.is_grantable))
102
+ )
103
+ AND EXISTS (
104
+ SELECT 1 FROM pg_catalog.pg_policy pol WHERE pol.polrelid = ${tableRef}
105
+ AND pol.polname = ${literal(names.name)} AND pol.polcmd = 'r' AND pol.polpermissive
106
+ AND pol.polroles = ARRAY[${ownerRef}]::pg_catalog.oid[]
107
+ AND pg_catalog.pg_get_expr(pol.polqual, pol.polrelid) = '(CURRENT_USER = ' || pg_catalog.quote_literal(${literal(names.owner)}) || '::name)'
108
+ )
109
+ ) AS "result"`;
110
+ const parts = [`SELECT "tenantId" FROM ${fn}(`, `) ORDER BY "tenantId" COLLATE pg_catalog."C"`];
111
+ return (0, unthrown.Ok)({
112
+ migration: {
113
+ id: `tenantListing.${encodeURIComponent(names.schema).replaceAll(".", "%2E")}.${encodeURIComponent(names.name).replaceAll(".", "%2E")}`,
114
+ label: `List tenant IDs from ${table}`,
115
+ operationClass: "additive",
116
+ target: {
117
+ id: "postgres",
118
+ details: {
119
+ schema: names.schema,
120
+ objectType: "rlsPolicy",
121
+ table: names.table,
122
+ name: names.name
123
+ }
124
+ },
125
+ precheck: [{
126
+ description: "require a row-secured table, restricted roles, and unused function and policy names",
127
+ sql: `SELECT ${requirements} AND pg_catalog.pg_has_role(CURRENT_USER, ${ownerRef}, ${assumption}) AND NOT EXISTS (${functions}) AND NOT EXISTS (
128
+ SELECT 1 FROM pg_catalog.pg_policy WHERE polrelid = ${tableRef} AND polname = ${literal(names.name)}
129
+ ) AS "result"`
130
+ }],
131
+ execute: [
132
+ {
133
+ description: "allow the function owner to read tenant IDs under forced RLS",
134
+ sql: `CREATE POLICY ${identifier(names.name)} ON ${table} FOR SELECT TO ${owner} USING (${policy})`
135
+ },
136
+ {
137
+ description: "grant schema access to the function owner and caller",
138
+ sql: `GRANT USAGE ON SCHEMA ${schema} TO ${owner}, ${role}`
139
+ },
140
+ {
141
+ description: "grant the function owner the tenant column",
142
+ sql: `GRANT SELECT (${column}) ON TABLE ${table} TO ${owner}`
143
+ },
144
+ {
145
+ description: "create the tenant-id-only function with a fixed search path",
146
+ sql: `CREATE FUNCTION ${fn}("after" pg_catalog.text DEFAULT NULL) RETURNS TABLE ("tenantId" pg_catalog.text) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, pg_temp SET row_security = on AS ${literal(body)}`
147
+ },
148
+ {
149
+ description: "remove public execution",
150
+ sql: `REVOKE ALL ON FUNCTION ${fn}(pg_catalog.text) FROM PUBLIC`
151
+ },
152
+ {
153
+ description: "grant execution to the application role",
154
+ sql: `GRANT EXECUTE ON FUNCTION ${fn}(pg_catalog.text) TO ${role}`
155
+ },
156
+ {
157
+ description: "assign the restricted function owner",
158
+ sql: `ALTER FUNCTION ${fn}(pg_catalog.text) OWNER TO ${owner}`
159
+ }
160
+ ],
161
+ postcheck: [{
162
+ description: "verify the listing function, owner policy, and narrow execution grants",
163
+ sql: ready
164
+ }]
165
+ },
166
+ list: (db, after) => require_result.tryQuery(() => {
167
+ const client = db;
168
+ const plan = (after === void 0 ? client.raw.sql(statement([`${parts[0]}NULL${parts[1]}`])) : client.raw.sql(statement(parts), after)).returnsRow({ tenantId: "pg/text@1" }).build();
169
+ return client.runtime().query(plan);
170
+ }).map((rows) => rows.map((row) => row.tenantId))
171
+ });
172
+ };
173
+ //#endregion
3
174
  //#region src/rls.ts
4
175
  /**
5
176
  * Runs `work` in a transaction whose connection is pinned to `tenant`, so a
@@ -38,4 +209,6 @@ const tenantPinned = (db, tenant, work, options) => {
38
209
  }), options);
39
210
  };
40
211
  //#endregion
212
+ exports.TenantListingInvalid = TenantListingInvalid;
213
+ exports.tenantListing = tenantListing;
41
214
  exports.tenantPinned = tenantPinned;
package/dist/rls.d.cts CHANGED
@@ -1,5 +1,77 @@
1
1
  import { QualifyOptions, Raised, SqlError } from "./result.cjs";
2
- import { AsyncResult } from "unthrown";
2
+ import { AsyncResult, Result } from "unthrown";
3
+ //#region src/tenant-listing.d.ts
4
+ declare const TenantListingInvalid_base: import("unthrown").TaggedErrorConstructor<"TenantListingInvalid">;
5
+ /** A tenant-listing declaration names an invalid identifier or shares its privileged owner with its caller. */
6
+ export declare class TenantListingInvalid extends TenantListingInvalid_base<{
7
+ readonly field: keyof TenantListingOptions;
8
+ readonly reason: "invalid-identifier" | "shared-role";
9
+ }> {}
10
+ /** Physical database names and existing roles for a narrow tenant-listing capability. */
11
+ type TenantListingOptions = {
12
+ readonly schema: string;
13
+ readonly table: string;
14
+ /** The physical tenant column. Default `tenantId`. */
15
+ readonly tenantColumn?: string;
16
+ /** A new paged function in `schema`; also names its select policy. */
17
+ readonly name: string;
18
+ /** Trusted function owner with no payload SELECT, superuser or BYPASSRLS; needs schema CREATE. */
19
+ readonly owner: string;
20
+ /** Application role granted execution, with no membership in `owner` and no schema CREATE privilege. */
21
+ readonly role: string;
22
+ };
23
+ type Statement = {
24
+ readonly description: string;
25
+ readonly sql: string;
26
+ };
27
+ /** A declaration's migration, ready for Prisma 8's `rawSql`, and its tenant-id-only query. */
28
+ type TenantListing = {
29
+ readonly migration: {
30
+ readonly id: string;
31
+ readonly label: string;
32
+ readonly operationClass: "additive";
33
+ readonly target: {
34
+ readonly id: "postgres";
35
+ readonly details: {
36
+ readonly schema: string;
37
+ readonly objectType: "rlsPolicy";
38
+ readonly table: string;
39
+ readonly name: string;
40
+ };
41
+ };
42
+ readonly precheck: readonly Statement[];
43
+ readonly execute: readonly Statement[];
44
+ readonly postcheck: readonly Statement[];
45
+ };
46
+ /** At most 100 IDs in bytewise order. Resume with the last ID; stop on an empty page. */
47
+ readonly list: (db: {
48
+ readonly raw: {
49
+ readonly sql: unknown;
50
+ };
51
+ readonly runtime: unknown;
52
+ }, after?: string) => AsyncResult<readonly string[], SqlError>;
53
+ };
54
+ /**
55
+ * Declare a paged SECURITY DEFINER function returning distinct, non-null
56
+ * tenant IDs as text, and a typed query calling only that function. Each page
57
+ * holds at most 100 IDs in bytewise order; resume after its last ID until empty.
58
+ *
59
+ * Pass `migration` to Prisma 8's `rawSql` inside a reviewed migration. Its
60
+ * transaction installs an owner-only SELECT policy, grants the owner access
61
+ * to the tenant column, fixes the function's search path, revokes PUBLIC
62
+ * execution and grants execution to `role`. Existing table policies and FORCE
63
+ * ROW LEVEL SECURITY are left intact. No application table privilege is added.
64
+ *
65
+ * Roles and the table must already exist. The migrator must be able to SET ROLE
66
+ * to the owner; provision that membership for an ordinary migration role.
67
+ * The migration refuses a privileged application or function-owner role, caller membership in the owner, schema
68
+ * CREATE for the caller, owner payload SELECT, or any same-name function overload. Its postcheck verifies the
69
+ * function, policy and execution grants before Prisma records it as applied.
70
+ * Apply the operation transactionally; the function is initially private only
71
+ * once the REVOKE runs. Do not grant the application the owner role afterwards.
72
+ */
73
+ export declare const tenantListing: (options: TenantListingOptions) => Result<TenantListing, TenantListingInvalid>;
74
+ //#endregion
3
75
  //#region src/rls.d.ts
4
76
  /**
5
77
  * Options of {@link tenantPinned}: `raises`, as {@link tryQuery} takes it,
@@ -63,4 +135,5 @@ type Queryable = {
63
135
  * ```
64
136
  */
65
137
  export declare const tenantPinned: <Tx extends Queryable, R, const S extends string = never>(db: Pinnable<Tx>, tenant: string, work: (tx: Tx) => PromiseLike<R>, options?: TenantPinnedOptions<S>) => AsyncResult<R, SqlError | Raised<S>>;
66
- //#endregion
138
+ //#endregion
139
+ export type { TenantListing, TenantListingOptions };
package/dist/rls.d.mts CHANGED
@@ -1,5 +1,77 @@
1
1
  import { QualifyOptions, Raised, SqlError } from "./result.mjs";
2
- import { AsyncResult } from "unthrown";
2
+ import { AsyncResult, Result } from "unthrown";
3
+ //#region src/tenant-listing.d.ts
4
+ declare const TenantListingInvalid_base: import("unthrown").TaggedErrorConstructor<"TenantListingInvalid">;
5
+ /** A tenant-listing declaration names an invalid identifier or shares its privileged owner with its caller. */
6
+ export declare class TenantListingInvalid extends TenantListingInvalid_base<{
7
+ readonly field: keyof TenantListingOptions;
8
+ readonly reason: "invalid-identifier" | "shared-role";
9
+ }> {}
10
+ /** Physical database names and existing roles for a narrow tenant-listing capability. */
11
+ type TenantListingOptions = {
12
+ readonly schema: string;
13
+ readonly table: string;
14
+ /** The physical tenant column. Default `tenantId`. */
15
+ readonly tenantColumn?: string;
16
+ /** A new paged function in `schema`; also names its select policy. */
17
+ readonly name: string;
18
+ /** Trusted function owner with no payload SELECT, superuser or BYPASSRLS; needs schema CREATE. */
19
+ readonly owner: string;
20
+ /** Application role granted execution, with no membership in `owner` and no schema CREATE privilege. */
21
+ readonly role: string;
22
+ };
23
+ type Statement = {
24
+ readonly description: string;
25
+ readonly sql: string;
26
+ };
27
+ /** A declaration's migration, ready for Prisma 8's `rawSql`, and its tenant-id-only query. */
28
+ type TenantListing = {
29
+ readonly migration: {
30
+ readonly id: string;
31
+ readonly label: string;
32
+ readonly operationClass: "additive";
33
+ readonly target: {
34
+ readonly id: "postgres";
35
+ readonly details: {
36
+ readonly schema: string;
37
+ readonly objectType: "rlsPolicy";
38
+ readonly table: string;
39
+ readonly name: string;
40
+ };
41
+ };
42
+ readonly precheck: readonly Statement[];
43
+ readonly execute: readonly Statement[];
44
+ readonly postcheck: readonly Statement[];
45
+ };
46
+ /** At most 100 IDs in bytewise order. Resume with the last ID; stop on an empty page. */
47
+ readonly list: (db: {
48
+ readonly raw: {
49
+ readonly sql: unknown;
50
+ };
51
+ readonly runtime: unknown;
52
+ }, after?: string) => AsyncResult<readonly string[], SqlError>;
53
+ };
54
+ /**
55
+ * Declare a paged SECURITY DEFINER function returning distinct, non-null
56
+ * tenant IDs as text, and a typed query calling only that function. Each page
57
+ * holds at most 100 IDs in bytewise order; resume after its last ID until empty.
58
+ *
59
+ * Pass `migration` to Prisma 8's `rawSql` inside a reviewed migration. Its
60
+ * transaction installs an owner-only SELECT policy, grants the owner access
61
+ * to the tenant column, fixes the function's search path, revokes PUBLIC
62
+ * execution and grants execution to `role`. Existing table policies and FORCE
63
+ * ROW LEVEL SECURITY are left intact. No application table privilege is added.
64
+ *
65
+ * Roles and the table must already exist. The migrator must be able to SET ROLE
66
+ * to the owner; provision that membership for an ordinary migration role.
67
+ * The migration refuses a privileged application or function-owner role, caller membership in the owner, schema
68
+ * CREATE for the caller, owner payload SELECT, or any same-name function overload. Its postcheck verifies the
69
+ * function, policy and execution grants before Prisma records it as applied.
70
+ * Apply the operation transactionally; the function is initially private only
71
+ * once the REVOKE runs. Do not grant the application the owner role afterwards.
72
+ */
73
+ export declare const tenantListing: (options: TenantListingOptions) => Result<TenantListing, TenantListingInvalid>;
74
+ //#endregion
3
75
  //#region src/rls.d.ts
4
76
  /**
5
77
  * Options of {@link tenantPinned}: `raises`, as {@link tryQuery} takes it,
@@ -63,4 +135,5 @@ type Queryable = {
63
135
  * ```
64
136
  */
65
137
  export declare const tenantPinned: <Tx extends Queryable, R, const S extends string = never>(db: Pinnable<Tx>, tenant: string, work: (tx: Tx) => PromiseLike<R>, options?: TenantPinnedOptions<S>) => AsyncResult<R, SqlError | Raised<S>>;
66
- //#endregion
138
+ //#endregion
139
+ export type { TenantListing, TenantListingOptions };
package/dist/rls.mjs CHANGED
@@ -1,4 +1,175 @@
1
1
  import { tryQuery } from "./result.mjs";
2
+ import { Err, Ok, TaggedError } from "unthrown";
3
+ //#region src/tenant-listing.ts
4
+ /** A tenant-listing declaration names an invalid identifier or shares its privileged owner with its caller. */
5
+ var TenantListingInvalid = class extends TaggedError("TenantListingInvalid") {};
6
+ const identifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
7
+ const literal = (value) => `E'${value.replaceAll("\\", "\\\\").replaceAll("'", "''")}'`;
8
+ const statement = (parts) => Object.assign(parts, { raw: [...parts] });
9
+ /**
10
+ * Declare a paged SECURITY DEFINER function returning distinct, non-null
11
+ * tenant IDs as text, and a typed query calling only that function. Each page
12
+ * holds at most 100 IDs in bytewise order; resume after its last ID until empty.
13
+ *
14
+ * Pass `migration` to Prisma 8's `rawSql` inside a reviewed migration. Its
15
+ * transaction installs an owner-only SELECT policy, grants the owner access
16
+ * to the tenant column, fixes the function's search path, revokes PUBLIC
17
+ * execution and grants execution to `role`. Existing table policies and FORCE
18
+ * ROW LEVEL SECURITY are left intact. No application table privilege is added.
19
+ *
20
+ * Roles and the table must already exist. The migrator must be able to SET ROLE
21
+ * to the owner; provision that membership for an ordinary migration role.
22
+ * The migration refuses a privileged application or function-owner role, caller membership in the owner, schema
23
+ * CREATE for the caller, owner payload SELECT, or any same-name function overload. Its postcheck verifies the
24
+ * function, policy and execution grants before Prisma records it as applied.
25
+ * Apply the operation transactionally; the function is initially private only
26
+ * once the REVOKE runs. Do not grant the application the owner role afterwards.
27
+ */
28
+ const tenantListing = (options) => {
29
+ const names = {
30
+ schema: options.schema,
31
+ table: options.table,
32
+ tenantColumn: options.tenantColumn ?? "tenantId",
33
+ name: options.name,
34
+ owner: options.owner,
35
+ role: options.role
36
+ };
37
+ for (const [field, value] of Object.entries(names)) if (typeof value !== "string" || value.length === 0 || value.includes("\0") || Buffer.from(value, "utf8").toString("utf8") !== value || Buffer.byteLength(value, "utf8") > 63) return Err(new TenantListingInvalid({
38
+ field,
39
+ reason: "invalid-identifier"
40
+ }));
41
+ if (names.owner === names.role) return Err(new TenantListingInvalid({
42
+ field: "role",
43
+ reason: "shared-role"
44
+ }));
45
+ const schema = identifier(names.schema);
46
+ const table = `${schema}.${identifier(names.table)}`;
47
+ const fn = `${schema}.${identifier(names.name)}`;
48
+ const owner = identifier(names.owner);
49
+ const role = identifier(names.role);
50
+ const column = identifier(names.tenantColumn);
51
+ const functionRef = `pg_catalog.to_regprocedure(${literal(`${fn}(pg_catalog.text)`)})`;
52
+ const tableRef = `pg_catalog.to_regclass(${literal(table)})`;
53
+ const ownerRef = `(SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(names.owner)})`;
54
+ const roleRef = `(SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(names.role)})`;
55
+ const body = `SELECT DISTINCT ${column}::pg_catalog.text COLLATE pg_catalog."C" AS "tenantId" FROM ${table} WHERE ${column} IS NOT NULL AND ($1 IS NULL OR ${column}::pg_catalog.text COLLATE pg_catalog."C" > $1 COLLATE pg_catalog."C") ORDER BY "tenantId" LIMIT 100`;
56
+ const policy = `CURRENT_USER = ${literal(names.owner)}::pg_catalog.name`;
57
+ const assumption = "CASE WHEN pg_catalog.current_setting('server_version_num')::pg_catalog.int4 >= 160000 THEN 'SET' ELSE 'MEMBER' END";
58
+ const requirements = `EXISTS (
59
+ SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
60
+ JOIN pg_catalog.pg_roles o ON o.rolname = ${literal(names.owner)}
61
+ JOIN pg_catalog.pg_roles r ON r.rolname = ${literal(names.role)}
62
+ WHERE c.oid = ${tableRef} AND c.relrowsecurity AND a.attname = ${literal(names.tenantColumn)} AND a.attnum > 0 AND NOT a.attisdropped
63
+ AND NOT o.rolsuper AND NOT o.rolbypassrls AND NOT r.rolsuper AND NOT r.rolbypassrls
64
+ AND NOT pg_catalog.pg_has_role(r.oid, o.oid, 'MEMBER')
65
+ AND NOT EXISTS (
66
+ SELECT 1 FROM pg_catalog.pg_attribute payload
67
+ WHERE payload.attrelid = c.oid AND payload.attnum > 0 AND NOT payload.attisdropped
68
+ AND payload.attname <> ${literal(names.tenantColumn)}
69
+ AND pg_catalog.has_column_privilege(o.oid, c.oid, payload.attname, 'SELECT')
70
+ )
71
+ AND pg_catalog.has_schema_privilege(o.oid, ${literal(names.schema)}, 'CREATE')
72
+ AND NOT EXISTS (
73
+ SELECT 1 FROM pg_catalog.pg_roles creator
74
+ WHERE pg_catalog.pg_has_role(r.oid, creator.oid, ${assumption})
75
+ AND pg_catalog.has_schema_privilege(creator.oid, ${literal(names.schema)}, 'CREATE')
76
+ )
77
+ AND NOT EXISTS (
78
+ SELECT 1 FROM pg_catalog.pg_policy blocked WHERE blocked.polrelid = c.oid
79
+ AND NOT blocked.polpermissive AND blocked.polcmd IN ('r', '*')
80
+ AND EXISTS (SELECT 1 FROM pg_catalog.unnest(blocked.polroles) inherited(role)
81
+ WHERE CASE WHEN inherited.role = 0 THEN true ELSE pg_catalog.pg_has_role(o.oid, inherited.role, 'USAGE') END)
82
+ )
83
+ )`;
84
+ const functions = `SELECT p.oid FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = ${literal(names.schema)} AND p.proname = ${literal(names.name)}`;
85
+ const ready = `SELECT ${requirements} AND (SELECT count(*) FROM (${functions}) candidates) = 1 AND EXISTS (
86
+ SELECT 1 FROM pg_catalog.pg_proc p
87
+ WHERE p.oid = ${functionRef}
88
+ AND p.prosecdef AND NOT p.proisstrict AND p.proretset AND p.pronargs = 1 AND p.pronargdefaults = 1 AND pg_catalog.pg_get_expr(p.proargdefaults, 0) IN ('NULL::text', 'NULL::pg_catalog.text') AND p.provolatile = 's'
89
+ AND p.prolang = (SELECT oid FROM pg_catalog.pg_language WHERE lanname = 'sql')
90
+ AND p.proowner = ${ownerRef} AND p.prosrc = ${literal(body)}
91
+ AND p.proallargtypes = ARRAY['pg_catalog.text'::pg_catalog.regtype, 'pg_catalog.text'::pg_catalog.regtype]::pg_catalog.oid[]
92
+ AND p.proargnames = ARRAY['after', 'tenantId']::pg_catalog.text[] AND p.proargmodes = ARRAY['i', 't']::pg_catalog."char"[]
93
+ AND p.proconfig = ARRAY['search_path=pg_catalog, pg_temp', 'row_security=on']::pg_catalog.text[]
94
+ AND pg_catalog.has_function_privilege(${roleRef}, p.oid, 'EXECUTE')
95
+ AND pg_catalog.has_schema_privilege(${roleRef}, ${literal(names.schema)}, 'USAGE')
96
+ AND pg_catalog.has_schema_privilege(${ownerRef}, ${literal(names.schema)}, 'USAGE')
97
+ AND pg_catalog.has_column_privilege(${ownerRef}, ${tableRef}, ${literal(names.tenantColumn)}, 'SELECT')
98
+ AND NOT EXISTS (
99
+ SELECT 1 FROM pg_catalog.aclexplode(COALESCE(p.proacl, pg_catalog.acldefault('f', p.proowner))) a
100
+ WHERE a.privilege_type = 'EXECUTE' AND (a.grantee NOT IN (${ownerRef}, ${roleRef}) OR (a.grantee = ${roleRef} AND a.is_grantable))
101
+ )
102
+ AND EXISTS (
103
+ SELECT 1 FROM pg_catalog.pg_policy pol WHERE pol.polrelid = ${tableRef}
104
+ AND pol.polname = ${literal(names.name)} AND pol.polcmd = 'r' AND pol.polpermissive
105
+ AND pol.polroles = ARRAY[${ownerRef}]::pg_catalog.oid[]
106
+ AND pg_catalog.pg_get_expr(pol.polqual, pol.polrelid) = '(CURRENT_USER = ' || pg_catalog.quote_literal(${literal(names.owner)}) || '::name)'
107
+ )
108
+ ) AS "result"`;
109
+ const parts = [`SELECT "tenantId" FROM ${fn}(`, `) ORDER BY "tenantId" COLLATE pg_catalog."C"`];
110
+ return Ok({
111
+ migration: {
112
+ id: `tenantListing.${encodeURIComponent(names.schema).replaceAll(".", "%2E")}.${encodeURIComponent(names.name).replaceAll(".", "%2E")}`,
113
+ label: `List tenant IDs from ${table}`,
114
+ operationClass: "additive",
115
+ target: {
116
+ id: "postgres",
117
+ details: {
118
+ schema: names.schema,
119
+ objectType: "rlsPolicy",
120
+ table: names.table,
121
+ name: names.name
122
+ }
123
+ },
124
+ precheck: [{
125
+ description: "require a row-secured table, restricted roles, and unused function and policy names",
126
+ sql: `SELECT ${requirements} AND pg_catalog.pg_has_role(CURRENT_USER, ${ownerRef}, ${assumption}) AND NOT EXISTS (${functions}) AND NOT EXISTS (
127
+ SELECT 1 FROM pg_catalog.pg_policy WHERE polrelid = ${tableRef} AND polname = ${literal(names.name)}
128
+ ) AS "result"`
129
+ }],
130
+ execute: [
131
+ {
132
+ description: "allow the function owner to read tenant IDs under forced RLS",
133
+ sql: `CREATE POLICY ${identifier(names.name)} ON ${table} FOR SELECT TO ${owner} USING (${policy})`
134
+ },
135
+ {
136
+ description: "grant schema access to the function owner and caller",
137
+ sql: `GRANT USAGE ON SCHEMA ${schema} TO ${owner}, ${role}`
138
+ },
139
+ {
140
+ description: "grant the function owner the tenant column",
141
+ sql: `GRANT SELECT (${column}) ON TABLE ${table} TO ${owner}`
142
+ },
143
+ {
144
+ description: "create the tenant-id-only function with a fixed search path",
145
+ sql: `CREATE FUNCTION ${fn}("after" pg_catalog.text DEFAULT NULL) RETURNS TABLE ("tenantId" pg_catalog.text) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, pg_temp SET row_security = on AS ${literal(body)}`
146
+ },
147
+ {
148
+ description: "remove public execution",
149
+ sql: `REVOKE ALL ON FUNCTION ${fn}(pg_catalog.text) FROM PUBLIC`
150
+ },
151
+ {
152
+ description: "grant execution to the application role",
153
+ sql: `GRANT EXECUTE ON FUNCTION ${fn}(pg_catalog.text) TO ${role}`
154
+ },
155
+ {
156
+ description: "assign the restricted function owner",
157
+ sql: `ALTER FUNCTION ${fn}(pg_catalog.text) OWNER TO ${owner}`
158
+ }
159
+ ],
160
+ postcheck: [{
161
+ description: "verify the listing function, owner policy, and narrow execution grants",
162
+ sql: ready
163
+ }]
164
+ },
165
+ list: (db, after) => tryQuery(() => {
166
+ const client = db;
167
+ const plan = (after === void 0 ? client.raw.sql(statement([`${parts[0]}NULL${parts[1]}`])) : client.raw.sql(statement(parts), after)).returnsRow({ tenantId: "pg/text@1" }).build();
168
+ return client.runtime().query(plan);
169
+ }).map((rows) => rows.map((row) => row.tenantId))
170
+ });
171
+ };
172
+ //#endregion
2
173
  //#region src/rls.ts
3
174
  /**
4
175
  * Runs `work` in a transaction whose connection is pinned to `tenant`, so a
@@ -37,4 +208,4 @@ const tenantPinned = (db, tenant, work, options) => {
37
208
  }), options);
38
209
  };
39
210
  //#endregion
40
- export { tenantPinned };
211
+ export { TenantListingInvalid, tenantListing, tenantPinned };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btravstack/prisma",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "The Prisma starter for @btravstack/core: DATABASE_URL through Config, the Postgres driver adapter, and a client whose pool is the application scope's",
5
5
  "keywords": [
6
6
  "database",
@@ -63,9 +63,9 @@
63
63
  "./package.json": "./package.json"
64
64
  },
65
65
  "devDependencies": {
66
- "@btravstack/config": "0.32.0",
67
- "@btravstack/core": "0.32.0",
68
- "@btravstack/di": "0.32.0",
66
+ "@btravstack/config": "0.34.0",
67
+ "@btravstack/core": "0.34.0",
68
+ "@btravstack/di": "0.34.0",
69
69
  "@btravstack/tsconfig": "0.4.0",
70
70
  "@prisma/orm-postgres": "8.0.0-rc.11",
71
71
  "@types/node": "26.6.4",
@@ -73,13 +73,14 @@
73
73
  "@vitest/coverage-v8": "5.0.3",
74
74
  "tsdown": "0.23.0",
75
75
  "typescript": "7.0.2",
76
+ "typescript-consumer": "npm:typescript@5.9.3",
76
77
  "unthrown": "5.12.1",
77
78
  "vitest": "5.0.3"
78
79
  },
79
80
  "peerDependencies": {
80
- "@btravstack/config": "^0.32.0",
81
- "@btravstack/core": "^0.32.0",
82
- "@btravstack/di": "^0.32.0",
81
+ "@btravstack/config": "^0.34.0",
82
+ "@btravstack/core": "^0.34.0",
83
+ "@btravstack/di": "^0.34.0",
83
84
  "@prisma/orm-postgres": "^8.0.0-rc.11",
84
85
  "unthrown": "^5.0.0"
85
86
  },
@@ -90,6 +91,6 @@
90
91
  "build": "tsdown src/index.ts src/rls.ts src/result.ts --format cjs,esm --dts --clean",
91
92
  "dev": "tsdown src/index.ts src/rls.ts src/result.ts --format cjs,esm --dts --watch",
92
93
  "test": "vitest run --coverage",
93
- "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json"
94
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json && node ./node_modules/typescript-consumer/bin/tsc --noEmit -p tsconfig.test-d.json"
94
95
  }
95
96
  }