@rowstile/client 0.1.0-alpha.4 → 0.1.0-alpha.6
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 +47 -70
- package/dist/index.d.ts +9 -2
- package/dist/index.js +30 -6
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -1,83 +1,60 @@
|
|
|
1
|
-
# rowstile
|
|
1
|
+
# @rowstile/client
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
This package is the base of the TypeScript SDK: who a transaction acts for, the errors a refusal becomes, and
|
|
8
|
+
the runtime's calls (`can`, `perms`, `list`, `share`, ...). The driver packages build on it: `@rowstile/prisma`,
|
|
9
|
+
`@rowstile/pg`, `@rowstile/postgres`, `@rowstile/drizzle`. It holds no rowstile logic: it calls the `authz.*`
|
|
10
|
+
functions the policy made, and translates their answers.
|
|
10
11
|
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
import "@rowstile/next"; // signed-in reads never land in a cache
|
|
16
|
-
import "./authz.gen"; // the policy's names (rowstile client)
|
|
17
|
-
|
|
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());
|
|
12
|
+
```sh
|
|
13
|
+
# @next while only an alpha is published: a plain install gets an older one
|
|
14
|
+
npm install @rowstile/client@next
|
|
15
|
+
npm install --save-dev rowstile@next # the command: rowstile dev, rowstile migrate
|
|
21
16
|
```
|
|
22
17
|
|
|
18
|
+
A background job that acts for a service, whatever started it:
|
|
19
|
+
|
|
23
20
|
```ts
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
});
|
|
21
|
+
// src/jobs.ts
|
|
22
|
+
import { current, job } from "@rowstile/client";
|
|
23
|
+
import { db } from "./db.ts";
|
|
24
|
+
|
|
25
|
+
export const digest = job(["service", 1], async () => ({ count: await db.project.count(), who: current() }));
|
|
29
26
|
```
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|---|---|
|
|
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 |
|
|
28
|
+
## What is in it
|
|
41
29
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
30
|
+
- `actingAs(who, fn)` and `job(who, fn)`: every transaction `fn` begins signs in as `who`. `42` or `"42"` is
|
|
31
|
+
a user, `["service", 3]` another principal type of the policy, `null` nobody.
|
|
32
|
+
- `current()`: who the code running now acts for.
|
|
33
|
+
- `Refused` (403, with the rule and the reason), `NotFound` (404), `NotSignedIn`, `ConnectionProblem`.
|
|
34
|
+
- `translate(e)`: any driver's error as one of those, or `null`. `problemOf(e)` and `problemResponse(e)`: the
|
|
35
|
+
status and an RFC 9457 body for an HTTP answer.
|
|
36
|
+
- `errorCode(e)`: rowstile's code for an error (`AZ709`; `rowstile help AZ709` explains it).
|
|
37
|
+
- `calls(queryable)`: the runtime's functions over anything with `query(text, values)`.
|
|
38
|
+
- `Register`: the interface the generated names fill in (`rowstile client`), so a wrong permission name
|
|
39
|
+
doesn't type-check.
|
|
50
40
|
|
|
51
|
-
|
|
41
|
+
## More
|
|
52
42
|
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
-
|
|
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.
|
|
43
|
+
- [Node apps: pg, postgres.js and Drizzle](https://rowstile.dev/stacks/node): the whole setup, step by step, from an app the tests run.
|
|
44
|
+
- [The TypeScript SDK](https://rowstile.dev/sdk/typescript): every package, and the traps they handle.
|
|
45
|
+
- [Getting started](https://rowstile.dev/getting-started): a first policy, step by step.
|
|
46
|
+
- [Source and issues](https://github.com/rowstile/rowstile).
|
|
74
47
|
|
|
75
|
-
The
|
|
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`).
|
|
48
|
+
The other packages:
|
|
79
49
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
(
|
|
50
|
+
| package | for |
|
|
51
|
+
|---|---|
|
|
52
|
+
| [`@rowstile/prisma`](https://www.npmjs.com/package/@rowstile/prisma) | Prisma |
|
|
53
|
+
| [`@rowstile/pg`](https://www.npmjs.com/package/@rowstile/pg) | node-postgres |
|
|
54
|
+
| [`@rowstile/postgres`](https://www.npmjs.com/package/@rowstile/postgres) | postgres.js |
|
|
55
|
+
| [`@rowstile/drizzle`](https://www.npmjs.com/package/@rowstile/drizzle) | Drizzle ORM |
|
|
56
|
+
| [`@rowstile/next`](https://www.npmjs.com/package/@rowstile/next) | Next.js: route handlers, server actions, caches |
|
|
57
|
+
| [`@rowstile/react`](https://www.npmjs.com/package/@rowstile/react) | React: hooks and a share dialog |
|
|
58
|
+
| [`@rowstile/vitest`](https://www.npmjs.com/package/@rowstile/vitest) | Vitest: matchers, a database per worker |
|
|
59
|
+
|
|
60
|
+
rowstile is a 0.x preview: names may still change before 1.0. Apache-2.0.
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,9 @@ export type Id = string | number | bigint | readonly (string | number | bigint)[
|
|
|
26
26
|
/** An id as the database compares it: a composite key as Postgres writes a row (every field quoted); a key of
|
|
27
27
|
* one column given as an array is that value. */
|
|
28
28
|
export declare function idText(id: Id): string;
|
|
29
|
+
/** A row's key as the database writes it, for a message: 7, or (1,2) for a composite key (a field is quoted
|
|
30
|
+
* only where Postgres would: when it is empty or holds a comma, a quote, a parenthesis or a space). */
|
|
31
|
+
export declare function idShown(id: Id): string;
|
|
29
32
|
/** A user (type "user"), another principal type the policy declares (a service, a bot), or nobody (id null:
|
|
30
33
|
* only what `anyone` may see). */
|
|
31
34
|
export interface Principal {
|
|
@@ -179,12 +182,16 @@ export interface AuthzCalls {
|
|
|
179
182
|
/** The runtime's functions over q (a signed-in transaction, or something that signs each query in). */
|
|
180
183
|
export declare function calls(q: Queryable): AuthzCalls & {
|
|
181
184
|
expect<R>(result: R, table: string, command: "update" | "delete", id: Id, row?: object): Promise<R>;
|
|
185
|
+
/** What an UPDATE or DELETE that changed nothing was, asked of the database: the error to throw. */
|
|
186
|
+
verdict(table: string, command: string, id: Id, row?: object): Promise<NotFound | Refused>;
|
|
182
187
|
check(): Promise<void>;
|
|
183
188
|
};
|
|
184
189
|
/** Whether an UPDATE or DELETE's result changed anything: rows, a count, {count}, {rowCount}, postgres.js's .count. */
|
|
185
190
|
export declare function changed(result: unknown): boolean;
|
|
186
191
|
/** What an UPDATE or DELETE that changed nothing was: NotFound when the row isn't there for this user (why is
|
|
187
192
|
* null), or when the rule allows the write (its first line says yes: the statement matched nothing for
|
|
188
|
-
* another reason, such as a where with more than the key); Refused with the reason otherwise
|
|
189
|
-
|
|
193
|
+
* another reason, such as a where with more than the key); Refused with the reason otherwise, worded as the
|
|
194
|
+
* database words a refused insert: "permission denied: user 2 may not update row 7 of app.notes".
|
|
195
|
+
* who: "user 2", as the database says it; left out, whoever the code acts for now. */
|
|
196
|
+
export declare function verdict(table: string, command: string, id: Id, why: string[] | null, who?: string): NotFound | Refused;
|
|
190
197
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,20 @@ export function idText(id) {
|
|
|
23
23
|
}
|
|
24
24
|
return String(id);
|
|
25
25
|
}
|
|
26
|
+
/** A row's key as the database writes it, for a message: 7, or (1,2) for a composite key (a field is quoted
|
|
27
|
+
* only where Postgres would: when it is empty or holds a comma, a quote, a parenthesis or a space). */
|
|
28
|
+
export function idShown(id) {
|
|
29
|
+
if (!Array.isArray(id))
|
|
30
|
+
return String(id);
|
|
31
|
+
if (id.length === 1)
|
|
32
|
+
return String(id[0]);
|
|
33
|
+
return "(" + id.map((v) => {
|
|
34
|
+
if (v === null || v === undefined)
|
|
35
|
+
return "";
|
|
36
|
+
const text = String(v);
|
|
37
|
+
return text === "" || /[",\\()\s]/.test(text) ? '"' + text.replace(/\\/g, "\\\\").replace(/"/g, '""') + '"' : text;
|
|
38
|
+
}).join(",") + ")";
|
|
39
|
+
}
|
|
26
40
|
export const NOBODY = Object.freeze({ type: "user", id: null });
|
|
27
41
|
export function principal(who) {
|
|
28
42
|
if (who === null || who === undefined)
|
|
@@ -272,7 +286,9 @@ export function checkProblems(rows) {
|
|
|
272
286
|
// looked up on the search_path
|
|
273
287
|
const EXPLAIN_RULE = "SELECT authz.explain_rule(coalesce((SELECT n.nspname || '.' || c.relname FROM pg_catalog.pg_class c " +
|
|
274
288
|
"JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE position('.' in $1::text) = 0 " +
|
|
275
|
-
"AND c.oid = pg_catalog.to_regclass(pg_catalog.quote_ident($1::text))), $1::text), $2, $3, $4::jsonb) AS e"
|
|
289
|
+
"AND c.oid = pg_catalog.to_regclass(pg_catalog.quote_ident($1::text))), $1::text), $2, $3, $4::jsonb) AS e, " +
|
|
290
|
+
// who is signed in, in the words the database's own refusals use
|
|
291
|
+
"coalesce((SELECT p.principal_type || ' ' || p.principal_id FROM authz.principal() p), 'someone not signed in') AS who";
|
|
276
292
|
/** The runtime's functions over q (a signed-in transaction, or something that signs each query in). */
|
|
277
293
|
export function calls(q) {
|
|
278
294
|
const one = async (text, values) => (await q.query(text, values)).rows[0];
|
|
@@ -300,7 +316,11 @@ export function calls(q) {
|
|
|
300
316
|
expect: async (result, table, command, id, row) => {
|
|
301
317
|
if (changed(result))
|
|
302
318
|
return result;
|
|
303
|
-
throw
|
|
319
|
+
throw await c.verdict(table, command, id, row);
|
|
320
|
+
},
|
|
321
|
+
verdict: async (table, command, id, row) => {
|
|
322
|
+
const got = await one(EXPLAIN_RULE, [table, command, idText(id), row === undefined ? null : JSON.stringify(row)]);
|
|
323
|
+
return verdict(table, command, id, (got.e ?? null), typeof got.who === "string" ? got.who : undefined);
|
|
304
324
|
},
|
|
305
325
|
/** Throws ConnectionProblem if this connection skips row-level security. As nobody: it runs at start-up,
|
|
306
326
|
* where there is no request to ask who is signed in. */
|
|
@@ -331,9 +351,13 @@ export function changed(result) {
|
|
|
331
351
|
}
|
|
332
352
|
/** What an UPDATE or DELETE that changed nothing was: NotFound when the row isn't there for this user (why is
|
|
333
353
|
* null), or when the rule allows the write (its first line says yes: the statement matched nothing for
|
|
334
|
-
* another reason, such as a where with more than the key); Refused with the reason otherwise
|
|
335
|
-
|
|
354
|
+
* another reason, such as a where with more than the key); Refused with the reason otherwise, worded as the
|
|
355
|
+
* database words a refused insert: "permission denied: user 2 may not update row 7 of app.notes".
|
|
356
|
+
* who: "user 2", as the database says it; left out, whoever the code acts for now. */
|
|
357
|
+
export function verdict(table, command, id, why, who) {
|
|
336
358
|
if (why === null || /^\s*yes\b/.test(why[0] ?? ""))
|
|
337
|
-
return new NotFound(table,
|
|
338
|
-
|
|
359
|
+
return new NotFound(table, idShown(id));
|
|
360
|
+
const p = current() ?? NOBODY;
|
|
361
|
+
const by = who ?? (p.id === null ? "someone not signed in" : `${p.type} ${p.id}`);
|
|
362
|
+
return new Refused(`permission denied: ${by} may not ${command} row ${idShown(id)} of ${table}`, table, command, why);
|
|
339
363
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rowstile/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.6",
|
|
4
4
|
"description": "rowstile for TypeScript apps: who each transaction acts for, refusals as errors, generated names",
|
|
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", "typescript"],
|
|
6
8
|
"repository": { "type": "git", "url": "git+https://github.com/rowstile/rowstile.git", "directory": "sdk/typescript/client" },
|
|
7
9
|
"type": "module",
|
|
8
10
|
"main": "./dist/index.js",
|