@pramen/server 0.0.1 → 0.0.2
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/dist/auth.d.ts +7 -3
- package/dist/auth.js +10 -7
- package/dist/durable-object.d.ts +1 -0
- package/dist/durable-object.js +55 -1
- package/dist/index.d.ts +1 -1
- package/dist/runtime/acl.d.ts +27 -1
- package/dist/runtime/acl.js +124 -30
- package/dist/runtime/db.d.ts +14 -7
- package/dist/runtime/db.js +40 -10
- package/dist/runtime/dispatch.js +2 -2
- package/dist/runtime/read-engine.d.ts +7 -0
- package/dist/runtime/read-engine.js +14 -0
- package/dist/sdk/infer.d.ts +9 -0
- package/dist/worker.js +23 -5
- package/package.json +1 -1
- package/src/auth.ts +9 -7
- package/src/durable-object.ts +53 -1
- package/src/index.ts +1 -0
- package/src/runtime/acl.ts +128 -30
- package/src/runtime/db.ts +42 -14
- package/src/runtime/dispatch.ts +2 -2
- package/src/runtime/read-engine.ts +19 -1
- package/src/sdk/infer.ts +17 -0
- package/src/worker.ts +25 -5
package/dist/sdk/infer.d.ts
CHANGED
|
@@ -45,6 +45,15 @@ export type WhereInput<F extends EntityFields> = {
|
|
|
45
45
|
AND?: WhereInput<F>[];
|
|
46
46
|
OR?: WhereInput<F>[];
|
|
47
47
|
};
|
|
48
|
+
type PrevDepth = [never, 0, 1, 2, 3, 4, 5];
|
|
49
|
+
type RelTargetTable<S extends SchemaDef, R> = R extends {
|
|
50
|
+
target: infer Tg;
|
|
51
|
+
} ? (Tg extends keyof S ? Tg : never) : never;
|
|
52
|
+
/** A `where` clause: column predicates + AND/OR, plus relation keys that take a
|
|
53
|
+
* nested `WhereClause` over the related entity (traversal). */
|
|
54
|
+
export type WhereClause<S extends SchemaDef, T extends keyof S, D extends number = 5> = WhereInput<FieldsOf<S[T]>> & ([D] extends [never] ? object : {
|
|
55
|
+
[K in keyof RelationsOf<S[T]>]?: WhereClause<S, RelTargetTable<S, RelationsOf<S[T]>[K]>, PrevDepth[D]>;
|
|
56
|
+
});
|
|
48
57
|
/** Patch input for updates: every column optional, value typed (nullable). */
|
|
49
58
|
export type InferUpdate<F extends EntityFields> = Partial<{
|
|
50
59
|
[K in keyof F]: FieldTsType<F[K]> | null;
|
package/dist/worker.js
CHANGED
|
@@ -131,9 +131,9 @@ export function makeWorker(app) {
|
|
|
131
131
|
// --- admin: list known tenants ---
|
|
132
132
|
if (url.pathname === "/tenants") {
|
|
133
133
|
if (!isAdmin(identity))
|
|
134
|
-
return forbidden("tenants");
|
|
134
|
+
return withCors(forbidden("tenants"), cors);
|
|
135
135
|
const list = await env.KV.list({ prefix: "tenant:" });
|
|
136
|
-
return json({ ok: true, result: list.keys.map((k) => k.name.slice("tenant:".length)) });
|
|
136
|
+
return withCors(json({ ok: true, result: list.keys.map((k) => k.name.slice("tenant:".length)) }), cors);
|
|
137
137
|
}
|
|
138
138
|
// --- admin: point-in-time recovery for a tenant ---
|
|
139
139
|
if (url.pathname === "/admin/recover" && request.method === "POST") {
|
|
@@ -155,17 +155,35 @@ export function makeWorker(app) {
|
|
|
155
155
|
// --- admin: a tenant's applied schema (hash + tables) ---
|
|
156
156
|
if (url.pathname === "/admin/schema") {
|
|
157
157
|
if (!isAdmin(identity))
|
|
158
|
-
return forbidden("schema");
|
|
158
|
+
return withCors(forbidden("schema"), cors);
|
|
159
159
|
const tenant = url.searchParams.get("tenant") ?? "main";
|
|
160
160
|
const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
|
|
161
|
-
|
|
161
|
+
const res = await stub.fetch(new Request("https://do/__schema", { headers: { "x-pramen-tenant": tenant } }));
|
|
162
|
+
return withCors(res, cors);
|
|
163
|
+
}
|
|
164
|
+
// --- admin: generic data ops over a tenant's tables (browse/edit any row).
|
|
165
|
+
// Body: { tenant, table, op: list|get|create|update|delete|count, ... }. Runs
|
|
166
|
+
// in the DO under SYSTEM scope (ACL bypassed) — gated to admins here. ---
|
|
167
|
+
if (url.pathname === "/admin/data" && request.method === "POST") {
|
|
168
|
+
if (!isAdmin(identity))
|
|
169
|
+
return forbidden("data");
|
|
170
|
+
const body = (await request.json().catch(() => ({})));
|
|
171
|
+
const tenant = typeof body.tenant === "string" && body.tenant ? body.tenant : "main";
|
|
172
|
+
const stub = env.PRAMEN.get(env.PRAMEN.idFromName(tenant));
|
|
173
|
+
const res = await stub.fetch(new Request("https://do/__admin/data", {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "content-type": "application/json", "x-pramen-tenant": tenant },
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
}));
|
|
178
|
+
return withCors(res, cors);
|
|
162
179
|
}
|
|
163
180
|
const isRpc = url.pathname.startsWith("/rpc/");
|
|
164
181
|
const isLive = url.pathname === "/live";
|
|
165
182
|
if (!isRpc && !(isLive && isWs)) {
|
|
166
183
|
return new Response("pramen — POST /rpc/<handler> (JSON body), or WebSocket /live for live queries. " +
|
|
167
184
|
"Header X-Pramen-Tenant selects the store (default: main). " +
|
|
168
|
-
"Admin: GET /tenants, POST /admin/recover {tenant,timestamp}, GET /admin/schema?tenant
|
|
185
|
+
"Admin: GET /tenants, POST /admin/recover {tenant,timestamp}, GET /admin/schema?tenant=, " +
|
|
186
|
+
"POST /admin/data {tenant,table,op}.\n", { headers: { "content-type": "text/plain" } });
|
|
169
187
|
}
|
|
170
188
|
// Authorize the tenant against the identity before reaching the DO, so a
|
|
171
189
|
// caller can't address (or register) tenants they have no claim to.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pramen/server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "pramen server runtime — schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/auth.ts
CHANGED
|
@@ -197,14 +197,16 @@ export async function resolveIdentity(request: Request, strategy: VerifyStrategy
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
/** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
|
|
200
|
-
* can't reach (or register) arbitrary tenants. Default policy:
|
|
201
|
-
*
|
|
202
|
-
*
|
|
200
|
+
* can't reach (or register) arbitrary tenants. Default policy:
|
|
201
|
+
* - `main` is the open default tenant: any caller may reach it (anonymous too) —
|
|
202
|
+
* data access is still governed by ACL roles (single-tenant apps live here, and
|
|
203
|
+
* issued login tokens need no `tenants` claim to use it).
|
|
204
|
+
* - other tenants: admins → any; everyone else → only tenants in their `tenants`
|
|
205
|
+
* claim; anonymous → none.
|
|
206
|
+
* Customize for your tenancy model (e.g. tenant === identity.org, or a lookup). */
|
|
203
207
|
export function authorizeTenant(identity: Identity | null, tenant: string): boolean {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// not letting unauthenticated callers address/register arbitrary tenants.
|
|
207
|
-
if (!identity) return tenant === "main";
|
|
208
|
+
if (tenant === "main") return true;
|
|
209
|
+
if (!identity) return false;
|
|
208
210
|
if (identity.roles?.includes("admin")) return true;
|
|
209
211
|
const allowed = Array.isArray(identity.tenants) ? (identity.tenants as string[]) : [];
|
|
210
212
|
return allowed.includes(tenant);
|
package/src/durable-object.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { DurableObject } from "cloudflare:workers";
|
|
19
19
|
import { migrate } from "./runtime/migrate";
|
|
20
20
|
import { dispatch } from "./runtime/dispatch";
|
|
21
|
+
import { Db } from "./runtime/db";
|
|
21
22
|
import { digest } from "./runtime/digest";
|
|
22
23
|
import { compileAcl, type AclContext, type CompiledAcl } from "./runtime/acl";
|
|
23
24
|
import { DoSqliteDriver, type Driver } from "./runtime/driver";
|
|
@@ -90,6 +91,7 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
90
91
|
const path = new URL(request.url).pathname;
|
|
91
92
|
if (path === "/__recover") return this.handleRecover(request);
|
|
92
93
|
if (path === "/__schema") return this.handleSchema();
|
|
94
|
+
if (path === "/__admin/data") return this.handleAdminData(request);
|
|
93
95
|
|
|
94
96
|
const identity = this.identityOf(request);
|
|
95
97
|
|
|
@@ -291,8 +293,58 @@ export class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
291
293
|
return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
|
|
292
294
|
}
|
|
293
295
|
|
|
296
|
+
// Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
|
|
297
|
+
// Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
|
|
298
|
+
// json/fileRef codec, transactions, and live-query broadcast still apply.
|
|
299
|
+
private async handleAdminData(request: Request): Promise<Response> {
|
|
300
|
+
const b = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
|
301
|
+
const table = typeof b.table === "string" ? b.table : "";
|
|
302
|
+
const op = typeof b.op === "string" ? b.op : "";
|
|
303
|
+
if (!table || !(table in this.app.schema)) {
|
|
304
|
+
return Response.json({ ok: false, error: "unknown table", code: "bad_request" }, { status: 400 });
|
|
305
|
+
}
|
|
306
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
307
|
+
const db = new Db(this.driver, { acl: this.acl, identity: null, system: true }, this.app.schema) as any;
|
|
308
|
+
try {
|
|
309
|
+
let result: unknown;
|
|
310
|
+
let mutated = false;
|
|
311
|
+
switch (op) {
|
|
312
|
+
case "list":
|
|
313
|
+
result = await db.find({ from: table, where: b.where, orderBy: b.orderBy, limit: b.limit, offset: b.offset });
|
|
314
|
+
break;
|
|
315
|
+
case "count":
|
|
316
|
+
result = await db.count({ from: table, where: b.where });
|
|
317
|
+
break;
|
|
318
|
+
case "get":
|
|
319
|
+
result = (await db.find({ from: table, where: { id: b.id }, limit: 1 }))[0] ?? null;
|
|
320
|
+
break;
|
|
321
|
+
case "create":
|
|
322
|
+
result = await this.driver.transaction(() => db.insert(table, b.values));
|
|
323
|
+
mutated = true;
|
|
324
|
+
break;
|
|
325
|
+
case "update":
|
|
326
|
+
result = (await this.driver.transaction(() => db.update(table, b.id, b.patch))) ?? null;
|
|
327
|
+
mutated = true;
|
|
328
|
+
break;
|
|
329
|
+
case "delete":
|
|
330
|
+
result = await this.driver.transaction(() => db.delete(table, b.id));
|
|
331
|
+
mutated = true;
|
|
332
|
+
break;
|
|
333
|
+
default:
|
|
334
|
+
return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
|
|
335
|
+
}
|
|
336
|
+
if (mutated && db.touched.size > 0) await this.broadcast([...db.touched]);
|
|
337
|
+
return Response.json({ ok: true, result });
|
|
338
|
+
} catch (err) {
|
|
339
|
+
const { status, body } = toResponse(err);
|
|
340
|
+
return Response.json(body, { status });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
294
344
|
private ctxFor(identity: Identity | null): AclContext {
|
|
295
|
-
|
|
345
|
+
// Carry the schema so any consumer of this context (not just Db) can compile
|
|
346
|
+
// relation-aware `where` rules into subqueries.
|
|
347
|
+
return { acl: this.acl, identity, schema: this.app.schema };
|
|
296
348
|
}
|
|
297
349
|
|
|
298
350
|
// The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
|
package/src/index.ts
CHANGED
package/src/runtime/acl.ts
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
type ResolverDb,
|
|
16
16
|
type Role,
|
|
17
17
|
type Validator,
|
|
18
|
-
type WhereRule,
|
|
19
18
|
deny,
|
|
20
19
|
isAllow,
|
|
21
20
|
isDeny,
|
|
@@ -23,8 +22,9 @@ import {
|
|
|
23
22
|
isInputMarker,
|
|
24
23
|
isResolver,
|
|
25
24
|
} from "../sdk/acl";
|
|
26
|
-
import { compileWhere, evalExpr, FALSE, or, type SqlExpr } from "./read-engine";
|
|
27
|
-
import { PramenError } from "./errors";
|
|
25
|
+
import { and, compileWhere, evalExpr, FALSE, or, TRUE, type SqlExpr } from "./read-engine";
|
|
26
|
+
import { BadRequest, PramenError } from "./errors";
|
|
27
|
+
import type { FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
|
|
28
28
|
|
|
29
29
|
export class AclDenied extends PramenError {
|
|
30
30
|
constructor(
|
|
@@ -57,6 +57,9 @@ export interface AclContext {
|
|
|
57
57
|
readonly resolved?: Map<number, PolicyRule>;
|
|
58
58
|
/** SYSTEM mode bypasses all ACL — used for warmup reads and internal ops. */
|
|
59
59
|
readonly system?: boolean;
|
|
60
|
+
/** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
|
|
61
|
+
* compiled to a subquery with the related entity's read scope AND-merged in. */
|
|
62
|
+
readonly schema?: SchemaDef;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
/** Evaluate every resolver reachable by the identity's roles, once per request.
|
|
@@ -128,14 +131,16 @@ interface Grant {
|
|
|
128
131
|
fieldsFns: FieldsFn[];
|
|
129
132
|
}
|
|
130
133
|
|
|
131
|
-
/** Build a grant from a policy/relation rule, resolving
|
|
132
|
-
* `
|
|
133
|
-
function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null,
|
|
134
|
+
/** Build a grant from a policy/relation rule, resolving markers in `where` and each
|
|
135
|
+
* conditional `when` (the `when` predicate is single-table — evaluated in memory). */
|
|
136
|
+
function grantOf(rule: PolicyRules | RelationAclRule, where: SqlExpr | null, entity: string, ctx: AclContext, depth: number): Grant {
|
|
134
137
|
return {
|
|
135
138
|
where,
|
|
136
139
|
fields: rule.fields ?? null,
|
|
137
140
|
conditional: (rule.conditionalFields ?? []).map((cf) => ({
|
|
138
|
-
when
|
|
141
|
+
// Cell-level `when` is evaluated per-row in memory (evalExpr), so it must stay
|
|
142
|
+
// single-table — `allowRelations: false` rejects a relation key up front.
|
|
143
|
+
when: compileScopedWhere(cf.when as Record<string, unknown>, entity, ctx, depth, false),
|
|
139
144
|
fields: cf.fields,
|
|
140
145
|
})),
|
|
141
146
|
fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
|
|
@@ -173,23 +178,18 @@ function resolveValue(v: unknown, identity: Identity | null, input: unknown): un
|
|
|
173
178
|
return v;
|
|
174
179
|
}
|
|
175
180
|
|
|
176
|
-
// Resolve every $identity marker in a
|
|
177
|
-
// objects, in/notIn arrays
|
|
178
|
-
//
|
|
181
|
+
// Resolve every $identity/$input marker in a SINGLE level of a where-rule (bare
|
|
182
|
+
// values, operator objects, in/notIn arrays). AND/OR groups are split off by
|
|
183
|
+
// `compileScopedWhere` before this runs, so this only ever sees plain columns.
|
|
184
|
+
// Returns a plain WhereInput, or null if any marker is unresolvable — in which
|
|
185
|
+
// case this branch matches nothing (FALSE). Note: because branches are resolved
|
|
186
|
+
// independently, an unresolvable marker nullifies only its own branch, not the
|
|
187
|
+
// whole rule — so `OR: [{ x: $identity(...) }, { public: true }]` still matches
|
|
188
|
+
// the `public` branch for a caller whose marker can't resolve. (See the comment
|
|
189
|
+
// on `compileScopedWhere`.)
|
|
179
190
|
function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null, input: unknown): Record<string, unknown> | null {
|
|
180
191
|
const out: Record<string, unknown> = {};
|
|
181
192
|
for (const [key, v] of Object.entries(rule)) {
|
|
182
|
-
if (key === "AND" || key === "OR") {
|
|
183
|
-
const groups: Record<string, unknown>[] = [];
|
|
184
|
-
for (const g of v as Record<string, unknown>[]) {
|
|
185
|
-
const resolved = resolveMarkers(g, identity, input);
|
|
186
|
-
if (resolved === null) return null;
|
|
187
|
-
groups.push(resolved);
|
|
188
|
-
}
|
|
189
|
-
out[key] = groups;
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
193
|
const isMarker = isIdentityMarker(v) || isInputMarker(v);
|
|
194
194
|
if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
|
|
195
195
|
const ops: Record<string, unknown> = {};
|
|
@@ -222,12 +222,106 @@ function resolveMarkers(rule: Record<string, unknown>, identity: Identity | null
|
|
|
222
222
|
return out;
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
-
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
225
|
+
/** Max relation-traversal nesting depth in a `where` (guards cyclic relations).
|
|
226
|
+
* Kept in lockstep with the `WhereClause` type's depth bound in sdk/infer.ts — if
|
|
227
|
+
* you change one, change the other. */
|
|
228
|
+
export const MAX_REL_DEPTH = 5;
|
|
229
|
+
|
|
230
|
+
/** Primary-key column of an entity (the column a belongsTo points at / a hasMany
|
|
231
|
+
* joins back to). Defaults to `id`. */
|
|
232
|
+
function pkOf(schema: SchemaDef | undefined, entity: string): string {
|
|
233
|
+
const fields = schema?.[entity]?.fields;
|
|
234
|
+
if (fields) for (const [n, f] of Object.entries(fields)) if ((f as FieldDef).primaryKey) return n;
|
|
235
|
+
return "id";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Compile a relation predicate `{ rel: { … } }` to a subquery, AND-merging the
|
|
239
|
+
* related entity's read scope (and rejecting filters on fields it can't read) so
|
|
240
|
+
* traversal can never widen access beyond a direct read of the target. */
|
|
241
|
+
function relationPredicate(rel: RelationDef, nested: unknown, parentEntity: string, ctx: AclContext, depth: number): SqlExpr {
|
|
242
|
+
if (depth >= MAX_REL_DEPTH) throw new BadRequest("relation `where` is nested too deep");
|
|
243
|
+
if (nested === null || typeof nested !== "object" || Array.isArray(nested)) {
|
|
244
|
+
throw new BadRequest(`relation filter for '${rel.target}' must be an object`);
|
|
245
|
+
}
|
|
246
|
+
let inner = compileScopedWhere(nested as Record<string, unknown>, rel.target, ctx, depth + 1);
|
|
247
|
+
|
|
248
|
+
// Security: a relation filter must respect the target's read ACL (else it leaks).
|
|
249
|
+
// Two distinct "no" outcomes, matching how the rest of the read path behaves:
|
|
250
|
+
// - No read grant on the target at all -> the relation simply yields no rows
|
|
251
|
+
// to match against (FALSE, empty result) — a valid query over a table you
|
|
252
|
+
// can't see.
|
|
253
|
+
// - Readable target, but the filter names a column you can't read -> 403, the
|
|
254
|
+
// same as ordering/aggregating by a hidden column (you referenced something
|
|
255
|
+
// forbidden). Top-level user `where` enforces the same rule (Db.readWhere).
|
|
256
|
+
if (!ctx.system) {
|
|
257
|
+
const tScope = resolveScope(ctx, rel.target, "read", depth + 1);
|
|
258
|
+
if (!tScope.allowed) {
|
|
259
|
+
inner = FALSE; // can't filter through a relation you can't read
|
|
260
|
+
} else {
|
|
261
|
+
if (tScope.fields !== null) {
|
|
262
|
+
const targetRels = (ctx.schema?.[rel.target]?.relations ?? {}) as Record<string, unknown>;
|
|
263
|
+
for (const k of Object.keys(nested as Record<string, unknown>)) {
|
|
264
|
+
if (k === "AND" || k === "OR" || targetRels[k]) continue;
|
|
265
|
+
if (!tScope.fields.includes(k)) throw new AclDenied(rel.target, "read", k);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (tScope.where) inner = and(inner, tScope.where);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// belongsTo: parent.<fk> IN (SELECT <target pk> FROM target WHERE inner)
|
|
273
|
+
// hasMany: parent.<pk> IN (SELECT <target fk> FROM target WHERE inner)
|
|
274
|
+
return rel.kind === "belongsTo"
|
|
275
|
+
? { t: "sub", outerCol: rel.column, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false }
|
|
276
|
+
: { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.target, selectCol: rel.column, where: inner, negate: false };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Compile a where-rule (user query or policy) into a SqlExpr. Plain columns go
|
|
280
|
+
* through the marker-resolving compiler; relation keys (`{ rel: { … } }`) become
|
|
281
|
+
* security-scoped subqueries. Supports operators, AND/OR, and $identity/$input
|
|
282
|
+
* markers; an unresolvable marker makes its branch match nothing. Schema-less
|
|
283
|
+
* contexts (no relations) behave exactly like the flat compiler.
|
|
284
|
+
*
|
|
285
|
+
* Marker semantics: AND/OR branches compile independently, so an unresolvable
|
|
286
|
+
* marker collapses ONLY its own branch to FALSE — it does not nullify sibling
|
|
287
|
+
* branches. `OR: [{ ownerId: $identity("userId") }, { public: true }]` therefore
|
|
288
|
+
* still grants the `public` branch to a caller whose `userId` can't resolve (and,
|
|
289
|
+
* conversely, an unresolvable marker in one OR branch no longer revokes access the
|
|
290
|
+
* other branches would grant). This is plain boolean logic; the cases are covered
|
|
291
|
+
* by the relwhere suite.
|
|
292
|
+
*
|
|
293
|
+
* `allowRelations` is false for single-table contexts (cell-level `when`, which is
|
|
294
|
+
* evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
|
|
295
|
+
* clear authoring error instead of emitting a `sub` node that throws at read time. */
|
|
296
|
+
export function compileScopedWhere(
|
|
297
|
+
rule: Record<string, unknown>,
|
|
298
|
+
entity: string,
|
|
299
|
+
ctx: AclContext,
|
|
300
|
+
depth = 0,
|
|
301
|
+
allowRelations = true,
|
|
302
|
+
): SqlExpr {
|
|
303
|
+
const relations = (ctx.schema?.[entity]?.relations ?? {}) as Record<string, RelationDef>;
|
|
304
|
+
const parts: SqlExpr[] = [];
|
|
305
|
+
const plain: Record<string, unknown> = {};
|
|
306
|
+
for (const [k, v] of Object.entries(rule)) {
|
|
307
|
+
if (k === "AND" || k === "OR") {
|
|
308
|
+
const groups = (v as Record<string, unknown>[]).map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
|
|
309
|
+
parts.push(k === "AND" ? and(...groups) : or(...groups));
|
|
310
|
+
} else if (relations[k]) {
|
|
311
|
+
if (!allowRelations) {
|
|
312
|
+
throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
|
|
313
|
+
}
|
|
314
|
+
parts.push(relationPredicate(relations[k]!, v, entity, ctx, depth));
|
|
315
|
+
} else {
|
|
316
|
+
plain[k] = v;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (Object.keys(plain).length > 0) {
|
|
320
|
+
const resolvedPlain = resolveMarkers(plain, ctx.identity, ctx.input);
|
|
321
|
+
parts.push(resolvedPlain === null ? FALSE : compileWhere(resolvedPlain));
|
|
322
|
+
}
|
|
323
|
+
if (parts.length === 0) return TRUE; // empty where -> match all
|
|
324
|
+
return parts.length === 1 ? parts[0]! : and(...parts);
|
|
231
325
|
}
|
|
232
326
|
|
|
233
327
|
/** The concrete rules that apply for (entity, action) under this identity — with
|
|
@@ -276,12 +370,15 @@ function mergeGrants(grants: Grant[]): Scope {
|
|
|
276
370
|
};
|
|
277
371
|
}
|
|
278
372
|
|
|
279
|
-
export function resolveScope(ctx: AclContext, entity: string, action: Action): Scope {
|
|
373
|
+
export function resolveScope(ctx: AclContext, entity: string, action: Action, depth = 0): Scope {
|
|
280
374
|
const grants: Grant[] = [];
|
|
281
375
|
for (const rule of matchedRules(ctx, entity, action)) {
|
|
282
376
|
if (isDeny(rule)) continue;
|
|
283
377
|
if (isAllow(rule)) grants.push(ALLOW_GRANT);
|
|
284
|
-
else
|
|
378
|
+
else {
|
|
379
|
+
const where = compileScopedWhere((rule.where ?? {}) as Record<string, unknown>, entity, ctx, depth);
|
|
380
|
+
grants.push(grantOf(rule, where, entity, ctx, depth));
|
|
381
|
+
}
|
|
285
382
|
}
|
|
286
383
|
return mergeGrants(grants);
|
|
287
384
|
}
|
|
@@ -346,7 +443,8 @@ export function resolveRelationScope(
|
|
|
346
443
|
if (isAllow(rule) || isDeny(rule)) continue;
|
|
347
444
|
const rel = rule.relations?.[relName];
|
|
348
445
|
if (rel?.directAccess) {
|
|
349
|
-
|
|
446
|
+
const relWhere = rel.where ? compileScopedWhere(rel.where as Record<string, unknown>, target, ctx, 0) : null;
|
|
447
|
+
grants.push(grantOf(rel, relWhere, target, ctx, 0));
|
|
350
448
|
}
|
|
351
449
|
}
|
|
352
450
|
|
package/src/runtime/db.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import {
|
|
15
15
|
AclDenied,
|
|
16
16
|
ALLOW_ALL,
|
|
17
|
+
compileScopedWhere,
|
|
17
18
|
effectiveFields,
|
|
18
19
|
projectRow,
|
|
19
20
|
resolveRelationScope,
|
|
@@ -30,7 +31,6 @@ import {
|
|
|
30
31
|
compileCount,
|
|
31
32
|
compileExpr,
|
|
32
33
|
compileSelect,
|
|
33
|
-
compileWhere,
|
|
34
34
|
eq,
|
|
35
35
|
inList,
|
|
36
36
|
or,
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
import { BadRequest } from "./errors";
|
|
43
43
|
import type { Dialect, Driver } from "./driver";
|
|
44
44
|
import type { EntityFields, FieldDef, RelationDef, SchemaDef } from "../sdk/schema";
|
|
45
|
-
import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult,
|
|
45
|
+
import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
|
|
46
46
|
|
|
47
47
|
type Row = Record<string, unknown>;
|
|
48
48
|
type Action = "read" | "create" | "update" | "delete";
|
|
@@ -63,7 +63,7 @@ type OrderSpec<S extends SchemaDef, T extends keyof S> = {
|
|
|
63
63
|
|
|
64
64
|
export interface FindSpec<S extends SchemaDef, T extends keyof S> {
|
|
65
65
|
from: T;
|
|
66
|
-
where?:
|
|
66
|
+
where?: WhereClause<S, T>;
|
|
67
67
|
orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
|
|
68
68
|
limit?: number;
|
|
69
69
|
offset?: number;
|
|
@@ -74,7 +74,7 @@ export interface FindSpec<S extends SchemaDef, T extends keyof S> {
|
|
|
74
74
|
/** Cursor (keyset) pagination input — `after` is an opaque cursor from a prior page. */
|
|
75
75
|
export interface PageSpec<S extends SchemaDef, T extends keyof S> {
|
|
76
76
|
from: T;
|
|
77
|
-
where?:
|
|
77
|
+
where?: WhereClause<S, T>;
|
|
78
78
|
orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
|
|
79
79
|
limit?: number;
|
|
80
80
|
after?: string;
|
|
@@ -93,7 +93,7 @@ type Aggregations<F extends EntityFields> = Record<string, { fn: AggFn; column?:
|
|
|
93
93
|
|
|
94
94
|
export interface AggregateSpec<S extends SchemaDef, T extends keyof S> {
|
|
95
95
|
from: T;
|
|
96
|
-
where?:
|
|
96
|
+
where?: WhereClause<S, T>;
|
|
97
97
|
groupBy?: (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[];
|
|
98
98
|
aggregations: Aggregations<FieldsOf<S[T]>>;
|
|
99
99
|
}
|
|
@@ -154,12 +154,17 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
154
154
|
/** Tables read or written during this Db's lifetime. */
|
|
155
155
|
readonly touched = new Set<string>();
|
|
156
156
|
private readonly dialect: Dialect;
|
|
157
|
+
private readonly acl: AclContext;
|
|
157
158
|
|
|
158
159
|
constructor(
|
|
159
160
|
private readonly driver: Driver,
|
|
160
|
-
|
|
161
|
+
acl: AclContext,
|
|
161
162
|
private readonly schema: SchemaDef,
|
|
162
163
|
) {
|
|
164
|
+
// The ACL context normally already carries the schema (set at the dispatch / DO
|
|
165
|
+
// boundary). Inject it as a safety net so a context built without one can still
|
|
166
|
+
// compile relation-aware `where` rules into subqueries rather than failing obscurely.
|
|
167
|
+
this.acl = acl.schema ? acl : { ...acl, schema };
|
|
163
168
|
this.dialect = driver.dialect;
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -224,7 +229,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
224
229
|
const scope = this.scopeFor(from, "read");
|
|
225
230
|
if (!scope.allowed) throw new AclDenied(from, "read");
|
|
226
231
|
|
|
227
|
-
const where = this.readWhere(spec.where, scope);
|
|
232
|
+
const where = this.readWhere(from, spec.where, scope);
|
|
228
233
|
const orderBy = normalizeOrder(spec.orderBy);
|
|
229
234
|
if (orderBy) this.assertReadableCols(from, scope, orderBy.map((o) => o.column));
|
|
230
235
|
const raw = await this.selectRaw(from, where, orderBy, spec.limit, spec.offset);
|
|
@@ -245,7 +250,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
245
250
|
|
|
246
251
|
const order = this.orderWithPk(from, spec.orderBy);
|
|
247
252
|
this.assertReadableCols(from, scope, order.map((o) => o.column)); // order + cursor must not leak hidden cols
|
|
248
|
-
let where = this.readWhere(spec.where, scope);
|
|
253
|
+
let where = this.readWhere(from, spec.where, scope);
|
|
249
254
|
if (spec.after != null) where = and(where, keysetAfter(order, decodeCursor(spec.after)));
|
|
250
255
|
|
|
251
256
|
const limit = spec.limit ?? DEFAULT_PAGE_SIZE;
|
|
@@ -261,12 +266,12 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
261
266
|
}
|
|
262
267
|
|
|
263
268
|
/** Count rows visible to the caller (ACL read scope applied). */
|
|
264
|
-
async count<T extends keyof S & string>(spec: { from: T; where?:
|
|
269
|
+
async count<T extends keyof S & string>(spec: { from: T; where?: WhereClause<S, T> }): Promise<number> {
|
|
265
270
|
const from = spec.from as string;
|
|
266
271
|
this.touched.add(from);
|
|
267
272
|
const scope = this.scopeFor(from, "read");
|
|
268
273
|
if (!scope.allowed) throw new AclDenied(from, "read");
|
|
269
|
-
const where = this.readWhere(spec.where, scope);
|
|
274
|
+
const where = this.readWhere(from, spec.where, scope);
|
|
270
275
|
const { sql, params } = compileCount(from, this.dialect, where);
|
|
271
276
|
const rows = (await this.driver.exec(sql, params)) as { n: number }[];
|
|
272
277
|
return Number(rows[0]?.n ?? 0);
|
|
@@ -282,7 +287,7 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
282
287
|
G extends (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[] = never,
|
|
283
288
|
>(spec: {
|
|
284
289
|
from: T;
|
|
285
|
-
where?:
|
|
290
|
+
where?: WhereClause<S, T>;
|
|
286
291
|
groupBy?: G;
|
|
287
292
|
aggregations: A;
|
|
288
293
|
}): Promise<AggregateResult<FieldsOf<S[T]>, G, A>[]> {
|
|
@@ -309,18 +314,41 @@ export class Db<S extends SchemaDef = SchemaDef> {
|
|
|
309
314
|
for (const c of refs) if (!scope.fields.includes(c)) throw new AclDenied(from, "read", c);
|
|
310
315
|
}
|
|
311
316
|
|
|
312
|
-
const where = this.readWhere(spec.where, scope);
|
|
317
|
+
const where = this.readWhere(from, spec.where, scope);
|
|
313
318
|
const { sql, params } = compileAggregate({ from, where, groupBy, aggregations: spec.aggregations }, this.dialect);
|
|
314
319
|
return (await this.driver.exec(sql, params)) as AggregateResult<FieldsOf<S[T]>, G, A>[];
|
|
315
320
|
}
|
|
316
321
|
|
|
317
322
|
// --- read internals shared by find/page ---
|
|
318
323
|
|
|
319
|
-
private readWhere(userWhere: unknown, scope: Scope): SqlExpr {
|
|
320
|
-
|
|
324
|
+
private readWhere(from: string, userWhere: unknown, scope: Scope): SqlExpr {
|
|
325
|
+
// Compiles the user's where relation-aware (relation keys → security-scoped
|
|
326
|
+
// subqueries), then AND-merges the entity's own ACL row scope.
|
|
327
|
+
if (userWhere) this.assertReadableWhere(from, scope, userWhere);
|
|
328
|
+
const userExpr: SqlExpr = userWhere ? compileScopedWhere(userWhere as Record<string, unknown>, from, this.acl) : TRUE;
|
|
321
329
|
return scope.where ? and(userExpr, scope.where) : userExpr;
|
|
322
330
|
}
|
|
323
331
|
|
|
332
|
+
/** Reject a user `where` that filters on a column the caller cannot read (closes
|
|
333
|
+
* the same info-leak as ordering by a hidden column: a filter is an oracle for a
|
|
334
|
+
* hidden field's values). Mirrors `assertReadableCols`. Relation keys are skipped
|
|
335
|
+
* here — they're filtered through the TARGET's read scope in acl.relationPredicate
|
|
336
|
+
* — and AND/OR groups recurse. Operator objects (`{ gt: … }`) sit under the column
|
|
337
|
+
* key, so checking the top-level keys is sufficient. */
|
|
338
|
+
private assertReadableWhere(from: string, scope: Scope, where: unknown): void {
|
|
339
|
+
if (scope.fields === null || where == null || typeof where !== "object") return;
|
|
340
|
+
const relations = this.schema[from]?.relations ?? {};
|
|
341
|
+
for (const [k, v] of Object.entries(where as Record<string, unknown>)) {
|
|
342
|
+
if (k === "AND" || k === "OR") {
|
|
343
|
+
for (const g of v as unknown[]) this.assertReadableWhere(from, scope, g);
|
|
344
|
+
} else if (relations[k]) {
|
|
345
|
+
continue; // relation traversal: enforced against the target's scope downstream
|
|
346
|
+
} else if (!scope.fields.includes(k)) {
|
|
347
|
+
throw new AclDenied(from, "read", k);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
324
352
|
private async selectRaw(from: string, where: SqlExpr, orderBy?: OrderBy[], limit?: number, offset?: number): Promise<Row[]> {
|
|
325
353
|
const { sql, params } = compileSelect({ from, where, orderBy, limit, offset }, this.dialect);
|
|
326
354
|
return this.decodeRows(from, await this.driver.exec(sql, params));
|
package/src/runtime/dispatch.ts
CHANGED
|
@@ -50,10 +50,10 @@ export async function dispatch(
|
|
|
50
50
|
|
|
51
51
|
// Warmup: evaluate dynamic resolvers once, reading through a SYSTEM-mode db
|
|
52
52
|
// (separate from the handler's db, so its reads don't pollute `touched`).
|
|
53
|
-
const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true }, schema);
|
|
53
|
+
const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema }, schema);
|
|
54
54
|
const resolved = await warmup(acl.acl, acl.identity, systemDb as unknown as ResolverDb);
|
|
55
55
|
|
|
56
|
-
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved }, schema);
|
|
56
|
+
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema }, schema);
|
|
57
57
|
const ctx: HandlerContext = { db, kv, files, env, identity: acl.identity };
|
|
58
58
|
|
|
59
59
|
const result =
|
|
@@ -23,7 +23,11 @@ export type SqlExpr =
|
|
|
23
23
|
| { t: "in"; col: string; values: unknown[]; negate: boolean }
|
|
24
24
|
| { t: "null"; col: string; negate: boolean }
|
|
25
25
|
| { t: "and"; parts: SqlExpr[] }
|
|
26
|
-
| { t: "or"; parts: SqlExpr[] }
|
|
26
|
+
| { t: "or"; parts: SqlExpr[] }
|
|
27
|
+
// Relation traversal: `outerCol IN (SELECT selectCol FROM from WHERE where)`.
|
|
28
|
+
// Built by the ACL layer (which knows schema + the target's read scope); the
|
|
29
|
+
// inner predicate compiles inline so placeholders share the outer param sequence.
|
|
30
|
+
| { t: "sub"; outerCol: string; from: string; selectCol: string; where: SqlExpr; negate: boolean };
|
|
27
31
|
|
|
28
32
|
export const TRUE: SqlExpr = { t: "true" };
|
|
29
33
|
export const FALSE: SqlExpr = { t: "false" };
|
|
@@ -104,6 +108,16 @@ export function compileExpr(expr: SqlExpr, dialect: Dialect, params: unknown[] =
|
|
|
104
108
|
const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
|
|
105
109
|
return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
|
|
106
110
|
}
|
|
111
|
+
case "sub": {
|
|
112
|
+
// Inner predicate shares `params`, so placeholder numbering stays correct
|
|
113
|
+
// across dialects (? and $n alike).
|
|
114
|
+
const inner = compileExpr(expr.where, dialect, params).sql;
|
|
115
|
+
const op = expr.negate ? "NOT IN" : "IN";
|
|
116
|
+
return {
|
|
117
|
+
sql: `${dialect.id(expr.outerCol)} ${op} (SELECT ${dialect.id(expr.selectCol)} FROM ${dialect.id(expr.from)} WHERE ${inner})`,
|
|
118
|
+
params,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
107
121
|
}
|
|
108
122
|
}
|
|
109
123
|
|
|
@@ -161,6 +175,10 @@ export function evalExpr(expr: SqlExpr, row: Record<string, unknown>): boolean {
|
|
|
161
175
|
return expr.parts.every((p) => evalExpr(p, row));
|
|
162
176
|
case "or":
|
|
163
177
|
return expr.parts.some((p) => evalExpr(p, row));
|
|
178
|
+
case "sub":
|
|
179
|
+
// Relation traversal needs a SQL round-trip; it isn't supported in the
|
|
180
|
+
// in-memory cell-ACL `when` evaluator (those predicates must be single-table).
|
|
181
|
+
throw new Error("relation predicates are not supported in cell-level `when`");
|
|
164
182
|
}
|
|
165
183
|
}
|
|
166
184
|
|
package/src/sdk/infer.ts
CHANGED
|
@@ -62,6 +62,23 @@ export type WhereInput<F extends EntityFields> = {
|
|
|
62
62
|
OR?: WhereInput<F>[];
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
// --- relation-aware where (a relation key takes a nested clause over its target,
|
|
66
|
+
// compiled to a security-scoped subquery). Depth-bounded so the type stays finite
|
|
67
|
+
// under cyclic relations (e.g. user.notes ↔ note.owner). The bound matches the
|
|
68
|
+
// runtime MAX_REL_DEPTH in runtime/acl.ts — keep the two in lockstep. ---
|
|
69
|
+
|
|
70
|
+
type PrevDepth = [never, 0, 1, 2, 3, 4, 5];
|
|
71
|
+
type RelTargetTable<S extends SchemaDef, R> = R extends { target: infer Tg } ? (Tg extends keyof S ? Tg : never) : never;
|
|
72
|
+
|
|
73
|
+
/** A `where` clause: column predicates + AND/OR, plus relation keys that take a
|
|
74
|
+
* nested `WhereClause` over the related entity (traversal). */
|
|
75
|
+
export type WhereClause<S extends SchemaDef, T extends keyof S, D extends number = 5> = WhereInput<FieldsOf<S[T]>> &
|
|
76
|
+
([D] extends [never]
|
|
77
|
+
? object
|
|
78
|
+
: {
|
|
79
|
+
[K in keyof RelationsOf<S[T]>]?: WhereClause<S, RelTargetTable<S, RelationsOf<S[T]>[K]>, PrevDepth[D]>;
|
|
80
|
+
});
|
|
81
|
+
|
|
65
82
|
/** Patch input for updates: every column optional, value typed (nullable). */
|
|
66
83
|
export type InferUpdate<F extends EntityFields> = Partial<{ [K in keyof F]: FieldTsType<F[K]> | null }>;
|
|
67
84
|
|