@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 +1 -1
- package/src/errors.js +12 -3
- package/src/handleRequest.js +33 -5
- package/src/handleRequest.test.js +48 -11
- package/src/index.d.ts +13 -0
- package/src/index.js +3 -0
- package/src/permissions.js +46 -0
- package/src/permissions.test.js +31 -0
package/package.json
CHANGED
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.
|
package/src/handleRequest.js
CHANGED
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
});
|