@cfast/permissions 0.2.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/dist/{client-C7xdwHTk.d.ts → client-DKSiBBkt.d.ts} +99 -4
- package/dist/client.d.ts +1 -1
- package/dist/index.d.ts +25 -3
- package/dist/index.js +35 -22
- package/llms.txt +52 -2
- package/package.json +1 -1
|
@@ -92,9 +92,61 @@ declare const CRUD_ACTIONS: readonly CrudAction[];
|
|
|
92
92
|
*
|
|
93
93
|
* @param columns - The table's column references for building filter expressions.
|
|
94
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.
|
|
95
98
|
* @returns A Drizzle SQL expression to restrict matching rows, or `undefined` for no restriction.
|
|
96
99
|
*/
|
|
97
|
-
type WhereClause = (columns: Record<string, unknown>, user: unknown) => DrizzleSQL | undefined;
|
|
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>>;
|
|
98
150
|
/**
|
|
99
151
|
* A single permission grant: an action on a subject, optionally restricted by a `where` clause.
|
|
100
152
|
*
|
|
@@ -112,6 +164,37 @@ type Grant = {
|
|
|
112
164
|
* or `"all"` for every table.
|
|
113
165
|
*/
|
|
114
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;
|
|
115
198
|
/** Optional row-level filter that restricts which rows this grant covers. */
|
|
116
199
|
where?: WhereClause;
|
|
117
200
|
};
|
|
@@ -127,8 +210,20 @@ type Grant = {
|
|
|
127
210
|
* @typeParam TTables - Optional schema map (e.g. `typeof schema`) used to
|
|
128
211
|
* constrain string subjects to known table-name literals.
|
|
129
212
|
*/
|
|
130
|
-
type GrantFn<TUser, TTables extends SchemaMap = SchemaMap> = (action: PermissionAction, subject: SubjectInput<TTables>, options?: {
|
|
131
|
-
|
|
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;
|
|
132
227
|
}) => Grant;
|
|
133
228
|
/**
|
|
134
229
|
* Structural description of a permission requirement.
|
|
@@ -251,4 +346,4 @@ declare class ForbiddenError extends Error {
|
|
|
251
346
|
};
|
|
252
347
|
}
|
|
253
348
|
|
|
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
|
|
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/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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-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
|
|
@@ -95,11 +95,33 @@ declare function definePermissions<TUser, TTables extends SchemaMap = SchemaMap>
|
|
|
95
95
|
* where: (post, user) => eq(post.authorId, user.id),
|
|
96
96
|
* });
|
|
97
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
|
+
*
|
|
98
119
|
* // Full access to everything
|
|
99
120
|
* grant("manage", "all");
|
|
100
121
|
* ```
|
|
101
122
|
*/
|
|
102
123
|
declare function grant<TTables extends SchemaMap = SchemaMap>(action: PermissionAction, subject: SubjectInput<TTables>, options?: {
|
|
124
|
+
with?: WithLookups;
|
|
103
125
|
where?: WhereClause;
|
|
104
126
|
}): Grant;
|
|
105
127
|
|
|
@@ -215,4 +237,4 @@ type UserWithRoles = {
|
|
|
215
237
|
declare function resolveGrants(permissions: Permissions, user: UserWithRoles): Grant[];
|
|
216
238
|
declare function resolveGrants(permissions: Permissions, roles: readonly string[]): Grant[];
|
|
217
239
|
|
|
218
|
-
export { Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, SchemaMap, SubjectInput, type UserWithRoles, WhereClause, can, checkPermissions, definePermissions, grant, resolveGrants };
|
|
240
|
+
export { Grant, PermissionAction, PermissionCheckResult, PermissionDescriptor, Permissions, PermissionsConfig, SchemaMap, SubjectInput, type UserWithRoles, WhereClause, WithLookups, can, checkPermissions, definePermissions, grant, resolveGrants };
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ 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
|
}
|
|
@@ -143,36 +144,48 @@ function resolveGrants(permissions, userOrRoles) {
|
|
|
143
144
|
const key = `${g.action}:${getSubjectKey(g.subject)}`;
|
|
144
145
|
let group = groups.get(key);
|
|
145
146
|
if (!group) {
|
|
146
|
-
group = { action: g.action, subject: g.subject,
|
|
147
|
+
group = { action: g.action, subject: g.subject, grants: [] };
|
|
147
148
|
groups.set(key, group);
|
|
148
149
|
}
|
|
149
|
-
group.
|
|
150
|
+
group.grants.push(g);
|
|
150
151
|
}
|
|
151
152
|
const result = [];
|
|
152
153
|
for (const group of groups.values()) {
|
|
153
|
-
const hasUnrestricted = group.
|
|
154
|
+
const hasUnrestricted = group.grants.some((g) => !g.where);
|
|
154
155
|
if (hasUnrestricted) {
|
|
155
156
|
result.push({ action: group.action, subject: group.subject });
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
)
|
|
159
183
|
);
|
|
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
|
-
}
|
|
184
|
+
result.push({
|
|
185
|
+
action: group.action,
|
|
186
|
+
subject: group.subject,
|
|
187
|
+
where: merged
|
|
188
|
+
});
|
|
176
189
|
}
|
|
177
190
|
}
|
|
178
191
|
return result;
|
package/llms.txt
CHANGED
|
@@ -71,12 +71,62 @@ type Permissions<TRoles> = {
|
|
|
71
71
|
|
|
72
72
|
### `grant(action, subject, options?): Grant`
|
|
73
73
|
```typescript
|
|
74
|
-
grant(action: PermissionAction, subject: DrizzleTable | string, options?: {
|
|
74
|
+
grant(action: PermissionAction, subject: DrizzleTable | string, options?: {
|
|
75
|
+
with?: WithLookups;
|
|
76
|
+
where?: WhereClause;
|
|
77
|
+
}): Grant
|
|
75
78
|
```
|
|
76
79
|
- `PermissionAction`: `"read" | "create" | "update" | "delete" | "manage"`
|
|
77
|
-
- `WhereClause`: `(columns, user) => DrizzleSQL | undefined`
|
|
80
|
+
- `WhereClause`: `(columns, user, lookups) => DrizzleSQL | undefined`
|
|
81
|
+
- `WithLookups`: `Record<string, (user, db) => Promise<unknown> | unknown>`
|
|
78
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.
|
|
79
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.
|
|
129
|
+
|
|
80
130
|
### `checkPermissions(role, permissions, descriptors): PermissionCheckResult`
|
|
81
131
|
```typescript
|
|
82
132
|
import { checkPermissions } from "@cfast/permissions";
|