@teamkeel/functions-runtime 0.285.0 → 0.287.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamkeel/functions-runtime",
3
- "version": "0.285.0",
3
+ "version": "0.287.0",
4
4
  "description": "Internal package used by @teamkeel/sdk",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/errors.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { createJSONRPCErrorResponse } = require("json-rpc-2.0");
2
+ const { PermissionError } = require("./permissions");
2
3
 
3
4
  const RuntimeErrors = {
4
5
  // Catchall error type for unhandled execution errors during custom function
@@ -12,11 +13,22 @@ const RuntimeErrors = {
12
13
  ForeignKeyConstraintError: -32005,
13
14
  NotNullConstraintError: -32006,
14
15
  UniqueConstraintError: -32007,
16
+ PermissionError: -32008,
15
17
  };
16
18
 
17
19
  // errorToJSONRPCResponse transforms a JavaScript Error instance (or derivative) into a valid JSONRPC response object to pass back to the Keel runtime.
18
20
  function errorToJSONRPCResponse(request, e) {
19
21
  if (!e.error) {
22
+ // it isnt wrapped
23
+
24
+ if (e instanceof PermissionError) {
25
+ return createJSONRPCErrorResponse(
26
+ request.id,
27
+ RuntimeErrors.PermissionError,
28
+ e.message
29
+ );
30
+ }
31
+
20
32
  return createJSONRPCErrorResponse(
21
33
  request.id,
22
34
  RuntimeErrors.UnknownError,
@@ -24,9 +36,6 @@ function errorToJSONRPCResponse(request, e) {
24
36
  );
25
37
  }
26
38
  // we want to switch on instanceof but there is no way to do that in js, so best to check the constructor class of the error
27
-
28
- // todo: fuzzy matching on postgres errors from both rds-data-api and pg-protocol
29
-
30
39
  switch (e.error.constructor.name) {
31
40
  // Any error thrown in the ModelAPI class is
32
41
  // wrapped in a DatabaseError in order to differentiate 'our code' vs the user's own code.
@@ -4,6 +4,9 @@ const {
4
4
  JSONRPCErrorCode,
5
5
  } = require("json-rpc-2.0");
6
6
 
7
+ const { getDatabase } = require("./database");
8
+ const { PERMISSION_STATE, PermissionError } = require("./permissions");
9
+
7
10
  const { errorToJSONRPCResponse, RuntimeErrors } = require("./errors");
8
11
 
9
12
  // Generic handler function that is agnostic to runtime environment (local or lambda)
@@ -24,11 +27,36 @@ async function handleRequest(request, config) {
24
27
  // headers reference passed to custom function where object data can be modified
25
28
  const headers = new Headers();
26
29
 
27
- const result = await functions[request.method](
28
- request.params,
29
- createFunctionAPI(headers),
30
- createContextAPI(request.meta)
31
- );
30
+ const db = getDatabase();
31
+
32
+ // We want to wrap the execution of the custom function in a transaction so that any call the user makes
33
+ // to any of the model apis we provide to the custom function is processed in a transaction.
34
+ // This is useful for permissions where we want to only proceed with database writes if all permission rules
35
+ // have been validated.
36
+ const result = await db.transaction().execute(async (trx) => {
37
+ const api = createFunctionAPI({ headers, db: trx });
38
+ const ctx = createContextAPI(request.meta);
39
+
40
+ // Call the user's custom function!
41
+ const fnResult = await functions[request.method](
42
+ request.params,
43
+ api,
44
+ ctx
45
+ );
46
+
47
+ // api.permissions maintains an internal state of whether the current operation has been permitted either by the user or by built-in permission rules
48
+ // we need to check that the final state is permitted. if it's not, then we want to rollback
49
+ // the transaction
50
+ if (api.permissions.getState() !== PERMISSION_STATE.PERMITTED) {
51
+ // Any error thrown inside of Kysely's transaction execute() will cause the transaction to be rolled back.
52
+ // PermissionError is handled by our JSONRPC error serialisation code
53
+ throw new PermissionError(`Not permitted to access ${request.method}`);
54
+ } else {
55
+ // otherwise, if everything is permitted, then we just return the function result from
56
+ // the transaction closure.
57
+ return fnResult;
58
+ }
59
+ });
32
60
 
33
61
  if (result === undefined) {
34
62
  // no result returned from custom function
@@ -4,19 +4,29 @@ 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";
8
+
7
9
  import KSUID from "ksuid";
8
10
 
11
+ process.env.KEEL_DB_CONN_TYPE = "pg";
12
+ process.env.KEEL_DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
13
+
9
14
  test("when the custom function returns expected value", async () => {
10
15
  const config = {
11
16
  functions: {
12
- createPost: async () => {
17
+ createPost: async (inputs, api, ctx) => {
18
+ api.permissions.allow();
13
19
  return {
14
20
  title: "a post",
15
21
  id: "abcde",
16
22
  };
17
23
  },
18
24
  },
19
- createFunctionAPI: () => {},
25
+ createFunctionAPI: ({ headers, db }) => {
26
+ return {
27
+ permissions: new Permissions(),
28
+ };
29
+ },
20
30
  createContextAPI: () => {},
21
31
  };
22
32
 
@@ -38,9 +48,15 @@ test("when the custom function returns expected value", async () => {
38
48
  test("when the custom function doesnt return a value", async () => {
39
49
  const config = {
40
50
  functions: {
41
- createPost: async () => {},
51
+ createPost: async (inputs, api, ctx) => {
52
+ api.permissions.allow();
53
+ },
54
+ },
55
+ createFunctionAPI: ({ headers, db }) => {
56
+ return {
57
+ permissions: new Permissions(),
58
+ };
42
59
  },
43
- createFunctionAPI: () => {},
44
60
  createContextAPI: () => {},
45
61
  };
46
62
 
@@ -59,9 +75,13 @@ test("when the custom function doesnt return a value", async () => {
59
75
  test("when there is no matching function for the path", async () => {
60
76
  const config = {
61
77
  functions: {
62
- createPost: async () => {},
78
+ createPost: async (inputs, api, ctx) => {},
79
+ },
80
+ createFunctionAPI: ({ headers, db }) => {
81
+ return {
82
+ permissions: new Permissions(),
83
+ };
63
84
  },
64
- createFunctionAPI: () => {},
65
85
  createContextAPI: () => {},
66
86
  };
67
87
 
@@ -80,11 +100,17 @@ test("when there is no matching function for the path", async () => {
80
100
  test("when there is an unexpected error in the custom function", async () => {
81
101
  const config = {
82
102
  functions: {
83
- createPost: () => {
103
+ createPost: (inputs, api, ctx) => {
104
+ api.permissions.allow();
105
+
84
106
  throw new Error("oopsie daisy");
85
107
  },
86
108
  },
87
- createFunctionAPI: () => {},
109
+ createFunctionAPI: ({ headers, db }) => {
110
+ return {
111
+ permissions: new Permissions(),
112
+ };
113
+ },
88
114
  createContextAPI: () => {},
89
115
  };
90
116
 
@@ -103,11 +129,17 @@ test("when there is an unexpected error in the custom function", async () => {
103
129
  test("when there is an unexpected object thrown in the custom function", async () => {
104
130
  const config = {
105
131
  functions: {
106
- createPost: () => {
132
+ createPost: (inputs, api, ctx) => {
133
+ api.permissions.allow();
134
+
107
135
  throw { err: "oopsie daisy" };
108
136
  },
109
137
  },
110
- createFunctionAPI: () => {},
138
+ createFunctionAPI: ({ headers, db }) => {
139
+ return {
140
+ permissions: new Permissions(),
141
+ };
142
+ },
111
143
  createContextAPI: () => {},
112
144
  };
113
145
 
@@ -159,17 +191,22 @@ describe("ModelAPI error handling", () => {
159
191
  functionConfig = {
160
192
  functions: {
161
193
  createPost: async (inputs, api, ctx) => {
194
+ api.permissions.allow();
195
+
162
196
  const post = await api.models.post.create(inputs);
163
197
 
164
198
  return post;
165
199
  },
166
200
  deletePost: async (inputs, api, ctx) => {
201
+ api.permissions.allow();
202
+
167
203
  const deleted = await api.models.post.delete(inputs);
168
204
 
169
205
  return deleted;
170
206
  },
171
207
  },
172
- createFunctionAPI: () => ({
208
+ createFunctionAPI: ({ headers, db }) => ({
209
+ permissions: new Permissions(),
173
210
  models: {
174
211
  post: new ModelAPI(
175
212
  "post",
package/src/index.d.ts CHANGED
@@ -64,3 +64,16 @@ export type RequestHeaders = {
64
64
  get(name: string): string;
65
65
  has(name: string): boolean;
66
66
  };
67
+
68
+ export declare class Permissions {
69
+ constructor();
70
+
71
+ // check() can be used to check the given row(s) against the permission rules defined in the schema
72
+ async check(rows: any | any[]): Promise<void>;
73
+
74
+ // allow() can be used to explicitly permit access to an action
75
+ allow(): void;
76
+
77
+ // deny() can be used to explicitly deny access to an action
78
+ deny(): void;
79
+ }
package/src/index.js CHANGED
@@ -3,12 +3,15 @@ const { RequestHeaders } = require("./RequestHeaders");
3
3
  const { handleRequest } = require("./handleRequest");
4
4
  const KSUID = require("ksuid");
5
5
  const { getDatabase } = require("./database");
6
+ const { Permissions, PERMISSION_STATE } = require("./permissions");
6
7
 
7
8
  module.exports = {
8
9
  ModelAPI,
9
10
  RequestHeaders,
10
11
  handleRequest,
11
12
  getDatabase,
13
+ Permissions,
14
+ PERMISSION_STATE,
12
15
  ksuid() {
13
16
  return KSUID.randomSync().string;
14
17
  },
@@ -0,0 +1,46 @@
1
+ class PermissionError extends Error {}
2
+
3
+ const PERMISSION_STATE = {
4
+ PERMITTED: "permitted",
5
+ UNPERMITTED: "unpermitted",
6
+ };
7
+
8
+ class Permissions {
9
+ constructor() {
10
+ this.state = {
11
+ // permitted starts off as null to indicate that the end user
12
+ // hasn't explicitly marked a function execution as permitted yet
13
+ permitted: null,
14
+ };
15
+ }
16
+
17
+ async check(rows) {
18
+ throw new Error("Not implemented");
19
+ }
20
+
21
+ allow() {
22
+ this.state.permitted = true;
23
+ }
24
+
25
+ deny() {
26
+ // if a user is explicitly calling deny() then we want to throw an error
27
+ // so that any further execution of the custom function stops abruptly
28
+ // we don't need to explicitly set pending to false as the error will have been thrown
29
+ // so we know an action has been taken
30
+ throw new PermissionError();
31
+ }
32
+
33
+ getState() {
34
+ switch (true) {
35
+ // this will cover both permitted being false, and null (initial state)
36
+ case !this.state.permitted:
37
+ return PERMISSION_STATE.UNPERMITTED;
38
+ default:
39
+ return PERMISSION_STATE.PERMITTED;
40
+ }
41
+ }
42
+ }
43
+
44
+ module.exports.PermissionError = PermissionError;
45
+ module.exports.PERMISSION_STATE = PERMISSION_STATE;
46
+ module.exports.Permissions = Permissions;
@@ -0,0 +1,31 @@
1
+ import { Permissions, PERMISSION_STATE, PermissionError } from "./permissions";
2
+
3
+ import { beforeEach, expect, test } from "vitest";
4
+
5
+ let permissions;
6
+
7
+ beforeEach(() => {
8
+ permissions = new Permissions();
9
+ });
10
+
11
+ test("explicitly allowing execution", () => {
12
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
13
+
14
+ permissions.allow();
15
+
16
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.PERMITTED);
17
+ });
18
+
19
+ test("explicitly denying execution", () => {
20
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
21
+
22
+ expect(() => permissions.deny()).toThrowError(PermissionError);
23
+
24
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
25
+ });
26
+
27
+ test("check", async () => {
28
+ await expect(() => permissions.check()).rejects.toThrowError(
29
+ "Not implemented"
30
+ );
31
+ });