@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 +1 -1
- package/src/consts.js +20 -0
- package/src/handleRequest.js +51 -20
- package/src/handleRequest.test.js +20 -2
- package/src/index.d.ts +0 -6
- package/src/index.js +6 -1
- package/src/permissions.js +33 -9
- package/src/permissions.test.js +107 -18
package/package.json
CHANGED
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;
|
package/src/handleRequest.js
CHANGED
|
@@ -5,7 +5,12 @@ const {
|
|
|
5
5
|
} = require("json-rpc-2.0");
|
|
6
6
|
|
|
7
7
|
const { getDatabase } = require("./database");
|
|
8
|
-
const {
|
|
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 {
|
|
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 (
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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 {
|
|
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
|
},
|
package/src/permissions.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
25
|
+
this.state.permitted = false;
|
|
26
|
+
|
|
30
27
|
throw new PermissionError();
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
getState() {
|
|
34
31
|
switch (true) {
|
|
35
|
-
|
|
36
|
-
case !this.state.permitted:
|
|
32
|
+
case this.state.permitted === false:
|
|
37
33
|
return PERMISSION_STATE.UNPERMITTED;
|
|
38
|
-
|
|
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;
|
package/src/permissions.test.js
CHANGED
|
@@ -1,31 +1,120 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
describe("explicit", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
permissions = new Permissions();
|
|
21
|
+
});
|
|
10
22
|
|
|
11
|
-
test("explicitly allowing execution", () => {
|
|
12
|
-
|
|
23
|
+
test("explicitly allowing execution", () => {
|
|
24
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.UNKNOWN);
|
|
13
25
|
|
|
14
|
-
|
|
26
|
+
permissions.allow();
|
|
15
27
|
|
|
16
|
-
|
|
17
|
-
});
|
|
28
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.PERMITTED);
|
|
29
|
+
});
|
|
18
30
|
|
|
19
|
-
test("explicitly denying execution", () => {
|
|
20
|
-
|
|
31
|
+
test("explicitly denying execution", () => {
|
|
32
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.UNKNOWN);
|
|
21
33
|
|
|
22
|
-
|
|
34
|
+
expect(() => permissions.deny()).toThrowError(PermissionError);
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
expect(permissions.getState()).toEqual(PERMISSION_STATE.UNPERMITTED);
|
|
37
|
+
});
|
|
25
38
|
});
|
|
26
39
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
});
|