@cfast/permissions 0.2.0 → 0.4.0
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/{chunk-NCLN5YBQ.js → chunk-ISTOPBSB.js} +18 -1
- package/dist/{client-C7xdwHTk.d.ts → client-D6goQV8b.d.ts} +164 -12
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +69 -21
- package/dist/index.js +86 -28
- package/llms.txt +86 -17
- package/package.json +1 -1
|
@@ -49,9 +49,26 @@ var ForbiddenError = class extends Error {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
};
|
|
52
|
+
var PermissionRegistrationError = class extends Error {
|
|
53
|
+
/** The unresolved subject string that was passed to `grant()`. */
|
|
54
|
+
subject;
|
|
55
|
+
/** The available table names (both JS keys and SQL names) from the schema. */
|
|
56
|
+
availableTables;
|
|
57
|
+
constructor(subject, availableTables) {
|
|
58
|
+
const sorted = [...availableTables].sort();
|
|
59
|
+
const list = sorted.length === 0 ? "<empty schema>" : sorted.map((n) => `"${n}"`).join(", ");
|
|
60
|
+
super(
|
|
61
|
+
`grant() subject "${subject}" does not match any table in the schema. Available tables (by JS key and SQL name): ${list}. Did you forget to pass the schema as the second generic to definePermissions<User, typeof schema>()?`
|
|
62
|
+
);
|
|
63
|
+
this.name = "PermissionRegistrationError";
|
|
64
|
+
this.subject = subject;
|
|
65
|
+
this.availableTables = sorted;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
52
68
|
|
|
53
69
|
export {
|
|
54
70
|
getTableName,
|
|
55
71
|
CRUD_ACTIONS,
|
|
56
|
-
ForbiddenError
|
|
72
|
+
ForbiddenError,
|
|
73
|
+
PermissionRegistrationError
|
|
57
74
|
};
|
|
@@ -17,27 +17,61 @@ type DrizzleSQL = {
|
|
|
17
17
|
getSQL(): unknown;
|
|
18
18
|
};
|
|
19
19
|
/**
|
|
20
|
-
* A schema map: an object mapping
|
|
20
|
+
* A schema map: an object mapping JS schema keys to Drizzle table references.
|
|
21
21
|
*
|
|
22
22
|
* Typically the result of `import * as schema from "./schema"`. Used as the
|
|
23
23
|
* `TTables` generic parameter for {@link definePermissions}, {@link can}, and
|
|
24
24
|
* the curried {@link grant} callback so that string subjects (e.g.
|
|
25
|
-
* `"projects"`) are constrained to known table names
|
|
25
|
+
* `"projects"` or `"project_versions"`) are constrained to known table names
|
|
26
|
+
* at compile time.
|
|
26
27
|
*/
|
|
27
28
|
type SchemaMap = Record<string, DrizzleTable>;
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
+
* Structural shape of a Drizzle table's static metadata used purely for
|
|
31
|
+
* type-level SQL-name extraction.
|
|
30
32
|
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
+
* Drizzle tables expose their SQL name on a readonly `_.name` field (see
|
|
34
|
+
* `drizzle-orm/table` `Table._.name`). By narrowing against this shape we can
|
|
35
|
+
* extract `"project_versions"` from `typeof projectVersions` even though the
|
|
36
|
+
* runtime value lives on a `Symbol.for("drizzle:Name")` key.
|
|
33
37
|
*/
|
|
34
|
-
type
|
|
38
|
+
type TableWithSqlName = {
|
|
39
|
+
_: {
|
|
40
|
+
name: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Extracts the SQL table name literal from a Drizzle table reference, or
|
|
45
|
+
* `never` when the argument is not a Drizzle table with a `_.name` field.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const postVersions = sqliteTable("post_versions", { ... });
|
|
50
|
+
* type N = SqlNameOf<typeof postVersions>; // "post_versions"
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
type SqlNameOf<T> = T extends TableWithSqlName ? T["_"]["name"] extends string ? T["_"]["name"] : never : never;
|
|
54
|
+
/**
|
|
55
|
+
* Extracts the string-literal union of valid subject keys from a
|
|
56
|
+
* {@link SchemaMap}, including **both** the JS schema keys (e.g.
|
|
57
|
+
* `"postVersions"`) and the SQL table names (e.g. `"post_versions"`).
|
|
58
|
+
*
|
|
59
|
+
* The runtime side of `definePermissions<User, Schema>()` builds a
|
|
60
|
+
* matching lookup table keyed by both forms, so the two string forms are
|
|
61
|
+
* fully interchangeable — whichever matches your mental model.
|
|
62
|
+
*
|
|
63
|
+
* Without this, string subjects were accidentally constrained to JS keys
|
|
64
|
+
* only (#177), causing confusion when a schema's JS key differs from its
|
|
65
|
+
* SQL table name (e.g. `documentVersions` vs `document_versions`).
|
|
66
|
+
*/
|
|
67
|
+
type TableName<TTables extends SchemaMap> = Extract<keyof TTables, string> | SqlNameOf<TTables[keyof TTables]>;
|
|
35
68
|
/**
|
|
36
69
|
* The set of acceptable subject inputs for a grant or `can()` check.
|
|
37
70
|
*
|
|
38
71
|
* - A {@link DrizzleTable} object reference (the original form, always allowed).
|
|
39
|
-
* - A string-literal table name from {@link TableName}
|
|
40
|
-
*
|
|
72
|
+
* - A string-literal table name from {@link TableName} — either the JS schema
|
|
73
|
+
* key (`"postVersions"`) or the SQL table name (`"post_versions"`) — both
|
|
74
|
+
* resolve to the same underlying table at runtime.
|
|
41
75
|
* - The literal `"all"` for grants that apply to every table.
|
|
42
76
|
*/
|
|
43
77
|
type SubjectInput<TTables extends SchemaMap = SchemaMap> = DrizzleTable | TableName<TTables> | "all";
|
|
@@ -92,9 +126,61 @@ declare const CRUD_ACTIONS: readonly CrudAction[];
|
|
|
92
126
|
*
|
|
93
127
|
* @param columns - The table's column references for building filter expressions.
|
|
94
128
|
* @param user - The current user object (from `@cfast/auth`).
|
|
129
|
+
* @param lookups - Resolved values from the grant's `with` map (see {@link Grant.with}).
|
|
130
|
+
* Empty object when the grant declares no `with` map. Each key holds the awaited
|
|
131
|
+
* result of the lookup function with the same name.
|
|
95
132
|
* @returns A Drizzle SQL expression to restrict matching rows, or `undefined` for no restriction.
|
|
96
133
|
*/
|
|
97
|
-
type WhereClause = (columns: Record<string, unknown>, user: unknown) => DrizzleSQL | undefined;
|
|
134
|
+
type WhereClause = (columns: Record<string, unknown>, user: unknown, lookups: Record<string, unknown>) => DrizzleSQL | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* Minimal structural type for the read-only database handle passed to grant
|
|
137
|
+
* `with` lookup functions.
|
|
138
|
+
*
|
|
139
|
+
* Defined here so the permissions package stays independent of `@cfast/db`.
|
|
140
|
+
* The runtime instance is provided by `@cfast/db` (an unsafe-mode db) and
|
|
141
|
+
* is structurally compatible with this interface, so authors can call
|
|
142
|
+
* `db.query(table).findMany().run()` inside a lookup without casting.
|
|
143
|
+
*
|
|
144
|
+
* The `query` method is intentionally typed loosely (`unknown` arguments and
|
|
145
|
+
* results) to avoid pulling Drizzle generics into the permissions surface.
|
|
146
|
+
* Authors typically know exactly which table they are querying and cast the
|
|
147
|
+
* result inline.
|
|
148
|
+
*/
|
|
149
|
+
interface LookupDb {
|
|
150
|
+
query: (table: DrizzleTable | string) => {
|
|
151
|
+
findMany: (options?: unknown) => {
|
|
152
|
+
run: (params?: unknown) => Promise<unknown[]>;
|
|
153
|
+
};
|
|
154
|
+
findFirst: (options?: unknown) => {
|
|
155
|
+
run: (params?: unknown) => Promise<unknown>;
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A single prerequisite lookup function for a grant's `with` map.
|
|
161
|
+
*
|
|
162
|
+
* Lookups run **before** the main query and their resolved values are passed
|
|
163
|
+
* as the third argument to the grant's {@link WhereClause}. Use them to gather
|
|
164
|
+
* data from other tables that a row-level filter depends on (for example,
|
|
165
|
+
* the set of friend ids that a user can see content for).
|
|
166
|
+
*
|
|
167
|
+
* Lookups receive an unsafe-mode db handle so they can read freely without
|
|
168
|
+
* recursively triggering permission checks. Their results are cached for the
|
|
169
|
+
* lifetime of the per-request `Db` instance, so a single lookup runs at most
|
|
170
|
+
* once even when the same grant is consulted multiple times.
|
|
171
|
+
*
|
|
172
|
+
* @typeParam TUser - The user type (typed via `definePermissions<TUser>`).
|
|
173
|
+
*/
|
|
174
|
+
type LookupFn<TUser = unknown> = (user: TUser, db: LookupDb) => Promise<unknown> | unknown;
|
|
175
|
+
/**
|
|
176
|
+
* A map of named prerequisite lookups for a grant.
|
|
177
|
+
*
|
|
178
|
+
* Each value is a {@link LookupFn} whose resolved result is passed to the
|
|
179
|
+
* grant's `where` clause via the third `lookups` argument under the same key.
|
|
180
|
+
*
|
|
181
|
+
* @typeParam TUser - The user type (typed via `definePermissions<TUser>`).
|
|
182
|
+
*/
|
|
183
|
+
type WithLookups<TUser = unknown> = Record<string, LookupFn<TUser>>;
|
|
98
184
|
/**
|
|
99
185
|
* A single permission grant: an action on a subject, optionally restricted by a `where` clause.
|
|
100
186
|
*
|
|
@@ -112,6 +198,37 @@ type Grant = {
|
|
|
112
198
|
* or `"all"` for every table.
|
|
113
199
|
*/
|
|
114
200
|
subject: DrizzleTable | string;
|
|
201
|
+
/**
|
|
202
|
+
* Optional prerequisite lookups that resolve **before** the main query and
|
|
203
|
+
* are passed to the {@link where} clause as its third argument.
|
|
204
|
+
*
|
|
205
|
+
* Each lookup receives the current user and an unsafe-mode db handle and
|
|
206
|
+
* may return any value (synchronously or as a promise). Results are cached
|
|
207
|
+
* per `Db` instance, so a single lookup runs at most once per request even
|
|
208
|
+
* when the same grant participates in multiple queries.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* grant("read", recipes, {
|
|
213
|
+
* with: {
|
|
214
|
+
* friendIds: async (user, db) => {
|
|
215
|
+
* const rows = await db
|
|
216
|
+
* .query(friendGrants)
|
|
217
|
+
* .findMany({ where: eq(friendGrants.grantee, user.id) })
|
|
218
|
+
* .run();
|
|
219
|
+
* return (rows as { target: string }[]).map((r) => r.target);
|
|
220
|
+
* },
|
|
221
|
+
* },
|
|
222
|
+
* where: (recipe, user, { friendIds }) =>
|
|
223
|
+
* or(
|
|
224
|
+
* eq(recipe.visibility, "public"),
|
|
225
|
+
* eq(recipe.authorId, user.id),
|
|
226
|
+
* inArray(recipe.authorId, friendIds as string[]),
|
|
227
|
+
* ),
|
|
228
|
+
* });
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
with?: WithLookups;
|
|
115
232
|
/** Optional row-level filter that restricts which rows this grant covers. */
|
|
116
233
|
where?: WhereClause;
|
|
117
234
|
};
|
|
@@ -127,8 +244,20 @@ type Grant = {
|
|
|
127
244
|
* @typeParam TTables - Optional schema map (e.g. `typeof schema`) used to
|
|
128
245
|
* constrain string subjects to known table-name literals.
|
|
129
246
|
*/
|
|
130
|
-
type GrantFn<TUser, TTables extends SchemaMap = SchemaMap> = (action: PermissionAction, subject: SubjectInput<TTables>, options?: {
|
|
131
|
-
|
|
247
|
+
type GrantFn<TUser, TTables extends SchemaMap = SchemaMap> = <TWith extends WithLookups<TUser> = WithLookups<TUser>>(action: PermissionAction, subject: SubjectInput<TTables>, options?: {
|
|
248
|
+
/**
|
|
249
|
+
* Prerequisite lookups whose resolved values are passed to {@link where}
|
|
250
|
+
* via its third argument. See {@link Grant.with}.
|
|
251
|
+
*/
|
|
252
|
+
with?: TWith;
|
|
253
|
+
/**
|
|
254
|
+
* Row-level filter. The third argument exposes the resolved values from
|
|
255
|
+
* {@link with}, keyed by the same names; pass an empty `with` map (or
|
|
256
|
+
* omit it) when the filter does not need cross-table data.
|
|
257
|
+
*/
|
|
258
|
+
where?: (columns: Record<string, unknown>, user: TUser, lookups: {
|
|
259
|
+
[K in keyof TWith]: Awaited<ReturnType<TWith[K]>>;
|
|
260
|
+
}) => DrizzleSQL | undefined;
|
|
132
261
|
}) => Grant;
|
|
133
262
|
/**
|
|
134
263
|
* Structural description of a permission requirement.
|
|
@@ -250,5 +379,28 @@ declare class ForbiddenError extends Error {
|
|
|
250
379
|
role: string | undefined;
|
|
251
380
|
};
|
|
252
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* Error thrown at **permission-definition time** when a string-form subject
|
|
384
|
+
* passed to the `grant` callback inside
|
|
385
|
+
* `definePermissions<User, typeof schema>()` cannot be resolved to a real
|
|
386
|
+
* Drizzle table reference from the schema map.
|
|
387
|
+
*
|
|
388
|
+
* This is the runtime sibling of the compile-time constraint on
|
|
389
|
+
* {@link TableName} — it catches cases where the schema generic was skipped,
|
|
390
|
+
* the string was typed wrong (typo, wrong case), or the table was removed
|
|
391
|
+
* from the schema without updating the grant.
|
|
392
|
+
*
|
|
393
|
+
* Unlike {@link ForbiddenError} (thrown per-request on a permission check),
|
|
394
|
+
* `PermissionRegistrationError` is thrown **once at startup** from
|
|
395
|
+
* `definePermissions()` — so a misconfigured grant fails fast and loud
|
|
396
|
+
* instead of silently matching nothing at query time.
|
|
397
|
+
*/
|
|
398
|
+
declare class PermissionRegistrationError extends Error {
|
|
399
|
+
/** The unresolved subject string that was passed to `grant()`. */
|
|
400
|
+
readonly subject: string;
|
|
401
|
+
/** The available table names (both JS keys and SQL names) from the schema. */
|
|
402
|
+
readonly availableTables: readonly string[];
|
|
403
|
+
constructor(subject: string, availableTables: readonly string[]);
|
|
404
|
+
}
|
|
253
405
|
|
|
254
|
-
export { CRUD_ACTIONS as C, type DrizzleTable as D, ForbiddenError as F, type Grant as G, type PermissionsConfig as P, type SchemaMap as S, type TableName as T, type
|
|
406
|
+
export { CRUD_ACTIONS as C, type DrizzleTable as D, ForbiddenError as F, type Grant as G, type LookupDb as L, type PermissionsConfig as P, type SchemaMap as S, type TableName as T, type WithLookups as W, type Permissions as a, type PermissionAction as b, type SubjectInput as c, type WhereClause as d, type PermissionDescriptor as e, type PermissionCheckResult as f, type CrudAction as g, type GrantFn as h, type LookupFn as i, PermissionRegistrationError as j, type SqlNameOf as k, getTableName as l };
|
package/dist/client.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { g as CrudAction, F as ForbiddenError, b as PermissionAction, f as PermissionCheckResult, e as PermissionDescriptor } from './client-D6goQV8b.js';
|
package/dist/client.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,20 +1,44 @@
|
|
|
1
|
-
import { P as PermissionsConfig, a as Permissions, S as SchemaMap, b as PermissionAction, c as SubjectInput, W as WhereClause, G as Grant,
|
|
2
|
-
export { C as CRUD_ACTIONS,
|
|
1
|
+
import { P as PermissionsConfig, a as Permissions, S as SchemaMap, b as PermissionAction, c as SubjectInput, W as WithLookups, d as WhereClause, G as Grant, e as PermissionDescriptor, f as PermissionCheckResult } from './client-D6goQV8b.js';
|
|
2
|
+
export { C as CRUD_ACTIONS, g as CrudAction, D as DrizzleTable, F as ForbiddenError, h as GrantFn, L as LookupDb, i as LookupFn, j as PermissionRegistrationError, k as SqlNameOf, T as TableName, l as getTableName } from './client-D6goQV8b.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Creates a permission configuration that can be shared between server-side
|
|
6
6
|
* enforcement (`@cfast/db`) and client-side introspection (`@cfast/actions`).
|
|
7
7
|
*
|
|
8
|
-
* Supports
|
|
9
|
-
* - **Direct:** `definePermissions(config)` when no custom user type is needed.
|
|
10
|
-
* - **Curried (user only):** `definePermissions<MyUser>()(config)` for typed
|
|
11
|
-
* `where` clause user parameters.
|
|
12
|
-
* - **Curried (user + tables):** `definePermissions<MyUser, typeof schema>()(config)`
|
|
13
|
-
* to additionally constrain string subjects passed to the `grant` callback
|
|
14
|
-
* to known table names from the schema map.
|
|
8
|
+
* Supports four calling styles:
|
|
15
9
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
10
|
+
* 1. **Direct:** `definePermissions(config)` — when no custom user type and
|
|
11
|
+
* no schema map is needed.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Curried (user only):** `definePermissions<MyUser>()(config)` — types
|
|
14
|
+
* the `user` parameter inside `where` clauses. String subjects are still
|
|
15
|
+
* accepted but are **not** compile-time or runtime validated against a
|
|
16
|
+
* schema; they are stored as opaque strings and matched by `getTableName`.
|
|
17
|
+
*
|
|
18
|
+
* 3. **Curried (user + schema types only, no runtime schema):**
|
|
19
|
+
* `definePermissions<MyUser, typeof schema>()(config)` — constrains
|
|
20
|
+
* string subjects to `"jsKey" | "sql_name"` at compile time, but at
|
|
21
|
+
* runtime still stores strings opaquely. Prefer style (4) when you can.
|
|
22
|
+
*
|
|
23
|
+
* 4. **Curried with runtime schema (RECOMMENDED):**
|
|
24
|
+
* `definePermissions<MyUser, typeof schema>({ schema })(config)` — walks
|
|
25
|
+
* the schema at startup and resolves every string-form subject to the
|
|
26
|
+
* exact Drizzle table reference the schema uses. This is the only form
|
|
27
|
+
* that lets string-form grants work with Drizzle relational `with`
|
|
28
|
+
* queries, because Drizzle identifies tables by reference, not name
|
|
29
|
+
* (see issues #146, #175, #177).
|
|
30
|
+
*
|
|
31
|
+
* - Unknown strings throw {@link PermissionRegistrationError} **at
|
|
32
|
+
* startup** (fast, loud failure instead of silently matching nothing).
|
|
33
|
+
* - Both the JS key (`"documentVersions"`) and the SQL table name
|
|
34
|
+
* (`"document_versions"`) resolve to the same table reference.
|
|
35
|
+
* - Object-form subjects still work unchanged.
|
|
36
|
+
*
|
|
37
|
+
* @param configOrOptions - Either the permissions config (style 1) or the
|
|
38
|
+
* resolver options `{ schema }` (style 4). When omitted entirely, returns
|
|
39
|
+
* the curried form (styles 2 and 3).
|
|
40
|
+
* @returns A {@link Permissions} object, or a function that takes a config
|
|
41
|
+
* and returns one.
|
|
18
42
|
*
|
|
19
43
|
* @example
|
|
20
44
|
* ```typescript
|
|
@@ -23,31 +47,30 @@ export { C as CRUD_ACTIONS, f as CrudAction, D as DrizzleTable, F as ForbiddenEr
|
|
|
23
47
|
* import * as schema from "./schema";
|
|
24
48
|
* const { posts, comments } = schema;
|
|
25
49
|
*
|
|
26
|
-
* //
|
|
50
|
+
* // Style 1 — direct, accepts table objects
|
|
27
51
|
* const permissions = definePermissions({
|
|
28
52
|
* roles: ["anonymous", "user", "admin"] as const,
|
|
29
53
|
* grants: {
|
|
30
54
|
* anonymous: [
|
|
31
55
|
* grant("read", posts, { where: (p) => eq(p.published, true) }),
|
|
32
56
|
* ],
|
|
33
|
-
* user: [
|
|
34
|
-
* grant("read", posts),
|
|
35
|
-
* grant("create", posts),
|
|
36
|
-
* ],
|
|
57
|
+
* user: [grant("read", posts), grant("create", posts)],
|
|
37
58
|
* admin: [grant("manage", "all")],
|
|
38
59
|
* },
|
|
39
60
|
* });
|
|
40
61
|
*
|
|
41
|
-
* //
|
|
62
|
+
* // Style 4 — recommended: runtime-resolved string subjects
|
|
42
63
|
* type AuthUser = { id: string };
|
|
43
|
-
* const perms = definePermissions<AuthUser, typeof schema>()({
|
|
64
|
+
* const perms = definePermissions<AuthUser, typeof schema>({ schema })({
|
|
44
65
|
* roles: ["user", "admin"] as const,
|
|
45
66
|
* grants: (grant) => ({
|
|
46
67
|
* user: [
|
|
47
|
-
* grant("read", "posts"),
|
|
48
|
-
* grant("
|
|
68
|
+
* grant("read", "posts"), // JS key — resolves
|
|
69
|
+
* grant("create", "post_versions"), // SQL name — also resolves
|
|
70
|
+
* grant("update", posts, { // object form still works
|
|
49
71
|
* where: (p, u) => eq(p.authorId, u.id),
|
|
50
72
|
* }),
|
|
73
|
+
* // grant("read", "unknownTable"), // throws at startup
|
|
51
74
|
* ],
|
|
52
75
|
* admin: [grant("manage", "all")],
|
|
53
76
|
* }),
|
|
@@ -55,6 +78,9 @@ export { C as CRUD_ACTIONS, f as CrudAction, D as DrizzleTable, F as ForbiddenEr
|
|
|
55
78
|
* ```
|
|
56
79
|
*/
|
|
57
80
|
declare function definePermissions<TRoles extends readonly string[]>(config: PermissionsConfig<TRoles>): Permissions<TRoles>;
|
|
81
|
+
declare function definePermissions<TUser, TTables extends SchemaMap>(options: {
|
|
82
|
+
schema: TTables;
|
|
83
|
+
}): <TRoles extends readonly string[]>(config: PermissionsConfig<TRoles, TUser, TTables>) => Permissions<TRoles>;
|
|
58
84
|
declare function definePermissions<TUser, TTables extends SchemaMap = SchemaMap>(): <TRoles extends readonly string[]>(config: PermissionsConfig<TRoles, TUser, TTables>) => Permissions<TRoles>;
|
|
59
85
|
|
|
60
86
|
/**
|
|
@@ -95,11 +121,33 @@ declare function definePermissions<TUser, TTables extends SchemaMap = SchemaMap>
|
|
|
95
121
|
* where: (post, user) => eq(post.authorId, user.id),
|
|
96
122
|
* });
|
|
97
123
|
*
|
|
124
|
+
* // Cross-table grant: read recipes that public, owned by the user, or
|
|
125
|
+
* // owned by a friend (resolved via a prerequisite lookup that runs once
|
|
126
|
+
* // per request and is cached for subsequent reads).
|
|
127
|
+
* grant("read", recipes, {
|
|
128
|
+
* with: {
|
|
129
|
+
* friendIds: async (user, db) => {
|
|
130
|
+
* const rows = await db
|
|
131
|
+
* .query(friendGrants)
|
|
132
|
+
* .findMany({ where: eq(friendGrants.grantee, user.id) })
|
|
133
|
+
* .run();
|
|
134
|
+
* return (rows as { target: string }[]).map((r) => r.target);
|
|
135
|
+
* },
|
|
136
|
+
* },
|
|
137
|
+
* where: (recipe, user, { friendIds }) =>
|
|
138
|
+
* or(
|
|
139
|
+
* eq(recipe.visibility, "public"),
|
|
140
|
+
* eq(recipe.authorId, user.id),
|
|
141
|
+
* inArray(recipe.authorId, friendIds as string[]),
|
|
142
|
+
* ),
|
|
143
|
+
* });
|
|
144
|
+
*
|
|
98
145
|
* // Full access to everything
|
|
99
146
|
* grant("manage", "all");
|
|
100
147
|
* ```
|
|
101
148
|
*/
|
|
102
149
|
declare function grant<TTables extends SchemaMap = SchemaMap>(action: PermissionAction, subject: SubjectInput<TTables>, options?: {
|
|
150
|
+
with?: WithLookups;
|
|
103
151
|
where?: WhereClause;
|
|
104
152
|
}): Grant;
|
|
105
153
|
|
|
@@ -215,4 +263,4 @@ type UserWithRoles = {
|
|
|
215
263
|
declare function resolveGrants(permissions: Permissions, user: UserWithRoles): Grant[];
|
|
216
264
|
declare function resolveGrants(permissions: Permissions, roles: readonly string[]): Grant[];
|
|
217
265
|
|
|
218
|
-
export { Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, SchemaMap, SubjectInput, type UserWithRoles, WhereClause, can, checkPermissions, definePermissions, grant, resolveGrants };
|
|
266
|
+
export { Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, SchemaMap, SubjectInput, type UserWithRoles, WhereClause, WithLookups, can, checkPermissions, definePermissions, grant, resolveGrants };
|
package/dist/index.js
CHANGED
|
@@ -1,22 +1,58 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CRUD_ACTIONS,
|
|
3
3
|
ForbiddenError,
|
|
4
|
+
PermissionRegistrationError,
|
|
4
5
|
getTableName
|
|
5
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-ISTOPBSB.js";
|
|
6
7
|
|
|
7
8
|
// src/grant.ts
|
|
8
9
|
function grant(action, subject, options) {
|
|
9
10
|
return {
|
|
10
11
|
action,
|
|
11
12
|
subject,
|
|
13
|
+
with: options?.with,
|
|
12
14
|
where: options?.where
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
// src/define-permissions.ts
|
|
17
|
-
function
|
|
19
|
+
function buildSchemaLookup(schema) {
|
|
20
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
21
|
+
const available = /* @__PURE__ */ new Set();
|
|
22
|
+
if (!schema) return { lookup, available: [] };
|
|
23
|
+
for (const [jsKey, table] of Object.entries(schema)) {
|
|
24
|
+
if (!table || typeof table !== "object" && typeof table !== "function") {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
lookup.set(jsKey, table);
|
|
28
|
+
available.add(jsKey);
|
|
29
|
+
const sqlName = getTableName(table);
|
|
30
|
+
if (sqlName && sqlName !== "unknown") {
|
|
31
|
+
lookup.set(sqlName, table);
|
|
32
|
+
available.add(sqlName);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { lookup, available: [...available] };
|
|
36
|
+
}
|
|
37
|
+
function makeSchemaAwareGrant(lookup, available) {
|
|
38
|
+
const schemaGrant = ((action, subject, options) => {
|
|
39
|
+
if (typeof subject !== "string" || subject === "all") {
|
|
40
|
+
return grant(action, subject, options);
|
|
41
|
+
}
|
|
42
|
+
const resolved = lookup.get(subject);
|
|
43
|
+
if (!resolved) {
|
|
44
|
+
throw new PermissionRegistrationError(subject, available);
|
|
45
|
+
}
|
|
46
|
+
return grant(action, resolved, options);
|
|
47
|
+
});
|
|
48
|
+
return schemaGrant;
|
|
49
|
+
}
|
|
50
|
+
function buildPermissions(config, schema) {
|
|
18
51
|
const { roles, hierarchy } = config;
|
|
19
|
-
const grantFn =
|
|
52
|
+
const grantFn = schema ? (() => {
|
|
53
|
+
const { lookup, available } = buildSchemaLookup(schema);
|
|
54
|
+
return makeSchemaAwareGrant(lookup, available);
|
|
55
|
+
})() : grant;
|
|
20
56
|
const grants = typeof config.grants === "function" ? config.grants(grantFn) : config.grants;
|
|
21
57
|
const resolvedGrants = resolveHierarchy(roles, grants, hierarchy);
|
|
22
58
|
return {
|
|
@@ -25,11 +61,20 @@ function buildPermissions(config) {
|
|
|
25
61
|
resolvedGrants
|
|
26
62
|
};
|
|
27
63
|
}
|
|
28
|
-
function definePermissions(
|
|
29
|
-
if (
|
|
64
|
+
function definePermissions(configOrOptions) {
|
|
65
|
+
if (configOrOptions === void 0) {
|
|
30
66
|
return (c) => buildPermissions(c);
|
|
31
67
|
}
|
|
32
|
-
|
|
68
|
+
if (isSchemaOptions(configOrOptions)) {
|
|
69
|
+
const { schema } = configOrOptions;
|
|
70
|
+
return (c) => buildPermissions(c, schema);
|
|
71
|
+
}
|
|
72
|
+
return buildPermissions(configOrOptions);
|
|
73
|
+
}
|
|
74
|
+
function isSchemaOptions(value) {
|
|
75
|
+
if (typeof value !== "object" || value === null) return false;
|
|
76
|
+
const v = value;
|
|
77
|
+
return v.schema !== void 0 && typeof v.schema === "object" && v.schema !== null && v.roles === void 0;
|
|
33
78
|
}
|
|
34
79
|
function resolveHierarchy(roles, grants, hierarchy) {
|
|
35
80
|
if (!hierarchy) {
|
|
@@ -143,36 +188,48 @@ function resolveGrants(permissions, userOrRoles) {
|
|
|
143
188
|
const key = `${g.action}:${getSubjectKey(g.subject)}`;
|
|
144
189
|
let group = groups.get(key);
|
|
145
190
|
if (!group) {
|
|
146
|
-
group = { action: g.action, subject: g.subject,
|
|
191
|
+
group = { action: g.action, subject: g.subject, grants: [] };
|
|
147
192
|
groups.set(key, group);
|
|
148
193
|
}
|
|
149
|
-
group.
|
|
194
|
+
group.grants.push(g);
|
|
150
195
|
}
|
|
151
196
|
const result = [];
|
|
152
197
|
for (const group of groups.values()) {
|
|
153
|
-
const hasUnrestricted = group.
|
|
198
|
+
const hasUnrestricted = group.grants.some((g) => !g.where);
|
|
154
199
|
if (hasUnrestricted) {
|
|
155
200
|
result.push({ action: group.action, subject: group.subject });
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const withLookups = group.grants.filter((g) => g.with !== void 0);
|
|
204
|
+
const plain = group.grants.filter((g) => g.with === void 0 && g.where);
|
|
205
|
+
for (const g of withLookups) {
|
|
206
|
+
result.push({
|
|
207
|
+
action: group.action,
|
|
208
|
+
subject: group.subject,
|
|
209
|
+
with: g.with,
|
|
210
|
+
where: g.where
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (plain.length === 1) {
|
|
214
|
+
result.push({
|
|
215
|
+
action: group.action,
|
|
216
|
+
subject: group.subject,
|
|
217
|
+
where: plain[0].where
|
|
218
|
+
});
|
|
219
|
+
} else if (plain.length > 1) {
|
|
220
|
+
const whereFns = plain.map(
|
|
221
|
+
(g) => g.where
|
|
222
|
+
);
|
|
223
|
+
const merged = (columns, user, lookups) => or(
|
|
224
|
+
...whereFns.map(
|
|
225
|
+
(fn) => toSQLWrapper(fn(columns, user, lookups))
|
|
226
|
+
)
|
|
159
227
|
);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
});
|
|
166
|
-
} else {
|
|
167
|
-
const merged = (columns, user) => or(
|
|
168
|
-
...whereFns.map((fn) => toSQLWrapper(fn(columns, user)))
|
|
169
|
-
);
|
|
170
|
-
result.push({
|
|
171
|
-
action: group.action,
|
|
172
|
-
subject: group.subject,
|
|
173
|
-
where: merged
|
|
174
|
-
});
|
|
175
|
-
}
|
|
228
|
+
result.push({
|
|
229
|
+
action: group.action,
|
|
230
|
+
subject: group.subject,
|
|
231
|
+
where: merged
|
|
232
|
+
});
|
|
176
233
|
}
|
|
177
234
|
}
|
|
178
235
|
return result;
|
|
@@ -180,6 +237,7 @@ function resolveGrants(permissions, userOrRoles) {
|
|
|
180
237
|
export {
|
|
181
238
|
CRUD_ACTIONS,
|
|
182
239
|
ForbiddenError,
|
|
240
|
+
PermissionRegistrationError,
|
|
183
241
|
can,
|
|
184
242
|
checkPermissions,
|
|
185
243
|
definePermissions,
|
package/llms.txt
CHANGED
|
@@ -32,34 +32,39 @@ const permissions = definePermissions({
|
|
|
32
32
|
});
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
**Curried form**
|
|
36
|
-
```typescript
|
|
37
|
-
const permissions = definePermissions<MyUser>()({
|
|
38
|
-
roles: [...] as const,
|
|
39
|
-
grants: (grant) => ({
|
|
40
|
-
user: [ grant("update", posts, { where: (post, user) => eq(post.authorId, user.id) }) ],
|
|
41
|
-
// ...
|
|
42
|
-
}),
|
|
43
|
-
});
|
|
44
|
-
```
|
|
35
|
+
**Curried form with schema (RECOMMENDED)** — pass the schema as a runtime argument to resolve string subjects to the exact same Drizzle table reference your schema uses. This is the only form that lets string-form grants work with Drizzle relational `with` queries, because `@cfast/db` identifies tables by reference (not name):
|
|
45
36
|
|
|
46
|
-
**Curried form with schema** to constrain string subjects to known table names at compile time:
|
|
47
37
|
```typescript
|
|
48
38
|
import * as schema from "./schema";
|
|
49
39
|
|
|
50
|
-
const permissions = definePermissions<MyUser, typeof schema>()({
|
|
40
|
+
const permissions = definePermissions<MyUser, typeof schema>({ schema })({
|
|
51
41
|
roles: ["member", "admin"] as const,
|
|
52
42
|
grants: (grant) => ({
|
|
53
43
|
member: [
|
|
54
|
-
grant("read", "projects"), // ✓
|
|
44
|
+
grant("read", "projects"), // ✓ JS key
|
|
45
|
+
grant("read", "project_versions"), // ✓ SQL name (snake_case)
|
|
55
46
|
grant("read", schema.projects), // ✓ object form still works
|
|
56
|
-
// grant("read", "unknownTable"), // ✗ TypeScript error
|
|
47
|
+
// grant("read", "unknownTable"), // ✗ TypeScript error + runtime throw
|
|
57
48
|
],
|
|
58
49
|
admin: [grant("manage", "all")],
|
|
59
50
|
}),
|
|
60
51
|
});
|
|
61
52
|
```
|
|
62
53
|
|
|
54
|
+
Both JS schema keys (e.g. `"projectVersions"`) and SQL table names (e.g. `"project_versions"`) resolve to the same table reference. Unknown strings throw `PermissionRegistrationError` at **startup** — fast, loud failure instead of silently matching nothing at query time.
|
|
55
|
+
|
|
56
|
+
**Curried form without runtime schema** — legacy shape that types the `user` parameter but does not resolve strings. String subjects are stored opaquely and matched by name only; cross-table `with` relational queries will silently return empty arrays. Prefer the recommended form above:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
const permissions = definePermissions<MyUser>()({
|
|
60
|
+
roles: [...] as const,
|
|
61
|
+
grants: (grant) => ({
|
|
62
|
+
user: [ grant("update", posts, { where: (post, user) => eq(post.authorId, user.id) }) ],
|
|
63
|
+
// ...
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
63
68
|
**Returns** `Permissions<TRoles>`:
|
|
64
69
|
```typescript
|
|
65
70
|
type Permissions<TRoles> = {
|
|
@@ -71,11 +76,61 @@ type Permissions<TRoles> = {
|
|
|
71
76
|
|
|
72
77
|
### `grant(action, subject, options?): Grant`
|
|
73
78
|
```typescript
|
|
74
|
-
grant(action: PermissionAction, subject: DrizzleTable | string, options?: {
|
|
79
|
+
grant(action: PermissionAction, subject: DrizzleTable | string, options?: {
|
|
80
|
+
with?: WithLookups;
|
|
81
|
+
where?: WhereClause;
|
|
82
|
+
}): Grant
|
|
75
83
|
```
|
|
76
84
|
- `PermissionAction`: `"read" | "create" | "update" | "delete" | "manage"`
|
|
77
|
-
- `WhereClause`: `(columns, user) => DrizzleSQL | undefined`
|
|
78
|
-
- `
|
|
85
|
+
- `WhereClause`: `(columns, user, lookups) => DrizzleSQL | undefined`
|
|
86
|
+
- `WithLookups`: `Record<string, (user, db) => Promise<unknown> | unknown>`
|
|
87
|
+
- `subject` may be a Drizzle table object, a string table name (JS key **or** SQL name), or `"all"`. When called via the `grant` callback inside `definePermissions<User, typeof schema>({ schema })`, string subjects are resolved at startup to the exact same Drizzle table reference the schema uses, and unknown strings throw `PermissionRegistrationError`. When called standalone (not via the schema-aware callback), string subjects are stored opaquely and matched by name — prefer the resolved form so cross-table relational `with` queries work correctly.
|
|
88
|
+
|
|
89
|
+
#### Cross-table grants via `with`
|
|
90
|
+
|
|
91
|
+
When a row-level filter needs data from another table (e.g. "recipes are visible if the user is in the recipe author's friend list"), declare the prerequisite read in the `with` map. Each entry runs **before** the main query, its result is passed to `where` under the same name, and results are cached per-request via the `Db` instance — a lookup runs at most once per request even when consulted by multiple queries.
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
import { definePermissions } from "@cfast/permissions";
|
|
95
|
+
import { eq, inArray, or } from "drizzle-orm";
|
|
96
|
+
import * as schema from "./schema";
|
|
97
|
+
const { recipes, friendGrants } = schema;
|
|
98
|
+
|
|
99
|
+
type AppUser = { id: string; roles: string[] };
|
|
100
|
+
|
|
101
|
+
export const permissions = definePermissions<AppUser, typeof schema>({ schema })({
|
|
102
|
+
roles: ["user"] as const,
|
|
103
|
+
grants: (g) => ({
|
|
104
|
+
user: [
|
|
105
|
+
g("read", recipes, {
|
|
106
|
+
with: {
|
|
107
|
+
// db is an unsafe-mode handle so the lookup itself isn't filtered.
|
|
108
|
+
friendIds: async (user, db) => {
|
|
109
|
+
const rows = await db
|
|
110
|
+
.query(friendGrants)
|
|
111
|
+
.findMany({ where: eq(friendGrants.grantee, user.id) })
|
|
112
|
+
.run();
|
|
113
|
+
return (rows as { target: string }[]).map((r) => r.target);
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
where: (recipe, user, { friendIds }) =>
|
|
117
|
+
or(
|
|
118
|
+
eq(recipe.visibility, "public"),
|
|
119
|
+
eq(recipe.authorId, user.id),
|
|
120
|
+
inArray(recipe.authorId, friendIds as string[]),
|
|
121
|
+
),
|
|
122
|
+
}),
|
|
123
|
+
],
|
|
124
|
+
}),
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Notes on `with` semantics:
|
|
129
|
+
- Lookup functions receive the user and a `LookupDb` (the unsafe sibling of the per-request `Db`); they may return any value, sync or async.
|
|
130
|
+
- Multiple lookups in a single grant resolve in **parallel** before the where clause is invoked.
|
|
131
|
+
- Results live in a per-`Db` cache keyed by grant identity, so the same grant participating in many `findMany`/`findFirst`/`paginate`/`update`/`delete` calls during one request only triggers each lookup once.
|
|
132
|
+
- A sibling **unrestricted** grant (e.g. an admin role) collapses the group, so the lookup is skipped entirely.
|
|
133
|
+
- A `with`-grant is never folded into another grant's `where` closure during `resolveGrants` — it stays a standalone grant so its lookup map can resolve independently.
|
|
79
134
|
|
|
80
135
|
### `checkPermissions(role, permissions, descriptors): PermissionCheckResult`
|
|
81
136
|
```typescript
|
|
@@ -118,6 +173,20 @@ class ForbiddenError extends Error {
|
|
|
118
173
|
}
|
|
119
174
|
```
|
|
120
175
|
|
|
176
|
+
Thrown at request time when a permission check fails (row-level denial).
|
|
177
|
+
|
|
178
|
+
### `PermissionRegistrationError`
|
|
179
|
+
```typescript
|
|
180
|
+
import { PermissionRegistrationError } from "@cfast/permissions";
|
|
181
|
+
|
|
182
|
+
class PermissionRegistrationError extends Error {
|
|
183
|
+
readonly subject: string; // the unresolved string
|
|
184
|
+
readonly availableTables: readonly string[]; // sorted JS keys + SQL names
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Thrown **at startup** from `definePermissions<User, typeof schema>({ schema })(...)` when a string-form grant subject does not match any table in the schema. Fast, loud failure so a typo or a removed table surfaces immediately instead of silently matching nothing at query time.
|
|
189
|
+
|
|
121
190
|
### `can(grants, action, table): boolean`
|
|
122
191
|
```typescript
|
|
123
192
|
import { can } from "@cfast/permissions";
|