@teamkeel/functions-runtime 0.321.1 → 0.322.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ModelAPI.js CHANGED
@@ -40,11 +40,9 @@ class ModelAPI {
40
40
  /**
41
41
  * @param {string} tableName The name of the table this API is for
42
42
  * @param {Function} defaultValues A function that returns the default values for a row in this table
43
- * @param {import("kysely").Kysely} db
44
43
  * @param {TableConfigMap} tableConfigMap
45
44
  */
46
- constructor(tableName, defaultValues, db, tableConfigMap = {}) {
47
- this._db = db || getDatabase();
45
+ constructor(tableName, defaultValues, tableConfigMap = {}) {
48
46
  this._defaultValues = defaultValues;
49
47
  this._tableName = tableName;
50
48
  this._tableConfigMap = tableConfigMap;
@@ -53,11 +51,12 @@ class ModelAPI {
53
51
 
54
52
  async create(values) {
55
53
  const name = spanName(this._modelName, "create");
54
+ const db = getDatabase();
56
55
 
57
56
  return tracing.withSpan(name, async (span) => {
58
57
  try {
59
58
  const defaults = this._defaultValues();
60
- const query = this._db
59
+ const query = db
61
60
  .insertInto(this._tableName)
62
61
  .values(
63
62
  snakeCaseObject({
@@ -79,9 +78,10 @@ class ModelAPI {
79
78
 
80
79
  async findOne(where = {}) {
81
80
  const name = spanName(this._modelName, "findOne");
81
+ const db = getDatabase();
82
82
 
83
83
  return tracing.withSpan(name, async (span) => {
84
- let builder = this._db
84
+ let builder = db
85
85
  .selectFrom(this._tableName)
86
86
  .distinctOn(`${this._tableName}.id`)
87
87
  .selectAll(this._tableName);
@@ -103,9 +103,10 @@ class ModelAPI {
103
103
 
104
104
  async findMany(where = {}) {
105
105
  const name = spanName(this._modelName, "findMany");
106
+ const db = getDatabase();
106
107
 
107
108
  return tracing.withSpan(name, async (span) => {
108
- let builder = this._db
109
+ let builder = db
109
110
  .selectFrom(this._tableName)
110
111
  .distinctOn(`${this._tableName}.id`)
111
112
  .selectAll(this._tableName);
@@ -124,9 +125,10 @@ class ModelAPI {
124
125
 
125
126
  async update(where, values) {
126
127
  const name = spanName(this._modelName, "update");
128
+ const db = getDatabase();
127
129
 
128
130
  return tracing.withSpan(name, async (span) => {
129
- let builder = this._db.updateTable(this._tableName).returningAll();
131
+ let builder = db.updateTable(this._tableName).returningAll();
130
132
 
131
133
  builder = builder.set(snakeCaseObject(values));
132
134
 
@@ -148,9 +150,10 @@ class ModelAPI {
148
150
 
149
151
  async delete(where) {
150
152
  const name = spanName(this._modelName, "delete");
153
+ const db = getDatabase();
151
154
 
152
155
  return tracing.withSpan(name, async (span) => {
153
- let builder = this._db.deleteFrom(this._tableName).returning(["id"]);
156
+ let builder = db.deleteFrom(this._tableName).returning(["id"]);
154
157
 
155
158
  const context = new QueryContext([this._tableName], this._tableConfigMap);
156
159
 
@@ -168,7 +171,9 @@ class ModelAPI {
168
171
  }
169
172
 
170
173
  where(where) {
171
- let builder = this._db
174
+ const db = getDatabase();
175
+
176
+ let builder = db
172
177
  .selectFrom(this._tableName)
173
178
  .distinctOn(`${this._tableName}.id`)
174
179
  .selectAll(this._tableName);
@@ -62,7 +62,6 @@ beforeEach(async () => {
62
62
  date: new Date("2022-01-01"),
63
63
  };
64
64
  },
65
- db,
66
65
  tableConfigMap
67
66
  );
68
67
 
@@ -73,7 +72,6 @@ beforeEach(async () => {
73
72
  id: KSUID.randomSync().string,
74
73
  };
75
74
  },
76
- db,
77
75
  tableConfigMap
78
76
  );
79
77
 
@@ -84,7 +82,6 @@ beforeEach(async () => {
84
82
  id: KSUID.randomSync().string,
85
83
  };
86
84
  },
87
- db,
88
85
  tableConfigMap
89
86
  );
90
87
  });
package/src/database.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { Kysely, PostgresDialect } = require("kysely");
2
+ const { AsyncLocalStorage } = require("async_hooks");
2
3
  const pg = require("pg");
3
4
 
4
5
  function mustEnv(key) {
@@ -25,8 +26,16 @@ function getDialect() {
25
26
  }
26
27
 
27
28
  let db = null;
29
+ const dbInstance = new AsyncLocalStorage();
28
30
 
31
+ // getDatabase will first check for an instance of Kysely in AsyncLocalStorage,
32
+ // otherwise it will create a new instance and reuse it..
29
33
  function getDatabase() {
34
+ let fromStore = dbInstance.getStore();
35
+ if (fromStore) {
36
+ return fromStore;
37
+ }
38
+
30
39
  if (db) {
31
40
  return db;
32
41
  }
@@ -38,4 +47,5 @@ function getDatabase() {
38
47
  return db;
39
48
  }
40
49
 
50
+ module.exports.dbInstance = dbInstance;
41
51
  module.exports.getDatabase = getDatabase;
@@ -3,15 +3,15 @@ const {
3
3
  createJSONRPCSuccessResponse,
4
4
  JSONRPCErrorCode,
5
5
  } = require("json-rpc-2.0");
6
-
7
- const { getDatabase } = require("./database");
6
+ const { getDatabase, dbInstance } = require("./database");
8
7
  const {
9
8
  PERMISSION_STATE,
9
+ Permissions,
10
10
  PermissionError,
11
11
  checkBuiltInPermissions,
12
+ permissionsApiInstance,
12
13
  } = require("./permissions");
13
14
  const { PROTO_ACTION_TYPES } = require("./consts");
14
-
15
15
  const { errorToJSONRPCResponse, RuntimeErrors } = require("./errors");
16
16
  const opentelemetry = require("@opentelemetry/api");
17
17
  const { getTracer, withSpan } = require("./tracing");
@@ -31,13 +31,8 @@ async function handleRequest(request, config) {
31
31
  // Wrapping span for the whole request
32
32
  return withSpan(request.method, async (span) => {
33
33
  try {
34
- const {
35
- createFunctionAPI,
36
- createContextAPI,
37
- functions,
38
- permissions,
39
- actionTypes,
40
- } = config;
34
+ const { createContextAPI, functions, permissionFns, actionTypes } =
35
+ config;
41
36
 
42
37
  if (!(request.method in functions)) {
43
38
  const message = `no corresponding function found for '${request.method}'`;
@@ -55,77 +50,85 @@ async function handleRequest(request, config) {
55
50
  // headers reference passed to custom function where object data can be modified
56
51
  const headers = new Headers();
57
52
 
58
- const db = getDatabase();
53
+ // The ctx argument passed into the custom function.
54
+ const ctx = createContextAPI({
55
+ responseHeaders: headers,
56
+ meta: request.meta,
57
+ });
59
58
 
60
- // We want to wrap the execution of the custom function in a transaction so that any call the user makes
61
- // to any of the model apis we provide to the custom function is processed in a transaction.
62
- // This is useful for permissions where we want to only proceed with database writes if all permission rules
63
- // have been validated.
64
- const result = await db.transaction().execute(async (transaction) => {
65
- const ctx = createContextAPI({
66
- responseHeaders: headers,
67
- meta: request.meta,
68
- });
69
- const api = createFunctionAPI({
70
- meta: request.meta,
71
- db: transaction,
72
- });
59
+ const permitted =
60
+ request.meta && request.meta.permissionState.status === "granted"
61
+ ? true
62
+ : null;
73
63
 
74
- const customFunction = functions[request.method];
75
-
76
- // Call the user's custom function!
77
- const fnResult = await customFunction(ctx, request.params, api);
78
-
79
- // api.permissions maintains an internal state of whether the current operation has been *explicitly* permitted/denied by the user in the course of their custom function, or if execution has already been permitted by a role based permission (evaluated in the main runtime).
80
- // we need to check that the final state is permitted or unpermitted. if it's not, then it means that the user has taken no explicit action to permit/deny
81
- // and therefore we default to checking the permissions defined in the schema automatically.
82
- switch (api.permissions.getState()) {
83
- case PERMISSION_STATE.PERMITTED:
84
- return fnResult;
85
- case PERMISSION_STATE.UNPERMITTED:
86
- throw new PermissionError(
87
- `Not permitted to access ${request.method}`
88
- );
89
- default:
90
- // unknown state, proceed with checking against the built in permissions in the schema
91
- const relevantPermissions = permissions[request.method];
92
-
93
- const actionType = actionTypes[request.method];
94
-
95
- const peakInsideTransaction =
96
- actionType === PROTO_ACTION_TYPES.CREATE;
97
-
98
- let rowsForPermissions = [];
99
- switch (actionType) {
100
- case PROTO_ACTION_TYPES.LIST:
101
- rowsForPermissions = fnResult;
102
-
103
- break;
104
- case PROTO_ACTION_TYPES.DELETE:
105
- rowsForPermissions = [{ id: fnResult }];
106
- break;
107
- default:
108
- rowsForPermissions = [fnResult];
109
- break;
110
- }
111
-
112
- // check will throw a PermissionError if a permission rule is invalid
113
- await checkBuiltInPermissions({
114
- rows: rowsForPermissions,
115
- permissions: relevantPermissions,
116
- // it is important that we pass db here as db represents the connection to the database
117
- // *outside* of the current transaction. Given that any changes inside of a transaction
118
- // are opaque to the outside, we can utilize this when running permission rules and then deciding to
119
- // rollback any changes if they do not pass. However, for creates we need to be able to 'peak' inside the transaction to read the created record, as this won't exist outside of the transaction.
120
- db: peakInsideTransaction ? transaction : db,
121
- ctx,
122
- functionName: request.method,
64
+ const db = getDatabase();
65
+ const permissions = new Permissions();
66
+
67
+ const result = await permissionsApiInstance.run(
68
+ { permitted: permitted },
69
+ () => {
70
+ // We want to wrap the execution of the custom function in a transaction so that any call the user makes
71
+ // to any of the model apis we provide to the custom function is processed in a transaction.
72
+ // This is useful for permissions where we want to only proceed with database writes if all permission rules
73
+ // have been validated.
74
+
75
+ return db.transaction().execute(async (transaction) => {
76
+ return dbInstance.run(transaction, async () => {
77
+ // Call the user's custom function!
78
+ const customFunction = functions[request.method];
79
+ const fnResult = await customFunction(ctx, request.params);
80
+
81
+ // api.permissions maintains an internal state of whether the current operation has been *explicitly* permitted/denied by the user in the course of their custom function, or if execution has already been permitted by a role based permission (evaluated in the main runtime).
82
+ // we need to check that the final state is permitted or unpermitted. if it's not, then it means that the user has taken no explicit action to permit/deny
83
+ // and therefore we default to checking the permissions defined in the schema automatically.
84
+ switch (permissions.getState()) {
85
+ case PERMISSION_STATE.PERMITTED:
86
+ return fnResult;
87
+ case PERMISSION_STATE.UNPERMITTED:
88
+ throw new PermissionError(
89
+ `Not permitted to access ${request.method}`
90
+ );
91
+ default:
92
+ // unknown state, proceed with checking against the built in permissions in the schema
93
+ const relevantPermissions = permissionFns[request.method];
94
+ const actionType = actionTypes[request.method];
95
+
96
+ const peakInsideTransaction =
97
+ actionType === PROTO_ACTION_TYPES.CREATE;
98
+
99
+ let rowsForPermissions = [];
100
+ switch (actionType) {
101
+ case PROTO_ACTION_TYPES.LIST:
102
+ rowsForPermissions = fnResult;
103
+ break;
104
+ case PROTO_ACTION_TYPES.DELETE:
105
+ rowsForPermissions = [{ id: fnResult }];
106
+ break;
107
+ default:
108
+ rowsForPermissions = [fnResult];
109
+ break;
110
+ }
111
+
112
+ // check will throw a PermissionError if a permission rule is invalid
113
+ await checkBuiltInPermissions({
114
+ rows: rowsForPermissions,
115
+ permissionFns: relevantPermissions,
116
+ // it is important that we pass db here as db represents the connection to the database
117
+ // *outside* of the current transaction. Given that any changes inside of a transaction
118
+ // are opaque to the outside, we can utilize this when running permission rules and then deciding to
119
+ // rollback any changes if they do not pass. However, for creates we need to be able to 'peak' inside the transaction to read the created record, as this won't exist outside of the transaction.
120
+ db: peakInsideTransaction ? transaction : db,
121
+ ctx,
122
+ functionName: request.method,
123
+ });
124
+
125
+ // If the built in permission check above doesn't throw, then it means that the request is permitted and we can continue returning the return value from the custom function out of the transaction
126
+ return fnResult;
127
+ }
123
128
  });
124
-
125
- // If the built in permission check above doesn't throw, then it means that the request is permitted and we can continue returning the return value from the custom function out of the transaction
126
- return fnResult;
129
+ });
127
130
  }
128
- });
131
+ );
129
132
 
130
133
  if (result === undefined) {
131
134
  // no result returned from custom function
@@ -4,9 +4,8 @@ import { handleRequest, RuntimeErrors } from "./handleRequest";
4
4
  import { test, expect, beforeEach, describe } from "vitest";
5
5
  import { ModelAPI } from "./ModelAPI";
6
6
  import { getDatabase } from "./database";
7
- import { Permissions } from "./permissions";
7
+ const { Permissions } = require("./permissions");
8
8
  import { PROTO_ACTION_TYPES } from "./consts";
9
-
10
9
  import KSUID from "ksuid";
11
10
 
12
11
  process.env.KEEL_DB_CONN_TYPE = "pg";
@@ -15,8 +14,9 @@ process.env.KEEL_DB_CONN = `postgresql://postgres:postgres@localhost:5432/functi
15
14
  test("when the custom function returns expected value", async () => {
16
15
  const config = {
17
16
  functions: {
18
- createPost: async (ctx, inputs, api) => {
19
- api.permissions.allow();
17
+ createPost: async (ctx, inputs) => {
18
+ new Permissions().allow();
19
+
20
20
  return {
21
21
  title: "a post",
22
22
  id: "abcde",
@@ -26,11 +26,6 @@ test("when the custom function returns expected value", async () => {
26
26
  actionTypes: {
27
27
  createPost: PROTO_ACTION_TYPES.CREATE,
28
28
  },
29
- createFunctionAPI: ({ db }) => {
30
- return {
31
- permissions: new Permissions(),
32
- };
33
- },
34
29
  createContextAPI: () => {},
35
30
  };
36
31
 
@@ -52,19 +47,14 @@ test("when the custom function returns expected value", async () => {
52
47
  test("when the custom function doesnt return a value", async () => {
53
48
  const config = {
54
49
  functions: {
55
- createPost: async (ctx, inputs, api) => {
56
- api.permissions.allow();
50
+ createPost: async (ctx, inputs) => {
51
+ new Permissions().allow();
57
52
  },
58
53
  },
59
54
  permissions: {},
60
55
  actionTypes: {
61
56
  createPost: PROTO_ACTION_TYPES.CREATE,
62
57
  },
63
- createFunctionAPI: ({ db }) => {
64
- return {
65
- permissions: new Permissions(),
66
- };
67
- },
68
58
  createContextAPI: () => {},
69
59
  };
70
60
 
@@ -83,16 +73,11 @@ test("when the custom function doesnt return a value", async () => {
83
73
  test("when there is no matching function for the path", async () => {
84
74
  const config = {
85
75
  functions: {
86
- createPost: async (ctx, inputs, api) => {},
76
+ createPost: async (ctx, inputs) => {},
87
77
  },
88
78
  actionTypes: {
89
79
  createPost: PROTO_ACTION_TYPES.CREATE,
90
80
  },
91
- createFunctionAPI: ({ db }) => {
92
- return {
93
- permissions: new Permissions(),
94
- };
95
- },
96
81
  createContextAPI: () => {},
97
82
  };
98
83
 
@@ -111,20 +96,13 @@ test("when there is no matching function for the path", async () => {
111
96
  test("when there is an unexpected error in the custom function", async () => {
112
97
  const config = {
113
98
  functions: {
114
- createPost: async (ctx, inputs, api) => {
115
- api.permissions.allow();
116
-
99
+ createPost: async (ctx, inputs) => {
117
100
  throw new Error("oopsie daisy");
118
101
  },
119
102
  },
120
103
  actionTypes: {
121
104
  createPost: PROTO_ACTION_TYPES.CREATE,
122
105
  },
123
- createFunctionAPI: ({ db }) => {
124
- return {
125
- permissions: new Permissions(),
126
- };
127
- },
128
106
  createContextAPI: () => {},
129
107
  };
130
108
 
@@ -152,16 +130,16 @@ test("when a role based permission has already been granted by the main runtime"
152
130
  actionTypes: {
153
131
  createPost: PROTO_ACTION_TYPES.CREATE,
154
132
  },
155
- createFunctionAPI: ({ db }) => {
156
- return {
157
- permissions: new Permissions({ status: "granted", reason: "role" }),
158
- };
159
- },
133
+ createModelAPI: () => {},
160
134
  createContextAPI: () => {},
161
135
  };
162
136
 
163
- const rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
137
+ let rpcReq = createJSONRPCRequest("123", "createPost", { title: "a post" });
164
138
 
139
+ Object.assign(rpcReq, {
140
+ ...rpcReq,
141
+ meta: { permissionState: { status: "granted", reason: "role" } },
142
+ });
165
143
  expect(await handleRequest(rpcReq, config)).toEqual({
166
144
  id: "123",
167
145
  jsonrpc: "2.0",
@@ -177,19 +155,13 @@ test("when a role based permission has already been granted by the main runtime"
177
155
  test("when there is an unexpected object thrown in the custom function", async () => {
178
156
  const config = {
179
157
  functions: {
180
- createPost: async (ctx, inputs, api) => {
181
- api.permissions.allow();
158
+ createPost: async (ctx, inputs) => {
182
159
  throw { err: "oopsie daisy" };
183
160
  },
184
161
  },
185
162
  actionTypes: {
186
163
  createPost: PROTO_ACTION_TYPES.CREATE,
187
164
  },
188
- createFunctionAPI: ({ db }) => {
189
- return {
190
- permissions: new Permissions(),
191
- };
192
- },
193
165
  createContextAPI: () => {},
194
166
  };
195
167
 
@@ -238,47 +210,44 @@ describe("ModelAPI error handling", () => {
238
210
  INSERT INTO author (id, name) VALUES ('adam', 'adam bull')
239
211
  `.execute(db);
240
212
 
213
+ const models = {
214
+ post: new ModelAPI(
215
+ "post",
216
+ () => {
217
+ return {
218
+ id: KSUID.randomSync().string,
219
+ };
220
+ },
221
+ {
222
+ post: {
223
+ author: {
224
+ relationshipType: "belongsTo",
225
+ foreignKey: "author_id",
226
+ referencesTable: "person",
227
+ },
228
+ },
229
+ }
230
+ ),
231
+ };
232
+
241
233
  functionConfig = {
242
- permissions: {},
234
+ permissionFns: {},
243
235
  functions: {
244
- createPost: async (ctx, inputs, api) => {
245
- api.permissions.allow();
236
+ createPost: async (ctx, inputs) => {
237
+ new Permissions().allow();
246
238
 
247
- const post = await api.models.post.create(inputs);
239
+ const post = await models.post.create(inputs);
248
240
 
249
241
  return post;
250
242
  },
251
- deletePost: async (ctx, inputs, api) => {
252
- api.permissions.allow();
243
+ deletePost: async (ctx, inputs) => {
244
+ new Permissions().allow();
253
245
 
254
- const deleted = await api.models.post.delete(inputs);
246
+ const deleted = await models.post.delete(inputs);
255
247
 
256
248
  return deleted;
257
249
  },
258
250
  },
259
- createFunctionAPI: ({ db }) => ({
260
- permissions: new Permissions(),
261
- models: {
262
- post: new ModelAPI(
263
- "post",
264
- () => {
265
- return {
266
- id: KSUID.randomSync().string,
267
- };
268
- },
269
- db,
270
- {
271
- post: {
272
- author: {
273
- relationshipType: "belongsTo",
274
- foreignKey: "author_id",
275
- referencesTable: "person",
276
- },
277
- },
278
- }
279
- ),
280
- },
281
- }),
282
251
  createContextAPI: () => ({}),
283
252
  };
284
253
  });
@@ -1,3 +1,5 @@
1
+ const { AsyncLocalStorage } = require("async_hooks");
2
+
1
3
  class PermissionError extends Error {}
2
4
 
3
5
  const PERMISSION_STATE = {
@@ -6,45 +8,34 @@ const PERMISSION_STATE = {
6
8
  UNPERMITTED: "unpermitted",
7
9
  };
8
10
 
9
- const defaultState = {
10
- status: "unknown",
11
- };
11
+ const permissionsApiInstance = new AsyncLocalStorage();
12
12
 
13
13
  class Permissions {
14
- // The permissionState here is the prior state passed in from the Go runtime
15
14
  // The Go runtime performs role based permission rule checks prior to calling the functions
16
15
  // 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
17
16
  // 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.
18
- constructor(permissionState = defaultState) {
19
- this.state = {
20
- // permitted starts off as null to indicate that the end user
21
- // hasn't explicitly marked a function execution as permitted yet
22
- permitted:
23
- permissionState !== null && permissionState.status === "granted"
24
- ? true
25
- : null,
26
- };
27
- }
28
17
 
29
18
  allow() {
30
- this.state.permitted = true;
19
+ const store = (permissionsApiInstance.getStore().permitted = true);
31
20
  }
32
21
 
33
22
  deny() {
34
23
  // if a user is explicitly calling deny() then we want to throw an error
35
24
  // so that any further execution of the custom function stops abruptly
36
- this.state.permitted = false;
25
+ permissionsApiInstance.getStore().permitted = false;
37
26
 
38
27
  throw new PermissionError();
39
28
  }
40
29
 
41
30
  getState() {
31
+ const permitted = permissionsApiInstance.getStore().permitted;
32
+
42
33
  switch (true) {
43
- case this.state.permitted === false:
34
+ case permitted === false:
44
35
  return PERMISSION_STATE.UNPERMITTED;
45
- case this.state.permitted === null:
36
+ case permitted === null:
46
37
  return PERMISSION_STATE.UNKNOWN;
47
- case this.state.permitted === true:
38
+ case permitted === true:
48
39
  return PERMISSION_STATE.PERMITTED;
49
40
  }
50
41
  }
@@ -52,12 +43,12 @@ class Permissions {
52
43
 
53
44
  const checkBuiltInPermissions = async ({
54
45
  rows,
55
- permissions,
46
+ permissionFns,
56
47
  ctx,
57
48
  db,
58
49
  functionName,
59
50
  }) => {
60
- for (const permissionFn of permissions) {
51
+ for (const permissionFn of permissionFns) {
61
52
  const result = await permissionFn(rows, ctx, db);
62
53
 
63
54
  // if any of the permission functions return true,
@@ -70,6 +61,7 @@ const checkBuiltInPermissions = async ({
70
61
  throw new PermissionError(`Not permitted to access ${functionName}`);
71
62
  };
72
63
 
64
+ module.exports.permissionsApiInstance = permissionsApiInstance;
73
65
  module.exports.checkBuiltInPermissions = checkBuiltInPermissions;
74
66
  module.exports.PermissionError = PermissionError;
75
67
  module.exports.PERMISSION_STATE = PERMISSION_STATE;