@cfast/permissions 0.1.0 → 0.3.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/LICENSE +21 -0
- package/dist/{chunk-I35WLWUH.js → chunk-NCLN5YBQ.js} +4 -1
- package/dist/client-DKSiBBkt.d.ts +349 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +107 -20
- package/dist/index.js +53 -31
- package/llms.txt +98 -7
- package/package.json +9 -9
- package/dist/client-CJBFS0IS.d.ts +0 -197
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Schmidt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// src/types.ts
|
|
2
2
|
var DRIZZLE_NAME_SYMBOL = /* @__PURE__ */ Symbol.for("drizzle:Name");
|
|
3
3
|
function getTableName(table) {
|
|
4
|
+
if (typeof table === "string") {
|
|
5
|
+
return table;
|
|
6
|
+
}
|
|
4
7
|
const name = Reflect.get(table, DRIZZLE_NAME_SYMBOL);
|
|
5
8
|
return typeof name === "string" ? name : "unknown";
|
|
6
9
|
}
|
|
@@ -10,7 +13,7 @@ var CRUD_ACTIONS = ["read", "create", "update", "delete"];
|
|
|
10
13
|
var ForbiddenError = class extends Error {
|
|
11
14
|
/** The action that was denied (e.g., `"delete"`). */
|
|
12
15
|
action;
|
|
13
|
-
/** The Drizzle table the action targeted. */
|
|
16
|
+
/** The Drizzle table or string table name the action targeted. */
|
|
14
17
|
table;
|
|
15
18
|
/** The role that lacked the permission, or `undefined` if not specified. */
|
|
16
19
|
role;
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal structural type for a Drizzle ORM table reference.
|
|
3
|
+
*
|
|
4
|
+
* Drizzle stores table metadata via Symbols (e.g., `Symbol('drizzle:Name')`).
|
|
5
|
+
* Uses `object` so real Drizzle table classes (which lack an explicit index
|
|
6
|
+
* signature) are assignable without an `as` cast, while still excluding
|
|
7
|
+
* primitives.
|
|
8
|
+
*/
|
|
9
|
+
type DrizzleTable = object;
|
|
10
|
+
/**
|
|
11
|
+
* Minimal structural type for a Drizzle SQL expression.
|
|
12
|
+
*
|
|
13
|
+
* Matches any object with a `getSQL()` method, which includes Drizzle's
|
|
14
|
+
* `SQL`, `SQLWrapper`, and condition builder results.
|
|
15
|
+
*/
|
|
16
|
+
type DrizzleSQL = {
|
|
17
|
+
getSQL(): unknown;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* A schema map: an object mapping table names to Drizzle table references.
|
|
21
|
+
*
|
|
22
|
+
* Typically the result of `import * as schema from "./schema"`. Used as the
|
|
23
|
+
* `TTables` generic parameter for {@link definePermissions}, {@link can}, and
|
|
24
|
+
* the curried {@link grant} callback so that string subjects (e.g.
|
|
25
|
+
* `"projects"`) are constrained to known table names at compile time.
|
|
26
|
+
*/
|
|
27
|
+
type SchemaMap = Record<string, DrizzleTable>;
|
|
28
|
+
/**
|
|
29
|
+
* Extracts the string-literal union of valid table names from a {@link SchemaMap}.
|
|
30
|
+
*
|
|
31
|
+
* Given `typeof schema` (where `schema` exports tables as named bindings),
|
|
32
|
+
* `TableName<typeof schema>` is the union of all exported table-key strings.
|
|
33
|
+
*/
|
|
34
|
+
type TableName<TTables extends SchemaMap> = Extract<keyof TTables, string>;
|
|
35
|
+
/**
|
|
36
|
+
* The set of acceptable subject inputs for a grant or `can()` check.
|
|
37
|
+
*
|
|
38
|
+
* - A {@link DrizzleTable} object reference (the original form, always allowed).
|
|
39
|
+
* - A string-literal table name from {@link TableName}, constrained to the
|
|
40
|
+
* provided `TTables` schema map at compile time when one is supplied.
|
|
41
|
+
* - The literal `"all"` for grants that apply to every table.
|
|
42
|
+
*/
|
|
43
|
+
type SubjectInput<TTables extends SchemaMap = SchemaMap> = DrizzleTable | TableName<TTables> | "all";
|
|
44
|
+
/**
|
|
45
|
+
* Extracts the table name string from a Drizzle table reference, or returns
|
|
46
|
+
* a string subject as-is.
|
|
47
|
+
*
|
|
48
|
+
* Accepts both:
|
|
49
|
+
* - A Drizzle table object containing the `drizzle:Name` symbol — the symbol
|
|
50
|
+
* is read via `Reflect.get`.
|
|
51
|
+
* - A bare string (e.g., a table-name literal like `"projects"`) — returned
|
|
52
|
+
* unchanged so callers can use the same key for grant matching whether the
|
|
53
|
+
* subject was declared as an object or a string.
|
|
54
|
+
*
|
|
55
|
+
* @param table - A Drizzle table object or a string subject key.
|
|
56
|
+
* @returns The table name, the input string, or `"unknown"` if a symbol-less
|
|
57
|
+
* object was provided.
|
|
58
|
+
*/
|
|
59
|
+
declare function getTableName(table: DrizzleTable | string): string;
|
|
60
|
+
/**
|
|
61
|
+
* A permission action: one of the four CRUD operations, or `"manage"` for all.
|
|
62
|
+
*
|
|
63
|
+
* - `"read"` maps to `SELECT` queries.
|
|
64
|
+
* - `"create"` maps to `INSERT` statements.
|
|
65
|
+
* - `"update"` maps to `UPDATE` statements.
|
|
66
|
+
* - `"delete"` maps to `DELETE` statements.
|
|
67
|
+
* - `"manage"` is shorthand for granting all four CRUD actions.
|
|
68
|
+
*/
|
|
69
|
+
type PermissionAction = "read" | "create" | "update" | "delete" | "manage";
|
|
70
|
+
/**
|
|
71
|
+
* A CRUD-only permission action (excludes `"manage"`).
|
|
72
|
+
*
|
|
73
|
+
* Useful when you need to iterate over concrete operations without the
|
|
74
|
+
* `"manage"` shorthand. See also {@link CRUD_ACTIONS}.
|
|
75
|
+
*/
|
|
76
|
+
type CrudAction = Exclude<PermissionAction, "manage">;
|
|
77
|
+
/**
|
|
78
|
+
* Readonly array of the four CRUD action strings, useful for iteration.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* import { CRUD_ACTIONS } from "@cfast/permissions";
|
|
83
|
+
*
|
|
84
|
+
* for (const action of CRUD_ACTIONS) {
|
|
85
|
+
* console.log(action); // "read", "create", "update", "delete"
|
|
86
|
+
* }
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
declare const CRUD_ACTIONS: readonly CrudAction[];
|
|
90
|
+
/**
|
|
91
|
+
* A function that produces a Drizzle `WHERE` clause for row-level permission filtering.
|
|
92
|
+
*
|
|
93
|
+
* @param columns - The table's column references for building filter expressions.
|
|
94
|
+
* @param user - The current user object (from `@cfast/auth`).
|
|
95
|
+
* @param lookups - Resolved values from the grant's `with` map (see {@link Grant.with}).
|
|
96
|
+
* Empty object when the grant declares no `with` map. Each key holds the awaited
|
|
97
|
+
* result of the lookup function with the same name.
|
|
98
|
+
* @returns A Drizzle SQL expression to restrict matching rows, or `undefined` for no restriction.
|
|
99
|
+
*/
|
|
100
|
+
type WhereClause = (columns: Record<string, unknown>, user: unknown, lookups: Record<string, unknown>) => DrizzleSQL | undefined;
|
|
101
|
+
/**
|
|
102
|
+
* Minimal structural type for the read-only database handle passed to grant
|
|
103
|
+
* `with` lookup functions.
|
|
104
|
+
*
|
|
105
|
+
* Defined here so the permissions package stays independent of `@cfast/db`.
|
|
106
|
+
* The runtime instance is provided by `@cfast/db` (an unsafe-mode db) and
|
|
107
|
+
* is structurally compatible with this interface, so authors can call
|
|
108
|
+
* `db.query(table).findMany().run()` inside a lookup without casting.
|
|
109
|
+
*
|
|
110
|
+
* The `query` method is intentionally typed loosely (`unknown` arguments and
|
|
111
|
+
* results) to avoid pulling Drizzle generics into the permissions surface.
|
|
112
|
+
* Authors typically know exactly which table they are querying and cast the
|
|
113
|
+
* result inline.
|
|
114
|
+
*/
|
|
115
|
+
interface LookupDb {
|
|
116
|
+
query: (table: DrizzleTable | string) => {
|
|
117
|
+
findMany: (options?: unknown) => {
|
|
118
|
+
run: (params?: unknown) => Promise<unknown[]>;
|
|
119
|
+
};
|
|
120
|
+
findFirst: (options?: unknown) => {
|
|
121
|
+
run: (params?: unknown) => Promise<unknown>;
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* A single prerequisite lookup function for a grant's `with` map.
|
|
127
|
+
*
|
|
128
|
+
* Lookups run **before** the main query and their resolved values are passed
|
|
129
|
+
* as the third argument to the grant's {@link WhereClause}. Use them to gather
|
|
130
|
+
* data from other tables that a row-level filter depends on (for example,
|
|
131
|
+
* the set of friend ids that a user can see content for).
|
|
132
|
+
*
|
|
133
|
+
* Lookups receive an unsafe-mode db handle so they can read freely without
|
|
134
|
+
* recursively triggering permission checks. Their results are cached for the
|
|
135
|
+
* lifetime of the per-request `Db` instance, so a single lookup runs at most
|
|
136
|
+
* once even when the same grant is consulted multiple times.
|
|
137
|
+
*
|
|
138
|
+
* @typeParam TUser - The user type (typed via `definePermissions<TUser>`).
|
|
139
|
+
*/
|
|
140
|
+
type LookupFn<TUser = unknown> = (user: TUser, db: LookupDb) => Promise<unknown> | unknown;
|
|
141
|
+
/**
|
|
142
|
+
* A map of named prerequisite lookups for a grant.
|
|
143
|
+
*
|
|
144
|
+
* Each value is a {@link LookupFn} whose resolved result is passed to the
|
|
145
|
+
* grant's `where` clause via the third `lookups` argument under the same key.
|
|
146
|
+
*
|
|
147
|
+
* @typeParam TUser - The user type (typed via `definePermissions<TUser>`).
|
|
148
|
+
*/
|
|
149
|
+
type WithLookups<TUser = unknown> = Record<string, LookupFn<TUser>>;
|
|
150
|
+
/**
|
|
151
|
+
* A single permission grant: an action on a subject, optionally restricted by a `where` clause.
|
|
152
|
+
*
|
|
153
|
+
* `subject` may be a Drizzle table object, a string table name (e.g.
|
|
154
|
+
* `"projects"`), or the literal `"all"` for grants that apply to every table.
|
|
155
|
+
* String and object subjects are normalized to the same key by
|
|
156
|
+
* {@link getTableName} during matching, so the two forms are interchangeable
|
|
157
|
+
* at runtime.
|
|
158
|
+
*/
|
|
159
|
+
type Grant = {
|
|
160
|
+
/** The permitted operation. `"manage"` is shorthand for all four CRUD actions. */
|
|
161
|
+
action: PermissionAction;
|
|
162
|
+
/**
|
|
163
|
+
* The subject of the grant: a Drizzle table object, a string table name,
|
|
164
|
+
* or `"all"` for every table.
|
|
165
|
+
*/
|
|
166
|
+
subject: DrizzleTable | string;
|
|
167
|
+
/**
|
|
168
|
+
* Optional prerequisite lookups that resolve **before** the main query and
|
|
169
|
+
* are passed to the {@link where} clause as its third argument.
|
|
170
|
+
*
|
|
171
|
+
* Each lookup receives the current user and an unsafe-mode db handle and
|
|
172
|
+
* may return any value (synchronously or as a promise). Results are cached
|
|
173
|
+
* per `Db` instance, so a single lookup runs at most once per request even
|
|
174
|
+
* when the same grant participates in multiple queries.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* grant("read", recipes, {
|
|
179
|
+
* with: {
|
|
180
|
+
* friendIds: async (user, db) => {
|
|
181
|
+
* const rows = await db
|
|
182
|
+
* .query(friendGrants)
|
|
183
|
+
* .findMany({ where: eq(friendGrants.grantee, user.id) })
|
|
184
|
+
* .run();
|
|
185
|
+
* return (rows as { target: string }[]).map((r) => r.target);
|
|
186
|
+
* },
|
|
187
|
+
* },
|
|
188
|
+
* where: (recipe, user, { friendIds }) =>
|
|
189
|
+
* or(
|
|
190
|
+
* eq(recipe.visibility, "public"),
|
|
191
|
+
* eq(recipe.authorId, user.id),
|
|
192
|
+
* inArray(recipe.authorId, friendIds as string[]),
|
|
193
|
+
* ),
|
|
194
|
+
* });
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
with?: WithLookups;
|
|
198
|
+
/** Optional row-level filter that restricts which rows this grant covers. */
|
|
199
|
+
where?: WhereClause;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Type-safe grant builder function, parameterized by the user type and an
|
|
203
|
+
* optional schema map.
|
|
204
|
+
*
|
|
205
|
+
* Used when `grants` is provided as a callback in {@link PermissionsConfig}
|
|
206
|
+
* so that `where` clauses receive a correctly typed `user` parameter and
|
|
207
|
+
* string subjects are constrained to known table names from `TTables`.
|
|
208
|
+
*
|
|
209
|
+
* @typeParam TUser - The user type passed to `where` clause callbacks.
|
|
210
|
+
* @typeParam TTables - Optional schema map (e.g. `typeof schema`) used to
|
|
211
|
+
* constrain string subjects to known table-name literals.
|
|
212
|
+
*/
|
|
213
|
+
type GrantFn<TUser, TTables extends SchemaMap = SchemaMap> = <TWith extends WithLookups<TUser> = WithLookups<TUser>>(action: PermissionAction, subject: SubjectInput<TTables>, options?: {
|
|
214
|
+
/**
|
|
215
|
+
* Prerequisite lookups whose resolved values are passed to {@link where}
|
|
216
|
+
* via its third argument. See {@link Grant.with}.
|
|
217
|
+
*/
|
|
218
|
+
with?: TWith;
|
|
219
|
+
/**
|
|
220
|
+
* Row-level filter. The third argument exposes the resolved values from
|
|
221
|
+
* {@link with}, keyed by the same names; pass an empty `with` map (or
|
|
222
|
+
* omit it) when the filter does not need cross-table data.
|
|
223
|
+
*/
|
|
224
|
+
where?: (columns: Record<string, unknown>, user: TUser, lookups: {
|
|
225
|
+
[K in keyof TWith]: Awaited<ReturnType<TWith[K]>>;
|
|
226
|
+
}) => DrizzleSQL | undefined;
|
|
227
|
+
}) => Grant;
|
|
228
|
+
/**
|
|
229
|
+
* Structural description of a permission requirement.
|
|
230
|
+
*
|
|
231
|
+
* Describes *what kind* of operation on *which table* without specifying concrete row values.
|
|
232
|
+
* This is what makes client-side permission introspection possible: you can check whether a
|
|
233
|
+
* role has the right grants without knowing the specific row being accessed.
|
|
234
|
+
*
|
|
235
|
+
* The `table` field accepts either a Drizzle table object or a string table
|
|
236
|
+
* name; both forms are normalized to the same key by {@link getTableName}.
|
|
237
|
+
*
|
|
238
|
+
* @example
|
|
239
|
+
* ```typescript
|
|
240
|
+
* const descriptor: PermissionDescriptor = {
|
|
241
|
+
* action: "update",
|
|
242
|
+
* table: posts, // object form
|
|
243
|
+
* };
|
|
244
|
+
*
|
|
245
|
+
* const descriptor2: PermissionDescriptor = {
|
|
246
|
+
* action: "create",
|
|
247
|
+
* table: "posts", // string form
|
|
248
|
+
* };
|
|
249
|
+
* ```
|
|
250
|
+
*/
|
|
251
|
+
type PermissionDescriptor = {
|
|
252
|
+
/** The operation being checked. */
|
|
253
|
+
action: PermissionAction;
|
|
254
|
+
/** The Drizzle table or string table name the operation targets. */
|
|
255
|
+
table: DrizzleTable | string;
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Result of a permission check via {@link checkPermissions}.
|
|
259
|
+
*/
|
|
260
|
+
type PermissionCheckResult = {
|
|
261
|
+
/** `true` only if every descriptor in the check was satisfied. */
|
|
262
|
+
permitted: boolean;
|
|
263
|
+
/** The descriptors that were not satisfied. */
|
|
264
|
+
denied: PermissionDescriptor[];
|
|
265
|
+
/** Human-readable reasons for each denial. */
|
|
266
|
+
reasons: string[];
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Configuration object for {@link definePermissions}.
|
|
270
|
+
*
|
|
271
|
+
* @typeParam TRoles - Tuple of role name string literals (use `as const`).
|
|
272
|
+
* @typeParam TUser - The user type for typed `where` clauses (defaults to `unknown`).
|
|
273
|
+
* @typeParam TTables - Optional schema map used to constrain string subjects
|
|
274
|
+
* inside the `grants` callback to known table names.
|
|
275
|
+
*/
|
|
276
|
+
type PermissionsConfig<TRoles extends readonly string[], TUser = unknown, TTables extends SchemaMap = SchemaMap> = {
|
|
277
|
+
/** All roles in the application, declared with `as const` for type inference. */
|
|
278
|
+
roles: TRoles;
|
|
279
|
+
/** A map from role to grant arrays, or a callback that receives a typed `grant` function. */
|
|
280
|
+
grants: Record<TRoles[number], Grant[]> | ((grant: GrantFn<TUser, TTables>) => Record<TRoles[number], Grant[]>);
|
|
281
|
+
/** Optional role hierarchy declaring which roles inherit from which. */
|
|
282
|
+
hierarchy?: Partial<Record<TRoles[number], TRoles[number][]>>;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* The resolved permissions object returned by {@link definePermissions}.
|
|
286
|
+
*
|
|
287
|
+
* Contains the original roles and grants, plus the hierarchy-expanded `resolvedGrants`.
|
|
288
|
+
* Pass this to `createDb()` for server-side enforcement or import it on the client
|
|
289
|
+
* for UI-level permission introspection.
|
|
290
|
+
*
|
|
291
|
+
* @typeParam TRoles - Tuple of role name string literals.
|
|
292
|
+
*/
|
|
293
|
+
type Permissions<TRoles extends readonly string[] = readonly string[]> = {
|
|
294
|
+
/** The role names from the configuration. */
|
|
295
|
+
roles: TRoles;
|
|
296
|
+
/** The raw grants as declared (before hierarchy expansion). */
|
|
297
|
+
grants: Record<TRoles[number], Grant[]>;
|
|
298
|
+
/** Grants expanded with inherited grants from the role hierarchy. */
|
|
299
|
+
resolvedGrants: Record<TRoles[number], Grant[]>;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** Options for constructing a {@link ForbiddenError}. */
|
|
303
|
+
type ForbiddenErrorOptions = {
|
|
304
|
+
/** The action that was denied. */
|
|
305
|
+
action: PermissionAction;
|
|
306
|
+
/** The Drizzle table or string table name the action targeted. */
|
|
307
|
+
table: DrizzleTable | string;
|
|
308
|
+
/** The role that lacked the permission, if known. */
|
|
309
|
+
role?: string;
|
|
310
|
+
/** The full list of permission descriptors that were checked. */
|
|
311
|
+
descriptors?: PermissionDescriptor[];
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* Error thrown when a permission check fails during an operation.
|
|
315
|
+
*
|
|
316
|
+
* Extends `Error` with structured fields for the denied action, target table,
|
|
317
|
+
* and role. Includes a `toJSON()` method so it can be serialized across the
|
|
318
|
+
* server/client boundary.
|
|
319
|
+
*/
|
|
320
|
+
declare class ForbiddenError extends Error {
|
|
321
|
+
/** The action that was denied (e.g., `"delete"`). */
|
|
322
|
+
readonly action: PermissionAction;
|
|
323
|
+
/** The Drizzle table or string table name the action targeted. */
|
|
324
|
+
readonly table: DrizzleTable | string;
|
|
325
|
+
/** The role that lacked the permission, or `undefined` if not specified. */
|
|
326
|
+
readonly role: string | undefined;
|
|
327
|
+
/** The full list of permission descriptors that were checked. */
|
|
328
|
+
readonly descriptors: PermissionDescriptor[];
|
|
329
|
+
/**
|
|
330
|
+
* Creates a new `ForbiddenError`.
|
|
331
|
+
*
|
|
332
|
+
* @param options - The action, table, and optional role/descriptors for the error.
|
|
333
|
+
*/
|
|
334
|
+
constructor(options: ForbiddenErrorOptions);
|
|
335
|
+
/**
|
|
336
|
+
* Serializes the error to a JSON-safe object for server-to-client transfer.
|
|
337
|
+
*
|
|
338
|
+
* @returns A plain object with `name`, `message`, `action`, `table`, and `role` fields.
|
|
339
|
+
*/
|
|
340
|
+
toJSON(): {
|
|
341
|
+
name: string;
|
|
342
|
+
message: string;
|
|
343
|
+
action: PermissionAction;
|
|
344
|
+
table: string;
|
|
345
|
+
role: string | undefined;
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
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, getTableName as j };
|
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-DKSiBBkt.js';
|
package/dist/client.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { P as PermissionsConfig, a as Permissions, b as PermissionAction,
|
|
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-DKSiBBkt.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, T as TableName, j as getTableName } from './client-DKSiBBkt.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
|
|
8
|
+
* Supports three calling styles:
|
|
9
9
|
* - **Direct:** `definePermissions(config)` when no custom user type is needed.
|
|
10
|
-
* - **Curried:** `definePermissions<MyUser>()(config)`
|
|
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.
|
|
11
15
|
*
|
|
12
16
|
* @param config - The permissions configuration with roles, grants, and optional hierarchy.
|
|
13
17
|
* @returns A {@link Permissions} object containing roles, raw grants, and hierarchy-expanded `resolvedGrants`.
|
|
@@ -16,8 +20,10 @@ export { C as CRUD_ACTIONS, e as CrudAction, F as ForbiddenError, f as GrantFn,
|
|
|
16
20
|
* ```typescript
|
|
17
21
|
* import { definePermissions, grant } from "@cfast/permissions";
|
|
18
22
|
* import { eq } from "drizzle-orm";
|
|
19
|
-
* import
|
|
23
|
+
* import * as schema from "./schema";
|
|
24
|
+
* const { posts, comments } = schema;
|
|
20
25
|
*
|
|
26
|
+
* // Direct form — accepts table objects
|
|
21
27
|
* const permissions = definePermissions({
|
|
22
28
|
* roles: ["anonymous", "user", "admin"] as const,
|
|
23
29
|
* grants: {
|
|
@@ -27,15 +33,29 @@ export { C as CRUD_ACTIONS, e as CrudAction, F as ForbiddenError, f as GrantFn,
|
|
|
27
33
|
* user: [
|
|
28
34
|
* grant("read", posts),
|
|
29
35
|
* grant("create", posts),
|
|
30
|
-
* grant("update", posts, { where: (p, u) => eq(p.authorId, u.id) }),
|
|
31
36
|
* ],
|
|
32
37
|
* admin: [grant("manage", "all")],
|
|
33
38
|
* },
|
|
34
39
|
* });
|
|
40
|
+
*
|
|
41
|
+
* // Curried form — string subjects constrained to known tables
|
|
42
|
+
* type AuthUser = { id: string };
|
|
43
|
+
* const perms = definePermissions<AuthUser, typeof schema>()({
|
|
44
|
+
* roles: ["user", "admin"] as const,
|
|
45
|
+
* grants: (grant) => ({
|
|
46
|
+
* user: [
|
|
47
|
+
* grant("read", "posts"), // string form
|
|
48
|
+
* grant("update", posts, { // object form still works
|
|
49
|
+
* where: (p, u) => eq(p.authorId, u.id),
|
|
50
|
+
* }),
|
|
51
|
+
* ],
|
|
52
|
+
* admin: [grant("manage", "all")],
|
|
53
|
+
* }),
|
|
54
|
+
* });
|
|
35
55
|
* ```
|
|
36
56
|
*/
|
|
37
57
|
declare function definePermissions<TRoles extends readonly string[]>(config: PermissionsConfig<TRoles>): Permissions<TRoles>;
|
|
38
|
-
declare function definePermissions<TUser>(): <TRoles extends readonly string[]>(config: PermissionsConfig<TRoles, TUser>) => Permissions<TRoles>;
|
|
58
|
+
declare function definePermissions<TUser, TTables extends SchemaMap = SchemaMap>(): <TRoles extends readonly string[]>(config: PermissionsConfig<TRoles, TUser, TTables>) => Permissions<TRoles>;
|
|
39
59
|
|
|
40
60
|
/**
|
|
41
61
|
* Declares that a role can perform an action on a subject, optionally restricted
|
|
@@ -44,8 +64,15 @@ declare function definePermissions<TUser>(): <TRoles extends readonly string[]>(
|
|
|
44
64
|
* Used inside the `grants` map of {@link definePermissions} to build permission rules.
|
|
45
65
|
* A grant without a `where` clause applies to all rows.
|
|
46
66
|
*
|
|
67
|
+
* Subjects accept three forms (all interchangeable at runtime):
|
|
68
|
+
* - A {@link DrizzleTable} object reference (the original form).
|
|
69
|
+
* - A string table name (e.g. `"projects"`) — when called via the `grant`
|
|
70
|
+
* callback inside `definePermissions<User, typeof schema>()`, strings are
|
|
71
|
+
* constrained to known table names by TypeScript.
|
|
72
|
+
* - The literal `"all"` for grants that apply to every table.
|
|
73
|
+
*
|
|
47
74
|
* @param action - The operation being permitted (`"read"`, `"create"`, `"update"`, `"delete"`, or `"manage"` for all four).
|
|
48
|
-
* @param subject - A Drizzle table reference, or `"all"` to apply to every table.
|
|
75
|
+
* @param subject - A Drizzle table reference, a string table name, or `"all"` to apply to every table.
|
|
49
76
|
* @param options - Optional configuration.
|
|
50
77
|
* @param options.where - A Drizzle filter function `(columns, user) => SQL` that restricts which rows this grant covers.
|
|
51
78
|
* @returns A {@link Grant} object for use in a permissions configuration.
|
|
@@ -56,19 +83,45 @@ declare function definePermissions<TUser>(): <TRoles extends readonly string[]>(
|
|
|
56
83
|
* import { eq } from "drizzle-orm";
|
|
57
84
|
* import { posts } from "./schema";
|
|
58
85
|
*
|
|
59
|
-
* //
|
|
86
|
+
* // Object form (always works)
|
|
60
87
|
* grant("read", posts);
|
|
61
88
|
*
|
|
89
|
+
* // String form (works standalone; type-checked when called via the
|
|
90
|
+
* // `grant` callback inside `definePermissions<User, typeof schema>()`)
|
|
91
|
+
* grant("read", "posts");
|
|
92
|
+
*
|
|
62
93
|
* // Only allow updating own posts
|
|
63
94
|
* grant("update", posts, {
|
|
64
95
|
* where: (post, user) => eq(post.authorId, user.id),
|
|
65
96
|
* });
|
|
66
97
|
*
|
|
98
|
+
* // Cross-table grant: read recipes that public, owned by the user, or
|
|
99
|
+
* // owned by a friend (resolved via a prerequisite lookup that runs once
|
|
100
|
+
* // per request and is cached for subsequent reads).
|
|
101
|
+
* grant("read", recipes, {
|
|
102
|
+
* with: {
|
|
103
|
+
* friendIds: async (user, db) => {
|
|
104
|
+
* const rows = await db
|
|
105
|
+
* .query(friendGrants)
|
|
106
|
+
* .findMany({ where: eq(friendGrants.grantee, user.id) })
|
|
107
|
+
* .run();
|
|
108
|
+
* return (rows as { target: string }[]).map((r) => r.target);
|
|
109
|
+
* },
|
|
110
|
+
* },
|
|
111
|
+
* where: (recipe, user, { friendIds }) =>
|
|
112
|
+
* or(
|
|
113
|
+
* eq(recipe.visibility, "public"),
|
|
114
|
+
* eq(recipe.authorId, user.id),
|
|
115
|
+
* inArray(recipe.authorId, friendIds as string[]),
|
|
116
|
+
* ),
|
|
117
|
+
* });
|
|
118
|
+
*
|
|
67
119
|
* // Full access to everything
|
|
68
120
|
* grant("manage", "all");
|
|
69
121
|
* ```
|
|
70
122
|
*/
|
|
71
|
-
declare function grant(action: PermissionAction, subject:
|
|
123
|
+
declare function grant<TTables extends SchemaMap = SchemaMap>(action: PermissionAction, subject: SubjectInput<TTables>, options?: {
|
|
124
|
+
with?: WithLookups;
|
|
72
125
|
where?: WhereClause;
|
|
73
126
|
}): Grant;
|
|
74
127
|
|
|
@@ -79,25 +132,34 @@ declare function grant(action: PermissionAction, subject: DrizzleTable | "all",
|
|
|
79
132
|
* object), `can` works directly with the user's resolved grants — the same grants
|
|
80
133
|
* available in loaders, actions, and client-side via `useActions`.
|
|
81
134
|
*
|
|
135
|
+
* Accepts either a Drizzle table object or a string table name. The two forms
|
|
136
|
+
* are interchangeable and resolve to the same underlying key. To get
|
|
137
|
+
* compile-time validation that a string table name actually exists in your
|
|
138
|
+
* schema, supply the schema map as a generic argument: `can<typeof schema>(...)`.
|
|
139
|
+
*
|
|
140
|
+
* @typeParam TTables - Optional schema map (e.g. `typeof schema`) used to
|
|
141
|
+
* constrain string subjects to known table-name literals.
|
|
82
142
|
* @param grants - The user's resolved permission grants.
|
|
83
143
|
* @param action - The permission action to check (e.g., `"create"`, `"read"`).
|
|
84
|
-
* @param table - The Drizzle table object to check against.
|
|
144
|
+
* @param table - The Drizzle table object or string table name to check against.
|
|
85
145
|
* @returns `true` if any grant permits the action on the table.
|
|
86
146
|
*
|
|
87
147
|
* @example
|
|
88
148
|
* ```ts
|
|
89
149
|
* import { can } from "@cfast/permissions";
|
|
150
|
+
* import * as schema from "../db/schema";
|
|
151
|
+
*
|
|
152
|
+
* // Object form (always works)
|
|
153
|
+
* if (!can(ctx.auth.grants, "create", schema.posts)) throw redirect("/");
|
|
90
154
|
*
|
|
91
|
-
* //
|
|
92
|
-
* if (!can(ctx.auth.grants, "create", posts))
|
|
93
|
-
* throw redirect("/");
|
|
94
|
-
* }
|
|
155
|
+
* // String form (always allowed; type-checked when a schema generic is supplied)
|
|
156
|
+
* if (!can<typeof schema>(ctx.auth.grants, "create", "posts")) throw redirect("/");
|
|
95
157
|
*
|
|
96
158
|
* // In a component
|
|
97
|
-
* {can(grants, "update", posts) && <Button>Edit</Button>}
|
|
159
|
+
* {can(grants, "update", schema.posts) && <Button>Edit</Button>}
|
|
98
160
|
* ```
|
|
99
161
|
*/
|
|
100
|
-
declare function can(grants: Grant[], action: PermissionAction, table:
|
|
162
|
+
declare function can<TTables extends SchemaMap = SchemaMap>(grants: Grant[], action: PermissionAction, table: SubjectInput<TTables>): boolean;
|
|
101
163
|
|
|
102
164
|
/**
|
|
103
165
|
* Checks whether a role satisfies a set of permission descriptors.
|
|
@@ -133,6 +195,14 @@ declare function can(grants: Grant[], action: PermissionAction, table: DrizzleTa
|
|
|
133
195
|
*/
|
|
134
196
|
declare function checkPermissions(role: string, permissions: Permissions, descriptors: PermissionDescriptor[]): PermissionCheckResult;
|
|
135
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Minimal user shape accepted by {@link resolveGrants}: any object with a
|
|
200
|
+
* `roles` array. The function reads `.roles` and forwards them to the
|
|
201
|
+
* underlying merge logic.
|
|
202
|
+
*/
|
|
203
|
+
type UserWithRoles = {
|
|
204
|
+
roles: readonly string[];
|
|
205
|
+
};
|
|
136
206
|
/**
|
|
137
207
|
* Resolves and merges grants for multiple roles into a single flat array.
|
|
138
208
|
*
|
|
@@ -144,10 +214,27 @@ declare function checkPermissions(role: string, permissions: Permissions, descri
|
|
|
144
214
|
*
|
|
145
215
|
* This is used when a user has multiple roles and their grants need to be combined.
|
|
146
216
|
*
|
|
217
|
+
* Two calling styles are supported (both equivalent):
|
|
218
|
+
* - **User object (preferred):** `resolveGrants(permissions, user)` where
|
|
219
|
+
* `user` has a `roles` field. The function extracts roles internally.
|
|
220
|
+
* - **Roles array (legacy):** `resolveGrants(permissions, roles)` where
|
|
221
|
+
* `roles` is a string array. Still supported for backwards compatibility.
|
|
222
|
+
*
|
|
147
223
|
* @param permissions - The permissions object from {@link definePermissions}.
|
|
148
|
-
* @param
|
|
224
|
+
* @param userOrRoles - Either a user object with a `roles` field, or an
|
|
225
|
+
* array of role names whose grants should be merged.
|
|
149
226
|
* @returns A deduplicated array of {@link Grant} objects with merged `where` clauses.
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```ts
|
|
230
|
+
* // Preferred: pass the user directly
|
|
231
|
+
* const grants = resolveGrants(permissions, user);
|
|
232
|
+
*
|
|
233
|
+
* // Legacy: pass roles array (still works)
|
|
234
|
+
* const grants = resolveGrants(permissions, user.roles);
|
|
235
|
+
* ```
|
|
150
236
|
*/
|
|
151
|
-
declare function resolveGrants(permissions: Permissions,
|
|
237
|
+
declare function resolveGrants(permissions: Permissions, user: UserWithRoles): Grant[];
|
|
238
|
+
declare function resolveGrants(permissions: Permissions, roles: readonly string[]): Grant[];
|
|
152
239
|
|
|
153
|
-
export {
|
|
240
|
+
export { Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, SchemaMap, SubjectInput, type UserWithRoles, WhereClause, WithLookups, can, checkPermissions, definePermissions, grant, resolveGrants };
|
package/dist/index.js
CHANGED
|
@@ -2,13 +2,14 @@ import {
|
|
|
2
2
|
CRUD_ACTIONS,
|
|
3
3
|
ForbiddenError,
|
|
4
4
|
getTableName
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-NCLN5YBQ.js";
|
|
6
6
|
|
|
7
7
|
// src/grant.ts
|
|
8
8
|
function grant(action, subject, options) {
|
|
9
9
|
return {
|
|
10
10
|
action,
|
|
11
11
|
subject,
|
|
12
|
+
with: options?.with,
|
|
12
13
|
where: options?.where
|
|
13
14
|
};
|
|
14
15
|
}
|
|
@@ -60,18 +61,21 @@ function resolveHierarchy(roles, grants, hierarchy) {
|
|
|
60
61
|
|
|
61
62
|
// src/can.ts
|
|
62
63
|
function can(grants, action, table) {
|
|
64
|
+
const targetKey = getTableName(table);
|
|
63
65
|
return grants.some((g) => {
|
|
64
66
|
const actionOk = g.action === action || g.action === "manage";
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
if (!actionOk) return false;
|
|
68
|
+
if (g.subject === "all") return true;
|
|
69
|
+
return getTableName(g.subject) === targetKey;
|
|
67
70
|
});
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
// src/check.ts
|
|
71
74
|
function grantMatches(g, action, table) {
|
|
72
75
|
const actionOk = g.action === action || g.action === "manage";
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
if (!actionOk) return false;
|
|
77
|
+
if (g.subject === "all") return true;
|
|
78
|
+
return getTableName(g.subject) === getTableName(table);
|
|
75
79
|
}
|
|
76
80
|
function hasGrantFor(grants, action, table) {
|
|
77
81
|
return grants.some((g) => grantMatches(g, action, table));
|
|
@@ -110,7 +114,11 @@ import { or } from "drizzle-orm";
|
|
|
110
114
|
function toSQLWrapper(value) {
|
|
111
115
|
return value;
|
|
112
116
|
}
|
|
113
|
-
function
|
|
117
|
+
function isUserWithRoles(value) {
|
|
118
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Array.isArray(value.roles);
|
|
119
|
+
}
|
|
120
|
+
function resolveGrants(permissions, userOrRoles) {
|
|
121
|
+
const roles = isUserWithRoles(userOrRoles) ? userOrRoles.roles : userOrRoles;
|
|
114
122
|
const allGrants = [];
|
|
115
123
|
for (const role of roles) {
|
|
116
124
|
const roleGrants = permissions.resolvedGrants[role];
|
|
@@ -122,48 +130,62 @@ function resolveGrants(permissions, roles) {
|
|
|
122
130
|
const groups = /* @__PURE__ */ new Map();
|
|
123
131
|
const subjectIds = /* @__PURE__ */ new Map();
|
|
124
132
|
let nextId = 0;
|
|
125
|
-
function
|
|
133
|
+
function getSubjectKey(subject) {
|
|
134
|
+
if (subject === "all") return "all";
|
|
135
|
+
if (typeof subject === "string") return `s:${subject}`;
|
|
126
136
|
let id = subjectIds.get(subject);
|
|
127
137
|
if (id === void 0) {
|
|
128
138
|
id = nextId++;
|
|
129
139
|
subjectIds.set(subject, id);
|
|
130
140
|
}
|
|
131
|
-
return id
|
|
141
|
+
return `o:${id}`;
|
|
132
142
|
}
|
|
133
143
|
for (const g of allGrants) {
|
|
134
|
-
const key = `${g.action}:${
|
|
144
|
+
const key = `${g.action}:${getSubjectKey(g.subject)}`;
|
|
135
145
|
let group = groups.get(key);
|
|
136
146
|
if (!group) {
|
|
137
|
-
group = { action: g.action, subject: g.subject,
|
|
147
|
+
group = { action: g.action, subject: g.subject, grants: [] };
|
|
138
148
|
groups.set(key, group);
|
|
139
149
|
}
|
|
140
|
-
group.
|
|
150
|
+
group.grants.push(g);
|
|
141
151
|
}
|
|
142
152
|
const result = [];
|
|
143
153
|
for (const group of groups.values()) {
|
|
144
|
-
const hasUnrestricted = group.
|
|
154
|
+
const hasUnrestricted = group.grants.some((g) => !g.where);
|
|
145
155
|
if (hasUnrestricted) {
|
|
146
156
|
result.push({ action: group.action, subject: group.subject });
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const withLookups = group.grants.filter((g) => g.with !== void 0);
|
|
160
|
+
const plain = group.grants.filter((g) => g.with === void 0 && g.where);
|
|
161
|
+
for (const g of withLookups) {
|
|
162
|
+
result.push({
|
|
163
|
+
action: group.action,
|
|
164
|
+
subject: group.subject,
|
|
165
|
+
with: g.with,
|
|
166
|
+
where: g.where
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (plain.length === 1) {
|
|
170
|
+
result.push({
|
|
171
|
+
action: group.action,
|
|
172
|
+
subject: group.subject,
|
|
173
|
+
where: plain[0].where
|
|
174
|
+
});
|
|
175
|
+
} else if (plain.length > 1) {
|
|
176
|
+
const whereFns = plain.map(
|
|
177
|
+
(g) => g.where
|
|
178
|
+
);
|
|
179
|
+
const merged = (columns, user, lookups) => or(
|
|
180
|
+
...whereFns.map(
|
|
181
|
+
(fn) => toSQLWrapper(fn(columns, user, lookups))
|
|
182
|
+
)
|
|
150
183
|
);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
});
|
|
157
|
-
} else {
|
|
158
|
-
const merged = (columns, user) => or(
|
|
159
|
-
...whereFns.map((fn) => toSQLWrapper(fn(columns, user)))
|
|
160
|
-
);
|
|
161
|
-
result.push({
|
|
162
|
-
action: group.action,
|
|
163
|
-
subject: group.subject,
|
|
164
|
-
where: merged
|
|
165
|
-
});
|
|
166
|
-
}
|
|
184
|
+
result.push({
|
|
185
|
+
action: group.action,
|
|
186
|
+
subject: group.subject,
|
|
187
|
+
where: merged
|
|
188
|
+
});
|
|
167
189
|
}
|
|
168
190
|
}
|
|
169
191
|
return result;
|
package/llms.txt
CHANGED
|
@@ -43,6 +43,23 @@ const permissions = definePermissions<MyUser>()({
|
|
|
43
43
|
});
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
**Curried form with schema** to constrain string subjects to known table names at compile time:
|
|
47
|
+
```typescript
|
|
48
|
+
import * as schema from "./schema";
|
|
49
|
+
|
|
50
|
+
const permissions = definePermissions<MyUser, typeof schema>()({
|
|
51
|
+
roles: ["member", "admin"] as const,
|
|
52
|
+
grants: (grant) => ({
|
|
53
|
+
member: [
|
|
54
|
+
grant("read", "projects"), // ✓ string form, type-checked
|
|
55
|
+
grant("read", schema.projects), // ✓ object form still works
|
|
56
|
+
// grant("read", "unknownTable"), // ✗ TypeScript error
|
|
57
|
+
],
|
|
58
|
+
admin: [grant("manage", "all")],
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
46
63
|
**Returns** `Permissions<TRoles>`:
|
|
47
64
|
```typescript
|
|
48
65
|
type Permissions<TRoles> = {
|
|
@@ -54,26 +71,90 @@ type Permissions<TRoles> = {
|
|
|
54
71
|
|
|
55
72
|
### `grant(action, subject, options?): Grant`
|
|
56
73
|
```typescript
|
|
57
|
-
grant(action: PermissionAction, subject: DrizzleTable |
|
|
74
|
+
grant(action: PermissionAction, subject: DrizzleTable | string, options?: {
|
|
75
|
+
with?: WithLookups;
|
|
76
|
+
where?: WhereClause;
|
|
77
|
+
}): Grant
|
|
58
78
|
```
|
|
59
79
|
- `PermissionAction`: `"read" | "create" | "update" | "delete" | "manage"`
|
|
60
|
-
- `WhereClause`: `(columns, user) => DrizzleSQL | undefined`
|
|
80
|
+
- `WhereClause`: `(columns, user, lookups) => DrizzleSQL | undefined`
|
|
81
|
+
- `WithLookups`: `Record<string, (user, db) => Promise<unknown> | unknown>`
|
|
82
|
+
- `subject` may be a Drizzle table object, a string table name (e.g. `"projects"`), or `"all"`. String and object forms are interchangeable at runtime — both are normalized to the same key by `getTableName()`. To get compile-time validation that a string subject is a known table, use the `definePermissions<User, typeof schema>()` curried form.
|
|
83
|
+
|
|
84
|
+
#### Cross-table grants via `with`
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { definePermissions } from "@cfast/permissions";
|
|
90
|
+
import { eq, inArray, or } from "drizzle-orm";
|
|
91
|
+
import * as schema from "./schema";
|
|
92
|
+
const { recipes, friendGrants } = schema;
|
|
93
|
+
|
|
94
|
+
type AppUser = { id: string; roles: string[] };
|
|
95
|
+
|
|
96
|
+
export const permissions = definePermissions<AppUser, typeof schema>()({
|
|
97
|
+
roles: ["user"] as const,
|
|
98
|
+
grants: (g) => ({
|
|
99
|
+
user: [
|
|
100
|
+
g("read", recipes, {
|
|
101
|
+
with: {
|
|
102
|
+
// db is an unsafe-mode handle so the lookup itself isn't filtered.
|
|
103
|
+
friendIds: async (user, db) => {
|
|
104
|
+
const rows = await db
|
|
105
|
+
.query(friendGrants)
|
|
106
|
+
.findMany({ where: eq(friendGrants.grantee, user.id) })
|
|
107
|
+
.run();
|
|
108
|
+
return (rows as { target: string }[]).map((r) => r.target);
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
where: (recipe, user, { friendIds }) =>
|
|
112
|
+
or(
|
|
113
|
+
eq(recipe.visibility, "public"),
|
|
114
|
+
eq(recipe.authorId, user.id),
|
|
115
|
+
inArray(recipe.authorId, friendIds as string[]),
|
|
116
|
+
),
|
|
117
|
+
}),
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Notes on `with` semantics:
|
|
124
|
+
- Lookup functions receive the user and a `LookupDb` (the unsafe sibling of the per-request `Db`); they may return any value, sync or async.
|
|
125
|
+
- Multiple lookups in a single grant resolve in **parallel** before the where clause is invoked.
|
|
126
|
+
- 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.
|
|
127
|
+
- A sibling **unrestricted** grant (e.g. an admin role) collapses the group, so the lookup is skipped entirely.
|
|
128
|
+
- 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.
|
|
61
129
|
|
|
62
130
|
### `checkPermissions(role, permissions, descriptors): PermissionCheckResult`
|
|
63
131
|
```typescript
|
|
64
132
|
import { checkPermissions } from "@cfast/permissions";
|
|
65
133
|
|
|
66
134
|
const result = checkPermissions("user", permissions, [
|
|
67
|
-
{ action: "update", table: posts },
|
|
135
|
+
{ action: "update", table: posts }, // object form
|
|
136
|
+
{ action: "create", table: "comments" }, // string form also accepted
|
|
68
137
|
]);
|
|
69
138
|
result.permitted; // boolean
|
|
70
139
|
result.denied; // PermissionDescriptor[]
|
|
71
140
|
result.reasons; // string[]
|
|
72
141
|
```
|
|
73
142
|
|
|
74
|
-
### `resolveGrants(permissions,
|
|
143
|
+
### `resolveGrants(permissions, userOrRoles): Grant[]`
|
|
75
144
|
Merges grants from multiple roles into a flat array. Deduplicates by action+subject and OR-merges WHERE clauses.
|
|
76
145
|
|
|
146
|
+
Two calling styles are supported (both equivalent):
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
// Preferred: pass the user directly — the function extracts user.roles
|
|
150
|
+
const grants = resolveGrants(permissions, user);
|
|
151
|
+
|
|
152
|
+
// Legacy: pass the roles array yourself (still supported)
|
|
153
|
+
const grants = resolveGrants(permissions, user.roles);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The user-object form accepts any value with a `roles: readonly string[]` field, so it works directly with the user shape from `@cfast/auth` without manual extraction.
|
|
157
|
+
|
|
77
158
|
### `ForbiddenError`
|
|
78
159
|
```typescript
|
|
79
160
|
import { ForbiddenError } from "@cfast/permissions";
|
|
@@ -90,13 +171,23 @@ class ForbiddenError extends Error {
|
|
|
90
171
|
### `can(grants, action, table): boolean`
|
|
91
172
|
```typescript
|
|
92
173
|
import { can } from "@cfast/permissions";
|
|
93
|
-
function can
|
|
174
|
+
function can<TTables extends Record<string, DrizzleTable> = Record<string, DrizzleTable>>(
|
|
175
|
+
grants: Grant[],
|
|
176
|
+
action: PermissionAction,
|
|
177
|
+
table: DrizzleTable | string,
|
|
178
|
+
): boolean
|
|
94
179
|
```
|
|
95
180
|
Quick grant-level permission check that works directly with resolved grants. Unlike `checkPermissions` (which takes a role string + full permissions object), `can` works with the user's resolved grants -- the same grants available in loaders, actions, and client-side. Returns `true` if any grant permits the action on the table (including `manage`/`all` wildcards).
|
|
96
181
|
|
|
182
|
+
Accepts either a Drizzle table object or a string table name. To get compile-time validation that a string is an actual table, supply the schema map as a generic argument.
|
|
183
|
+
|
|
97
184
|
```typescript
|
|
98
|
-
|
|
99
|
-
|
|
185
|
+
import * as schema from "../db/schema";
|
|
186
|
+
|
|
187
|
+
can(grants, "create", schema.posts) // ✓ object form
|
|
188
|
+
can(grants, "create", "posts") // ✓ string form (untyped)
|
|
189
|
+
can<typeof schema>(grants, "create", "posts") // ✓ string form, type-checked
|
|
190
|
+
// can<typeof schema>(grants, "create", "unknownTable") // ✗ TypeScript error
|
|
100
191
|
```
|
|
101
192
|
|
|
102
193
|
### `CRUD_ACTIONS`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cfast/permissions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Isomorphic, composable permission system with Drizzle-native row-level access control",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cfast",
|
|
@@ -36,13 +36,6 @@
|
|
|
36
36
|
"publishConfig": {
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
|
-
"scripts": {
|
|
40
|
-
"build": "tsup src/index.ts src/client.ts --format esm --dts",
|
|
41
|
-
"dev": "tsup src/index.ts src/client.ts --format esm --dts --watch",
|
|
42
|
-
"typecheck": "tsc --noEmit",
|
|
43
|
-
"lint": "eslint src/",
|
|
44
|
-
"test": "vitest run"
|
|
45
|
-
},
|
|
46
39
|
"peerDependencies": {
|
|
47
40
|
"drizzle-orm": ">=0.35"
|
|
48
41
|
},
|
|
@@ -50,5 +43,12 @@
|
|
|
50
43
|
"tsup": "^8",
|
|
51
44
|
"typescript": "^5.7",
|
|
52
45
|
"vitest": "^4.1.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsup src/index.ts src/client.ts --format esm --dts",
|
|
49
|
+
"dev": "tsup src/index.ts src/client.ts --format esm --dts --watch",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"lint": "eslint src/",
|
|
52
|
+
"test": "vitest run"
|
|
53
53
|
}
|
|
54
|
-
}
|
|
54
|
+
}
|
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal structural type for a Drizzle ORM table reference.
|
|
3
|
-
*
|
|
4
|
-
* Drizzle stores table metadata via Symbols (e.g., `Symbol('drizzle:Name')`).
|
|
5
|
-
* Uses `object` so real Drizzle table classes (which lack an explicit index
|
|
6
|
-
* signature) are assignable without an `as` cast, while still excluding
|
|
7
|
-
* primitives.
|
|
8
|
-
*/
|
|
9
|
-
type DrizzleTable = object;
|
|
10
|
-
/**
|
|
11
|
-
* Minimal structural type for a Drizzle SQL expression.
|
|
12
|
-
*
|
|
13
|
-
* Matches any object with a `getSQL()` method, which includes Drizzle's
|
|
14
|
-
* `SQL`, `SQLWrapper`, and condition builder results.
|
|
15
|
-
*/
|
|
16
|
-
type DrizzleSQL = {
|
|
17
|
-
getSQL(): unknown;
|
|
18
|
-
};
|
|
19
|
-
/**
|
|
20
|
-
* Extracts the table name string from a Drizzle table reference.
|
|
21
|
-
*
|
|
22
|
-
* @param table - A Drizzle table object containing the `drizzle:Name` symbol.
|
|
23
|
-
* @returns The table name, or `"unknown"` if the symbol is not present.
|
|
24
|
-
*/
|
|
25
|
-
declare function getTableName(table: DrizzleTable): string;
|
|
26
|
-
/**
|
|
27
|
-
* A permission action: one of the four CRUD operations, or `"manage"` for all.
|
|
28
|
-
*
|
|
29
|
-
* - `"read"` maps to `SELECT` queries.
|
|
30
|
-
* - `"create"` maps to `INSERT` statements.
|
|
31
|
-
* - `"update"` maps to `UPDATE` statements.
|
|
32
|
-
* - `"delete"` maps to `DELETE` statements.
|
|
33
|
-
* - `"manage"` is shorthand for granting all four CRUD actions.
|
|
34
|
-
*/
|
|
35
|
-
type PermissionAction = "read" | "create" | "update" | "delete" | "manage";
|
|
36
|
-
/**
|
|
37
|
-
* A CRUD-only permission action (excludes `"manage"`).
|
|
38
|
-
*
|
|
39
|
-
* Useful when you need to iterate over concrete operations without the
|
|
40
|
-
* `"manage"` shorthand. See also {@link CRUD_ACTIONS}.
|
|
41
|
-
*/
|
|
42
|
-
type CrudAction = Exclude<PermissionAction, "manage">;
|
|
43
|
-
/**
|
|
44
|
-
* Readonly array of the four CRUD action strings, useful for iteration.
|
|
45
|
-
*
|
|
46
|
-
* @example
|
|
47
|
-
* ```typescript
|
|
48
|
-
* import { CRUD_ACTIONS } from "@cfast/permissions";
|
|
49
|
-
*
|
|
50
|
-
* for (const action of CRUD_ACTIONS) {
|
|
51
|
-
* console.log(action); // "read", "create", "update", "delete"
|
|
52
|
-
* }
|
|
53
|
-
* ```
|
|
54
|
-
*/
|
|
55
|
-
declare const CRUD_ACTIONS: readonly CrudAction[];
|
|
56
|
-
/**
|
|
57
|
-
* A function that produces a Drizzle `WHERE` clause for row-level permission filtering.
|
|
58
|
-
*
|
|
59
|
-
* @param columns - The table's column references for building filter expressions.
|
|
60
|
-
* @param user - The current user object (from `@cfast/auth`).
|
|
61
|
-
* @returns A Drizzle SQL expression to restrict matching rows, or `undefined` for no restriction.
|
|
62
|
-
*/
|
|
63
|
-
type WhereClause = (columns: Record<string, unknown>, user: unknown) => DrizzleSQL | undefined;
|
|
64
|
-
/**
|
|
65
|
-
* A single permission grant: an action on a subject, optionally restricted by a `where` clause.
|
|
66
|
-
*/
|
|
67
|
-
type Grant = {
|
|
68
|
-
/** The permitted operation. `"manage"` is shorthand for all four CRUD actions. */
|
|
69
|
-
action: PermissionAction;
|
|
70
|
-
/** The Drizzle table this grant applies to, or `"all"` for every table. */
|
|
71
|
-
subject: DrizzleTable | "all";
|
|
72
|
-
/** Optional row-level filter that restricts which rows this grant covers. */
|
|
73
|
-
where?: WhereClause;
|
|
74
|
-
};
|
|
75
|
-
/**
|
|
76
|
-
* Type-safe grant builder function, parameterized by the user type.
|
|
77
|
-
*
|
|
78
|
-
* Used when `grants` is provided as a callback in {@link PermissionsConfig}
|
|
79
|
-
* so that `where` clauses receive a correctly typed `user` parameter.
|
|
80
|
-
*
|
|
81
|
-
* @typeParam TUser - The user type passed to `where` clause callbacks.
|
|
82
|
-
*/
|
|
83
|
-
type GrantFn<TUser> = (action: PermissionAction, subject: DrizzleTable | "all", options?: {
|
|
84
|
-
where?: (columns: Record<string, unknown>, user: TUser) => DrizzleSQL | undefined;
|
|
85
|
-
}) => Grant;
|
|
86
|
-
/**
|
|
87
|
-
* Structural description of a permission requirement.
|
|
88
|
-
*
|
|
89
|
-
* Describes *what kind* of operation on *which table* without specifying concrete row values.
|
|
90
|
-
* This is what makes client-side permission introspection possible: you can check whether a
|
|
91
|
-
* role has the right grants without knowing the specific row being accessed.
|
|
92
|
-
*
|
|
93
|
-
* @example
|
|
94
|
-
* ```typescript
|
|
95
|
-
* const descriptor: PermissionDescriptor = {
|
|
96
|
-
* action: "update",
|
|
97
|
-
* table: posts,
|
|
98
|
-
* };
|
|
99
|
-
* ```
|
|
100
|
-
*/
|
|
101
|
-
type PermissionDescriptor = {
|
|
102
|
-
/** The operation being checked. */
|
|
103
|
-
action: PermissionAction;
|
|
104
|
-
/** The Drizzle table the operation targets. */
|
|
105
|
-
table: DrizzleTable;
|
|
106
|
-
};
|
|
107
|
-
/**
|
|
108
|
-
* Result of a permission check via {@link checkPermissions}.
|
|
109
|
-
*/
|
|
110
|
-
type PermissionCheckResult = {
|
|
111
|
-
/** `true` only if every descriptor in the check was satisfied. */
|
|
112
|
-
permitted: boolean;
|
|
113
|
-
/** The descriptors that were not satisfied. */
|
|
114
|
-
denied: PermissionDescriptor[];
|
|
115
|
-
/** Human-readable reasons for each denial. */
|
|
116
|
-
reasons: string[];
|
|
117
|
-
};
|
|
118
|
-
/**
|
|
119
|
-
* Configuration object for {@link definePermissions}.
|
|
120
|
-
*
|
|
121
|
-
* @typeParam TRoles - Tuple of role name string literals (use `as const`).
|
|
122
|
-
* @typeParam TUser - The user type for typed `where` clauses (defaults to `unknown`).
|
|
123
|
-
*/
|
|
124
|
-
type PermissionsConfig<TRoles extends readonly string[], TUser = unknown> = {
|
|
125
|
-
/** All roles in the application, declared with `as const` for type inference. */
|
|
126
|
-
roles: TRoles;
|
|
127
|
-
/** A map from role to grant arrays, or a callback that receives a typed `grant` function. */
|
|
128
|
-
grants: Record<TRoles[number], Grant[]> | ((grant: GrantFn<TUser>) => Record<TRoles[number], Grant[]>);
|
|
129
|
-
/** Optional role hierarchy declaring which roles inherit from which. */
|
|
130
|
-
hierarchy?: Partial<Record<TRoles[number], TRoles[number][]>>;
|
|
131
|
-
};
|
|
132
|
-
/**
|
|
133
|
-
* The resolved permissions object returned by {@link definePermissions}.
|
|
134
|
-
*
|
|
135
|
-
* Contains the original roles and grants, plus the hierarchy-expanded `resolvedGrants`.
|
|
136
|
-
* Pass this to `createDb()` for server-side enforcement or import it on the client
|
|
137
|
-
* for UI-level permission introspection.
|
|
138
|
-
*
|
|
139
|
-
* @typeParam TRoles - Tuple of role name string literals.
|
|
140
|
-
*/
|
|
141
|
-
type Permissions<TRoles extends readonly string[] = readonly string[]> = {
|
|
142
|
-
/** The role names from the configuration. */
|
|
143
|
-
roles: TRoles;
|
|
144
|
-
/** The raw grants as declared (before hierarchy expansion). */
|
|
145
|
-
grants: Record<TRoles[number], Grant[]>;
|
|
146
|
-
/** Grants expanded with inherited grants from the role hierarchy. */
|
|
147
|
-
resolvedGrants: Record<TRoles[number], Grant[]>;
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
/** Options for constructing a {@link ForbiddenError}. */
|
|
151
|
-
type ForbiddenErrorOptions = {
|
|
152
|
-
/** The action that was denied. */
|
|
153
|
-
action: PermissionAction;
|
|
154
|
-
/** The Drizzle table the action targeted. */
|
|
155
|
-
table: DrizzleTable;
|
|
156
|
-
/** The role that lacked the permission, if known. */
|
|
157
|
-
role?: string;
|
|
158
|
-
/** The full list of permission descriptors that were checked. */
|
|
159
|
-
descriptors?: PermissionDescriptor[];
|
|
160
|
-
};
|
|
161
|
-
/**
|
|
162
|
-
* Error thrown when a permission check fails during an operation.
|
|
163
|
-
*
|
|
164
|
-
* Extends `Error` with structured fields for the denied action, target table,
|
|
165
|
-
* and role. Includes a `toJSON()` method so it can be serialized across the
|
|
166
|
-
* server/client boundary.
|
|
167
|
-
*/
|
|
168
|
-
declare class ForbiddenError extends Error {
|
|
169
|
-
/** The action that was denied (e.g., `"delete"`). */
|
|
170
|
-
readonly action: PermissionAction;
|
|
171
|
-
/** The Drizzle table the action targeted. */
|
|
172
|
-
readonly table: DrizzleTable;
|
|
173
|
-
/** The role that lacked the permission, or `undefined` if not specified. */
|
|
174
|
-
readonly role: string | undefined;
|
|
175
|
-
/** The full list of permission descriptors that were checked. */
|
|
176
|
-
readonly descriptors: PermissionDescriptor[];
|
|
177
|
-
/**
|
|
178
|
-
* Creates a new `ForbiddenError`.
|
|
179
|
-
*
|
|
180
|
-
* @param options - The action, table, and optional role/descriptors for the error.
|
|
181
|
-
*/
|
|
182
|
-
constructor(options: ForbiddenErrorOptions);
|
|
183
|
-
/**
|
|
184
|
-
* Serializes the error to a JSON-safe object for server-to-client transfer.
|
|
185
|
-
*
|
|
186
|
-
* @returns A plain object with `name`, `message`, `action`, `table`, and `role` fields.
|
|
187
|
-
*/
|
|
188
|
-
toJSON(): {
|
|
189
|
-
name: string;
|
|
190
|
-
message: string;
|
|
191
|
-
action: PermissionAction;
|
|
192
|
-
table: string;
|
|
193
|
-
role: string | undefined;
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export { CRUD_ACTIONS as C, type DrizzleTable as D, ForbiddenError as F, type Grant as G, type PermissionsConfig as P, type WhereClause as W, type Permissions as a, type PermissionAction as b, type PermissionDescriptor as c, type PermissionCheckResult as d, type CrudAction as e, type GrantFn as f, getTableName as g };
|