@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 +40 -0
- package/dist/src/kyselyAccessControl.d.ts +18 -1
- package/dist/src/kyselyAccessControl.js +91 -2
- package/package.json +3 -3
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}.` : ""}${
|
|
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.
|
|
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
|
-
"
|
|
16
|
-
"
|
|
15
|
+
"kysely": "^0.26.3",
|
|
16
|
+
"typescript": "^5.0.0"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"tiny-invariant": "^1.3.1"
|