@rowstile/prisma 0.1.0-alpha.3 → 0.1.0-alpha.5

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
@@ -57,7 +57,9 @@ Traps it handles:
57
57
  transactions, from the first caller's context. `authz()` runs each `findUnique` in a transaction of its own,
58
58
  and refuses `$transaction([...])` (use `$transaction(async (tx) => ...)`), so no query runs as someone else.
59
59
  Use the client `$extends(authz())` returns, and only that one: the client it was made from is refused
60
- whenever it is used.
60
+ whenever it is used, inside your own extensions too. An extension you add to the client `authz()` returns is
61
+ placed before it, so `authz()` stays the last one: each of your query hooks runs once for a call, and sees
62
+ the errors `authz()` makes (`Refused`, `NotFound`).
61
63
  - **Inserts that read the row back** (`RETURNING`, Prisma's `create`) also need the select rule: Postgres's own
62
64
  message comes back as `Refused` naming the select rule.
63
65
  - **Prisma's promises are lazy**: `actingAs` and `job` await what they return inside their scope.
package/dist/index.js CHANGED
@@ -8,16 +8,19 @@
8
8
  * in with authz.act_as() as whoever the code acts for (actingAs, a job), else user(), else nobody; a query
9
9
  * outside a transaction runs in one of its own. `authz()` turns the answers into errors: an update or delete
10
10
  * of a row the user can't see is NotFound (Prisma's P2025), of one they may not change Refused with the
11
- * reason, a refused create Refused naming the rule; and adds db.$authz (can, ids, permsOf, ...). It refuses
11
+ * reason, a refused create Refused naming the rule, a findUniqueOrThrow or findFirstOrThrow that finds no row
12
+ * NotFound; and adds db.$authz (can, ids, permsOf, ...). It refuses
12
13
  * array transactions, $transaction([...]): Prisma starts them from another caller's context.
13
14
  *
14
15
  * Use the client `$extends(authz())` returns, and only that one: the client it was made from is refused
15
16
  * whenever it is used (Prisma answers the findUnique calls of one tick with one query, signed in as whoever
16
- * called first).
17
+ * called first). That holds inside the app's own extensions too, on either side of authz(): an extension added
18
+ * to the client authz() returns is placed before it, so authz() stays the last one, and every hook of the
19
+ * app's runs once for each call, outside what authz() lets through.
17
20
  */
18
21
  import { AsyncLocalStorage } from "node:async_hooks";
19
22
  import { Prisma } from "@prisma/client/extension";
20
- import { actAs, calls, idText, signingIn, translate, verdict, } from "@rowstile/client";
23
+ import { actAs, calls, idText, NotFound, signingIn, translate, verdict, } from "@rowstile/client";
21
24
  const ARG_TEXT = { scalarType: "string", dbType: undefined, arity: "scalar" };
22
25
  // Unless the adapter says otherwise (usePhantomQuery), Prisma sends COMMIT and ROLLBACK itself, and the
23
26
  // adapter's commit() and rollback() only give the connection back: so these send them too
@@ -106,6 +109,17 @@ export function signedIn(factory, options = {}) {
106
109
  },
107
110
  });
108
111
  }
112
+ async function transactionOf(tx) {
113
+ let found;
114
+ const make = tx._createPrismaPromise;
115
+ if (typeof make === "function")
116
+ await make(async (t) => { found = t; });
117
+ if (found?.kind !== "itx") {
118
+ throw new Error("@rowstile/prisma: this version of Prisma starts its transactions another way; " +
119
+ "report it at https://github.com/rowstile/rowstile/issues with the version of @prisma/client");
120
+ }
121
+ return found;
122
+ }
109
123
  /** The client extension: refusals and hidden rows as errors, and db.$authz. */
110
124
  export function authz(options = {}) {
111
125
  return Prisma.defineExtension((client) => {
@@ -151,8 +165,10 @@ export function authz(options = {}) {
151
165
  return Object.values(v);
152
166
  return undefined;
153
167
  };
168
+ // through the client this returns (set below), whose last hook lets the query through
169
+ let self = raw;
154
170
  const q = {
155
- query: async (text, values = []) => ({ rows: await extended(() => raw.$queryRawUnsafe(text, ...values)) }),
171
+ query: async (text, values = []) => ({ rows: await self.$queryRawUnsafe(text, ...values) }),
156
172
  };
157
173
  const c = calls(q);
158
174
  const $authz = {
@@ -176,9 +192,10 @@ export function authz(options = {}) {
176
192
  try {
177
193
  if (!internal?.transaction && model && (operation === "findUnique" || operation === "findUniqueOrThrow")) {
178
194
  // Prisma answers the findUnique calls of one tick together, from the first caller's context: run each
179
- // in an interactive transaction of its own, which begins (and signs in) in this caller's context
180
- const delegate = model.charAt(0).toLowerCase() + model.slice(1);
181
- return await extended(() => raw.$transaction(async (tx) => tx[delegate][operation](args)));
195
+ // in an interactive transaction of its own, which begins (and signs in) in this caller's context.
196
+ // What is left of the call (this hook is the last: Prisma's own work) runs in it; calling the model
197
+ // again on the transaction would run every hook of the app's a second time
198
+ return await extended(() => raw.$transaction(async (tx) => query(args, { ...internal, transaction: await transactionOf(tx) })));
182
199
  }
183
200
  return await extended(() => query(args));
184
201
  }
@@ -195,6 +212,12 @@ export function authz(options = {}) {
195
212
  }
196
213
  }
197
214
  }
215
+ // a read that must find a row and found none: the row isn't there, or this user can't see it (the
216
+ // two answer alike). Left as Prisma's P2025 it is a 500, where an update or a delete is a 404
217
+ if (model && isP2025(e) && (operation === "findUniqueOrThrow" || operation === "findFirstOrThrow")) {
218
+ const key = keyOf(model, args.where);
219
+ throw new NotFound(tableOf(model), key === undefined ? undefined : idText(key), { cause: e });
220
+ }
198
221
  throw translate(e, schemaOf) ?? e;
199
222
  }
200
223
  },
@@ -202,7 +225,7 @@ export function authz(options = {}) {
202
225
  client: { $authz },
203
226
  });
204
227
  // a second layer, so that $parent has the hooks above: transactions begin through this client too. It
205
- // changes nothing in $transaction's type.
228
+ // changes nothing in $transaction's type, nor in $extends's.
206
229
  // The transaction begins inside the mark, but the app's callback runs outside it: the callback is the app's
207
230
  // code, and a client not extended with authz() is refused there as anywhere (its findUnique calls would be
208
231
  // answered together). Queries through tx go through the transaction, which needs no mark. What the callback
@@ -215,8 +238,19 @@ export function authz(options = {}) {
215
238
  ? (tx) => through.exit(async () => await fn(tx)) : fn;
216
239
  return extended(() => parent.$transaction(app, ...rest));
217
240
  },
241
+ // An extension added to this client goes before authz(), which is then applied again: Prisma calls query
242
+ // hooks in the order they were added, so a hook added after authz()'s would run inside what it lets
243
+ // through, and the client authz() was made from would not be refused there. (A function is called with
244
+ // this client, as Prisma does: what it adds comes back here.)
245
+ $extends(extension) {
246
+ if (typeof extension === "function")
247
+ return extension(this);
248
+ return raw.$extends(extension).$extends(authz(options));
249
+ },
218
250
  };
219
- return hooks.$extends({ name: "rowstile-transactions", client: transactions });
251
+ const out = hooks.$extends({ name: "rowstile-transactions", client: transactions });
252
+ self = out;
253
+ return out;
220
254
  });
221
255
  }
222
256
  function isScalar(v) {
package/package.json CHANGED
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "name": "@rowstile/prisma",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.5",
4
4
  "description": "rowstile with Prisma: a client extension that signs every query in, and turns refusals into errors",
5
5
  "license": "Apache-2.0",
6
+ "homepage": "https://rowstile.dev",
7
+ "keywords": ["rowstile", "postgres", "postgresql", "authorization", "row-level-security", "rls", "permissions", "access-control", "rbac", "rebac", "multi-tenant", "prisma"],
6
8
  "repository": { "type": "git", "url": "git+https://github.com/rowstile/rowstile.git", "directory": "sdk/typescript/prisma" },
7
9
  "type": "module",
8
10
  "main": "./dist/index.js",
@@ -10,6 +12,6 @@
10
12
  "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
11
13
  "files": ["dist"],
12
14
  "sideEffects": false,
13
- "dependencies": { "@rowstile/client": "0.1.0-alpha.3" },
15
+ "dependencies": { "@rowstile/client": "0.1.0-alpha.5" },
14
16
  "peerDependencies": { "@prisma/client": ">=7" }
15
17
  }