@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/auth.d.ts
CHANGED
|
@@ -28,8 +28,12 @@ export declare class JwksStrategy implements VerifyStrategy {
|
|
|
28
28
|
}
|
|
29
29
|
export declare function resolveIdentity(request: Request, strategy: VerifyStrategy): Promise<Identity | null>;
|
|
30
30
|
/** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
|
|
31
|
-
* can't reach (or register) arbitrary tenants. Default policy:
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* can't reach (or register) arbitrary tenants. Default policy:
|
|
32
|
+
* - `main` is the open default tenant: any caller may reach it (anonymous too) —
|
|
33
|
+
* data access is still governed by ACL roles (single-tenant apps live here, and
|
|
34
|
+
* issued login tokens need no `tenants` claim to use it).
|
|
35
|
+
* - other tenants: admins → any; everyone else → only tenants in their `tenants`
|
|
36
|
+
* claim; anonymous → none.
|
|
37
|
+
* Customize for your tenancy model (e.g. tenant === identity.org, or a lookup). */
|
|
34
38
|
export declare function authorizeTenant(identity: Identity | null, tenant: string): boolean;
|
|
35
39
|
export declare function isAdmin(identity: Identity | null): boolean;
|
package/dist/auth.js
CHANGED
|
@@ -170,15 +170,18 @@ export async function resolveIdentity(request, strategy) {
|
|
|
170
170
|
return claims ? toIdentity(claims) : null;
|
|
171
171
|
}
|
|
172
172
|
/** May this identity address the given tenant? Gates `X-Pramen-Tenant` so a caller
|
|
173
|
-
* can't reach (or register) arbitrary tenants. Default policy:
|
|
174
|
-
*
|
|
175
|
-
*
|
|
173
|
+
* can't reach (or register) arbitrary tenants. Default policy:
|
|
174
|
+
* - `main` is the open default tenant: any caller may reach it (anonymous too) —
|
|
175
|
+
* data access is still governed by ACL roles (single-tenant apps live here, and
|
|
176
|
+
* issued login tokens need no `tenants` claim to use it).
|
|
177
|
+
* - other tenants: admins → any; everyone else → only tenants in their `tenants`
|
|
178
|
+
* claim; anonymous → none.
|
|
179
|
+
* Customize for your tenancy model (e.g. tenant === identity.org, or a lookup). */
|
|
176
180
|
export function authorizeTenant(identity, tenant) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// not letting unauthenticated callers address/register arbitrary tenants.
|
|
181
|
+
if (tenant === "main")
|
|
182
|
+
return true;
|
|
180
183
|
if (!identity)
|
|
181
|
-
return
|
|
184
|
+
return false;
|
|
182
185
|
if (identity.roles?.includes("admin"))
|
|
183
186
|
return true;
|
|
184
187
|
const allowed = Array.isArray(identity.tenants) ? identity.tenants : [];
|
package/dist/durable-object.d.ts
CHANGED
package/dist/durable-object.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { DurableObject } from "cloudflare:workers";
|
|
18
18
|
import { migrate } from "./runtime/migrate";
|
|
19
19
|
import { dispatch } from "./runtime/dispatch";
|
|
20
|
+
import { Db } from "./runtime/db";
|
|
20
21
|
import { digest } from "./runtime/digest";
|
|
21
22
|
import { compileAcl } from "./runtime/acl";
|
|
22
23
|
import { DoSqliteDriver } from "./runtime/driver";
|
|
@@ -59,6 +60,8 @@ export class PramenDOBase extends DurableObject {
|
|
|
59
60
|
return this.handleRecover(request);
|
|
60
61
|
if (path === "/__schema")
|
|
61
62
|
return this.handleSchema();
|
|
63
|
+
if (path === "/__admin/data")
|
|
64
|
+
return this.handleAdminData(request);
|
|
62
65
|
const identity = this.identityOf(request);
|
|
63
66
|
if (request.headers.get("Upgrade") === "websocket") {
|
|
64
67
|
const { 0: client, 1: server } = new WebSocketPair();
|
|
@@ -232,8 +235,59 @@ export class PramenDOBase extends DurableObject {
|
|
|
232
235
|
}
|
|
233
236
|
return Response.json({ ok: true, result: { hash: hashRow[0]?.value ?? null, tables } });
|
|
234
237
|
}
|
|
238
|
+
// Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
|
|
239
|
+
// Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
|
|
240
|
+
// json/fileRef codec, transactions, and live-query broadcast still apply.
|
|
241
|
+
async handleAdminData(request) {
|
|
242
|
+
const b = (await request.json().catch(() => ({})));
|
|
243
|
+
const table = typeof b.table === "string" ? b.table : "";
|
|
244
|
+
const op = typeof b.op === "string" ? b.op : "";
|
|
245
|
+
if (!table || !(table in this.app.schema)) {
|
|
246
|
+
return Response.json({ ok: false, error: "unknown table", code: "bad_request" }, { status: 400 });
|
|
247
|
+
}
|
|
248
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
249
|
+
const db = new Db(this.driver, { acl: this.acl, identity: null, system: true }, this.app.schema);
|
|
250
|
+
try {
|
|
251
|
+
let result;
|
|
252
|
+
let mutated = false;
|
|
253
|
+
switch (op) {
|
|
254
|
+
case "list":
|
|
255
|
+
result = await db.find({ from: table, where: b.where, orderBy: b.orderBy, limit: b.limit, offset: b.offset });
|
|
256
|
+
break;
|
|
257
|
+
case "count":
|
|
258
|
+
result = await db.count({ from: table, where: b.where });
|
|
259
|
+
break;
|
|
260
|
+
case "get":
|
|
261
|
+
result = (await db.find({ from: table, where: { id: b.id }, limit: 1 }))[0] ?? null;
|
|
262
|
+
break;
|
|
263
|
+
case "create":
|
|
264
|
+
result = await this.driver.transaction(() => db.insert(table, b.values));
|
|
265
|
+
mutated = true;
|
|
266
|
+
break;
|
|
267
|
+
case "update":
|
|
268
|
+
result = (await this.driver.transaction(() => db.update(table, b.id, b.patch))) ?? null;
|
|
269
|
+
mutated = true;
|
|
270
|
+
break;
|
|
271
|
+
case "delete":
|
|
272
|
+
result = await this.driver.transaction(() => db.delete(table, b.id));
|
|
273
|
+
mutated = true;
|
|
274
|
+
break;
|
|
275
|
+
default:
|
|
276
|
+
return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
|
|
277
|
+
}
|
|
278
|
+
if (mutated && db.touched.size > 0)
|
|
279
|
+
await this.broadcast([...db.touched]);
|
|
280
|
+
return Response.json({ ok: true, result });
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
const { status, body } = toResponse(err);
|
|
284
|
+
return Response.json(body, { status });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
235
287
|
ctxFor(identity) {
|
|
236
|
-
|
|
288
|
+
// Carry the schema so any consumer of this context (not just Db) can compile
|
|
289
|
+
// relation-aware `where` rules into subqueries.
|
|
290
|
+
return { acl: this.acl, identity, schema: this.app.schema };
|
|
237
291
|
}
|
|
238
292
|
// The DO env (bindings + vars + secrets) handed to handlers as ctx.env. Loosely
|
|
239
293
|
// typed at the boundary so handlers can read any var/secret without a DoEnv cast.
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { query, mutation } from "./sdk/handlers";
|
|
|
5
5
|
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
|
|
6
6
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
7
7
|
export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
|
|
8
|
-
export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereInput, WhereOps, } from "./sdk/infer";
|
|
8
|
+
export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
|
|
9
9
|
export type { FileRef, Files, SignUploadOpts, SignDownloadOpts, HeadResult } from "./sdk/files";
|
|
10
10
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
11
11
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
package/dist/runtime/acl.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Action, type FieldsFn, type Identity, type PolicyRule, type ResolverDb, type Role, type Validator } from "../sdk/acl";
|
|
2
2
|
import { type SqlExpr } from "./read-engine";
|
|
3
3
|
import { PramenError } from "./errors";
|
|
4
|
+
import type { SchemaDef } from "../sdk/schema";
|
|
4
5
|
export declare class AclDenied extends PramenError {
|
|
5
6
|
readonly entity: string;
|
|
6
7
|
readonly action: Action;
|
|
@@ -22,6 +23,9 @@ export interface AclContext {
|
|
|
22
23
|
readonly resolved?: Map<number, PolicyRule>;
|
|
23
24
|
/** SYSTEM mode bypasses all ACL — used for warmup reads and internal ops. */
|
|
24
25
|
readonly system?: boolean;
|
|
26
|
+
/** The app schema — lets `where` rules traverse relations (`{ rel: { col } }`),
|
|
27
|
+
* compiled to a subquery with the related entity's read scope AND-merged in. */
|
|
28
|
+
readonly schema?: SchemaDef;
|
|
25
29
|
}
|
|
26
30
|
/** Evaluate every resolver reachable by the identity's roles, once per request.
|
|
27
31
|
* Resolvers read through a SYSTEM-mode db (ACL bypassed) to avoid recursion. */
|
|
@@ -44,7 +48,29 @@ export interface Scope {
|
|
|
44
48
|
readonly fieldsFns: FieldsFn[];
|
|
45
49
|
}
|
|
46
50
|
export declare const ALLOW_ALL: Scope;
|
|
47
|
-
|
|
51
|
+
/** Max relation-traversal nesting depth in a `where` (guards cyclic relations).
|
|
52
|
+
* Kept in lockstep with the `WhereClause` type's depth bound in sdk/infer.ts — if
|
|
53
|
+
* you change one, change the other. */
|
|
54
|
+
export declare const MAX_REL_DEPTH = 5;
|
|
55
|
+
/** Compile a where-rule (user query or policy) into a SqlExpr. Plain columns go
|
|
56
|
+
* through the marker-resolving compiler; relation keys (`{ rel: { … } }`) become
|
|
57
|
+
* security-scoped subqueries. Supports operators, AND/OR, and $identity/$input
|
|
58
|
+
* markers; an unresolvable marker makes its branch match nothing. Schema-less
|
|
59
|
+
* contexts (no relations) behave exactly like the flat compiler.
|
|
60
|
+
*
|
|
61
|
+
* Marker semantics: AND/OR branches compile independently, so an unresolvable
|
|
62
|
+
* marker collapses ONLY its own branch to FALSE — it does not nullify sibling
|
|
63
|
+
* branches. `OR: [{ ownerId: $identity("userId") }, { public: true }]` therefore
|
|
64
|
+
* still grants the `public` branch to a caller whose `userId` can't resolve (and,
|
|
65
|
+
* conversely, an unresolvable marker in one OR branch no longer revokes access the
|
|
66
|
+
* other branches would grant). This is plain boolean logic; the cases are covered
|
|
67
|
+
* by the relwhere suite.
|
|
68
|
+
*
|
|
69
|
+
* `allowRelations` is false for single-table contexts (cell-level `when`, which is
|
|
70
|
+
* evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
|
|
71
|
+
* clear authoring error instead of emitting a `sub` node that throws at read time. */
|
|
72
|
+
export declare function compileScopedWhere(rule: Record<string, unknown>, entity: string, ctx: AclContext, depth?: number, allowRelations?: boolean): SqlExpr;
|
|
73
|
+
export declare function resolveScope(ctx: AclContext, entity: string, action: Action, depth?: number): Scope;
|
|
48
74
|
/** Effective visible fields for one row = base ∪ matching-conditional ∪ fn-output.
|
|
49
75
|
* Returns null (all fields) when the base is null or a resolver grants everything. */
|
|
50
76
|
export declare function effectiveFields(scope: Scope, row: Record<string, unknown>, identity: Identity | null): string[] | null;
|
package/dist/runtime/acl.js
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// granted, the row-level predicate to merge into the query, and any field
|
|
4
4
|
// restriction. Deny-by-default; grants OR-merge across the identity's roles.
|
|
5
5
|
import { deny, isAllow, isDeny, isIdentityMarker, isInputMarker, isResolver, } from "../sdk/acl";
|
|
6
|
-
import { compileWhere, evalExpr, FALSE, or } from "./read-engine";
|
|
7
|
-
import { PramenError } from "./errors";
|
|
6
|
+
import { and, compileWhere, evalExpr, FALSE, or, TRUE } from "./read-engine";
|
|
7
|
+
import { BadRequest, PramenError } from "./errors";
|
|
8
8
|
export class AclDenied extends PramenError {
|
|
9
9
|
entity;
|
|
10
10
|
action;
|
|
@@ -55,14 +55,16 @@ export function compileAcl(roles) {
|
|
|
55
55
|
}
|
|
56
56
|
export const ALLOW_ALL = { allowed: true, where: null, fields: null, conditional: [], fieldsFns: [] };
|
|
57
57
|
const DENIED = { allowed: false, where: null, fields: null, conditional: [], fieldsFns: [] };
|
|
58
|
-
/** Build a grant from a policy/relation rule, resolving
|
|
59
|
-
* `
|
|
60
|
-
function grantOf(rule, where,
|
|
58
|
+
/** Build a grant from a policy/relation rule, resolving markers in `where` and each
|
|
59
|
+
* conditional `when` (the `when` predicate is single-table — evaluated in memory). */
|
|
60
|
+
function grantOf(rule, where, entity, ctx, depth) {
|
|
61
61
|
return {
|
|
62
62
|
where,
|
|
63
63
|
fields: rule.fields ?? null,
|
|
64
64
|
conditional: (rule.conditionalFields ?? []).map((cf) => ({
|
|
65
|
-
when
|
|
65
|
+
// Cell-level `when` is evaluated per-row in memory (evalExpr), so it must stay
|
|
66
|
+
// single-table — `allowRelations: false` rejects a relation key up front.
|
|
67
|
+
when: compileScopedWhere(cf.when, entity, ctx, depth, false),
|
|
66
68
|
fields: cf.fields,
|
|
67
69
|
})),
|
|
68
70
|
fieldsFns: rule.fieldsFn ? [rule.fieldsFn] : [],
|
|
@@ -97,23 +99,18 @@ function resolveValue(v, identity, input) {
|
|
|
97
99
|
}
|
|
98
100
|
return v;
|
|
99
101
|
}
|
|
100
|
-
// Resolve every $identity marker in a
|
|
101
|
-
// objects, in/notIn arrays
|
|
102
|
-
//
|
|
102
|
+
// Resolve every $identity/$input marker in a SINGLE level of a where-rule (bare
|
|
103
|
+
// values, operator objects, in/notIn arrays). AND/OR groups are split off by
|
|
104
|
+
// `compileScopedWhere` before this runs, so this only ever sees plain columns.
|
|
105
|
+
// Returns a plain WhereInput, or null if any marker is unresolvable — in which
|
|
106
|
+
// case this branch matches nothing (FALSE). Note: because branches are resolved
|
|
107
|
+
// independently, an unresolvable marker nullifies only its own branch, not the
|
|
108
|
+
// whole rule — so `OR: [{ x: $identity(...) }, { public: true }]` still matches
|
|
109
|
+
// the `public` branch for a caller whose marker can't resolve. (See the comment
|
|
110
|
+
// on `compileScopedWhere`.)
|
|
103
111
|
function resolveMarkers(rule, identity, input) {
|
|
104
112
|
const out = {};
|
|
105
113
|
for (const [key, v] of Object.entries(rule)) {
|
|
106
|
-
if (key === "AND" || key === "OR") {
|
|
107
|
-
const groups = [];
|
|
108
|
-
for (const g of v) {
|
|
109
|
-
const resolved = resolveMarkers(g, identity, input);
|
|
110
|
-
if (resolved === null)
|
|
111
|
-
return null;
|
|
112
|
-
groups.push(resolved);
|
|
113
|
-
}
|
|
114
|
-
out[key] = groups;
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
114
|
const isMarker = isIdentityMarker(v) || isInputMarker(v);
|
|
118
115
|
if (v !== null && typeof v === "object" && !isMarker && !Array.isArray(v)) {
|
|
119
116
|
const ops = {};
|
|
@@ -153,12 +150,106 @@ function resolveMarkers(rule, identity, input) {
|
|
|
153
150
|
}
|
|
154
151
|
return out;
|
|
155
152
|
}
|
|
156
|
-
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
153
|
+
/** Max relation-traversal nesting depth in a `where` (guards cyclic relations).
|
|
154
|
+
* Kept in lockstep with the `WhereClause` type's depth bound in sdk/infer.ts — if
|
|
155
|
+
* you change one, change the other. */
|
|
156
|
+
export const MAX_REL_DEPTH = 5;
|
|
157
|
+
/** Primary-key column of an entity (the column a belongsTo points at / a hasMany
|
|
158
|
+
* joins back to). Defaults to `id`. */
|
|
159
|
+
function pkOf(schema, entity) {
|
|
160
|
+
const fields = schema?.[entity]?.fields;
|
|
161
|
+
if (fields)
|
|
162
|
+
for (const [n, f] of Object.entries(fields))
|
|
163
|
+
if (f.primaryKey)
|
|
164
|
+
return n;
|
|
165
|
+
return "id";
|
|
166
|
+
}
|
|
167
|
+
/** Compile a relation predicate `{ rel: { … } }` to a subquery, AND-merging the
|
|
168
|
+
* related entity's read scope (and rejecting filters on fields it can't read) so
|
|
169
|
+
* traversal can never widen access beyond a direct read of the target. */
|
|
170
|
+
function relationPredicate(rel, nested, parentEntity, ctx, depth) {
|
|
171
|
+
if (depth >= MAX_REL_DEPTH)
|
|
172
|
+
throw new BadRequest("relation `where` is nested too deep");
|
|
173
|
+
if (nested === null || typeof nested !== "object" || Array.isArray(nested)) {
|
|
174
|
+
throw new BadRequest(`relation filter for '${rel.target}' must be an object`);
|
|
175
|
+
}
|
|
176
|
+
let inner = compileScopedWhere(nested, rel.target, ctx, depth + 1);
|
|
177
|
+
// Security: a relation filter must respect the target's read ACL (else it leaks).
|
|
178
|
+
// Two distinct "no" outcomes, matching how the rest of the read path behaves:
|
|
179
|
+
// - No read grant on the target at all -> the relation simply yields no rows
|
|
180
|
+
// to match against (FALSE, empty result) — a valid query over a table you
|
|
181
|
+
// can't see.
|
|
182
|
+
// - Readable target, but the filter names a column you can't read -> 403, the
|
|
183
|
+
// same as ordering/aggregating by a hidden column (you referenced something
|
|
184
|
+
// forbidden). Top-level user `where` enforces the same rule (Db.readWhere).
|
|
185
|
+
if (!ctx.system) {
|
|
186
|
+
const tScope = resolveScope(ctx, rel.target, "read", depth + 1);
|
|
187
|
+
if (!tScope.allowed) {
|
|
188
|
+
inner = FALSE; // can't filter through a relation you can't read
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
if (tScope.fields !== null) {
|
|
192
|
+
const targetRels = (ctx.schema?.[rel.target]?.relations ?? {});
|
|
193
|
+
for (const k of Object.keys(nested)) {
|
|
194
|
+
if (k === "AND" || k === "OR" || targetRels[k])
|
|
195
|
+
continue;
|
|
196
|
+
if (!tScope.fields.includes(k))
|
|
197
|
+
throw new AclDenied(rel.target, "read", k);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (tScope.where)
|
|
201
|
+
inner = and(inner, tScope.where);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// belongsTo: parent.<fk> IN (SELECT <target pk> FROM target WHERE inner)
|
|
205
|
+
// hasMany: parent.<pk> IN (SELECT <target fk> FROM target WHERE inner)
|
|
206
|
+
return rel.kind === "belongsTo"
|
|
207
|
+
? { t: "sub", outerCol: rel.column, from: rel.target, selectCol: pkOf(ctx.schema, rel.target), where: inner, negate: false }
|
|
208
|
+
: { t: "sub", outerCol: pkOf(ctx.schema, parentEntity), from: rel.target, selectCol: rel.column, where: inner, negate: false };
|
|
209
|
+
}
|
|
210
|
+
/** Compile a where-rule (user query or policy) into a SqlExpr. Plain columns go
|
|
211
|
+
* through the marker-resolving compiler; relation keys (`{ rel: { … } }`) become
|
|
212
|
+
* security-scoped subqueries. Supports operators, AND/OR, and $identity/$input
|
|
213
|
+
* markers; an unresolvable marker makes its branch match nothing. Schema-less
|
|
214
|
+
* contexts (no relations) behave exactly like the flat compiler.
|
|
215
|
+
*
|
|
216
|
+
* Marker semantics: AND/OR branches compile independently, so an unresolvable
|
|
217
|
+
* marker collapses ONLY its own branch to FALSE — it does not nullify sibling
|
|
218
|
+
* branches. `OR: [{ ownerId: $identity("userId") }, { public: true }]` therefore
|
|
219
|
+
* still grants the `public` branch to a caller whose `userId` can't resolve (and,
|
|
220
|
+
* conversely, an unresolvable marker in one OR branch no longer revokes access the
|
|
221
|
+
* other branches would grant). This is plain boolean logic; the cases are covered
|
|
222
|
+
* by the relwhere suite.
|
|
223
|
+
*
|
|
224
|
+
* `allowRelations` is false for single-table contexts (cell-level `when`, which is
|
|
225
|
+
* evaluated in memory and cannot do a SQL round-trip): a relation key then raises a
|
|
226
|
+
* clear authoring error instead of emitting a `sub` node that throws at read time. */
|
|
227
|
+
export function compileScopedWhere(rule, entity, ctx, depth = 0, allowRelations = true) {
|
|
228
|
+
const relations = (ctx.schema?.[entity]?.relations ?? {});
|
|
229
|
+
const parts = [];
|
|
230
|
+
const plain = {};
|
|
231
|
+
for (const [k, v] of Object.entries(rule)) {
|
|
232
|
+
if (k === "AND" || k === "OR") {
|
|
233
|
+
const groups = v.map((g) => compileScopedWhere(g, entity, ctx, depth, allowRelations));
|
|
234
|
+
parts.push(k === "AND" ? and(...groups) : or(...groups));
|
|
235
|
+
}
|
|
236
|
+
else if (relations[k]) {
|
|
237
|
+
if (!allowRelations) {
|
|
238
|
+
throw new BadRequest(`cell-level \`when\` cannot traverse relations: '${k}' (relations need a SQL round-trip)`);
|
|
239
|
+
}
|
|
240
|
+
parts.push(relationPredicate(relations[k], v, entity, ctx, depth));
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
plain[k] = v;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (Object.keys(plain).length > 0) {
|
|
247
|
+
const resolvedPlain = resolveMarkers(plain, ctx.identity, ctx.input);
|
|
248
|
+
parts.push(resolvedPlain === null ? FALSE : compileWhere(resolvedPlain));
|
|
249
|
+
}
|
|
250
|
+
if (parts.length === 0)
|
|
251
|
+
return TRUE; // empty where -> match all
|
|
252
|
+
return parts.length === 1 ? parts[0] : and(...parts);
|
|
162
253
|
}
|
|
163
254
|
/** The concrete rules that apply for (entity, action) under this identity — with
|
|
164
255
|
* resolvers replaced by their warmup result and resolver/unresolved entries dropped. */
|
|
@@ -211,15 +302,17 @@ function mergeGrants(grants) {
|
|
|
211
302
|
fieldsFns,
|
|
212
303
|
};
|
|
213
304
|
}
|
|
214
|
-
export function resolveScope(ctx, entity, action) {
|
|
305
|
+
export function resolveScope(ctx, entity, action, depth = 0) {
|
|
215
306
|
const grants = [];
|
|
216
307
|
for (const rule of matchedRules(ctx, entity, action)) {
|
|
217
308
|
if (isDeny(rule))
|
|
218
309
|
continue;
|
|
219
310
|
if (isAllow(rule))
|
|
220
311
|
grants.push(ALLOW_GRANT);
|
|
221
|
-
else
|
|
222
|
-
|
|
312
|
+
else {
|
|
313
|
+
const where = compileScopedWhere((rule.where ?? {}), entity, ctx, depth);
|
|
314
|
+
grants.push(grantOf(rule, where, entity, ctx, depth));
|
|
315
|
+
}
|
|
223
316
|
}
|
|
224
317
|
return mergeGrants(grants);
|
|
225
318
|
}
|
|
@@ -272,7 +365,8 @@ export function resolveRelationScope(ctx, parentEntity, relName, target) {
|
|
|
272
365
|
continue;
|
|
273
366
|
const rel = rule.relations?.[relName];
|
|
274
367
|
if (rel?.directAccess) {
|
|
275
|
-
|
|
368
|
+
const relWhere = rel.where ? compileScopedWhere(rel.where, target, ctx, 0) : null;
|
|
369
|
+
grants.push(grantOf(rel, relWhere, target, ctx, 0));
|
|
276
370
|
}
|
|
277
371
|
}
|
|
278
372
|
return mergeGrants(grants);
|
package/dist/runtime/db.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type AclContext } from "./acl";
|
|
|
2
2
|
import { type AggFn } from "./read-engine";
|
|
3
3
|
import type { Driver } from "./driver";
|
|
4
4
|
import type { EntityFields, SchemaDef } from "../sdk/schema";
|
|
5
|
-
import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult,
|
|
5
|
+
import type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, RelationsOf, RelationsResult, WhereClause } from "../sdk/infer";
|
|
6
6
|
type Row = Record<string, unknown>;
|
|
7
7
|
type Id = string | number | bigint;
|
|
8
8
|
type OrderSpec<S extends SchemaDef, T extends keyof S> = {
|
|
@@ -11,7 +11,7 @@ type OrderSpec<S extends SchemaDef, T extends keyof S> = {
|
|
|
11
11
|
};
|
|
12
12
|
export interface FindSpec<S extends SchemaDef, T extends keyof S> {
|
|
13
13
|
from: T;
|
|
14
|
-
where?:
|
|
14
|
+
where?: WhereClause<S, T>;
|
|
15
15
|
orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
|
|
16
16
|
limit?: number;
|
|
17
17
|
offset?: number;
|
|
@@ -21,7 +21,7 @@ export interface FindSpec<S extends SchemaDef, T extends keyof S> {
|
|
|
21
21
|
/** Cursor (keyset) pagination input — `after` is an opaque cursor from a prior page. */
|
|
22
22
|
export interface PageSpec<S extends SchemaDef, T extends keyof S> {
|
|
23
23
|
from: T;
|
|
24
|
-
where?:
|
|
24
|
+
where?: WhereClause<S, T>;
|
|
25
25
|
orderBy?: OrderSpec<S, T> | OrderSpec<S, T>[];
|
|
26
26
|
limit?: number;
|
|
27
27
|
after?: string;
|
|
@@ -40,7 +40,7 @@ type Aggregations<F extends EntityFields> = Record<string, {
|
|
|
40
40
|
}>;
|
|
41
41
|
export interface AggregateSpec<S extends SchemaDef, T extends keyof S> {
|
|
42
42
|
from: T;
|
|
43
|
-
where?:
|
|
43
|
+
where?: WhereClause<S, T>;
|
|
44
44
|
groupBy?: (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[];
|
|
45
45
|
aggregations: Aggregations<FieldsOf<S[T]>>;
|
|
46
46
|
}
|
|
@@ -59,11 +59,11 @@ export type AggregateResult<F extends EntityFields, G, A extends Aggregations<F>
|
|
|
59
59
|
};
|
|
60
60
|
export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
61
61
|
private readonly driver;
|
|
62
|
-
private readonly acl;
|
|
63
62
|
private readonly schema;
|
|
64
63
|
/** Tables read or written during this Db's lifetime. */
|
|
65
64
|
readonly touched: Set<string>;
|
|
66
65
|
private readonly dialect;
|
|
66
|
+
private readonly acl;
|
|
67
67
|
constructor(driver: Driver, acl: AclContext, schema: SchemaDef);
|
|
68
68
|
/** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
|
|
69
69
|
private scopeFor;
|
|
@@ -91,7 +91,7 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
91
91
|
/** Count rows visible to the caller (ACL read scope applied). */
|
|
92
92
|
count<T extends keyof S & string>(spec: {
|
|
93
93
|
from: T;
|
|
94
|
-
where?:
|
|
94
|
+
where?: WhereClause<S, T>;
|
|
95
95
|
}): Promise<number>;
|
|
96
96
|
/** Grouped aggregation (count/sum/avg/min/max). ACL read scope is applied, and
|
|
97
97
|
* every referenced column must be readable under field permissions. The result
|
|
@@ -99,11 +99,18 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
99
99
|
* each aggregation gets its computed value type. */
|
|
100
100
|
aggregate<T extends keyof S & string, A extends Aggregations<FieldsOf<S[T]>>, G extends (keyof FieldsOf<S[T]> & string) | (keyof FieldsOf<S[T]> & string)[] = never>(spec: {
|
|
101
101
|
from: T;
|
|
102
|
-
where?:
|
|
102
|
+
where?: WhereClause<S, T>;
|
|
103
103
|
groupBy?: G;
|
|
104
104
|
aggregations: A;
|
|
105
105
|
}): Promise<AggregateResult<FieldsOf<S[T]>, G, A>[]>;
|
|
106
106
|
private readWhere;
|
|
107
|
+
/** Reject a user `where` that filters on a column the caller cannot read (closes
|
|
108
|
+
* the same info-leak as ordering by a hidden column: a filter is an oracle for a
|
|
109
|
+
* hidden field's values). Mirrors `assertReadableCols`. Relation keys are skipped
|
|
110
|
+
* here — they're filtered through the TARGET's read scope in acl.relationPredicate
|
|
111
|
+
* — and AND/OR groups recurse. Operator objects (`{ gt: … }`) sit under the column
|
|
112
|
+
* key, so checking the top-level keys is sufficient. */
|
|
113
|
+
private assertReadableWhere;
|
|
107
114
|
private selectRaw;
|
|
108
115
|
private jsonColsOf;
|
|
109
116
|
private decodeRows;
|
package/dist/runtime/db.js
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// Every Db also records the tables it touched during one handler run (`touched`),
|
|
11
11
|
// which the live-query layer uses to decide which subscriptions to re-check.
|
|
12
12
|
// Create a fresh Db per handler run so identity and `touched` are scoped.
|
|
13
|
-
import { AclDenied, ALLOW_ALL, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
|
|
14
|
-
import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect,
|
|
13
|
+
import { AclDenied, ALLOW_ALL, compileScopedWhere, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
|
|
14
|
+
import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, inList, or, TRUE, } from "./read-engine";
|
|
15
15
|
import { BadRequest } from "./errors";
|
|
16
16
|
const DEFAULT_PAGE_SIZE = 50;
|
|
17
17
|
function normalizeOrder(orderBy) {
|
|
@@ -50,15 +50,18 @@ function keysetAfter(order, values) {
|
|
|
50
50
|
}
|
|
51
51
|
export class Db {
|
|
52
52
|
driver;
|
|
53
|
-
acl;
|
|
54
53
|
schema;
|
|
55
54
|
/** Tables read or written during this Db's lifetime. */
|
|
56
55
|
touched = new Set();
|
|
57
56
|
dialect;
|
|
57
|
+
acl;
|
|
58
58
|
constructor(driver, acl, schema) {
|
|
59
59
|
this.driver = driver;
|
|
60
|
-
this.acl = acl;
|
|
61
60
|
this.schema = schema;
|
|
61
|
+
// The ACL context normally already carries the schema (set at the dispatch / DO
|
|
62
|
+
// boundary). Inject it as a safety net so a context built without one can still
|
|
63
|
+
// compile relation-aware `where` rules into subqueries rather than failing obscurely.
|
|
64
|
+
this.acl = acl.schema ? acl : { ...acl, schema };
|
|
62
65
|
this.dialect = driver.dialect;
|
|
63
66
|
}
|
|
64
67
|
/** Resolve the ACL scope for an operation, or grant everything in SYSTEM mode. */
|
|
@@ -116,7 +119,7 @@ export class Db {
|
|
|
116
119
|
const scope = this.scopeFor(from, "read");
|
|
117
120
|
if (!scope.allowed)
|
|
118
121
|
throw new AclDenied(from, "read");
|
|
119
|
-
const where = this.readWhere(spec.where, scope);
|
|
122
|
+
const where = this.readWhere(from, spec.where, scope);
|
|
120
123
|
const orderBy = normalizeOrder(spec.orderBy);
|
|
121
124
|
if (orderBy)
|
|
122
125
|
this.assertReadableCols(from, scope, orderBy.map((o) => o.column));
|
|
@@ -134,7 +137,7 @@ export class Db {
|
|
|
134
137
|
throw new AclDenied(from, "read");
|
|
135
138
|
const order = this.orderWithPk(from, spec.orderBy);
|
|
136
139
|
this.assertReadableCols(from, scope, order.map((o) => o.column)); // order + cursor must not leak hidden cols
|
|
137
|
-
let where = this.readWhere(spec.where, scope);
|
|
140
|
+
let where = this.readWhere(from, spec.where, scope);
|
|
138
141
|
if (spec.after != null)
|
|
139
142
|
where = and(where, keysetAfter(order, decodeCursor(spec.after)));
|
|
140
143
|
const limit = spec.limit ?? DEFAULT_PAGE_SIZE;
|
|
@@ -154,7 +157,7 @@ export class Db {
|
|
|
154
157
|
const scope = this.scopeFor(from, "read");
|
|
155
158
|
if (!scope.allowed)
|
|
156
159
|
throw new AclDenied(from, "read");
|
|
157
|
-
const where = this.readWhere(spec.where, scope);
|
|
160
|
+
const where = this.readWhere(from, spec.where, scope);
|
|
158
161
|
const { sql, params } = compileCount(from, this.dialect, where);
|
|
159
162
|
const rows = (await this.driver.exec(sql, params));
|
|
160
163
|
return Number(rows[0]?.n ?? 0);
|
|
@@ -190,15 +193,42 @@ export class Db {
|
|
|
190
193
|
if (!scope.fields.includes(c))
|
|
191
194
|
throw new AclDenied(from, "read", c);
|
|
192
195
|
}
|
|
193
|
-
const where = this.readWhere(spec.where, scope);
|
|
196
|
+
const where = this.readWhere(from, spec.where, scope);
|
|
194
197
|
const { sql, params } = compileAggregate({ from, where, groupBy, aggregations: spec.aggregations }, this.dialect);
|
|
195
198
|
return (await this.driver.exec(sql, params));
|
|
196
199
|
}
|
|
197
200
|
// --- read internals shared by find/page ---
|
|
198
|
-
readWhere(userWhere, scope) {
|
|
199
|
-
|
|
201
|
+
readWhere(from, userWhere, scope) {
|
|
202
|
+
// Compiles the user's where relation-aware (relation keys → security-scoped
|
|
203
|
+
// subqueries), then AND-merges the entity's own ACL row scope.
|
|
204
|
+
if (userWhere)
|
|
205
|
+
this.assertReadableWhere(from, scope, userWhere);
|
|
206
|
+
const userExpr = userWhere ? compileScopedWhere(userWhere, from, this.acl) : TRUE;
|
|
200
207
|
return scope.where ? and(userExpr, scope.where) : userExpr;
|
|
201
208
|
}
|
|
209
|
+
/** Reject a user `where` that filters on a column the caller cannot read (closes
|
|
210
|
+
* the same info-leak as ordering by a hidden column: a filter is an oracle for a
|
|
211
|
+
* hidden field's values). Mirrors `assertReadableCols`. Relation keys are skipped
|
|
212
|
+
* here — they're filtered through the TARGET's read scope in acl.relationPredicate
|
|
213
|
+
* — and AND/OR groups recurse. Operator objects (`{ gt: … }`) sit under the column
|
|
214
|
+
* key, so checking the top-level keys is sufficient. */
|
|
215
|
+
assertReadableWhere(from, scope, where) {
|
|
216
|
+
if (scope.fields === null || where == null || typeof where !== "object")
|
|
217
|
+
return;
|
|
218
|
+
const relations = this.schema[from]?.relations ?? {};
|
|
219
|
+
for (const [k, v] of Object.entries(where)) {
|
|
220
|
+
if (k === "AND" || k === "OR") {
|
|
221
|
+
for (const g of v)
|
|
222
|
+
this.assertReadableWhere(from, scope, g);
|
|
223
|
+
}
|
|
224
|
+
else if (relations[k]) {
|
|
225
|
+
continue; // relation traversal: enforced against the target's scope downstream
|
|
226
|
+
}
|
|
227
|
+
else if (!scope.fields.includes(k)) {
|
|
228
|
+
throw new AclDenied(from, "read", k);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
202
232
|
async selectRaw(from, where, orderBy, limit, offset) {
|
|
203
233
|
const { sql, params } = compileSelect({ from, where, orderBy, limit, offset }, this.dialect);
|
|
204
234
|
return this.decodeRows(from, await this.driver.exec(sql, params));
|
package/dist/runtime/dispatch.js
CHANGED
|
@@ -26,9 +26,9 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
|
|
|
26
26
|
}
|
|
27
27
|
// Warmup: evaluate dynamic resolvers once, reading through a SYSTEM-mode db
|
|
28
28
|
// (separate from the handler's db, so its reads don't pollute `touched`).
|
|
29
|
-
const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true }, schema);
|
|
29
|
+
const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema }, schema);
|
|
30
30
|
const resolved = await warmup(acl.acl, acl.identity, systemDb);
|
|
31
|
-
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved }, schema);
|
|
31
|
+
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema }, schema);
|
|
32
32
|
const ctx = { db, kv, files, env, identity: acl.identity };
|
|
33
33
|
const result = handler.kind === "query"
|
|
34
34
|
? await handler.run(ctx, parsed)
|
|
@@ -24,6 +24,13 @@ export type SqlExpr = {
|
|
|
24
24
|
} | {
|
|
25
25
|
t: "or";
|
|
26
26
|
parts: SqlExpr[];
|
|
27
|
+
} | {
|
|
28
|
+
t: "sub";
|
|
29
|
+
outerCol: string;
|
|
30
|
+
from: string;
|
|
31
|
+
selectCol: string;
|
|
32
|
+
where: SqlExpr;
|
|
33
|
+
negate: boolean;
|
|
27
34
|
};
|
|
28
35
|
export declare const TRUE: SqlExpr;
|
|
29
36
|
export declare const FALSE: SqlExpr;
|
|
@@ -105,6 +105,16 @@ export function compileExpr(expr, dialect, params = []) {
|
|
|
105
105
|
const sql = expr.parts.map((p) => compileExpr(p, dialect, params).sql).join(sep);
|
|
106
106
|
return { sql: expr.parts.length > 1 ? `(${sql})` : sql, params };
|
|
107
107
|
}
|
|
108
|
+
case "sub": {
|
|
109
|
+
// Inner predicate shares `params`, so placeholder numbering stays correct
|
|
110
|
+
// across dialects (? and $n alike).
|
|
111
|
+
const inner = compileExpr(expr.where, dialect, params).sql;
|
|
112
|
+
const op = expr.negate ? "NOT IN" : "IN";
|
|
113
|
+
return {
|
|
114
|
+
sql: `${dialect.id(expr.outerCol)} ${op} (SELECT ${dialect.id(expr.selectCol)} FROM ${dialect.id(expr.from)} WHERE ${inner})`,
|
|
115
|
+
params,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
120
|
// SQL LIKE -> RegExp. `%` is any run, `_` is any single char; SQLite LIKE is
|
|
@@ -167,6 +177,10 @@ export function evalExpr(expr, row) {
|
|
|
167
177
|
return expr.parts.every((p) => evalExpr(p, row));
|
|
168
178
|
case "or":
|
|
169
179
|
return expr.parts.some((p) => evalExpr(p, row));
|
|
180
|
+
case "sub":
|
|
181
|
+
// Relation traversal needs a SQL round-trip; it isn't supported in the
|
|
182
|
+
// in-memory cell-ACL `when` evaluator (those predicates must be single-table).
|
|
183
|
+
throw new Error("relation predicates are not supported in cell-level `when`");
|
|
170
184
|
}
|
|
171
185
|
}
|
|
172
186
|
const AGG_SQL = { count: "COUNT", sum: "SUM", avg: "AVG", min: "MIN", max: "MAX" };
|