@rowstile/client 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.
- package/README.md +49 -70
- package/dist/index.d.ts +23 -5
- package/dist/index.js +96 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,83 +1,62 @@
|
|
|
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 (and 404 for what a call names that isn't there, AZ708; 400
|
|
36
|
+
for a missing or wrong argument, AZ710; 401 for a call that needs someone signed in or a login refused,
|
|
37
|
+
AZ714 and AZ703; 409 for a move inside itself, AZ713).
|
|
38
|
+
- `errorCode(e)`: rowstile's code for an error (`AZ709`; `rowstile help AZ709` explains it).
|
|
39
|
+
- `calls(queryable)`: the runtime's functions over anything with `query(text, values)`.
|
|
40
|
+
- `Register`: the interface the generated names fill in (`rowstile client`), so a wrong permission name
|
|
41
|
+
doesn't type-check.
|
|
50
42
|
|
|
51
|
-
|
|
43
|
+
## More
|
|
52
44
|
|
|
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.
|
|
45
|
+
- [Node apps: pg, postgres.js and Drizzle](https://rowstile.dev/stacks/node): the whole setup, step by step, from an app the tests run.
|
|
46
|
+
- [The TypeScript SDK](https://rowstile.dev/sdk/typescript): every package, and the traps they handle.
|
|
47
|
+
- [Getting started](https://rowstile.dev/getting-started): a first policy, step by step.
|
|
48
|
+
- [Source and issues](https://github.com/rowstile/rowstile).
|
|
74
49
|
|
|
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`).
|
|
50
|
+
The other packages:
|
|
79
51
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
(
|
|
52
|
+
| package | for |
|
|
53
|
+
|---|---|
|
|
54
|
+
| [`@rowstile/prisma`](https://www.npmjs.com/package/@rowstile/prisma) | Prisma |
|
|
55
|
+
| [`@rowstile/pg`](https://www.npmjs.com/package/@rowstile/pg) | node-postgres |
|
|
56
|
+
| [`@rowstile/postgres`](https://www.npmjs.com/package/@rowstile/postgres) | postgres.js |
|
|
57
|
+
| [`@rowstile/drizzle`](https://www.npmjs.com/package/@rowstile/drizzle) | Drizzle ORM |
|
|
58
|
+
| [`@rowstile/next`](https://www.npmjs.com/package/@rowstile/next) | Next.js: route handlers, server actions, caches |
|
|
59
|
+
| [`@rowstile/react`](https://www.npmjs.com/package/@rowstile/react) | React: hooks and a share dialog |
|
|
60
|
+
| [`@rowstile/vitest`](https://www.npmjs.com/package/@rowstile/vitest) | Vitest: matchers, a database per worker |
|
|
61
|
+
|
|
62
|
+
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 {
|
|
@@ -81,9 +84,11 @@ export declare class Refused extends Error {
|
|
|
81
84
|
command?: string | undefined;
|
|
82
85
|
why: string[];
|
|
83
86
|
readonly status = 403;
|
|
84
|
-
|
|
87
|
+
/** rowstile's code for it: AZ709 for a rule's refusal, the database's own for another (AZ705: a share). */
|
|
88
|
+
readonly code: string;
|
|
85
89
|
constructor(message: string, table?: string | undefined, command?: string | undefined, why?: string[], options?: {
|
|
86
90
|
cause?: unknown;
|
|
91
|
+
code?: string;
|
|
87
92
|
});
|
|
88
93
|
problem(): Problem;
|
|
89
94
|
}
|
|
@@ -128,12 +133,18 @@ export declare function sqlstate(e: unknown): string | undefined;
|
|
|
128
133
|
* policies with the rule and why), else null. `schemaOf` qualifies a bare table name (Prisma knows it).
|
|
129
134
|
* Another 42501 (a table the app role was never granted) is the app's mistake, not a refusal: null. */
|
|
130
135
|
export declare function refusal(e: unknown, schemaOf?: (table: string) => string | undefined): Refused | null;
|
|
131
|
-
/** Whether e is strict sign-in's error: a query that needs to know who is asking, and nobody
|
|
136
|
+
/** Whether e is strict sign-in's error (rowstile help AZ701): a query that needs to know who is asking, and nobody
|
|
137
|
+
* signed in. A login refused (AZ703) and settings changed by hand (AZ702) share its SQLSTATE, 28000, but are no
|
|
138
|
+
* such thing. */
|
|
132
139
|
export declare function notSignedIn(e: unknown): boolean;
|
|
133
140
|
/** e as rowstile's error: Refused, NotFound or NotSignedIn as they are, a database refusal as Refused, strict
|
|
134
141
|
* sign-in's error as NotSignedIn; null for anything else. */
|
|
135
142
|
export declare function translate(e: unknown, schemaOf?: (table: string) => string | undefined): Refused | NotFound | NotSignedIn | null;
|
|
136
|
-
/** The problem body and status for e (Refused: 403, NotFound: 404), or null.
|
|
143
|
+
/** The problem body and status for e (Refused: 403, NotFound: 404), or null. A call the database turns down that is
|
|
144
|
+
* no refusal answers as its code's page says, with the database's words: a login refused or a call that needs
|
|
145
|
+
* someone signed in 401 (AZ703, AZ714), something it names that isn't there 404 (AZ708: a share with someone who
|
|
146
|
+
* doesn't exist), an argument missing or wrong 400 (AZ710: a negative page size), a move inside itself 409
|
|
147
|
+
* (AZ713). */
|
|
137
148
|
export declare function problemOf(e: unknown): Problem | null;
|
|
138
149
|
/** A Response (application/problem+json) for e, or null: for route handlers. */
|
|
139
150
|
export declare function problemResponse(e: unknown): Response | null;
|
|
@@ -179,12 +190,19 @@ export interface AuthzCalls {
|
|
|
179
190
|
/** The runtime's functions over q (a signed-in transaction, or something that signs each query in). */
|
|
180
191
|
export declare function calls(q: Queryable): AuthzCalls & {
|
|
181
192
|
expect<R>(result: R, table: string, command: "update" | "delete", id: Id, row?: object): Promise<R>;
|
|
193
|
+
/** What an UPDATE or DELETE that changed nothing was, asked of the database: the error to throw. */
|
|
194
|
+
verdict(table: string, command: string, id: Id, row?: object): Promise<NotFound | Refused>;
|
|
195
|
+
/** The table as the policy names it, with its schema: a bare name (a model that names no schema) is looked
|
|
196
|
+
* up on the search_path, once. */
|
|
197
|
+
tableName(table: string): Promise<string>;
|
|
182
198
|
check(): Promise<void>;
|
|
183
199
|
};
|
|
184
200
|
/** Whether an UPDATE or DELETE's result changed anything: rows, a count, {count}, {rowCount}, postgres.js's .count. */
|
|
185
201
|
export declare function changed(result: unknown): boolean;
|
|
186
202
|
/** What an UPDATE or DELETE that changed nothing was: NotFound when the row isn't there for this user (why is
|
|
187
203
|
* 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
|
-
|
|
204
|
+
* another reason, such as a where with more than the key); Refused with the reason otherwise, worded as the
|
|
205
|
+
* database words a refused insert: "permission denied: user 2 may not update row 7 of app.notes".
|
|
206
|
+
* who: "user 2", as the database says it; left out, whoever the code acts for now. */
|
|
207
|
+
export declare function verdict(table: string, command: string, id: Id, why: string[] | null, who?: string): NotFound | Refused;
|
|
190
208
|
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)
|
|
@@ -112,13 +126,15 @@ export class Refused extends Error {
|
|
|
112
126
|
command;
|
|
113
127
|
why;
|
|
114
128
|
status = 403;
|
|
115
|
-
code
|
|
129
|
+
/** rowstile's code for it: AZ709 for a rule's refusal, the database's own for another (AZ705: a share). */
|
|
130
|
+
code; // rowstile help AZ709
|
|
116
131
|
constructor(message, table, command, why = [], options) {
|
|
117
132
|
super(message, options);
|
|
118
133
|
this.table = table;
|
|
119
134
|
this.command = command;
|
|
120
135
|
this.why = why;
|
|
121
136
|
this.name = "Refused";
|
|
137
|
+
this.code = options?.code ?? "AZ709";
|
|
122
138
|
}
|
|
123
139
|
problem() {
|
|
124
140
|
return { type: "https://rowstile.dev/problems/refused", title: "Forbidden", status: 403, detail: this.message,
|
|
@@ -208,6 +224,15 @@ export function sqlstate(e) {
|
|
|
208
224
|
}
|
|
209
225
|
const RLS_GENERIC = /^new row violates row-level security policy (\(USING expression\) )?for table "([^"]+)"/;
|
|
210
226
|
const OURS = /may not (insert|update|delete) this row (?:into|of) (\S+?)(?: to these values)?$/;
|
|
227
|
+
/** The table in a column rule's refusal, "changing locked of app.notes 7 needs: folder.manage" (always an
|
|
228
|
+
* update); undefined for any other message. Read with indexOf: a pattern here would backtrack on a long one. */
|
|
229
|
+
function columnRuleTable(message) {
|
|
230
|
+
const of = message.indexOf(" of "), needs = message.indexOf(" needs: ");
|
|
231
|
+
if (!message.startsWith("changing ") || of < 0 || needs < of)
|
|
232
|
+
return undefined;
|
|
233
|
+
const named = message.slice(of + 4, needs); // "app.notes 7"
|
|
234
|
+
return named.split(" ", 1)[0] || undefined;
|
|
235
|
+
}
|
|
211
236
|
/** A Refused for an error that is the database refusing a write (SQLSTATE 42501, raised by rowstile's
|
|
212
237
|
* policies with the rule and why), else null. `schemaOf` qualifies a bare table name (Prisma knows it).
|
|
213
238
|
* Another 42501 (a table the app role was never granted) is the app's mistake, not a refusal: null. */
|
|
@@ -230,15 +255,25 @@ export function refusal(e, schemaOf) {
|
|
|
230
255
|
"back (RETURNING); read it back only if the select rule allows it", table, "select", [], { cause: e });
|
|
231
256
|
}
|
|
232
257
|
const ours = OURS.exec(err.message);
|
|
233
|
-
|
|
258
|
+
const code = errorCode(e);
|
|
259
|
+
if (!ours && !err.constraint?.startsWith("authz_") && code === undefined)
|
|
260
|
+
return null;
|
|
261
|
+
// sign in first (AZ714) is a 42501 too, but no refusal: a 401
|
|
262
|
+
if (code !== undefined && CALL_PROBLEMS.has(code))
|
|
234
263
|
return null;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
264
|
+
// the error's own fields say which table and command; a driver that drops them (Prisma's adapters) leaves
|
|
265
|
+
// the message, which names both
|
|
266
|
+
const column = columnRuleTable(err.message);
|
|
267
|
+
const table = err.schema && err.table ? `${err.schema}.${err.table}` : err.table ?? ours?.[2] ?? column;
|
|
268
|
+
const command = err.constraint?.startsWith("authz_") ? err.constraint.slice(6) : ours?.[1] ?? (column ? "update" : undefined);
|
|
269
|
+
return new Refused(err.message, table, command, err.detail ? err.detail.split("\n") : [], { cause: e, code });
|
|
238
270
|
}
|
|
239
|
-
/** Whether e is strict sign-in's error: a query that needs to know who is asking, and nobody
|
|
271
|
+
/** Whether e is strict sign-in's error (rowstile help AZ701): a query that needs to know who is asking, and nobody
|
|
272
|
+
* signed in. A login refused (AZ703) and settings changed by hand (AZ702) share its SQLSTATE, 28000, but are no
|
|
273
|
+
* such thing. */
|
|
240
274
|
export function notSignedIn(e) {
|
|
241
|
-
|
|
275
|
+
const code = errorCode(e);
|
|
276
|
+
return sqlstate(e) === "28000" && (code === undefined || code === "AZ701");
|
|
242
277
|
}
|
|
243
278
|
/** e as rowstile's error: Refused, NotFound or NotSignedIn as they are, a database refusal as Refused, strict
|
|
244
279
|
* sign-in's error as NotSignedIn; null for anything else. */
|
|
@@ -254,10 +289,28 @@ export function translate(e, schemaOf) {
|
|
|
254
289
|
}
|
|
255
290
|
return null;
|
|
256
291
|
}
|
|
257
|
-
|
|
292
|
+
// rowstile's codes for a call the database turned down that is no refusal, and what their pages say to answer
|
|
293
|
+
// (rowstile help AZ703: a login refused; AZ708: what the call names isn't there; AZ710: a missing or wrong
|
|
294
|
+
// argument; AZ713: moved inside itself; AZ714: sign in first)
|
|
295
|
+
const CALL_PROBLEMS = new Map([
|
|
296
|
+
["AZ703", { type: "https://rowstile.dev/problems/not-signed-in", title: "Unauthorized", status: 401 }],
|
|
297
|
+
["AZ708", { type: "https://rowstile.dev/problems/not-found", title: "Not Found", status: 404 }],
|
|
298
|
+
["AZ710", { type: "https://rowstile.dev/problems/bad-argument", title: "Bad Request", status: 400 }],
|
|
299
|
+
["AZ713", { type: "https://rowstile.dev/problems/conflict", title: "Conflict", status: 409 }],
|
|
300
|
+
["AZ714", { type: "https://rowstile.dev/problems/not-signed-in", title: "Unauthorized", status: 401 }],
|
|
301
|
+
]);
|
|
302
|
+
/** The problem body and status for e (Refused: 403, NotFound: 404), or null. A call the database turns down that is
|
|
303
|
+
* no refusal answers as its code's page says, with the database's words: a login refused or a call that needs
|
|
304
|
+
* someone signed in 401 (AZ703, AZ714), something it names that isn't there 404 (AZ708: a share with someone who
|
|
305
|
+
* doesn't exist), an argument missing or wrong 400 (AZ710: a negative page size), a move inside itself 409
|
|
306
|
+
* (AZ713). */
|
|
258
307
|
export function problemOf(e) {
|
|
259
308
|
const t = translate(e);
|
|
260
|
-
|
|
309
|
+
if (t instanceof Refused || t instanceof NotFound)
|
|
310
|
+
return t.problem();
|
|
311
|
+
const code = errorCode(e);
|
|
312
|
+
const call = code === undefined ? undefined : CALL_PROBLEMS.get(code);
|
|
313
|
+
return call ? { ...call, detail: dbError(e).message, code } : null;
|
|
261
314
|
}
|
|
262
315
|
/** A Response (application/problem+json) for e, or null: for route handlers. */
|
|
263
316
|
export function problemResponse(e) {
|
|
@@ -270,18 +323,24 @@ export function checkProblems(rows) {
|
|
|
270
323
|
}
|
|
271
324
|
// The policy names tables with their schema; a table named without one (a model that names no schema) is
|
|
272
325
|
// looked up on the search_path
|
|
273
|
-
const
|
|
326
|
+
const TABLE_NAME = "coalesce((SELECT n.nspname || '.' || c.relname FROM pg_catalog.pg_class c " +
|
|
274
327
|
"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)
|
|
328
|
+
"AND c.oid = pg_catalog.to_regclass(pg_catalog.quote_ident($1::text))), $1::text)";
|
|
329
|
+
const EXPLAIN_RULE = "SELECT authz.explain_rule(t.tbl, $2, $3, $4::jsonb) AS e, t.tbl, " +
|
|
330
|
+
// who is signed in, in the words the database's own refusals use
|
|
331
|
+
"coalesce((SELECT p.principal_type || ' ' || p.principal_id FROM authz.principal() p), 'someone not signed in') AS who " +
|
|
332
|
+
`FROM (SELECT ${TABLE_NAME} AS tbl) t`;
|
|
276
333
|
/** The runtime's functions over q (a signed-in transaction, or something that signs each query in). */
|
|
277
334
|
export function calls(q) {
|
|
278
335
|
const one = async (text, values) => (await q.query(text, values)).rows[0];
|
|
336
|
+
const names = new Map(); // a table as the app names it -> as the policy does
|
|
279
337
|
const c = {
|
|
280
338
|
can: async (type, id, perm) => Boolean((await one("SELECT authz.can($1, $2::text, $3) AS ok", [type, idText(id), perm])).ok),
|
|
281
|
-
|
|
339
|
+
// (an array, empty at worst: authz.perms never answers null)
|
|
340
|
+
perms: async (type, id) => (await one("SELECT authz.perms($1, $2::text) AS p", [type, idText(id)])).p,
|
|
282
341
|
permsOf: async (type, ids) => {
|
|
283
342
|
const rows = (await q.query("SELECT id, perms FROM authz.perms_of($1, $2::text[])", [type, ids.map(idText)])).rows;
|
|
284
|
-
return Object.fromEntries(rows.map((r) => [r.id, r.perms
|
|
343
|
+
return Object.fromEntries(rows.map((r) => [r.id, r.perms]));
|
|
285
344
|
},
|
|
286
345
|
list: async (type, perm, page = {}) => (await q.query("SELECT x FROM authz.list($1, $2, $3, $4) x", [type, perm, page.after === undefined ? null : idText(page.after), page.limit ?? null])).rows.map((r) => r.x),
|
|
287
346
|
listShares: async (type, id) => (await q.query("SELECT * FROM authz.list_shares($1, $2::text)", [type, idText(id)])).rows,
|
|
@@ -300,7 +359,21 @@ export function calls(q) {
|
|
|
300
359
|
expect: async (result, table, command, id, row) => {
|
|
301
360
|
if (changed(result))
|
|
302
361
|
return result;
|
|
303
|
-
throw
|
|
362
|
+
throw await c.verdict(table, command, id, row);
|
|
363
|
+
},
|
|
364
|
+
verdict: async (table, command, id, row) => {
|
|
365
|
+
const got = await one(EXPLAIN_RULE, [table, command, idText(id), row === undefined ? null : JSON.stringify(row)]);
|
|
366
|
+
// named as the database names it, as in a refused insert: "public.Note" for a model that says "Note" (the
|
|
367
|
+
// statement's tbl and who are text, never null)
|
|
368
|
+
const named = String(got.tbl);
|
|
369
|
+
names.set(table, named);
|
|
370
|
+
return verdict(named, command, id, (got.e ?? null), String(got.who));
|
|
371
|
+
},
|
|
372
|
+
tableName: async (table) => {
|
|
373
|
+
if (!names.has(table) && !table.includes(".")) {
|
|
374
|
+
names.set(table, String((await one(`SELECT ${TABLE_NAME} AS tbl`, [table])).tbl));
|
|
375
|
+
}
|
|
376
|
+
return names.get(table) ?? table;
|
|
304
377
|
},
|
|
305
378
|
/** Throws ConnectionProblem if this connection skips row-level security. As nobody: it runs at start-up,
|
|
306
379
|
* where there is no request to ask who is signed in. */
|
|
@@ -331,9 +404,13 @@ export function changed(result) {
|
|
|
331
404
|
}
|
|
332
405
|
/** What an UPDATE or DELETE that changed nothing was: NotFound when the row isn't there for this user (why is
|
|
333
406
|
* 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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
407
|
+
* another reason, such as a where with more than the key); Refused with the reason otherwise, worded as the
|
|
408
|
+
* database words a refused insert: "permission denied: user 2 may not update row 7 of app.notes".
|
|
409
|
+
* who: "user 2", as the database says it; left out, whoever the code acts for now. */
|
|
410
|
+
export function verdict(table, command, id, why, who) {
|
|
411
|
+
if (why === null || /^\s*yes\b/.test(why[0]))
|
|
412
|
+
return new NotFound(table, idShown(id));
|
|
413
|
+
const p = current() ?? NOBODY;
|
|
414
|
+
const by = who ?? (p.id === null ? "someone not signed in" : `${p.type} ${p.id}`);
|
|
415
|
+
return new Refused(`permission denied: ${by} may not ${command} row ${idShown(id)} of ${table}`, table, command, why);
|
|
339
416
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rowstile/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.7",
|
|
4
4
|
"description": "rowstile for TypeScript apps: who each transaction acts for, refusals as errors, generated names",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://rowstile.dev",
|