@sladkoff/kysely-access-control 0.0.6
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 +284 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +18 -0
- package/dist/src/kyselyAccessControl.d.ts +40 -0
- package/dist/src/kyselyAccessControl.js +459 -0
- package/dist/src/kyselyGrants.d.ts +26 -0
- package/dist/src/kyselyGrants.js +88 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
This package contains some utilities for implementing a permission system on top of the
|
|
2
|
+
[Kysely](https://github.com/koskimas/kysely) query builder.
|
|
3
|
+
|
|
4
|
+
It exposes two interfaces, a low-level interface accessible via `createAccessControlPlugin` and
|
|
5
|
+
and a higher level interface that is similar to Postgres's internal permissions
|
|
6
|
+
accessible via `createKyselyGrantGuard`.
|
|
7
|
+
|
|
8
|
+
It uses `bun` for package installation, monorepo management, building, and running scripts and tests,
|
|
9
|
+
but exports packages in a way that is compatible with Node or Deno normally.
|
|
10
|
+
|
|
11
|
+
# Motivation
|
|
12
|
+
|
|
13
|
+
Implementing permissions at the query builder layer makes more sense than in *each query*:
|
|
14
|
+
1. **DRY-er**: Common use cases like filtering a table or omitting a column are just specified once, instead of in every query in your application.
|
|
15
|
+
2. **Separation of concerns**: Maintain a part of your application responsible for generating different guards for different users and ensure that your core application logic is not polluted with permission checks, and doesn't need to change when permissions or new roles are created.
|
|
16
|
+
3. **Harder to forget**: No more odd bugs where you forget to add a check for `.is_deleted` or `.tenant_id = ?`
|
|
17
|
+
|
|
18
|
+
Even though PostgreSQL has a fully featured permission system, implementing permissions at the query builder layer
|
|
19
|
+
can makes more sense than in *the database* itself:
|
|
20
|
+
1. **Dynamically generate context specific permissions**: Postgres permissions are static, and so you can't, for example, generate permissions based on the current context / user role / action matrix. Although you can use a role per user approach, that role controls those users permissions in any context.
|
|
21
|
+
3. **No security definer escape**: When using database level permissions, it's common to use security definer functions as an escape hatch. When you do, you're back to manually re-implementing parts of the permissions you want to keep.
|
|
22
|
+
3. **More control**: Postgres, for example, has no deny rules, and so it can be easy to accidentally grant permissions that leak when additive roles combine.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# High Level Grants Usage
|
|
26
|
+
|
|
27
|
+
Construct a `Grant` with the following type, like:
|
|
28
|
+
```typescript
|
|
29
|
+
type Grant = {
|
|
30
|
+
on: Table;
|
|
31
|
+
for: 'select' | 'insert' | 'update' | 'delete' | 'all'
|
|
32
|
+
columns?: string[] // all columns are allowed if blank
|
|
33
|
+
where?: (
|
|
34
|
+
eb: ExpressionBuilder<KyselyDatabase, TableName>
|
|
35
|
+
) => ExpressionWrapper<KyselyDatabase, TableName, SqlBool>;
|
|
36
|
+
whereType?: "permissive" | "restrictive";
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`Grant.where` and `Grant.whereType` function similar to Postgres [row level security](https://www.postgresql.org/docs/current/sql-createpolicy.html).
|
|
41
|
+
|
|
42
|
+
You can check a list of grants into your codebase, like:
|
|
43
|
+
```typescript
|
|
44
|
+
// in some file db.ts
|
|
45
|
+
import { createKyselyGrantGuard, createAccessControlPlugin } from 'kysely-access-control'
|
|
46
|
+
|
|
47
|
+
const getSharedGrants = (currentUserId) => [
|
|
48
|
+
{
|
|
49
|
+
on: 'posts',
|
|
50
|
+
for: 'select'
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
on: 'comments',
|
|
54
|
+
for: 'select'
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
on: 'posts',
|
|
58
|
+
for: 'all',
|
|
59
|
+
where: (eb) => eb.eq('author_id', currentUserId)
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
on: 'comments',
|
|
63
|
+
for: 'all',
|
|
64
|
+
where: (eb) => eb.eq('author_id', currentUserId)
|
|
65
|
+
}
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
const adminGrants = [
|
|
69
|
+
{
|
|
70
|
+
on: 'accounts',
|
|
71
|
+
for: 'all',
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
const query = (userId, isAdmin) => {
|
|
76
|
+
return db.withPlugin(createAccessControlPlugin(
|
|
77
|
+
createKyselyGrantGuard(
|
|
78
|
+
getSharedGrants(userId).concat(isAdmin ? adminGrants : [])
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// in some api.ts
|
|
84
|
+
import { query } from './db.ts'
|
|
85
|
+
|
|
86
|
+
// in some request handler
|
|
87
|
+
// this query will have permissions enforced
|
|
88
|
+
await query(req.user.id, req.user.isAdmin).selectFrom('posts').select(['id']).execute();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Or you can generate them from a database, storing them in some `grants` table, or
|
|
92
|
+
anything else you can think of.
|
|
93
|
+
|
|
94
|
+
In my projects, I'm constructing the plugin in response to each request. In one, I'm doing it in a [tRPC middleware](https://trpc.io/docs/server/middlewares) and adding it to the RPC's context.
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
### Only Table/Column Grants
|
|
98
|
+
|
|
99
|
+
Currently only table x column permissions are implemented, i.e. all grants look like:
|
|
100
|
+
```sql
|
|
101
|
+
grant select (id, first_name, last_name) on person to a;
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
There is no intent to implement schema level ownership or other higher level permissions.
|
|
105
|
+
If you want a user to be able to access everything, just skip the `.withPlugin()` call.
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Lower Level Access Control Usage
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
import { createAccessControlPlugin, KyselyAccessControlGuard, Allow, Deny, Update, Delete, ColumnInUpdateSet } from 'kysely-access-control';
|
|
112
|
+
import { Database } from './my-kysely-types.ts'
|
|
113
|
+
|
|
114
|
+
// Define your guard
|
|
115
|
+
const guard: KyselyAccessControlGuard<Database> = {
|
|
116
|
+
table: (table, statementType, usageContext) => {
|
|
117
|
+
// table.name is restricted to keyof Database
|
|
118
|
+
if (table.name === 'events' && statementType === Delete) {
|
|
119
|
+
return Deny;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return Allow;
|
|
123
|
+
},
|
|
124
|
+
column: (table, column, statementType, usageContext) => {
|
|
125
|
+
// Control if the column can be inserted, updated independently
|
|
126
|
+
if (table.name === 'events' && column.name === 'is_deleted' && statementType === Update && usageContext === ColumnInUpdateSet) {
|
|
127
|
+
return Deny;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return Allow;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// When executing a query...
|
|
135
|
+
const events = await db
|
|
136
|
+
.withPlugin(createAccessControlPlugin(guard))
|
|
137
|
+
.updateTable('events)
|
|
138
|
+
.set({ is_deleted: false })
|
|
139
|
+
.execute();
|
|
140
|
+
// throws 'UPDATE denied on events.is_deleted'
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
# Limitations
|
|
144
|
+
|
|
145
|
+
## No Enforcement of Raw SQL
|
|
146
|
+
|
|
147
|
+
`kysely-access-control` works by operating on the internal `OperationNode`s used in Kysely's query builder. As a result, anything [specified in raw SQL](https://kysely-org.github.io/kysely-apidoc/interfaces/Sql.html) can't be enforced.
|
|
148
|
+
|
|
149
|
+
There are definitely legitimate uses that require raw SQL, but try to use it only when necessary in order to maintain most of
|
|
150
|
+
the benefits of `kysely-access-control`.
|
|
151
|
+
|
|
152
|
+
For example,
|
|
153
|
+
```typescript
|
|
154
|
+
db.selectFrom('person')
|
|
155
|
+
.select(({ fn, val, ref }) => [ fn<string>('concat', [ref('first_name'), val(' '), ref('last_name')]) ])
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Enforces column permissions, whereas:
|
|
159
|
+
```typescript
|
|
160
|
+
db.selectFrom('person')
|
|
161
|
+
.select(sql<string>`concat(first_name, ' ', last_name)`)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
enforces only table permissions, and:
|
|
165
|
+
```typescript
|
|
166
|
+
sql`select concat(first_name, ' ', last_name) from person`
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
enforces nothing.
|
|
170
|
+
|
|
171
|
+
## No RLS on Insert (or Check for Update out of RLS)
|
|
172
|
+
|
|
173
|
+
`kysely-access-control`'s RLS works by adding user supplied expression's as where clauses in the right places. As a result, it is only capable of implementing
|
|
174
|
+
the `USING` part of traditional RLS, and not the `WITH CHECK` part.
|
|
175
|
+
|
|
176
|
+
As a result, we can't check that a new row version (whether inserted or updated) matches the conditions specified.
|
|
177
|
+
|
|
178
|
+
## Types May Be Incorrect
|
|
179
|
+
|
|
180
|
+
If you use `kysely-access-control` to restrict access to a column, the query return types may still portray
|
|
181
|
+
that column as being present (and potentially even not null), even though it will be undefined in the actual result.
|
|
182
|
+
|
|
183
|
+
## Joins May Fail Where You Don't Expect
|
|
184
|
+
|
|
185
|
+
Even if a foreign key is not null, if you join to a table with a `where` guard on it, the join may fail
|
|
186
|
+
because the context does not permit the user to see the joined row.
|
|
187
|
+
|
|
188
|
+
This is true for Postgres RLS as well.
|
|
189
|
+
|
|
190
|
+
## Top Level `.selectAll()` is not allowed
|
|
191
|
+
|
|
192
|
+
While `kysely-access-control` allows usage of `.selectAll()` in subqueries, it does not allow it at the top level
|
|
193
|
+
because it would circumvent column permissions controls.
|
|
194
|
+
|
|
195
|
+
Unfortunately, even those you provide the column list to Kysely as a type, that type is not inspectable by the plugin
|
|
196
|
+
system (or at all by the runtime), and as a result we cannot do the sensible thing of replacing a `.selectAll()` with a
|
|
197
|
+
select of all columns.
|
|
198
|
+
|
|
199
|
+
# Features
|
|
200
|
+
|
|
201
|
+
## Table/Column Statement Type + Context Controls
|
|
202
|
+
|
|
203
|
+
`createAccessControlPlugin` allows you to control access to tables and columns based on the statement type and context.
|
|
204
|
+
For example, you can allow a user to select from a table, but not update it, or allow a user to update a table, but not set a particular column.
|
|
205
|
+
|
|
206
|
+
For full controls, see the types of the guard:
|
|
207
|
+
```typescript
|
|
208
|
+
type FullKyselyAccessControlGuard<KyselyDatabase> = {
|
|
209
|
+
table: (
|
|
210
|
+
table: TableNodeTableWithKeyOf<KyselyDatabase>,
|
|
211
|
+
statementType: StatementType,
|
|
212
|
+
tableUsageContext: TableUsageContext
|
|
213
|
+
) => TableGuardResult<KyselyDatabase>;
|
|
214
|
+
|
|
215
|
+
column: (
|
|
216
|
+
table: TableNodeTableWithKeyOf<KyselyDatabase>,
|
|
217
|
+
column: ColumnNode["column"],
|
|
218
|
+
statementType: StatementType,
|
|
219
|
+
columnUsageContext: ColumnUsageContext
|
|
220
|
+
) => ColumnGuardResult;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export enum StatementType {
|
|
224
|
+
Select = "select",
|
|
225
|
+
Insert = "insert",
|
|
226
|
+
Update = "update",
|
|
227
|
+
Delete = "delete",
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export enum ColumnUsageContext {
|
|
231
|
+
ColumnInSelectOrReturning = "column-in-select-or-returning",
|
|
232
|
+
ColumnInWhereOrJoin = "column-in-where-or-join",
|
|
233
|
+
ColumnInUpdateSet = "column-in-update-set",
|
|
234
|
+
ColumnInInsert = "column-in-insert",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export enum TableUsageContext {
|
|
238
|
+
TableTopLevel = "table-top-level",
|
|
239
|
+
TableInJoin = "table-in-join",
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## RLS in Select/Update/Delete
|
|
244
|
+
|
|
245
|
+
In addition to returning a simple `Allow` token to allow access, you can also return a tuple where the second
|
|
246
|
+
argument is a Kysely where clause to be added to the query.
|
|
247
|
+
|
|
248
|
+
For example, you can implement RLS like so:
|
|
249
|
+
```typescript
|
|
250
|
+
const guard: KyselyAccessControlGuard = {
|
|
251
|
+
table: (table) => {
|
|
252
|
+
if (table.name === 'people') {
|
|
253
|
+
return [
|
|
254
|
+
Allow,
|
|
255
|
+
expressionBuilder<Database, 'people'>().eb('is_deleted', 'is', false);
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Now, any query that targets the `people` table will have `is_deleted` is false inlined as a where clause.
|
|
263
|
+
|
|
264
|
+
## Column Omission vs Erroring
|
|
265
|
+
|
|
266
|
+
At column level select statements, you can choose `Omit` as a third option to `Allow` vs. `Deny`.
|
|
267
|
+
|
|
268
|
+
If you choose this option, the column you select will be omitted from the query, and the query will still succeed.
|
|
269
|
+
|
|
270
|
+
This also works for `returning` clauses as well, whether they are on a top level insert, update, or delete statement.
|
|
271
|
+
|
|
272
|
+
# Contributing
|
|
273
|
+
|
|
274
|
+
The most helpful form of contribution right now would be additional tests on complex queries in your
|
|
275
|
+
actual applications.
|
|
276
|
+
|
|
277
|
+
Currently, `kysely-access-control` has not been tested to properly enforce permissions with every type of SQL query
|
|
278
|
+
Kysely itself can generate.
|
|
279
|
+
|
|
280
|
+
However, it has been programmed to throw errors if it encounters a query type that is not yet implemented,
|
|
281
|
+
and it should generate these errors even if you don't enforce any particularly complex permission on them.
|
|
282
|
+
|
|
283
|
+
For any of these failures, it is possible to make `kysely-access-control` work, it just requires a few more `if`s, so
|
|
284
|
+
please open an issue.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./src/kyselyAccessControl"), exports);
|
|
18
|
+
__exportStar(require("./src/kyselyGrants"), exports);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ColumnNode, ExpressionWrapper, KyselyPlugin, TableNode, SqlBool } from "kysely";
|
|
2
|
+
export declare const Allow: "allow";
|
|
3
|
+
export declare const Deny: "deny";
|
|
4
|
+
export declare const Omit: "omit";
|
|
5
|
+
type TAllow = typeof Allow;
|
|
6
|
+
type TDeny = typeof Deny;
|
|
7
|
+
type TOmit = typeof Omit;
|
|
8
|
+
export declare enum StatementType {
|
|
9
|
+
Select = "select",
|
|
10
|
+
Insert = "insert",
|
|
11
|
+
Update = "update",
|
|
12
|
+
Delete = "delete"
|
|
13
|
+
}
|
|
14
|
+
export declare enum ColumnUsageContext {
|
|
15
|
+
ColumnInSelectOrReturning = "column-in-select-or-returning",
|
|
16
|
+
ColumnInWhereOrJoin = "column-in-where-or-join",
|
|
17
|
+
ColumnInUpdateSet = "column-in-update-set",
|
|
18
|
+
ColumnInInsert = "column-in-insert"
|
|
19
|
+
}
|
|
20
|
+
export declare enum TableUsageContext {
|
|
21
|
+
TableTopLevel = "table-top-level",
|
|
22
|
+
TableInJoin = "table-in-join"
|
|
23
|
+
}
|
|
24
|
+
type TableGuardResult<KyselyDatabase> = TAllow | [TAllow, ExpressionWrapper<KyselyDatabase, any, SqlBool>] | TDeny | [TDeny, string];
|
|
25
|
+
type ColumnGuardResult = TAllow | TOmit | TDeny | [TDeny, string];
|
|
26
|
+
type TableNodeTable = TableNode["table"];
|
|
27
|
+
type TableNodeTableIdentifierWithNamesAsKeyOf<KyselyDatabase> = Omit<TableNodeTable["identifier"], "name"> & {
|
|
28
|
+
name: keyof KyselyDatabase;
|
|
29
|
+
};
|
|
30
|
+
type TableNodeTableWithKeyOf<KyselyDatabase> = Omit<TableNodeTable, "identifier"> & {
|
|
31
|
+
identifier: TableNodeTableIdentifierWithNamesAsKeyOf<KyselyDatabase>;
|
|
32
|
+
};
|
|
33
|
+
export declare const throwIfDenyWithReason: (guardResult: ColumnGuardResult | TableGuardResult<unknown>, coreErrorString: string) => void;
|
|
34
|
+
type FullKyselyAccessControlGuard<KyselyDatabase = unknown> = {
|
|
35
|
+
table: (table: TableNodeTableWithKeyOf<KyselyDatabase>, statementType: StatementType, tableUsageContext: TableUsageContext) => TableGuardResult<KyselyDatabase>;
|
|
36
|
+
column: (table: TableNodeTableWithKeyOf<KyselyDatabase>, column: ColumnNode["column"], statementType: StatementType, columnUsageContext: ColumnUsageContext) => ColumnGuardResult;
|
|
37
|
+
};
|
|
38
|
+
export type KyselyAccessControlGuard<KyselyDatabase = unknown> = Partial<FullKyselyAccessControlGuard<KyselyDatabase>>;
|
|
39
|
+
export declare const createAccessControlPlugin: <KyselyDatabase = unknown>(guard: Partial<FullKyselyAccessControlGuard<KyselyDatabase>>) => KyselyPlugin;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
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;
|
|
7
|
+
const kysely_1 = require("kysely");
|
|
8
|
+
const tiny_invariant_1 = __importDefault(require("tiny-invariant"));
|
|
9
|
+
exports.Allow = "allow";
|
|
10
|
+
exports.Deny = "deny";
|
|
11
|
+
exports.Omit = "omit";
|
|
12
|
+
var StatementType;
|
|
13
|
+
(function (StatementType) {
|
|
14
|
+
StatementType["Select"] = "select";
|
|
15
|
+
StatementType["Insert"] = "insert";
|
|
16
|
+
StatementType["Update"] = "update";
|
|
17
|
+
StatementType["Delete"] = "delete";
|
|
18
|
+
})(StatementType || (exports.StatementType = StatementType = {}));
|
|
19
|
+
var ColumnUsageContext;
|
|
20
|
+
(function (ColumnUsageContext) {
|
|
21
|
+
ColumnUsageContext["ColumnInSelectOrReturning"] = "column-in-select-or-returning";
|
|
22
|
+
ColumnUsageContext["ColumnInWhereOrJoin"] = "column-in-where-or-join";
|
|
23
|
+
ColumnUsageContext["ColumnInUpdateSet"] = "column-in-update-set";
|
|
24
|
+
ColumnUsageContext["ColumnInInsert"] = "column-in-insert";
|
|
25
|
+
})(ColumnUsageContext || (exports.ColumnUsageContext = ColumnUsageContext = {}));
|
|
26
|
+
var TableUsageContext;
|
|
27
|
+
(function (TableUsageContext) {
|
|
28
|
+
TableUsageContext["TableTopLevel"] = "table-top-level";
|
|
29
|
+
TableUsageContext["TableInJoin"] = "table-in-join";
|
|
30
|
+
})(TableUsageContext || (exports.TableUsageContext = TableUsageContext = {}));
|
|
31
|
+
const throwIfDenyWithReason = (guardResult, coreErrorString) => {
|
|
32
|
+
if (guardResult === exports.Deny) {
|
|
33
|
+
throw new Error(coreErrorString);
|
|
34
|
+
}
|
|
35
|
+
if (guardResult[0] === exports.Deny) {
|
|
36
|
+
throw new Error(`${coreErrorString}: ${guardResult[1]}`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
exports.throwIfDenyWithReason = throwIfDenyWithReason;
|
|
40
|
+
const createAccessControlPlugin = (guard) => {
|
|
41
|
+
// 2 things are accomplished in this translation into fullGuard
|
|
42
|
+
// 1. Default guards are provided if the user does not provide either .table or .column
|
|
43
|
+
// 2. We lose table and column keyof typings so that we can safely call these guards internally
|
|
44
|
+
// without extra coercion
|
|
45
|
+
const fullGuard = Object.assign({ table: () => {
|
|
46
|
+
return exports.Allow;
|
|
47
|
+
}, column: () => {
|
|
48
|
+
return exports.Allow;
|
|
49
|
+
} }, guard);
|
|
50
|
+
class Transformer extends kysely_1.OperationNodeTransformer {
|
|
51
|
+
getParentNode() {
|
|
52
|
+
return this.nodeStack[this.nodeStack.length - 2]; // last element is the current one, one before is the parent
|
|
53
|
+
}
|
|
54
|
+
isAChildOf(nodeType) {
|
|
55
|
+
return this.nodeStack.find((node) => nodeType.is(node)) !== undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Enforce update on a table
|
|
59
|
+
* - enforces whether the table is allowed to be updated
|
|
60
|
+
* - enforce the target columns to be updated
|
|
61
|
+
*/
|
|
62
|
+
transformUpdateQuery(node) {
|
|
63
|
+
var _a, _b;
|
|
64
|
+
const tableNode = node.table;
|
|
65
|
+
(0, tiny_invariant_1.default)(kysely_1.TableNode.is(tableNode), "kysely-access-control: only table nodes are supported for update queries");
|
|
66
|
+
const guardResult = fullGuard.table(tableNode.table, StatementType.Update, TableUsageContext.TableTopLevel);
|
|
67
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `UPDATE denied on table ${((_a = tableNode.table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${tableNode.table.schema.name}.` : ""}${tableNode.table.identifier.name}`);
|
|
68
|
+
// Enforce column permissions
|
|
69
|
+
if (node.updates) {
|
|
70
|
+
for (const columnUpdateNode of node.updates) {
|
|
71
|
+
const column = columnUpdateNode.column;
|
|
72
|
+
const guardResult = fullGuard.column(tableNode.table, column.column, StatementType.Update, ColumnUsageContext.ColumnInUpdateSet);
|
|
73
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `UPDATE denied on column ${((_b = tableNode.table.schema) === null || _b === void 0 ? void 0 : _b.name)
|
|
74
|
+
? `${tableNode.table.schema.name}.`
|
|
75
|
+
: ""}${tableNode.table.identifier.name}.${column.column.name}`);
|
|
76
|
+
if (guardResult === exports.Omit) {
|
|
77
|
+
throw new Error(`Omit is not supported in update set: got Omit for ${column.column.name}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Must be allow
|
|
82
|
+
return super.transformUpdateQuery(node);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Enforce insert on a table
|
|
86
|
+
* - enforces whether the table is allowed to be inserted into
|
|
87
|
+
*
|
|
88
|
+
* Enforcement of returning limitations is handled in transformReturning
|
|
89
|
+
*/
|
|
90
|
+
transformInsertQuery(node) {
|
|
91
|
+
var _a, _b;
|
|
92
|
+
const tableNode = node.into;
|
|
93
|
+
const columns = node.columns;
|
|
94
|
+
const guardResult = fullGuard.table(tableNode.table, StatementType.Insert, TableUsageContext.TableTopLevel);
|
|
95
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `INSERT denied on table ${((_a = tableNode.table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${tableNode.table.schema.name}.` : ""}${tableNode.table.identifier.name}`);
|
|
96
|
+
// Skip column enforcement if there are none
|
|
97
|
+
if (columns === undefined) {
|
|
98
|
+
return super.transformInsertQuery(node);
|
|
99
|
+
}
|
|
100
|
+
const transformedColumns = [];
|
|
101
|
+
for (const column of columns) {
|
|
102
|
+
const guardResult = fullGuard.column(tableNode.table, column.column, StatementType.Insert, ColumnUsageContext.ColumnInInsert);
|
|
103
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `INSERT denied on column ${((_b = tableNode.table.schema) === null || _b === void 0 ? void 0 : _b.name)
|
|
104
|
+
? `${tableNode.table.schema.name}.`
|
|
105
|
+
: ""}${tableNode.table.identifier.name}.${column.column.name}`);
|
|
106
|
+
if (guardResult === exports.Omit) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
transformedColumns.push(column);
|
|
110
|
+
}
|
|
111
|
+
return super.transformInsertQuery(Object.assign(Object.assign({}, node), { columns: transformedColumns }));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Handles enforcement of column permissions in returning
|
|
115
|
+
* for insert/update/delete
|
|
116
|
+
*/
|
|
117
|
+
transformReturning(node) {
|
|
118
|
+
// Check whether it's insert, update, or delete via node stack
|
|
119
|
+
const parentNode = this.getParentNode();
|
|
120
|
+
const mode = kysely_1.InsertQueryNode.is(parentNode)
|
|
121
|
+
? StatementType.Insert
|
|
122
|
+
: kysely_1.UpdateQueryNode.is(parentNode)
|
|
123
|
+
? StatementType.Update
|
|
124
|
+
: kysely_1.DeleteQueryNode.is(parentNode)
|
|
125
|
+
? StatementType.Delete
|
|
126
|
+
: undefined;
|
|
127
|
+
(0, tiny_invariant_1.default)(mode !== undefined, `kysely-access-control: returning must be used with insert, update, or delete. kind was ${parentNode.kind}`);
|
|
128
|
+
const { selections } = node;
|
|
129
|
+
const [statementType, tableNode] = kysely_1.InsertQueryNode.is(parentNode)
|
|
130
|
+
? [StatementType.Insert, parentNode.into]
|
|
131
|
+
: kysely_1.UpdateQueryNode.is(parentNode)
|
|
132
|
+
? [StatementType.Update, parentNode.table]
|
|
133
|
+
: kysely_1.DeleteQueryNode.is(parentNode)
|
|
134
|
+
? [StatementType.Delete, parentNode.from.froms[0]]
|
|
135
|
+
: [undefined, undefined];
|
|
136
|
+
// Only inserting into a table is supported
|
|
137
|
+
(0, tiny_invariant_1.default)(statementType !== undefined, "kysely-access-control: currently only insert/update/delete returning is supported");
|
|
138
|
+
(0, tiny_invariant_1.default)(kysely_1.TableNode.is(tableNode), "kysely-access-control: currently only update/delete from a table");
|
|
139
|
+
const transformedSelections = this._transformSelections(selections.slice(), tableNode, false, statementType);
|
|
140
|
+
const transformedNode = Object.assign(Object.assign({}, node), { selections: transformedSelections });
|
|
141
|
+
return super.transformReturning(transformedNode);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Enforce delete on a table
|
|
145
|
+
* - enforces whether the table is allowed to be deleted from
|
|
146
|
+
*/
|
|
147
|
+
transformDeleteQuery(node) {
|
|
148
|
+
var _a;
|
|
149
|
+
// Ensure only 1 from and that its a table
|
|
150
|
+
(0, tiny_invariant_1.default)(node.from.froms.length === 1, "kysely-access-control: can only delete from one table at a time");
|
|
151
|
+
const tableNode = node.from.froms[0];
|
|
152
|
+
(0, tiny_invariant_1.default)(kysely_1.TableNode.is(tableNode), "kysely-access-control: can only delete from tables");
|
|
153
|
+
const guardResult = fullGuard.table(tableNode.table, StatementType.Delete, TableUsageContext.TableTopLevel);
|
|
154
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `DELETE denied on table ${((_a = tableNode.table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${tableNode.table.schema.name}.` : ""}${tableNode.table.identifier.name}`);
|
|
155
|
+
// Must be allow - TODO add RLS
|
|
156
|
+
return super.transformDeleteQuery(node);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* In transformSelectQuery, we:
|
|
160
|
+
* - throw if any columns are selected that shouldn't be
|
|
161
|
+
* - omit any columns we should omit (throwing if selectAll is used)
|
|
162
|
+
*/
|
|
163
|
+
transformSelectQuery(node) {
|
|
164
|
+
var _a;
|
|
165
|
+
const { from: fromNode, selections, joins, where } = node;
|
|
166
|
+
if (!fromNode) {
|
|
167
|
+
// This covers queries such as select 1, or select following only by subselects
|
|
168
|
+
// We do nothing here
|
|
169
|
+
return super.transformSelectQuery(node);
|
|
170
|
+
}
|
|
171
|
+
(0, tiny_invariant_1.default)(fromNode.froms.length === 1, "kysely-access-control: there must be exactly one from node when not joining");
|
|
172
|
+
const tableNode = fromNode.froms[0];
|
|
173
|
+
(0, tiny_invariant_1.default)(kysely_1.TableNode.is(tableNode), "kysely-access-control: currently only select from table/view is supported");
|
|
174
|
+
(0, tiny_invariant_1.default)(selections !== undefined, "kysely-access-control: selections should be defined");
|
|
175
|
+
const table = tableNode.table;
|
|
176
|
+
const guardResult = fullGuard.table(table, StatementType.Select, TableUsageContext.TableTopLevel);
|
|
177
|
+
(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}`);
|
|
178
|
+
/* COLUMN ENFORCEMENT */
|
|
179
|
+
// Some selected columns include a table, some don't
|
|
180
|
+
// If there's no joins and therefore only one valid relation to reference
|
|
181
|
+
// we can assume that the column's table is the same as the fromNode's table
|
|
182
|
+
//
|
|
183
|
+
// If there is a join, we require that the user specifies the table
|
|
184
|
+
// Even though kysely's type system and SQL engines can resolve the reference,
|
|
185
|
+
// We cannot
|
|
186
|
+
const hasJoin = joins !== undefined && joins.length > 0;
|
|
187
|
+
const transformedSelections = this._transformSelections(selections.slice(), tableNode, hasJoin, StatementType.Select);
|
|
188
|
+
const newNode = Object.assign(Object.assign({}, node), { selections: transformedSelections, where: this._transformWhere(guardResult, node.where) });
|
|
189
|
+
return super.transformSelectQuery(newNode);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Next 3 methods enforce table level permissions
|
|
193
|
+
* included Allow/Deny and row level permissions
|
|
194
|
+
* for the 3 different types of joins:
|
|
195
|
+
* - select * from x join y on x.key = y.key
|
|
196
|
+
* - update x from y where x.key = y.key
|
|
197
|
+
* - delete from x using y where x.key = y.key
|
|
198
|
+
*/
|
|
199
|
+
transformJoin(node) {
|
|
200
|
+
var _a;
|
|
201
|
+
const tableNode = node.table;
|
|
202
|
+
if (!kysely_1.TableNode.is(tableNode)) {
|
|
203
|
+
// If it's not a table node (it's an alias node with a subselect, etc.)
|
|
204
|
+
// Any enforcement needed will happen on those components
|
|
205
|
+
return super.transformJoin(node);
|
|
206
|
+
}
|
|
207
|
+
const guardResult = fullGuard.table(tableNode.table, StatementType.Select, TableUsageContext.TableInJoin);
|
|
208
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `JOIN denied on table ${((_a = tableNode.table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${tableNode.table.schema.name}.` : ""}${tableNode.table.identifier.name}`);
|
|
209
|
+
if (guardResult === exports.Allow) {
|
|
210
|
+
return super.transformJoin(node);
|
|
211
|
+
}
|
|
212
|
+
// If RLS is applied, replace the table node with a select node that has the RLS applied inline
|
|
213
|
+
// This means replacing the "table" with an AliasNode of a SelectQueryNode + identifier with the same name
|
|
214
|
+
// Fortunately, our top level transformSelectQueryBuilder will handle applying RLS
|
|
215
|
+
// We just need to transform it to a SelectQueryBuilder with an alias so that those
|
|
216
|
+
// transformations can happen
|
|
217
|
+
const newTable = kysely_1.AliasNode.create(kysely_1.SelectQueryNode.cloneWithSelections(kysely_1.SelectQueryNode.createFrom([tableNode]), [kysely_1.SelectionNode.createSelectAll()]), kysely_1.IdentifierNode.create(tableNode.table.identifier.name));
|
|
218
|
+
return super.transformJoin(Object.assign(Object.assign({}, node), { table: newTable }));
|
|
219
|
+
}
|
|
220
|
+
transformFrom(node) {
|
|
221
|
+
const parentNode = this.getParentNode();
|
|
222
|
+
if (!kysely_1.UpdateQueryNode.is(parentNode)) {
|
|
223
|
+
return super.transformFrom(node);
|
|
224
|
+
}
|
|
225
|
+
const newFroms = node.froms.map((from) => {
|
|
226
|
+
var _a;
|
|
227
|
+
if (!kysely_1.TableNode.is(from)) {
|
|
228
|
+
// Only guard tables - non tables (subselects) will be handled further down in
|
|
229
|
+
// the internal SelectQueryNode
|
|
230
|
+
return from;
|
|
231
|
+
}
|
|
232
|
+
const guardResult = fullGuard.table(from.table, StatementType.Update, TableUsageContext.TableInJoin);
|
|
233
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `JOIN denied on table ${((_a = from.table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${from.table.schema.name}.` : ""}${from.table.identifier.name}`);
|
|
234
|
+
if (guardResult === exports.Allow) {
|
|
235
|
+
return from;
|
|
236
|
+
}
|
|
237
|
+
// Must be an RLS case
|
|
238
|
+
// Again, don't worry about the where clauses
|
|
239
|
+
// those will be handled by the internal SelectQueryNode
|
|
240
|
+
return kysely_1.AliasNode.create(kysely_1.SelectQueryNode.cloneWithSelections(kysely_1.SelectQueryNode.createFrom([from]), [kysely_1.SelectionNode.createSelectAll()]), kysely_1.IdentifierNode.create(from.table.identifier.name));
|
|
241
|
+
});
|
|
242
|
+
return super.transformFrom(Object.assign(Object.assign({}, node), { froms: newFroms }));
|
|
243
|
+
}
|
|
244
|
+
transformUsing(node) {
|
|
245
|
+
const parentNode = this.getParentNode();
|
|
246
|
+
if (!kysely_1.DeleteQueryNode.is(parentNode)) {
|
|
247
|
+
return super.transformUsing(node);
|
|
248
|
+
}
|
|
249
|
+
const newTables = node.tables.map((table) => {
|
|
250
|
+
var _a;
|
|
251
|
+
if (!kysely_1.TableNode.is(table)) {
|
|
252
|
+
// Only guard tables - non tables (subselects) will be handled further down in
|
|
253
|
+
// the internal SelectQueryNode
|
|
254
|
+
return table;
|
|
255
|
+
}
|
|
256
|
+
const guardResult = fullGuard.table(table.table, StatementType.Delete, TableUsageContext.TableInJoin);
|
|
257
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `JOIN denied on table ${((_a = table.table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${table.table.schema.name}.` : ""}${table.table.identifier.name}`);
|
|
258
|
+
if (guardResult === exports.Allow) {
|
|
259
|
+
return table;
|
|
260
|
+
}
|
|
261
|
+
// Must be an RLS case
|
|
262
|
+
// Again, don't worry about the where clauses
|
|
263
|
+
// those will be handled by the internal SelectQueryNode
|
|
264
|
+
return kysely_1.AliasNode.create(kysely_1.SelectQueryNode.cloneWithSelections(kysely_1.SelectQueryNode.createFrom([table]), [kysely_1.SelectionNode.createSelectAll()]), kysely_1.IdentifierNode.create(table.table.identifier.name));
|
|
265
|
+
});
|
|
266
|
+
return super.transformUsing(Object.assign(Object.assign({}, node), { tables: newTables }));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Enforce column permissions in update set clause
|
|
270
|
+
*/
|
|
271
|
+
// protected transform
|
|
272
|
+
/**
|
|
273
|
+
* Enforce column permissions in where clause
|
|
274
|
+
* These are always wrapped in a reference node
|
|
275
|
+
*
|
|
276
|
+
* Reference nodes are also used in select statements, so we return early
|
|
277
|
+
* if we're not in a recursion with a WhereNode parent
|
|
278
|
+
*/
|
|
279
|
+
transformReference(node) {
|
|
280
|
+
var _a;
|
|
281
|
+
const isAChildOfWhere = this.isAChildOf(kysely_1.WhereNode);
|
|
282
|
+
const isAChildOfJoin = this.isAChildOf(kysely_1.JoinNode);
|
|
283
|
+
if (!isAChildOfWhere && !isAChildOfJoin) {
|
|
284
|
+
return super.transformReference(node);
|
|
285
|
+
}
|
|
286
|
+
// If it's a child of where, then it's a column reference
|
|
287
|
+
// being used in a filter statement, so we call the guard with those parameters
|
|
288
|
+
// However, the table may not be specified, and so we need to search up the stack
|
|
289
|
+
// to something that has the table specified
|
|
290
|
+
// The entity with the specified table should be the one that is the parent of the where node
|
|
291
|
+
// but it could be an insert/update/delete or select statement
|
|
292
|
+
// TODO - we're calling the table with the wrong column here because there could be a top level join
|
|
293
|
+
// Need to refactor this to up front decide if the table specified is required
|
|
294
|
+
let tableNode;
|
|
295
|
+
const tableNodeSpecifiedWithColumn = node.table;
|
|
296
|
+
if (!tableNodeSpecifiedWithColumn) {
|
|
297
|
+
// If it's a child of join, we need the table specified
|
|
298
|
+
// It can't be inferred
|
|
299
|
+
if (!isAChildOfWhere) {
|
|
300
|
+
throw new Error("kysely-access-control: could not find table node for column reference in join");
|
|
301
|
+
}
|
|
302
|
+
const reversedStack = this.nodeStack.slice().reverse();
|
|
303
|
+
const idxOfWhere = reversedStack.findIndex((node) => kysely_1.WhereNode.is(node));
|
|
304
|
+
const idxOfParent = idxOfWhere + 1;
|
|
305
|
+
const parentOfWhere = reversedStack[idxOfParent];
|
|
306
|
+
(0, tiny_invariant_1.default)(parentOfWhere !== undefined, "kysely-access-control: could not find parent of where node");
|
|
307
|
+
const hasJoins = this._topLevelHasMoreThanOneTable(parentOfWhere);
|
|
308
|
+
if (hasJoins) {
|
|
309
|
+
throw new Error("kysely-access-control: if joins are present, each column reference in where must specify the table");
|
|
310
|
+
}
|
|
311
|
+
const foundTableNode = this._getTableNodeFromTopLevelQueryNode(parentOfWhere);
|
|
312
|
+
(0, tiny_invariant_1.default)(foundTableNode !== undefined, "kysely-access-control: could not find table node for column reference in filter statement");
|
|
313
|
+
(0, tiny_invariant_1.default)(kysely_1.TableNode.is(foundTableNode), "kysely-access-control: node for column reference in filter statement must be a table node");
|
|
314
|
+
tableNode = foundTableNode;
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
tableNode = tableNodeSpecifiedWithColumn;
|
|
318
|
+
}
|
|
319
|
+
const columnNode = node.column;
|
|
320
|
+
(0, tiny_invariant_1.default)(kysely_1.ColumnNode.is(columnNode), "kysely-access-control: select all in filter statement is not supported");
|
|
321
|
+
const guardResult = fullGuard.column(tableNode.table, columnNode.column, StatementType.Select, ColumnUsageContext.ColumnInWhereOrJoin);
|
|
322
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `FILTER denied on column ${((_a = tableNode.table.schema) === null || _a === void 0 ? void 0 : _a.name) ? `${tableNode.table.schema.name}.` : ""}${tableNode.table.identifier.name}.${columnNode.column.name}`);
|
|
323
|
+
// Must be allow now
|
|
324
|
+
return super.transformReference(node);
|
|
325
|
+
}
|
|
326
|
+
/*
|
|
327
|
+
* From here on down there are utility methods that are not directly called by the Kysely plugin machinery
|
|
328
|
+
*/
|
|
329
|
+
/**
|
|
330
|
+
* Get whether an SelectQueryNode, UpdateQueryNode, or DeleteQueryNode has more than one table in reference scope
|
|
331
|
+
* for select and where clauses
|
|
332
|
+
*/
|
|
333
|
+
_topLevelHasMoreThanOneTable(node) {
|
|
334
|
+
if (kysely_1.UpdateQueryNode.is(node)) {
|
|
335
|
+
const fromJoin = node.from;
|
|
336
|
+
if (fromJoin && fromJoin.froms.length > 0) {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
if (kysely_1.SelectQueryNode.is(node)) {
|
|
342
|
+
const join = node.joins;
|
|
343
|
+
return !!join && join.length > 0;
|
|
344
|
+
}
|
|
345
|
+
if (kysely_1.DeleteQueryNode.is(node)) {
|
|
346
|
+
const using = node.using;
|
|
347
|
+
if (using && using.tables && using.tables.length > 0) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
throw new Error("_topLevelHasMoreThanOneTable called with something that is not a select, update, or delete query");
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Get the table node for a top level query type (select, update, delete, or insert)
|
|
356
|
+
*/
|
|
357
|
+
_getTableNodeFromTopLevelQueryNode(node) {
|
|
358
|
+
if (kysely_1.UpdateQueryNode.is(node)) {
|
|
359
|
+
(0, tiny_invariant_1.default)(node.table !== undefined && kysely_1.TableNode.is(node.table), "kysely-access-control: update query must have a table");
|
|
360
|
+
return node.table;
|
|
361
|
+
}
|
|
362
|
+
if (kysely_1.SelectQueryNode.is(node)) {
|
|
363
|
+
(0, tiny_invariant_1.default)(node.from !== undefined && node.from.froms.length === 1, "kysely-access-control: select query must have exactly one from");
|
|
364
|
+
(0, tiny_invariant_1.default)(kysely_1.TableNode.is(node.from.froms[0]), "kysely-access-control: select query must have a table");
|
|
365
|
+
return node.from.froms[0];
|
|
366
|
+
}
|
|
367
|
+
if (kysely_1.DeleteQueryNode.is(node)) {
|
|
368
|
+
(0, tiny_invariant_1.default)(node.from !== undefined && node.from.froms.length === 1, "kysely-access-control: delete query must have exactly one from");
|
|
369
|
+
(0, tiny_invariant_1.default)(kysely_1.TableNode.is(node.from.froms[0]), "kysely-access-control: delete query must have a table");
|
|
370
|
+
return node.from.froms[0];
|
|
371
|
+
}
|
|
372
|
+
if (kysely_1.InsertQueryNode.is(node)) {
|
|
373
|
+
(0, tiny_invariant_1.default)(kysely_1.TableNode.is(node.into), "kysely-access-control: insert query must have a table");
|
|
374
|
+
return node.into;
|
|
375
|
+
}
|
|
376
|
+
throw new Error("_getTopLevelTableNode called with something that is not a select, update, delete, or insert query");
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Common utility used in transformSelectQuery, transformReturning, etc.
|
|
380
|
+
* that enforces column select permissions
|
|
381
|
+
*/
|
|
382
|
+
_transformSelections(selections, tableNode, scopedHasMoreThanOneTable, statementType) {
|
|
383
|
+
var _a;
|
|
384
|
+
const transformedSelections = [];
|
|
385
|
+
// We only allow a select all IF it's inside of a join
|
|
386
|
+
// otherwise, we require that the user specifies the columns
|
|
387
|
+
const selectAllIsAllowed = this.isAChildOf(kysely_1.JoinNode) ||
|
|
388
|
+
this.isAChildOf(kysely_1.FromNode) ||
|
|
389
|
+
this.isAChildOf(kysely_1.UsingNode);
|
|
390
|
+
if (selectAllIsAllowed) {
|
|
391
|
+
return selections.slice();
|
|
392
|
+
}
|
|
393
|
+
for (const selectionNode of selections) {
|
|
394
|
+
const { selection } = selectionNode;
|
|
395
|
+
// Handle SelectQueryNode selections (from jsonObjectFrom, jsonArrayFrom, etc.)
|
|
396
|
+
if (kysely_1.SelectQueryNode.is(selection)) {
|
|
397
|
+
// For subquery selections, recursively transform the subquery to apply access control
|
|
398
|
+
// The subquery transformation will be handled by the parent transformer
|
|
399
|
+
// We just need to ensure the subquery gets processed, so we pass it through
|
|
400
|
+
// The parent transformer will call transformSelectQuery on the subquery
|
|
401
|
+
transformedSelections.push(selectionNode);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
// Handle SelectAllNode selections (from selectAll())
|
|
405
|
+
if (selection.kind === "SelectAllNode") {
|
|
406
|
+
throw new Error("kysely-access-control: .selectAll() is not supported");
|
|
407
|
+
}
|
|
408
|
+
// Handle ReferenceNode selections (column references)
|
|
409
|
+
if (kysely_1.ReferenceNode.is(selection)) {
|
|
410
|
+
const { table: columnIncludedTableNode, column: columnNode } = selection;
|
|
411
|
+
(0, tiny_invariant_1.default)(columnNode.kind !== "SelectAllNode", "kysely-access-control: .selectAll() is not supported");
|
|
412
|
+
let tableNodeToUseForColumn = tableNode;
|
|
413
|
+
if (scopedHasMoreThanOneTable) {
|
|
414
|
+
(0, tiny_invariant_1.default)(columnIncludedTableNode !== undefined, `kysely-access-control: table must be specified for each column when joining - could not infer table for ${columnNode.column.name}`);
|
|
415
|
+
tableNodeToUseForColumn = columnIncludedTableNode;
|
|
416
|
+
}
|
|
417
|
+
const guardResult = fullGuard.column(tableNodeToUseForColumn.table, columnNode.column, statementType, ColumnUsageContext.ColumnInSelectOrReturning);
|
|
418
|
+
(0, exports.throwIfDenyWithReason)(guardResult, `SELECT denied on column ${((_a = tableNodeToUseForColumn.table.schema) === null || _a === void 0 ? void 0 : _a.name)
|
|
419
|
+
? `${tableNodeToUseForColumn.table.schema.name}.`
|
|
420
|
+
: ""}${tableNodeToUseForColumn.table.identifier.name}.${columnNode.column.name}`);
|
|
421
|
+
if (guardResult === exports.Omit) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
transformedSelections.push(selectionNode);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
// For other selection types (expressions, raw builders, etc.), allow them to pass through
|
|
428
|
+
// These will be transformed by the parent transformer and don't directly reference main table columns
|
|
429
|
+
transformedSelections.push(selectionNode);
|
|
430
|
+
}
|
|
431
|
+
return transformedSelections;
|
|
432
|
+
}
|
|
433
|
+
_transformWhere(guardResult, nodeWhere) {
|
|
434
|
+
if (guardResult === exports.Allow) {
|
|
435
|
+
return nodeWhere;
|
|
436
|
+
}
|
|
437
|
+
const guardWhereUnguarded = guardResult[0] === exports.Allow ? guardResult[1] : undefined;
|
|
438
|
+
(0, tiny_invariant_1.default)(guardWhereUnguarded !== undefined &&
|
|
439
|
+
typeof guardWhereUnguarded === "object" &&
|
|
440
|
+
"toOperationNode" in guardWhereUnguarded, "kysely-access-control: returned where must be an expression wrapper");
|
|
441
|
+
const guardWhere = kysely_1.WhereNode.create(guardWhereUnguarded.toOperationNode());
|
|
442
|
+
const newWhere = guardWhere && nodeWhere
|
|
443
|
+
? kysely_1.WhereNode.create(kysely_1.AndNode.create(guardWhere.where, nodeWhere.where))
|
|
444
|
+
: guardWhere || nodeWhere;
|
|
445
|
+
return super.transformWhere(newWhere);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const plugin = {
|
|
449
|
+
transformQuery: (args) => {
|
|
450
|
+
const transformer = new Transformer();
|
|
451
|
+
return transformer.transformNode(args.node);
|
|
452
|
+
},
|
|
453
|
+
transformResult: (args) => {
|
|
454
|
+
return Promise.resolve(args.result);
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
return plugin;
|
|
458
|
+
};
|
|
459
|
+
exports.createAccessControlPlugin = createAccessControlPlugin;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ExpressionBuilder, ExpressionWrapper, SqlBool } from "kysely";
|
|
2
|
+
import { ColumnUsageContext, StatementType } from "./kyselyAccessControl";
|
|
3
|
+
type GrantWithoutWhereClause<KyselyDatabase, TableName extends keyof KyselyDatabase> = {
|
|
4
|
+
table: TableName;
|
|
5
|
+
schema?: string;
|
|
6
|
+
for: "all" | "select" | "update" | "insert" | "delete";
|
|
7
|
+
columns?: (keyof KyselyDatabase[TableName])[];
|
|
8
|
+
};
|
|
9
|
+
type GrantWithWhereClause<KyselyDatabase, TableName extends keyof KyselyDatabase> = GrantWithoutWhereClause<KyselyDatabase, TableName> & {
|
|
10
|
+
where: (eb: ExpressionBuilder<KyselyDatabase, TableName>) => ExpressionWrapper<KyselyDatabase, TableName, SqlBool>;
|
|
11
|
+
whereType?: "permissive" | "restrictive";
|
|
12
|
+
};
|
|
13
|
+
export type Grant<KyselyDatabase, TableName extends keyof KyselyDatabase> = GrantWithWhereClause<KyselyDatabase, TableName> | GrantWithoutWhereClause<KyselyDatabase, TableName>;
|
|
14
|
+
export declare const createKyselyGrantGuard: <KyselyDatabase>(grants: Grant<KyselyDatabase, any>[]) => Partial<{
|
|
15
|
+
table: (table: Omit<import("kysely/dist/cjs/operation-node/schemable-identifier-node").SchemableIdentifierNode, "identifier"> & {
|
|
16
|
+
identifier: Omit<import("kysely").IdentifierNode, "name"> & {
|
|
17
|
+
name: never;
|
|
18
|
+
};
|
|
19
|
+
}, statementType: StatementType, tableUsageContext: import("./kyselyAccessControl").TableUsageContext) => "allow" | "deny" | ["deny", string] | ["allow", ExpressionWrapper<unknown, any, SqlBool>];
|
|
20
|
+
column: (table: Omit<import("kysely/dist/cjs/operation-node/schemable-identifier-node").SchemableIdentifierNode, "identifier"> & {
|
|
21
|
+
identifier: Omit<import("kysely").IdentifierNode, "name"> & {
|
|
22
|
+
name: never;
|
|
23
|
+
};
|
|
24
|
+
}, column: import("kysely").IdentifierNode, statementType: StatementType, columnUsageContext: ColumnUsageContext) => "allow" | "deny" | "omit" | ["deny", string];
|
|
25
|
+
}>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createKyselyGrantGuard = void 0;
|
|
4
|
+
const kysely_1 = require("kysely");
|
|
5
|
+
const kyselyAccessControl_1 = require("./kyselyAccessControl");
|
|
6
|
+
const isGrantWithWhereClause = (grant) => "where" in grant;
|
|
7
|
+
const createKyselyGrantGuard = (grants) => {
|
|
8
|
+
const guard = {
|
|
9
|
+
table: (table, statementType) => {
|
|
10
|
+
const allowGrants = grants.filter((grant) => {
|
|
11
|
+
var _a;
|
|
12
|
+
return ((grant.schema === undefined || grant.schema === ((_a = table.schema) === null || _a === void 0 ? void 0 : _a.name)) &&
|
|
13
|
+
grant.table === table.identifier.name &&
|
|
14
|
+
(grant.for === "all" || grant.for === statementType));
|
|
15
|
+
});
|
|
16
|
+
if (allowGrants.length === 0) {
|
|
17
|
+
return kyselyAccessControl_1.Deny;
|
|
18
|
+
}
|
|
19
|
+
// Now that we know we're allowing, we create all RLS
|
|
20
|
+
const grantsWithWheres = allowGrants.filter(isGrantWithWhereClause);
|
|
21
|
+
const grantsWithRestrictiveWheres = grantsWithWheres.filter((grant) => grant.whereType === "restrictive");
|
|
22
|
+
// Permissive is the default
|
|
23
|
+
const grantsWithPermissiveWheres = grantsWithWheres.filter((grant) => grant.whereType !== "restrictive");
|
|
24
|
+
if (grantsWithRestrictiveWheres.length === 0 &&
|
|
25
|
+
grantsWithPermissiveWheres.length === 0) {
|
|
26
|
+
return kyselyAccessControl_1.Allow;
|
|
27
|
+
}
|
|
28
|
+
if (grantsWithPermissiveWheres.length === 0 &&
|
|
29
|
+
grantsWithRestrictiveWheres.length > 0) {
|
|
30
|
+
// No rows will be returned - see https://www.postgresql.org/docs/current/sql-createpolicy.html
|
|
31
|
+
return [kyselyAccessControl_1.Allow, (0, kysely_1.expressionBuilder)().lit(false)];
|
|
32
|
+
}
|
|
33
|
+
const permissiveEbs = (0, kysely_1.expressionBuilder)().or(grantsWithPermissiveWheres.map((grant) => grant.where((0, kysely_1.expressionBuilder)())));
|
|
34
|
+
if (grantsWithRestrictiveWheres.length > 0) {
|
|
35
|
+
// And the or of the permissives and the and of the restrictives
|
|
36
|
+
return [
|
|
37
|
+
kyselyAccessControl_1.Allow,
|
|
38
|
+
(0, kysely_1.expressionBuilder)().and([
|
|
39
|
+
permissiveEbs,
|
|
40
|
+
(0, kysely_1.expressionBuilder)().and(grantsWithRestrictiveWheres.map((grant) => grant.where((0, kysely_1.expressionBuilder)()))),
|
|
41
|
+
]),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
return [kyselyAccessControl_1.Allow, permissiveEbs];
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
column: (table, column, statementType, columnUsageContext) => {
|
|
49
|
+
const allowGrant = grants.find((grant) => {
|
|
50
|
+
var _a;
|
|
51
|
+
const rightSchemaAndTableAndColumns = (grant.schema === undefined || grant.schema === ((_a = table.schema) === null || _a === void 0 ? void 0 : _a.name)) &&
|
|
52
|
+
grant.table === table.identifier.name &&
|
|
53
|
+
(grant.columns === undefined ||
|
|
54
|
+
(Array.isArray(grant.columns) &&
|
|
55
|
+
// TODO - retype this when column node is typed
|
|
56
|
+
grant.columns.includes(column.name)));
|
|
57
|
+
if (!rightSchemaAndTableAndColumns) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (grant.for === "all") {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (grant.for === kyselyAccessControl_1.StatementType.Select &&
|
|
64
|
+
[
|
|
65
|
+
kyselyAccessControl_1.ColumnUsageContext.ColumnInSelectOrReturning,
|
|
66
|
+
kyselyAccessControl_1.ColumnUsageContext.ColumnInWhereOrJoin,
|
|
67
|
+
].includes(columnUsageContext)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
if (grant.for === kyselyAccessControl_1.StatementType.Update &&
|
|
71
|
+
kyselyAccessControl_1.ColumnUsageContext.ColumnInUpdateSet === columnUsageContext) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (grant.for === kyselyAccessControl_1.StatementType.Insert &&
|
|
75
|
+
kyselyAccessControl_1.ColumnUsageContext.ColumnInInsert === columnUsageContext) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
});
|
|
80
|
+
if (!allowGrant) {
|
|
81
|
+
return kyselyAccessControl_1.Deny;
|
|
82
|
+
}
|
|
83
|
+
return kyselyAccessControl_1.Allow;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
return guard;
|
|
87
|
+
};
|
|
88
|
+
exports.createKyselyGrantGuard = createKyselyGrantGuard;
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sladkoff/kysely-access-control",
|
|
3
|
+
"main": "dist/index.js",
|
|
4
|
+
"types": "dist/index.d.ts",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"version": "0.0.6",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"compile": "tsc -p tsconfig.build.json"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"bun-types": "latest",
|
|
12
|
+
"typescript": "^5.2.2"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"typescript": "^5.0.0",
|
|
16
|
+
"kysely": "^0.26.3"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"tiny-invariant": "^1.3.1"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"require": "./dist/index.js",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git@github.com:ben-pr-p/kysely-access-control.git"
|
|
35
|
+
},
|
|
36
|
+
"author": "Ben Packer <ben.paul.ryan.packer@gmail.com>",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/ben-pr-p/kysely-access-control/issues"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/ben-pr-p/kysely-access-control"
|
|
42
|
+
}
|