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

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.
Files changed (3) hide show
  1. package/README.md +60 -69
  2. package/dist/index.js +34 -14
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,83 +1,74 @@
1
- # rowstile for TypeScript
1
+ # @rowstile/prisma
2
2
 
3
- The SDK for TypeScript apps on a rowstile policy: every transaction signs in as whoever the request
4
- or the job acts for, refused writes become a 403 with the database's reason, rows the user can't see a 404,
5
- signed-in reads stay out of Next.js's caches, and the app refuses to start on a connection that skips
6
- row-level security. It holds no rowstile logic: it calls the `authz.*` functions the policy made, and
7
- translates their answers.
3
+ [rowstile](https://rowstile.dev) is a policy language for PostgreSQL. You write who may see and change each
4
+ row in one file, and it becomes row-level security: the database filters every read and refuses every write
5
+ the rules don't allow, whatever the code asks.
8
6
 
9
- Next.js with Prisma:
7
+ This package is rowstile for Prisma. `signedIn(adapter)` signs every transaction Prisma begins in as the
8
+ request's user. `authz()` is a client extension: an update or delete of a row the user can't see is
9
+ `NotFound`, of one they may not change `Refused` with the database's reason, and `db.$authz` asks the
10
+ questions (`can`, `ids`, `permsOf`, `share`). Prisma 7, with a driver adapter.
11
+
12
+ ```sh
13
+ # @next on each while only an alpha is published: a plain install gets an older one
14
+ npm install @rowstile/client@next @rowstile/prisma@next
15
+ npm install --save-dev rowstile@next # the command: rowstile dev, rowstile migrate
16
+ ```
10
17
 
11
18
  ```ts
12
19
  // src/db.ts
13
20
  import { PrismaPg } from "@prisma/adapter-pg";
21
+ import pg from "pg";
14
22
  import { authz, signedIn } from "@rowstile/prisma";
15
- import "@rowstile/next"; // signed-in reads never land in a cache
16
- import "./authz.gen"; // the policy's names (rowstile client)
23
+ import "./authz.gen.ts"; // the policy's names, for the SDK's types
24
+
25
+ const url = process.env.ROWSTILE_APP_URL;
26
+ export const pool = new pg.Pool({ connectionString: url, max: Number(process.env.PG_POOL_MAX ?? 5) });
27
+ export const db = new PrismaClient({ adapter: signedIn(new PrismaPg(pool), { user: requestUser }) }).$extends(authz());
28
+ ```
17
29
 
18
- const adapter = signedIn(new PrismaPg({ connectionString: process.env.DATABASE_URL }),
19
- { user: async () => (await auth())?.user.id });
20
- export const db = new PrismaClient({ adapter }).$extends(authz());
30
+ `ROWSTILE_APP_URL` is the app's own connection, as the role the policy names. It is never the owner's URL,
31
+ which Prisma migrates with: row-level security doesn't apply to a table's owner.
32
+
33
+ Then the code has no checks. The database filters what a query reads:
34
+
35
+ ```ts
36
+ const notes = await db.note.findMany({ select: { id: true, body: true }, orderBy: { id: "asc" } });
21
37
  ```
22
38
 
39
+ and a query by permission is one call:
40
+
23
41
  ```ts
24
- // app/api/folders/[id]/route.ts: no checks; a hidden folder is 404, one the user may not rename 403
25
- export const PATCH = route(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
26
- await db.folder.update({ where: { id: Number((await params).id) }, data: await req.json() });
27
- return Response.json({ ok: true });
28
- });
42
+ const ids = await db.$authz.ids("project", "edit", Number);
43
+ const projects = await db.project.findMany({ where: { id: { in: ids } }, select: { id: true }, orderBy: { id: "asc" } });
29
44
  ```
30
45
 
31
- | package | what |
46
+ ## Good to know
47
+
48
+ - Use the client `$extends(authz())` returns, and only that one.
49
+ - `$transaction([...])` is refused: Prisma starts an array's transaction from whichever request came first.
50
+ Use `$transaction(async (tx) => ...)`. Inside it, why a write was refused is asked through the transaction:
51
+ it needs no second connection.
52
+ - A `create` reads its row back, so it needs the select rule too. A refusal there is `Refused` naming it.
53
+ - Policy changes ship as Prisma migrations: `tool = "prisma"` in `rowstile.toml`, then `npx rowstile migrate`.
54
+
55
+ ## More
56
+
57
+ - [Next.js with Prisma](https://rowstile.dev/stacks/nextjs): the whole setup, step by step, from an app the tests run.
58
+ - [The TypeScript SDK](https://rowstile.dev/sdk/typescript): every package, and the traps they handle.
59
+ - [Getting started](https://rowstile.dev/getting-started): a first policy, step by step.
60
+ - [Source and issues](https://github.com/rowstile/rowstile).
61
+
62
+ The other packages:
63
+
64
+ | package | for |
32
65
  |---|---|
33
- | `@rowstile/client` | `actingAs(who, fn)`, `job(who, fn)`, `current()`, `Refused`, `NotFound`, `NotSignedIn`, `translate(e)` and `problemResponse(e)` for any driver's error, `errorCode(e)` (rowstile's code: `AZ709`), `calls(queryable)` (the runtime's functions), the `Register` the generated names fill in |
34
- | `@rowstile/prisma` | `signedIn(adapter, { user })`: every transaction Prisma begins signs in; `authz()`: P2025 as `NotFound` or `Refused` with the reason, refused creates naming the rule, `db.$authz` (`can`, `ids`, `permsOf`, `share`, `check`, ...) |
35
- | `@rowstile/pg` | `authz(pool, { user })`: `transaction(fn)`, `query()`, the runtime's functions; `changes(pool)` for live updates |
36
- | `@rowstile/postgres` | `authz(sql, { user })`: `begin(fn)`, the runtime's functions |
37
- | `@rowstile/drizzle` | `withAuthz(db, { user })`: `transaction(fn)`; `inIds(column, type, perm)` and `ids()` for `where`; `expect(tx, result, ...)` for updates and deletes that changed nothing |
38
- | `@rowstile/next` | `route(handler)` (403 and 404 problem bodies), `action(fn)` (`{ ok, value }` or `{ ok: false, problem }`), `authzRoutes()` (what the React kit calls), `checkAtStart()` for `instrumentation.ts`; importing it keeps signed-in reads out of caches |
39
- | `@rowstile/react` | `<AuthzProvider>`, `usePerms(type, ids)` (one call for a list), `<Can>`, `useShares` and a headless `<ShareDialog>`, `useAccessRequest`; kept current over server-sent events |
40
- | `@rowstile/vitest` | `expect.extend(matchers)`: `toBeRefused(command, reason)`, `toBeNotFound()`; `asUser(who, fn)`; `databasePerWorker(url)`. Vitest 4 or 5 |
41
-
42
- Who a transaction acts for: `42` or `"42"` (a user), `["service", 3]` (another principal type of the policy),
43
- or `null` (nobody: only what `anyone` may see). A plain id is always a user's, whatever it holds: `"service:3"`
44
- is the user with that id, so an id from outside (a username, an identity provider's subject) can't name a
45
- service. `parsePrincipal("service:3")` reads back what `describe()` wrote, for text your own code wrote. Whoever the code acts for (`actingAs`, a `job`)
46
- comes first, else the `user` function, else nobody; a `who` given to one call (`transaction(fn, who)`) comes
47
- before all, and `null` there is nobody. Every transaction signs in, even as nobody, and nothing uses a
48
- session-level `SET`, so pools and poolers in transaction mode are safe; the change feed needs its own connection
49
- to Postgres ([Behind a pooler](../../docs/operations.md#behind-a-pooler)).
50
-
51
- Traps it handles:
52
-
53
- - **Next.js caching.** Before a transaction signs in as someone, `@rowstile/next` calls Next's `connection()`:
54
- the render is dynamic, and a signed-in read inside `"use cache"` or `unstable_cache` fails instead of being
55
- cached for the next user. Reads as nobody may be cached.
56
- - **Prisma answers some calls together.** It dispatches the `findUnique` calls of one tick, and array
57
- transactions, from the first caller's context. `authz()` runs each `findUnique` in a transaction of its own,
58
- and refuses `$transaction([...])` (use `$transaction(async (tx) => ...)`), so no query runs as someone else.
59
- Use the client `$extends(authz())` returns, and only that one: the client it was made from is refused
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`).
63
- - **Inserts that read the row back** (`RETURNING`, Prisma's `create`) also need the select rule: Postgres's own
64
- message comes back as `Refused` naming the select rule.
65
- - **Prisma's promises are lazy**: `actingAs` and `job` await what they return inside their scope.
66
- - **A connection that drops.** node-postgres stops the process when a client that is checked out loses its
67
- connection and nobody listens: `@rowstile/pg` listens while it holds one, so the transaction fails instead.
68
- `changes(pool)` listens again after its connection drops, and tells its subscribers once.
69
- - **An update that changed nothing** is `NotFound` when the user can't see the row, `Refused` when the database
70
- says no, and `NotFound` again when the rule allows it and the statement matched nothing for another reason
71
- (a `where` with more than the key).
72
- - **The answers in the browser are one user's.** Give `<AuthzProvider user={id}>` if the user can change
73
- without a page load: the hooks forget what they had and ask again.
74
-
75
- The types and permissions of the policy are generated names: with a `@rowstile/*` dependency in
76
- `package.json`, `rowstile client` writes only the names (`[clients] ts = "src/authz.gen.ts"`), registered with
77
- the SDK, so `db.$authz.can("note", 1, "edt")` doesn't type-check. Policy changes ship as migrations for your
78
- tool (`rowstile migrate`, with `tool = "prisma"` or `"drizzle"` in `rowstile.toml`).
79
-
80
- The packages are an npm workspace at the repository root (`npm ci`, then `npx tsc -b sdk/typescript`).
81
- `integrations/nextjs/` is the conformance suite: a small Next.js app on Prisma 7 and Prisma Migrate, and the
82
- checks every supported stack passes, with pg, postgres.js, Drizzle and the React kit
83
- (`integrations/nextjs/test.sh`).
66
+ | [`@rowstile/client`](https://www.npmjs.com/package/@rowstile/client) | who a transaction acts for, the errors, the runtime's calls |
67
+ | [`@rowstile/pg`](https://www.npmjs.com/package/@rowstile/pg) | node-postgres |
68
+ | [`@rowstile/postgres`](https://www.npmjs.com/package/@rowstile/postgres) | postgres.js |
69
+ | [`@rowstile/drizzle`](https://www.npmjs.com/package/@rowstile/drizzle) | Drizzle ORM |
70
+ | [`@rowstile/next`](https://www.npmjs.com/package/@rowstile/next) | Next.js: route handlers, server actions, caches |
71
+ | [`@rowstile/react`](https://www.npmjs.com/package/@rowstile/react) | React: hooks and a share dialog |
72
+ | [`@rowstile/vitest`](https://www.npmjs.com/package/@rowstile/vitest) | Vitest: matchers, a database per worker |
73
+
74
+ rowstile is a 0.x preview: names may still change before 1.0. Apache-2.0.
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { AsyncLocalStorage } from "node:async_hooks";
22
22
  import { Prisma } from "@prisma/client/extension";
23
- import { actAs, calls, idText, NotFound, signingIn, translate, verdict, } from "@rowstile/client";
23
+ import { actAs, calls, idShown, idText, NotFound, signingIn, translate, } from "@rowstile/client";
24
24
  const ARG_TEXT = { scalarType: "string", dbType: undefined, arity: "scalar" };
25
25
  // Unless the adapter says otherwise (usePhantomQuery), Prisma sends COMMIT and ROLLBACK itself, and the
26
26
  // adapter's commit() and rollback() only give the connection back: so these send them too
@@ -112,8 +112,11 @@ export function signedIn(factory, options = {}) {
112
112
  async function transactionOf(tx) {
113
113
  let found;
114
114
  const make = tx._createPrismaPromise;
115
+ // (the two guards are for another version of Prisma than the one the checks run: left out of the measure)
116
+ /* v8 ignore else */
115
117
  if (typeof make === "function")
116
118
  await make(async (t) => { found = t; });
119
+ /* v8 ignore next */
117
120
  if (found?.kind !== "itx") {
118
121
  throw new Error("@rowstile/prisma: this version of Prisma starts its transactions another way; " +
119
122
  "report it at https://github.com/rowstile/rowstile/issues with the version of @prisma/client");
@@ -124,10 +127,13 @@ async function transactionOf(tx) {
124
127
  export function authz(options = {}) {
125
128
  return Prisma.defineExtension((client) => {
126
129
  const raw = client;
130
+ // (Prisma 7 always has the data model: without it, another version, left out of the measure)
131
+ /* v8 ignore next */
127
132
  const models = raw._runtimeDataModel?.models ?? {};
133
+ // the schema of a table the database's words name without one: a model's table is its @@map, else its name
128
134
  const schemaOf = (table) => {
129
- for (const m of Object.values(models))
130
- if (m.dbName === table && m.schema)
135
+ for (const [name, m] of Object.entries(models))
136
+ if ((m.dbName ?? name) === table && m.schema)
131
137
  return m.schema;
132
138
  return undefined;
133
139
  };
@@ -136,11 +142,10 @@ export function authz(options = {}) {
136
142
  const name = m?.dbName ?? model;
137
143
  return m?.schema ? `${m.schema}.${name}` : name;
138
144
  };
139
- // the row's key (the policy's key for the table) from the where of an update or delete: the fields
140
- // options.keys names for the model, else id, else its one unique field or compound key
145
+ // the row's key (the policy's key for the table) from the where of an update, a delete or a findUnique: the
146
+ // fields options.keys names for the model, else id, else its one unique field or compound key (Prisma refuses
147
+ // any of them without a where before a query)
141
148
  const keyOf = (model, where) => {
142
- if (!where)
143
- return undefined;
144
149
  const named = options.keys?.[model];
145
150
  if (named) {
146
151
  const vals = named.map((k) => where[k]);
@@ -161,8 +166,11 @@ export function authz(options = {}) {
161
166
  const [, v] = entries[0];
162
167
  if (isScalar(v))
163
168
  return v;
164
- if (v && typeof v === "object" && Object.values(v).every(isScalar))
169
+ // a compound key's fields, in a plain object: not a date (or bytes, a decimal), which isn't written as the
170
+ // database writes it
171
+ if (v && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype && Object.values(v).every(isScalar)) {
165
172
  return Object.values(v);
173
+ }
166
174
  return undefined;
167
175
  };
168
176
  // through the client this returns (set below), whose last hook lets the query through
@@ -171,6 +179,15 @@ export function authz(options = {}) {
171
179
  query: async (text, values = []) => ({ rows: await self.$queryRawUnsafe(text, ...values) }),
172
180
  };
173
181
  const c = calls(q);
182
+ // The runtime's functions asked inside the app's interactive transaction, for a call made in one: an update or a
183
+ // read that found no row leaves it usable, and a question outside it would wait for a connection of its own,
184
+ // which on a pool of one the transaction holds until Prisma's timeout. A Prisma promise runs in the transaction
185
+ // it is handed (requestTransaction: how Prisma runs an array's queries in theirs).
186
+ const askingIn = (transaction) => transaction?.kind !== "itx" ? c : calls({
187
+ query: async (text, values = []) => ({
188
+ rows: await self.$queryRawUnsafe(text, ...values).requestTransaction(transaction),
189
+ }),
190
+ });
174
191
  const $authz = {
175
192
  ...c,
176
193
  ids: async (type, perm, as) => {
@@ -204,19 +221,22 @@ export function authz(options = {}) {
204
221
  const table = tableOf(model);
205
222
  const key = keyOf(model, args.where);
206
223
  if (key !== undefined) {
207
- const why = await c.explainRule(table, operation, key).catch(() => undefined);
208
- if (why !== undefined) {
209
- const v = verdict(table, operation, key, why);
224
+ const v = await askingIn(internal?.transaction).verdict(table, operation, key).catch(() => undefined);
225
+ if (v !== undefined) {
210
226
  v.cause = e;
211
227
  throw v;
212
228
  }
213
229
  }
214
230
  }
215
231
  // 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
232
+ // two answer alike). Left as Prisma's P2025 it is a 500, where an update or a delete is a 404.
233
+ // findUnique's where is a key; findFirst's is any filter, which names a row only by its id
217
234
  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 });
235
+ const where = args.where;
236
+ const key = operation === "findUniqueOrThrow" ? keyOf(model, where)
237
+ : isScalar(where?.id) ? where?.id : undefined;
238
+ const table = await askingIn(internal?.transaction).tableName(tableOf(model)).catch(() => tableOf(model));
239
+ throw new NotFound(table, key === undefined ? undefined : idShown(key), { cause: e });
220
240
  }
221
241
  throw translate(e, schemaOf) ?? e;
222
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rowstile/prisma",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
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
6
  "homepage": "https://rowstile.dev",
@@ -12,6 +12,6 @@
12
12
  "exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
13
13
  "files": ["dist"],
14
14
  "sideEffects": false,
15
- "dependencies": { "@rowstile/client": "0.1.0-alpha.5" },
15
+ "dependencies": { "@rowstile/client": "0.1.0-alpha.7" },
16
16
  "peerDependencies": { "@prisma/client": ">=7" }
17
17
  }