@teamkeel/functions-runtime 0.0.1
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/.env.test +2 -0
- package/README.md +3 -0
- package/compose.yaml +10 -0
- package/package.json +33 -0
- package/src/ModelAPI.js +229 -0
- package/src/ModelAPI.test.js +920 -0
- package/src/QueryBuilder.js +95 -0
- package/src/QueryContext.js +90 -0
- package/src/RequestHeaders.js +21 -0
- package/src/applyAdditionalQueryConstraints.js +22 -0
- package/src/applyJoins.js +65 -0
- package/src/applyWhereConditions.js +70 -0
- package/src/casing.js +30 -0
- package/src/casing.test.js +25 -0
- package/src/consts.js +13 -0
- package/src/database.js +163 -0
- package/src/errors.js +116 -0
- package/src/handleJob.js +100 -0
- package/src/handleJob.test.js +271 -0
- package/src/handleRequest.js +124 -0
- package/src/handleRequest.test.js +360 -0
- package/src/index.d.ts +86 -0
- package/src/index.js +27 -0
- package/src/permissions.js +78 -0
- package/src/permissions.test.js +120 -0
- package/src/tracing.js +135 -0
- package/src/tracing.test.js +119 -0
- package/src/tryExecuteFunction.js +74 -0
- package/vite.config.js +7 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createJSONRPCRequest, JSONRPCErrorCode } from "json-rpc-2.0";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
import { handleRequest, RuntimeErrors } from "./handleRequest";
|
|
4
|
+
import { test, expect, beforeEach, describe } from "vitest";
|
|
5
|
+
import { ModelAPI } from "./ModelAPI";
|
|
6
|
+
import { useDatabase } from "./database";
|
|
7
|
+
const { Permissions } = require("./permissions");
|
|
8
|
+
import { PROTO_ACTION_TYPES } from "./consts";
|
|
9
|
+
import KSUID from "ksuid";
|
|
10
|
+
|
|
11
|
+
test("when the custom function returns expected value", async () => {
|
|
12
|
+
const config = {
|
|
13
|
+
functions: {
|
|
14
|
+
createPost: async (ctx, inputs) => {
|
|
15
|
+
new Permissions().allow();
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
title: "a post",
|
|
19
|
+
id: "abcde",
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
actionTypes: {
|
|
24
|
+
createPost: PROTO_ACTION_TYPES.CREATE,
|
|
25
|
+
},
|
|
26
|
+
createContextAPI: () => {},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
|
|
30
|
+
|
|
31
|
+
expect(await handleRequest(rpcReq, config)).toEqual({
|
|
32
|
+
id: "123",
|
|
33
|
+
jsonrpc: "2.0",
|
|
34
|
+
meta: {
|
|
35
|
+
headers: {},
|
|
36
|
+
},
|
|
37
|
+
result: {
|
|
38
|
+
title: "a post",
|
|
39
|
+
id: "abcde",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("when the custom function doesnt return a value", async () => {
|
|
45
|
+
const config = {
|
|
46
|
+
functions: {
|
|
47
|
+
createPost: async (ctx, inputs) => {
|
|
48
|
+
new Permissions().allow();
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
permissions: {},
|
|
52
|
+
actionTypes: {
|
|
53
|
+
createPost: PROTO_ACTION_TYPES.CREATE,
|
|
54
|
+
},
|
|
55
|
+
createContextAPI: () => {},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
|
|
59
|
+
|
|
60
|
+
expect(await handleRequest(rpcReq, config)).toEqual({
|
|
61
|
+
id: "123",
|
|
62
|
+
jsonrpc: "2.0",
|
|
63
|
+
error: {
|
|
64
|
+
code: RuntimeErrors.NoResultError,
|
|
65
|
+
message: "no result returned from function 'createPost'",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("when there is no matching function for the path", async () => {
|
|
71
|
+
const config = {
|
|
72
|
+
functions: {
|
|
73
|
+
createPost: async (ctx, inputs) => {},
|
|
74
|
+
},
|
|
75
|
+
actionTypes: {
|
|
76
|
+
createPost: PROTO_ACTION_TYPES.CREATE,
|
|
77
|
+
},
|
|
78
|
+
createContextAPI: () => {},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const rpcReq = createJSONRPCRequest("123", "unknown", { title: "a post" });
|
|
82
|
+
|
|
83
|
+
expect(await handleRequest(rpcReq, config)).toEqual({
|
|
84
|
+
id: "123",
|
|
85
|
+
jsonrpc: "2.0",
|
|
86
|
+
error: {
|
|
87
|
+
code: JSONRPCErrorCode.MethodNotFound,
|
|
88
|
+
message: "no corresponding function found for 'unknown'",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("when there is an unexpected error in the custom function", async () => {
|
|
94
|
+
const config = {
|
|
95
|
+
functions: {
|
|
96
|
+
createPost: async (ctx, inputs) => {
|
|
97
|
+
throw new Error("oopsie daisy");
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
actionTypes: {
|
|
101
|
+
createPost: PROTO_ACTION_TYPES.CREATE,
|
|
102
|
+
},
|
|
103
|
+
createContextAPI: () => {},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
|
|
107
|
+
|
|
108
|
+
expect(await handleRequest(rpcReq, config)).toEqual({
|
|
109
|
+
id: "123",
|
|
110
|
+
jsonrpc: "2.0",
|
|
111
|
+
error: {
|
|
112
|
+
code: RuntimeErrors.UnknownError,
|
|
113
|
+
message: "oopsie daisy",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("when a role based permission has already been granted by the main runtime", async () => {
|
|
119
|
+
const config = {
|
|
120
|
+
functions: {
|
|
121
|
+
createPost: async (ctx, inputs, api) => {
|
|
122
|
+
return {
|
|
123
|
+
title: inputs.title,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
actionTypes: {
|
|
128
|
+
createPost: PROTO_ACTION_TYPES.CREATE,
|
|
129
|
+
},
|
|
130
|
+
createModelAPI: () => {},
|
|
131
|
+
createContextAPI: () => {},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
let rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
|
|
135
|
+
|
|
136
|
+
Object.assign(rpcReq, {
|
|
137
|
+
...rpcReq,
|
|
138
|
+
meta: { permissionState: { status: "granted", reason: "role" } },
|
|
139
|
+
});
|
|
140
|
+
expect(await handleRequest(rpcReq, config)).toEqual({
|
|
141
|
+
id: "123",
|
|
142
|
+
jsonrpc: "2.0",
|
|
143
|
+
result: {
|
|
144
|
+
title: "a post",
|
|
145
|
+
},
|
|
146
|
+
meta: {
|
|
147
|
+
headers: {},
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("when there is an unexpected object thrown in the custom function", async () => {
|
|
153
|
+
const config = {
|
|
154
|
+
functions: {
|
|
155
|
+
createPost: async (ctx, inputs) => {
|
|
156
|
+
throw { err: "oopsie daisy" };
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
actionTypes: {
|
|
160
|
+
createPost: PROTO_ACTION_TYPES.CREATE,
|
|
161
|
+
},
|
|
162
|
+
createContextAPI: () => {},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
|
|
166
|
+
|
|
167
|
+
expect(await handleRequest(rpcReq, config)).toEqual({
|
|
168
|
+
id: "123",
|
|
169
|
+
jsonrpc: "2.0",
|
|
170
|
+
error: {
|
|
171
|
+
code: RuntimeErrors.UnknownError,
|
|
172
|
+
message: '{"err":"oopsie daisy"}',
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// The following tests assert on the various
|
|
178
|
+
// jsonrpc responses that *should* happen when a user
|
|
179
|
+
// writes a custom function that inadvertently causes a pg constraint error to occur inside of our ModelAPI class instance.
|
|
180
|
+
describe("ModelAPI error handling", () => {
|
|
181
|
+
let functionConfig;
|
|
182
|
+
let db;
|
|
183
|
+
|
|
184
|
+
beforeEach(async () => {
|
|
185
|
+
process.env.KEEL_DB_CONN_TYPE = "pg";
|
|
186
|
+
process.env.KEEL_DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
|
|
187
|
+
|
|
188
|
+
db = useDatabase();
|
|
189
|
+
|
|
190
|
+
await sql`
|
|
191
|
+
DROP TABLE IF EXISTS post;
|
|
192
|
+
DROP TABLE IF EXISTS author;
|
|
193
|
+
|
|
194
|
+
CREATE TABLE author(
|
|
195
|
+
"id" text PRIMARY KEY,
|
|
196
|
+
"name" text NOT NULL
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
CREATE TABLE post(
|
|
200
|
+
"id" text PRIMARY KEY,
|
|
201
|
+
"title" text NOT NULL UNIQUE,
|
|
202
|
+
"author_id" text NOT NULL REFERENCES author(id)
|
|
203
|
+
);
|
|
204
|
+
`.execute(db);
|
|
205
|
+
|
|
206
|
+
await sql`
|
|
207
|
+
INSERT INTO author (id, name) VALUES ('adam', 'adam bull')
|
|
208
|
+
`.execute(db);
|
|
209
|
+
|
|
210
|
+
const models = {
|
|
211
|
+
post: new ModelAPI("post", undefined, {
|
|
212
|
+
post: {
|
|
213
|
+
author: {
|
|
214
|
+
relationshipType: "belongsTo",
|
|
215
|
+
foreignKey: "author_id",
|
|
216
|
+
referencesTable: "person",
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
functionConfig = {
|
|
223
|
+
permissionFns: {},
|
|
224
|
+
actionTypes: {
|
|
225
|
+
createPost: PROTO_ACTION_TYPES.CREATE,
|
|
226
|
+
deletePost: PROTO_ACTION_TYPES.DELETE,
|
|
227
|
+
},
|
|
228
|
+
functions: {
|
|
229
|
+
createPost: async (ctx, inputs) => {
|
|
230
|
+
new Permissions().allow();
|
|
231
|
+
|
|
232
|
+
const post = await models.post.create({
|
|
233
|
+
id: KSUID.randomSync().string,
|
|
234
|
+
...inputs,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
return post;
|
|
238
|
+
},
|
|
239
|
+
deletePost: async (ctx, inputs) => {
|
|
240
|
+
new Permissions().allow();
|
|
241
|
+
|
|
242
|
+
const deleted = await models.post.delete(inputs);
|
|
243
|
+
|
|
244
|
+
return deleted;
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
createContextAPI: () => ({}),
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("when kysely returns a no result error", async () => {
|
|
252
|
+
// a kysely NoResultError is thrown when attempting to delete/update a non existent record.
|
|
253
|
+
const rpcReq = createJSONRPCRequest("123", "deletePost", {
|
|
254
|
+
id: "non-existent-id",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
258
|
+
id: "123",
|
|
259
|
+
jsonrpc: "2.0",
|
|
260
|
+
error: {
|
|
261
|
+
code: RuntimeErrors.RecordNotFoundError,
|
|
262
|
+
message: "no result",
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("when there is a not null constraint error", async () => {
|
|
268
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: null });
|
|
269
|
+
|
|
270
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
271
|
+
id: "123",
|
|
272
|
+
jsonrpc: "2.0",
|
|
273
|
+
error: {
|
|
274
|
+
code: RuntimeErrors.NotNullConstraintError,
|
|
275
|
+
message: 'null value in column "title" violates not-null constraint',
|
|
276
|
+
data: {
|
|
277
|
+
code: "23502",
|
|
278
|
+
column: "title",
|
|
279
|
+
detail: expect.stringContaining("Failing row contains"),
|
|
280
|
+
table: "post",
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("when there is a uniqueness constraint error", async () => {
|
|
287
|
+
await sql`
|
|
288
|
+
|
|
289
|
+
INSERT INTO post (id, title, author_id) values(${
|
|
290
|
+
KSUID.randomSync().string
|
|
291
|
+
}, 'hello', 'adam')
|
|
292
|
+
`.execute(db);
|
|
293
|
+
|
|
294
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", {
|
|
295
|
+
title: "hello",
|
|
296
|
+
author_id: "something",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
300
|
+
id: "123",
|
|
301
|
+
jsonrpc: "2.0",
|
|
302
|
+
error: {
|
|
303
|
+
code: RuntimeErrors.UniqueConstraintError,
|
|
304
|
+
message:
|
|
305
|
+
'duplicate key value violates unique constraint "post_title_key"',
|
|
306
|
+
data: {
|
|
307
|
+
code: "23505",
|
|
308
|
+
column: "title",
|
|
309
|
+
detail: "Key (title)=(hello) already exists.",
|
|
310
|
+
table: "post",
|
|
311
|
+
value: "hello",
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("when there is a null value in a foreign key column", async () => {
|
|
318
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: "123" });
|
|
319
|
+
|
|
320
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
321
|
+
id: "123",
|
|
322
|
+
jsonrpc: "2.0",
|
|
323
|
+
error: {
|
|
324
|
+
code: RuntimeErrors.NotNullConstraintError,
|
|
325
|
+
message:
|
|
326
|
+
'null value in column "author_id" violates not-null constraint',
|
|
327
|
+
data: {
|
|
328
|
+
code: "23502",
|
|
329
|
+
column: "author_id",
|
|
330
|
+
detail: expect.stringContaining("Failing row contains"),
|
|
331
|
+
table: "post",
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("when there is a foreign key constraint violation", async () => {
|
|
338
|
+
const rpcReq2 = createJSONRPCRequest("123", "createPost", {
|
|
339
|
+
title: "123",
|
|
340
|
+
author_id: "fake",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(await handleRequest(rpcReq2, functionConfig)).toEqual({
|
|
344
|
+
id: "123",
|
|
345
|
+
jsonrpc: "2.0",
|
|
346
|
+
error: {
|
|
347
|
+
code: RuntimeErrors.ForeignKeyConstraintError,
|
|
348
|
+
message:
|
|
349
|
+
'insert or update on table "post" violates foreign key constraint "post_author_id_fkey"',
|
|
350
|
+
data: {
|
|
351
|
+
code: "23503",
|
|
352
|
+
column: "author_id",
|
|
353
|
+
detail: 'Key (author_id)=(fake) is not present in table "author".',
|
|
354
|
+
table: "post",
|
|
355
|
+
value: "fake",
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
});
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type IDWhereCondition = {
|
|
2
|
+
equals?: string | null;
|
|
3
|
+
oneOf?: string[] | null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type StringWhereCondition = {
|
|
7
|
+
startsWith?: string | null;
|
|
8
|
+
endsWith?: string | null;
|
|
9
|
+
oneOf?: string[] | null;
|
|
10
|
+
contains?: string | null;
|
|
11
|
+
notEquals?: string | null;
|
|
12
|
+
equals?: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type BooleanWhereCondition = {
|
|
16
|
+
equals?: boolean | null;
|
|
17
|
+
notEquals?: boolean | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type NumberWhereCondition = {
|
|
21
|
+
greaterThan?: number | null;
|
|
22
|
+
greaterThanOrEquals?: number | null;
|
|
23
|
+
lessThan?: number | null;
|
|
24
|
+
lessThanOrEquals?: number | null;
|
|
25
|
+
equals?: number | null;
|
|
26
|
+
notEquals?: number | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Date database API
|
|
30
|
+
export type DateWhereCondition = {
|
|
31
|
+
equals?: Date | string | null;
|
|
32
|
+
before?: Date | string | null;
|
|
33
|
+
onOrBefore?: Date | string | null;
|
|
34
|
+
after?: Date | string | null;
|
|
35
|
+
onOrAfter?: Date | string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Date query input
|
|
39
|
+
export type DateQueryInput = {
|
|
40
|
+
equals?: string | null;
|
|
41
|
+
before?: string | null;
|
|
42
|
+
onOrBefore?: string | null;
|
|
43
|
+
after?: string | null;
|
|
44
|
+
onOrAfter?: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Timestamp query input
|
|
48
|
+
export type TimestampQueryInput = {
|
|
49
|
+
before: string | null;
|
|
50
|
+
after: string | null;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type ContextAPI = {
|
|
54
|
+
headers: RequestHeaders;
|
|
55
|
+
response: Response;
|
|
56
|
+
isAuthenticated: boolean;
|
|
57
|
+
now(): Date;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type Response = {
|
|
61
|
+
headers: Headers;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type PageInfo = {
|
|
65
|
+
startCursor: string;
|
|
66
|
+
endCursor: string;
|
|
67
|
+
totalCount: number;
|
|
68
|
+
hasNextPage: boolean;
|
|
69
|
+
count: number;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Request headers query API
|
|
73
|
+
export type RequestHeaders = {
|
|
74
|
+
get(name: string): string;
|
|
75
|
+
has(name: string): boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export declare class Permissions {
|
|
79
|
+
constructor();
|
|
80
|
+
|
|
81
|
+
// allow() can be used to explicitly permit access to an action
|
|
82
|
+
allow(): void;
|
|
83
|
+
|
|
84
|
+
// deny() can be used to explicitly deny access to an action
|
|
85
|
+
deny(): never;
|
|
86
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const { ModelAPI } = require("./ModelAPI");
|
|
2
|
+
const { RequestHeaders } = require("./RequestHeaders");
|
|
3
|
+
const { handleRequest } = require("./handleRequest");
|
|
4
|
+
const { handleJob } = require("./handleJob");
|
|
5
|
+
const KSUID = require("ksuid");
|
|
6
|
+
const { useDatabase } = require("./database");
|
|
7
|
+
const {
|
|
8
|
+
Permissions,
|
|
9
|
+
PERMISSION_STATE,
|
|
10
|
+
checkBuiltInPermissions,
|
|
11
|
+
} = require("./permissions");
|
|
12
|
+
const tracing = require("./tracing");
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
ModelAPI,
|
|
16
|
+
RequestHeaders,
|
|
17
|
+
handleRequest,
|
|
18
|
+
handleJob,
|
|
19
|
+
useDatabase,
|
|
20
|
+
Permissions,
|
|
21
|
+
PERMISSION_STATE,
|
|
22
|
+
checkBuiltInPermissions,
|
|
23
|
+
tracing,
|
|
24
|
+
ksuid() {
|
|
25
|
+
return KSUID.randomSync().string;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const { AsyncLocalStorage } = require("async_hooks");
|
|
2
|
+
|
|
3
|
+
class PermissionError extends Error {}
|
|
4
|
+
|
|
5
|
+
const PERMISSION_STATE = {
|
|
6
|
+
UNKNOWN: "unknown",
|
|
7
|
+
PERMITTED: "permitted",
|
|
8
|
+
UNPERMITTED: "unpermitted",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// withPermissions sets the initial permission state from the go runtime in the AsyncLocalStorage so consumers further down the hierarchy can read or mutate the state
|
|
12
|
+
// at will
|
|
13
|
+
const withPermissions = async (initialValue, cb) => {
|
|
14
|
+
const permissions = new Permissions();
|
|
15
|
+
|
|
16
|
+
return await permissionsApiInstance.run({ permitted: initialValue }, () => {
|
|
17
|
+
return cb({ getPermissionState: permissions.getState });
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const permissionsApiInstance = new AsyncLocalStorage();
|
|
22
|
+
|
|
23
|
+
class Permissions {
|
|
24
|
+
// The Go runtime performs role based permission rule checks prior to calling the functions
|
|
25
|
+
// runtime, so the status could already be granted. If already granted, then we need to inherit that permission state as the state is later used to decide whether to run in process permission checks
|
|
26
|
+
// TLDR if a role based permission is relevant and it is granted, then it is effectively the same as the end user calling api.permissions.allow() explicitly in terms of behaviour.
|
|
27
|
+
|
|
28
|
+
allow() {
|
|
29
|
+
const store = (permissionsApiInstance.getStore().permitted = true);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
deny() {
|
|
33
|
+
// if a user is explicitly calling deny() then we want to throw an error
|
|
34
|
+
// so that any further execution of the custom function stops abruptly
|
|
35
|
+
permissionsApiInstance.getStore().permitted = false;
|
|
36
|
+
|
|
37
|
+
throw new PermissionError();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getState() {
|
|
41
|
+
const permitted = permissionsApiInstance.getStore().permitted;
|
|
42
|
+
|
|
43
|
+
switch (true) {
|
|
44
|
+
case permitted === false:
|
|
45
|
+
return PERMISSION_STATE.UNPERMITTED;
|
|
46
|
+
case permitted === null:
|
|
47
|
+
return PERMISSION_STATE.UNKNOWN;
|
|
48
|
+
case permitted === true:
|
|
49
|
+
return PERMISSION_STATE.PERMITTED;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const checkBuiltInPermissions = async ({
|
|
55
|
+
rows,
|
|
56
|
+
permissionFns,
|
|
57
|
+
ctx,
|
|
58
|
+
db,
|
|
59
|
+
functionName,
|
|
60
|
+
}) => {
|
|
61
|
+
for (const permissionFn of permissionFns) {
|
|
62
|
+
const result = await permissionFn(rows, ctx, db);
|
|
63
|
+
// if any of the permission functions return true,
|
|
64
|
+
// then we return early
|
|
65
|
+
if (result) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throw new PermissionError(`Not permitted to access ${functionName}`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
module.exports.checkBuiltInPermissions = checkBuiltInPermissions;
|
|
74
|
+
module.exports.PermissionError = PermissionError;
|
|
75
|
+
module.exports.PERMISSION_STATE = PERMISSION_STATE;
|
|
76
|
+
module.exports.Permissions = Permissions;
|
|
77
|
+
module.exports.withPermissions = withPermissions;
|
|
78
|
+
module.exports.permissionsApiInstance = permissionsApiInstance;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const {
|
|
2
|
+
permissionsApiInstance,
|
|
3
|
+
Permissions,
|
|
4
|
+
PERMISSION_STATE,
|
|
5
|
+
PermissionError,
|
|
6
|
+
checkBuiltInPermissions,
|
|
7
|
+
} = require("./permissions");
|
|
8
|
+
|
|
9
|
+
import { useDatabase } from "./database";
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
12
|
+
|
|
13
|
+
let permissions;
|
|
14
|
+
let ctx = {};
|
|
15
|
+
let db = useDatabase();
|
|
16
|
+
|
|
17
|
+
describe("explicit", () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
permissions = new Permissions();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("explicitly allowing execution", () => {
|
|
23
|
+
wrapWithAsyncLocalStorage({ permitted: null }, () => {
|
|
24
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.UNKNOWN);
|
|
25
|
+
|
|
26
|
+
permissions.allow();
|
|
27
|
+
|
|
28
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.PERMITTED);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("explicitly denying execution", () => {
|
|
33
|
+
wrapWithAsyncLocalStorage({ permitted: null }, () => {
|
|
34
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.UNKNOWN);
|
|
35
|
+
|
|
36
|
+
expect(() => permissions.deny()).toThrowError(PermissionError);
|
|
37
|
+
|
|
38
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("prior state", () => {
|
|
44
|
+
test("when the prior state is granted", () => {
|
|
45
|
+
wrapWithAsyncLocalStorage(
|
|
46
|
+
{
|
|
47
|
+
permitted: true,
|
|
48
|
+
},
|
|
49
|
+
() => {
|
|
50
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.PERMITTED);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("check", () => {
|
|
57
|
+
const functionName = "createPerson";
|
|
58
|
+
|
|
59
|
+
test("check - success", async () => {
|
|
60
|
+
const permissionRule1 = (records, ctx, db) => {
|
|
61
|
+
// Only allow names starting with Adam
|
|
62
|
+
return records.every((r) => r.name.startsWith("Adam"));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const rows = [
|
|
66
|
+
{
|
|
67
|
+
id: "123",
|
|
68
|
+
name: "Adam Bull",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "234",
|
|
72
|
+
name: "Adam Lambert",
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
await expect(
|
|
77
|
+
checkBuiltInPermissions({
|
|
78
|
+
rows,
|
|
79
|
+
ctx,
|
|
80
|
+
db,
|
|
81
|
+
functionName,
|
|
82
|
+
permissionFns: [permissionRule1],
|
|
83
|
+
})
|
|
84
|
+
).resolves.ok;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("check - failure", async () => {
|
|
88
|
+
// only allow Petes
|
|
89
|
+
const permissionRule1 = (records, ctx, db) => {
|
|
90
|
+
return records.every((r) => r.name === "Pete");
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const rows = [
|
|
94
|
+
{
|
|
95
|
+
id: "123",
|
|
96
|
+
name: "Adam", // this one will cause an error to be thrown because Adam is not Pete
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "234",
|
|
100
|
+
name: "Pete",
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
checkBuiltInPermissions({
|
|
106
|
+
rows,
|
|
107
|
+
ctx,
|
|
108
|
+
db,
|
|
109
|
+
functionName,
|
|
110
|
+
permissionFns: [permissionRule1],
|
|
111
|
+
})
|
|
112
|
+
).rejects.toThrow();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function wrapWithAsyncLocalStorage(initialState, testFn) {
|
|
117
|
+
permissionsApiInstance.run(initialState, () => {
|
|
118
|
+
testFn();
|
|
119
|
+
});
|
|
120
|
+
}
|