@sladkoff/kysely-access-control 0.0.9 → 0.0.11

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/README.md CHANGED
@@ -215,6 +215,8 @@ For example, the following pattern will not work:
215
215
 
216
216
  Use the full table name without aliases in subqueries to ensure proper permission enforcement.
217
217
 
218
+ **Note**: If you need to use table aliases in a subquery and want to bypass access control, you can use `bypassAccessControl()` to skip the checks, but this should be done with caution as it may introduce security risks.
219
+
218
220
  # Features
219
221
 
220
222
  ## Table/Column Statement Type + Context Controls
@@ -288,6 +290,44 @@ If you choose this option, the column you select will be omitted from the query,
288
290
 
289
291
  This also works for `returning` clauses as well, whether they are on a top level insert, update, or delete statement.
290
292
 
293
+ ## Bypassing Access Control for Subqueries
294
+
295
+ In some cases, you may want to embed a subquery that should bypass access control checks. This is useful when:
296
+
297
+ 1. **Embedding queries from a different context**: You want to include data from a table that the current user doesn't have access to, but you've already validated the access at a higher level.
298
+ 2. **Performance optimization**: You want to avoid redundant access control checks on subqueries that you know are safe.
299
+ 3. **Complex query patterns**: You're building complex queries where some subqueries need different access control behavior.
300
+
301
+ Use the `bypassAccessControl()` helper function to mark a query builder so that its subquery will skip access control enforcement:
302
+
303
+ ```typescript
304
+ import { bypassAccessControl, createAccessControlPlugin } from 'kysely-access-control';
305
+ import { jsonArrayFrom } from 'kysely/helpers/postgres';
306
+
307
+ const plugin = createAccessControlPlugin(guard);
308
+
309
+ // In a query with access control
310
+ const result = await db
311
+ .withPlugin(plugin)
312
+ .selectFrom("person")
313
+ .select((qb) => {
314
+ // This subquery will bypass access control checks
315
+ const rsvps = bypassAccessControl(qb.selectFrom("rsvp").select("id"));
316
+
317
+ return [
318
+ "person.first_name",
319
+ "person.last_name",
320
+ jsonArrayFrom(rsvps).as("rsvps"),
321
+ ];
322
+ })
323
+ .execute();
324
+ ```
325
+
326
+ **Important Notes:**
327
+ - `bypassAccessControl()` only affects the specific query builder it wraps. Other parts of the query still have access control enforced.
328
+ - Use this feature carefully - bypassing access control can introduce security vulnerabilities if not used correctly.
329
+ - The function works by marking the query builder, so it must be called before the query builder is used in `jsonArrayFrom`/`jsonObjectFrom` or other subquery contexts.
330
+
291
331
  # Contributing
292
332
 
293
333
  The most helpful form of contribution right now would be additional tests on complex queries in your
@@ -1,7 +1,24 @@
1
- import { ColumnNode, ExpressionWrapper, KyselyPlugin, TableNode, SqlBool } from "kysely";
1
+ import { ColumnNode, ExpressionWrapper, KyselyPlugin, TableNode, OperationNode, SqlBool } from "kysely";
2
2
  export declare const Allow: "allow";
3
3
  export declare const Deny: "deny";
4
4
  export declare const Omit: "omit";
5
+ /**
6
+ * Marks a query builder to bypass access control when used in subqueries.
7
+ * Use this for query builders created from `db` (without plugin) that you want
8
+ * to embed via jsonArrayFrom/jsonObjectFrom.
9
+ *
10
+ * This works by wrapping the query builder in a proxy that intercepts
11
+ * `.toOperationNode()` and marks the resulting node in a WeakMap.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const rsvps = bypassAccessControl(db.selectFrom("rsvp").select("id"));
16
+ * return [jsonArrayFrom(rsvps).as("rsvps")];
17
+ * ```
18
+ */
19
+ export declare function bypassAccessControl<T extends {
20
+ toOperationNode(): OperationNode;
21
+ }>(qb: T): T;
5
22
  type TAllow = typeof Allow;
6
23
  type TDeny = typeof Deny;
7
24
  type TOmit = typeof Omit;
@@ -3,12 +3,56 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.createAccessControlPlugin = exports.throwIfDenyWithReason = exports.TableUsageContext = exports.ColumnUsageContext = exports.StatementType = exports.Omit = exports.Deny = exports.Allow = void 0;
6
+ exports.createAccessControlPlugin = exports.throwIfDenyWithReason = exports.TableUsageContext = exports.ColumnUsageContext = exports.StatementType = exports.bypassAccessControl = exports.Omit = exports.Deny = exports.Allow = void 0;
7
7
  const kysely_1 = require("kysely");
8
8
  const tiny_invariant_1 = __importDefault(require("tiny-invariant"));
9
9
  exports.Allow = "allow";
10
10
  exports.Deny = "deny";
11
11
  exports.Omit = "omit";
12
+ // WeakMap to track OperationNodes that should bypass access control
13
+ const bypassAccessControlNodes = new WeakMap();
14
+ /**
15
+ * Marks a query builder to bypass access control when used in subqueries.
16
+ * Use this for query builders created from `db` (without plugin) that you want
17
+ * to embed via jsonArrayFrom/jsonObjectFrom.
18
+ *
19
+ * This works by wrapping the query builder in a proxy that intercepts
20
+ * `.toOperationNode()` and marks the resulting node in a WeakMap.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const rsvps = bypassAccessControl(db.selectFrom("rsvp").select("id"));
25
+ * return [jsonArrayFrom(rsvps).as("rsvps")];
26
+ * ```
27
+ */
28
+ function bypassAccessControl(qb) {
29
+ return new Proxy(qb, {
30
+ get(target, prop) {
31
+ if (prop === "toOperationNode") {
32
+ return () => {
33
+ const node = target.toOperationNode();
34
+ // Mark the node to bypass access control in WeakMap
35
+ bypassAccessControlNodes.set(node, true);
36
+ return node;
37
+ };
38
+ }
39
+ const value = target[prop];
40
+ // If it's a function that returns a query builder, wrap it too
41
+ if (typeof value === "function" && prop !== "toOperationNode") {
42
+ return (...args) => {
43
+ const result = value.apply(target, args);
44
+ // If the result is a query builder-like object, wrap it
45
+ if (result && typeof result === "object" && "toOperationNode" in result) {
46
+ return bypassAccessControl(result);
47
+ }
48
+ return result;
49
+ };
50
+ }
51
+ return value;
52
+ },
53
+ });
54
+ }
55
+ exports.bypassAccessControl = bypassAccessControl;
12
56
  var StatementType;
13
57
  (function (StatementType) {
14
58
  StatementType["Select"] = "select";
@@ -164,6 +208,12 @@ const createAccessControlPlugin = (guard) => {
164
208
  transformSelectQuery(node) {
165
209
  var _a;
166
210
  const { from: fromNode, selections, joins, where } = node;
211
+ // Skip access control for nested subqueries that were explicitly marked to bypass
212
+ // This happens when a query builder is wrapped with bypassAccessControl()
213
+ if (bypassAccessControlNodes.has(node)) {
214
+ // This subquery was marked to bypass access control, skip enforcement
215
+ return super.transformSelectQuery(node);
216
+ }
167
217
  if (!fromNode) {
168
218
  // This covers queries such as select 1, or select following only by subselects
169
219
  // We do nothing here
@@ -174,8 +224,17 @@ const createAccessControlPlugin = (guard) => {
174
224
  (0, tiny_invariant_1.default)(kysely_1.TableNode.is(tableNode), "kysely-access-control: currently only select from table/view is supported");
175
225
  (0, tiny_invariant_1.default)(selections !== undefined, "kysely-access-control: selections should be defined");
176
226
  const table = tableNode.table;
227
+ const tableName = table.identifier.name;
228
+ // Check if this table is a CTE that should bypass access control
229
+ const bypassedCteNames = this._getBypassedCteNames();
230
+ if (bypassedCteNames.has(tableName)) {
231
+ // This is a bypassed CTE, skip all access control enforcement
232
+ // The CTE query already handled access control (or bypassed it)
233
+ // Just pass through the query without additional checks
234
+ return super.transformSelectQuery(node);
235
+ }
177
236
  const guardResult = fullGuard.table(table, StatementType.Select, TableUsageContext.TableTopLevel);
178
- (0, exports.throwIfDenyWithReason)(guardResult, `SELECT denied on table ${((_a = table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${table.schema.name}.` : ""}${table.identifier.name}`);
237
+ (0, exports.throwIfDenyWithReason)(guardResult, `SELECT denied on table ${((_a = table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${table.schema.name}.` : ""}${tableName}`);
179
238
  /* COLUMN ENFORCEMENT */
180
239
  // Some selected columns include a table, some don't
181
240
  // If there's no joins and therefore only one valid relation to reference
@@ -352,6 +411,36 @@ const createAccessControlPlugin = (guard) => {
352
411
  }
353
412
  throw new Error("_topLevelHasMoreThanOneTable called with something that is not a select, update, or delete query");
354
413
  }
414
+ /**
415
+ * Get the set of CTE names that should bypass access control.
416
+ * Traverses up the node stack to find the top-level query with a `with` clause,
417
+ * then checks which CTEs have their query nodes marked in bypassAccessControlNodes.
418
+ */
419
+ _getBypassedCteNames() {
420
+ var _a, _b, _c, _d, _e;
421
+ const bypassedCteNames = new Set();
422
+ // Traverse up the node stack to find the top-level query with a `with` clause
423
+ for (let i = this.nodeStack.length - 1; i >= 0; i--) {
424
+ const node = this.nodeStack[i];
425
+ if (kysely_1.SelectQueryNode.is(node) && ((_a = node.with) === null || _a === void 0 ? void 0 : _a.expressions)) {
426
+ // Found a query with CTEs, check which ones are bypassed
427
+ for (const cteExpression of node.with.expressions) {
428
+ // Extract CTE name from the expression
429
+ // Structure: cteExpression.name.table.table.identifier.name
430
+ const cteName = (_e = (_d = (_c = (_b = cteExpression.name) === null || _b === void 0 ? void 0 : _b.table) === null || _c === void 0 ? void 0 : _c.table) === null || _d === void 0 ? void 0 : _d.identifier) === null || _e === void 0 ? void 0 : _e.name;
431
+ if (cteName && kysely_1.SelectQueryNode.is(cteExpression.expression)) {
432
+ // Check if this CTE's query node is marked to bypass access control
433
+ if (bypassAccessControlNodes.has(cteExpression.expression)) {
434
+ bypassedCteNames.add(cteName);
435
+ }
436
+ }
437
+ }
438
+ // Only check the top-level query's with clause, so break after finding it
439
+ break;
440
+ }
441
+ }
442
+ return bypassedCteNames;
443
+ }
355
444
  /**
356
445
  * Get the table node for a top level query type (select, update, delete, or insert)
357
446
  */
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "main": "dist/index.js",
4
4
  "types": "dist/index.d.ts",
5
5
  "module": "index.ts",
6
- "version": "0.0.9",
6
+ "version": "0.0.11",
7
7
  "scripts": {
8
8
  "compile": "tsc -p tsconfig.build.json"
9
9
  },
@@ -12,8 +12,8 @@
12
12
  "typescript": "^5.2.2"
13
13
  },
14
14
  "peerDependencies": {
15
- "typescript": "^5.0.0",
16
- "kysely": "^0.26.3"
15
+ "kysely": "^0.26.3",
16
+ "typescript": "^5.0.0"
17
17
  },
18
18
  "dependencies": {
19
19
  "tiny-invariant": "^1.3.1"