@teamkeel/functions-runtime 0.289.2 → 0.290.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamkeel/functions-runtime",
3
- "version": "0.289.2",
3
+ "version": "0.290.1",
4
4
  "description": "Internal package used by @teamkeel/sdk",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/consts.js ADDED
@@ -0,0 +1,20 @@
1
+ const PROTO_ACTION_TYPES = {
2
+ UNKNOWN: "OPERATION_TYPE_UNKNOWN",
3
+ CREATE: "OPERATION_TYPE_CREATE",
4
+ GET: "OPERATION_TYPE_GET",
5
+ LIST: "OPERATION_TYPE_LIST",
6
+ UPDATE: "OPERATION_TYPE_UPDATE",
7
+ DELETE: "OPERATION_TYPE_DELETE",
8
+ READ: "OPERATION_TYPE_READ",
9
+ WRITE: "OPERATION_TYPE_WRITE",
10
+ };
11
+
12
+ const PROTO_ACTION_TYPES_REQUEST_HANDLER = [
13
+ "OPERATION_TYPE_CREATE",
14
+ "OPERATION_TYPE_GET",
15
+ "OPERATION_TYPE_LIST",
16
+ ];
17
+
18
+ module.exports.PROTO_ACTION_TYPES_REQUEST_HANDLER =
19
+ PROTO_ACTION_TYPES_REQUEST_HANDLER;
20
+ module.exports.PROTO_ACTION_TYPES = PROTO_ACTION_TYPES;
@@ -5,7 +5,12 @@ const {
5
5
  } = require("json-rpc-2.0");
6
6
 
7
7
  const { getDatabase } = require("./database");
8
- const { PERMISSION_STATE, PermissionError } = require("./permissions");
8
+ const {
9
+ PERMISSION_STATE,
10
+ PermissionError,
11
+ checkBuiltInPermissions,
12
+ } = require("./permissions");
13
+ const { PROTO_ACTION_TYPES_REQUEST_HANDLER } = require("./consts");
9
14
 
10
15
  const { errorToJSONRPCResponse, RuntimeErrors } = require("./errors");
11
16
 
@@ -13,7 +18,13 @@ const { errorToJSONRPCResponse, RuntimeErrors } = require("./errors");
13
18
  // to execute a custom function based on the contents of a jsonrpc-2.0 payload object.
14
19
  // To read more about jsonrpc request and response shapes, please read https://www.jsonrpc.org/specification
15
20
  async function handleRequest(request, config) {
16
- const { createFunctionAPI, createContextAPI, functions } = config;
21
+ const {
22
+ createFunctionAPI,
23
+ createContextAPI,
24
+ functions,
25
+ permissions,
26
+ actionTypes,
27
+ } = config;
17
28
 
18
29
  if (!(request.method in functions)) {
19
30
  return createJSONRPCErrorResponse(
@@ -33,28 +44,48 @@ async function handleRequest(request, config) {
33
44
  // to any of the model apis we provide to the custom function is processed in a transaction.
34
45
  // This is useful for permissions where we want to only proceed with database writes if all permission rules
35
46
  // have been validated.
36
- const result = await db.transaction().execute(async (trx) => {
37
- const api = createFunctionAPI({ headers, db: trx });
47
+ const result = await db.transaction().execute(async (transaction) => {
38
48
  const ctx = createContextAPI(request.meta);
49
+ const api = createFunctionAPI({
50
+ headers,
51
+ db: transaction,
52
+ });
53
+
54
+ const customFunction = functions[request.method];
39
55
 
40
56
  // Call the user's custom function!
41
- const fnResult = await functions[request.method](
42
- request.params,
43
- api,
44
- ctx
45
- );
57
+ const fnResult = await customFunction(request.params, api, ctx);
58
+
59
+ // 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.
60
+ // 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
61
+ // and therefore we default to checking the permissions defined in the schema automatically.
62
+ switch (api.permissions.getState()) {
63
+ case PERMISSION_STATE.PERMITTED:
64
+ return fnResult;
65
+ case PERMISSION_STATE.UNPERMITTED:
66
+ throw new PermissionError(
67
+ `Not permitted to access ${request.method}`
68
+ );
69
+ default:
70
+ // unknown state, proceed with checking against the built in permissions in the schema
71
+ const relevantPermissions = permissions[request.method];
72
+
73
+ const actionType = actionTypes[request.method];
74
+ // We only want to run permission checks at the handleRequest level for action types list, get and create
75
+ // Delete and update permission checks need to be baked into the model api because they require reading records to be deleted / updated from the database first in order to ascertain whether the records to be deleted or updated fulfil the permission
76
+ if (PROTO_ACTION_TYPES_REQUEST_HANDLER.includes(actionType)) {
77
+ // check will throw a PermissionError if a permission rule is invalid
78
+ await checkBuiltInPermissions({
79
+ rows: fnResult,
80
+ permissions: relevantPermissions,
81
+ db: transaction,
82
+ ctx,
83
+ functionName: request.method,
84
+ });
85
+ }
46
86
 
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;
87
+ // 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
88
+ return fnResult;
58
89
  }
59
90
  });
60
91
 
@@ -5,6 +5,7 @@ import { test, expect, beforeEach, describe } from "vitest";
5
5
  import { ModelAPI } from "./ModelAPI";
6
6
  import { getDatabase } from "./database";
7
7
  import { Permissions } from "./permissions";
8
+ import { PROTO_ACTION_TYPES } from "./consts";
8
9
 
9
10
  import KSUID from "ksuid";
10
11
 
@@ -22,6 +23,9 @@ test("when the custom function returns expected value", async () => {
22
23
  };
23
24
  },
24
25
  },
26
+ actionTypes: {
27
+ createPost: PROTO_ACTION_TYPES.CREATE,
28
+ },
25
29
  createFunctionAPI: ({ headers, db }) => {
26
30
  return {
27
31
  permissions: new Permissions(),
@@ -52,6 +56,10 @@ test("when the custom function doesnt return a value", async () => {
52
56
  api.permissions.allow();
53
57
  },
54
58
  },
59
+ permissions: {},
60
+ actionTypes: {
61
+ createPost: PROTO_ACTION_TYPES.CREATE,
62
+ },
55
63
  createFunctionAPI: ({ headers, db }) => {
56
64
  return {
57
65
  permissions: new Permissions(),
@@ -77,6 +85,9 @@ test("when there is no matching function for the path", async () => {
77
85
  functions: {
78
86
  createPost: async (inputs, api, ctx) => {},
79
87
  },
88
+ actionTypes: {
89
+ createPost: PROTO_ACTION_TYPES.CREATE,
90
+ },
80
91
  createFunctionAPI: ({ headers, db }) => {
81
92
  return {
82
93
  permissions: new Permissions(),
@@ -100,12 +111,15 @@ test("when there is no matching function for the path", async () => {
100
111
  test("when there is an unexpected error in the custom function", async () => {
101
112
  const config = {
102
113
  functions: {
103
- createPost: (inputs, api, ctx) => {
114
+ createPost: async (inputs, api, ctx) => {
104
115
  api.permissions.allow();
105
116
 
106
117
  throw new Error("oopsie daisy");
107
118
  },
108
119
  },
120
+ actionTypes: {
121
+ createPost: PROTO_ACTION_TYPES.CREATE,
122
+ },
109
123
  createFunctionAPI: ({ headers, db }) => {
110
124
  return {
111
125
  permissions: new Permissions(),
@@ -129,12 +143,15 @@ test("when there is an unexpected error in the custom function", async () => {
129
143
  test("when there is an unexpected object thrown in the custom function", async () => {
130
144
  const config = {
131
145
  functions: {
132
- createPost: (inputs, api, ctx) => {
146
+ createPost: async (inputs, api, ctx) => {
133
147
  api.permissions.allow();
134
148
 
135
149
  throw { err: "oopsie daisy" };
136
150
  },
137
151
  },
152
+ actionTypes: {
153
+ createPost: PROTO_ACTION_TYPES.CREATE,
154
+ },
138
155
  createFunctionAPI: ({ headers, db }) => {
139
156
  return {
140
157
  permissions: new Permissions(),
@@ -189,6 +206,7 @@ describe("ModelAPI error handling", () => {
189
206
  `.execute(db);
190
207
 
191
208
  functionConfig = {
209
+ permissions: {},
192
210
  functions: {
193
211
  createPost: async (inputs, api, ctx) => {
194
212
  api.permissions.allow();
package/src/index.d.ts CHANGED
@@ -1,6 +1,3 @@
1
- // This file doesn't contain types describing this package, rather it contains generic types
2
- // that are used by the generated @teamkeel/sdk package.
3
-
4
1
  export type IDWhereCondition = {
5
2
  equals?: string | null;
6
3
  oneOf?: string[] | null;
@@ -68,9 +65,6 @@ export type RequestHeaders = {
68
65
  export declare class Permissions {
69
66
  constructor();
70
67
 
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
68
  // allow() can be used to explicitly permit access to an action
75
69
  allow(): void;
76
70
 
package/src/index.js CHANGED
@@ -3,7 +3,11 @@ 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
+ const {
7
+ Permissions,
8
+ PERMISSION_STATE,
9
+ checkBuiltInPermissions,
10
+ } = require("./permissions");
7
11
 
8
12
  module.exports = {
9
13
  ModelAPI,
@@ -12,6 +16,7 @@ module.exports = {
12
16
  getDatabase,
13
17
  Permissions,
14
18
  PERMISSION_STATE,
19
+ checkBuiltInPermissions,
15
20
  ksuid() {
16
21
  return KSUID.randomSync().string;
17
22
  },
@@ -1,6 +1,7 @@
1
1
  class PermissionError extends Error {}
2
2
 
3
3
  const PERMISSION_STATE = {
4
+ UNKNOWN: "unknown",
4
5
  PERMITTED: "permitted",
5
6
  UNPERMITTED: "unpermitted",
6
7
  };
@@ -14,10 +15,6 @@ class Permissions {
14
15
  };
15
16
  }
16
17
 
17
- async check(rows) {
18
- throw new Error("Not implemented");
19
- }
20
-
21
18
  allow() {
22
19
  this.state.permitted = true;
23
20
  }
@@ -25,22 +22,49 @@ class Permissions {
25
22
  deny() {
26
23
  // if a user is explicitly calling deny() then we want to throw an error
27
24
  // 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
25
+ this.state.permitted = false;
26
+
30
27
  throw new PermissionError();
31
28
  }
32
29
 
33
30
  getState() {
34
31
  switch (true) {
35
- // this will cover both permitted being false, and null (initial state)
36
- case !this.state.permitted:
32
+ case this.state.permitted === false:
37
33
  return PERMISSION_STATE.UNPERMITTED;
38
- default:
34
+ case this.state.permitted === null:
35
+ return PERMISSION_STATE.UNKNOWN;
36
+ case this.state.permitted === true:
39
37
  return PERMISSION_STATE.PERMITTED;
40
38
  }
41
39
  }
42
40
  }
43
41
 
42
+ const checkBuiltInPermissions = async ({
43
+ rows,
44
+ permissions,
45
+ ctx,
46
+ db,
47
+ functionName,
48
+ }) => {
49
+ // rows can actually just be a single record too so we need to wrap it
50
+ if (!Array.isArray(rows)) {
51
+ rows = [rows];
52
+ }
53
+
54
+ for (const permissionFn of permissions) {
55
+ const result = await permissionFn(rows, ctx, db);
56
+
57
+ // if any of the permission functions return true,
58
+ // then we return early
59
+ if (result) {
60
+ return;
61
+ }
62
+ }
63
+
64
+ throw new PermissionError(`Not permitted to access ${functionName}`);
65
+ };
66
+
67
+ module.exports.checkBuiltInPermissions = checkBuiltInPermissions;
44
68
  module.exports.PermissionError = PermissionError;
45
69
  module.exports.PERMISSION_STATE = PERMISSION_STATE;
46
70
  module.exports.Permissions = Permissions;
@@ -1,31 +1,120 @@
1
- import { Permissions, PERMISSION_STATE, PermissionError } from "./permissions";
1
+ import {
2
+ Permissions,
3
+ PERMISSION_STATE,
4
+ PermissionError,
5
+ checkBuiltInPermissions,
6
+ } from "./permissions";
7
+ import { getDatabase } from "./database";
2
8
 
3
- import { beforeEach, expect, test } from "vitest";
9
+ import { beforeEach, describe, expect, test } from "vitest";
10
+
11
+ process.env.KEEL_DB_CONN_TYPE = "pg";
12
+ process.env.KEEL_DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
4
13
 
5
14
  let permissions;
15
+ let ctx = {};
16
+ let db = getDatabase();
6
17
 
7
- beforeEach(() => {
8
- permissions = new Permissions();
9
- });
18
+ describe("explicit", () => {
19
+ beforeEach(() => {
20
+ permissions = new Permissions();
21
+ });
10
22
 
11
- test("explicitly allowing execution", () => {
12
- expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
23
+ test("explicitly allowing execution", () => {
24
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNKNOWN);
13
25
 
14
- permissions.allow();
26
+ permissions.allow();
15
27
 
16
- expect(permissions.getState()).toEqual(PERMISSION_STATE.PERMITTED);
17
- });
28
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.PERMITTED);
29
+ });
18
30
 
19
- test("explicitly denying execution", () => {
20
- expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
31
+ test("explicitly denying execution", () => {
32
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNKNOWN);
21
33
 
22
- expect(() => permissions.deny()).toThrowError(PermissionError);
34
+ expect(() => permissions.deny()).toThrowError(PermissionError);
23
35
 
24
- expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
36
+ expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
37
+ });
25
38
  });
26
39
 
27
- test("check", async () => {
28
- await expect(() => permissions.check()).rejects.toThrowError(
29
- "Not implemented"
30
- );
40
+ describe("check", () => {
41
+ const functionName = "createPerson";
42
+
43
+ test("check - success", async () => {
44
+ const permissionRule1 = (records, ctx, db) => {
45
+ // Only allow names starting with Adam
46
+ return records.every((r) => r.name.startsWith("Adam"));
47
+ };
48
+
49
+ const rows = [
50
+ {
51
+ id: "123",
52
+ name: "Adam Bull",
53
+ },
54
+ {
55
+ id: "234",
56
+ name: "Adam Lambert",
57
+ },
58
+ ];
59
+
60
+ await expect(
61
+ checkBuiltInPermissions({
62
+ rows,
63
+ ctx,
64
+ db,
65
+ functionName,
66
+ permissions: [permissionRule1],
67
+ })
68
+ ).resolves.ok;
69
+ });
70
+
71
+ test("check - failure", async () => {
72
+ // only allow Petes
73
+ const permissionRule1 = (records, ctx, db) => {
74
+ return records.every((r) => r.name === "Pete");
75
+ };
76
+
77
+ const rows = [
78
+ {
79
+ id: "123",
80
+ name: "Adam", // this one will cause an error to be thrown because Adam is not Pete
81
+ },
82
+ {
83
+ id: "234",
84
+ name: "Pete",
85
+ },
86
+ ];
87
+
88
+ await expect(
89
+ checkBuiltInPermissions({
90
+ rows,
91
+ ctx,
92
+ db,
93
+ functionName,
94
+ permissions: [permissionRule1],
95
+ })
96
+ ).rejects.toThrow();
97
+ });
98
+
99
+ test("with a single row", async () => {
100
+ const permissionRule1 = (records, ctx, db) => {
101
+ // Only allow names starting with Adam
102
+ return records.every((r) => r.name.startsWith("Adam"));
103
+ };
104
+
105
+ const rows = {
106
+ id: "123",
107
+ name: "Adam Bull",
108
+ };
109
+
110
+ await expect(
111
+ checkBuiltInPermissions({
112
+ rows,
113
+ ctx,
114
+ db,
115
+ functionName,
116
+ permissions: [permissionRule1],
117
+ })
118
+ ).resolves.ok;
119
+ });
31
120
  });