@teamkeel/functions-runtime 0.365.18 → 0.366.0-auditident7
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 +3 -2
- package/src/ModelAPI.js +68 -27
- package/src/auditing.js +119 -0
- package/src/auditing.test.js +362 -0
- package/src/database.js +5 -2
- package/src/handleRequest.test.js +110 -0
- package/src/handleSubscriber.js +1 -1
- package/src/tryExecuteFunction.js +6 -2
- package/src/tryExecuteJob.js +6 -3
- package/src/tryExecuteSubscriber.js +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teamkeel/functions-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.366.0-auditident7",
|
|
4
4
|
"description": "Internal package used by @teamkeel/sdk",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"json-rpc-2.0": "^1.4.1",
|
|
29
29
|
"ksuid": "^3.0.0",
|
|
30
30
|
"kysely": "^0.23.4",
|
|
31
|
-
"pg": "^8.8.0"
|
|
31
|
+
"pg": "^8.8.0",
|
|
32
|
+
"traceparent": "^1.0.0"
|
|
32
33
|
}
|
|
33
34
|
}
|
package/src/ModelAPI.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
const { useDatabase } = require("./database");
|
|
2
|
+
const { getAuditContext } = require("./auditing");
|
|
2
3
|
const { QueryBuilder } = require("./QueryBuilder");
|
|
3
4
|
const { QueryContext } = require("./QueryContext");
|
|
4
5
|
const { applyWhereConditions } = require("./applyWhereConditions");
|
|
5
6
|
const { applyJoins } = require("./applyJoins");
|
|
7
|
+
const { sql } = require("kysely");
|
|
8
|
+
|
|
6
9
|
const {
|
|
7
10
|
applyLimit,
|
|
8
11
|
applyOffset,
|
|
@@ -47,12 +50,52 @@ class ModelAPI {
|
|
|
47
50
|
this._modelName = upperCamelCase(this._tableName);
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// execute sets the audit context in the database and then runs individual
|
|
54
|
+
// database statements within a transaction if one hasn't been opened, which
|
|
55
|
+
// is necessary because the audit parameters will only be available within transactions.
|
|
56
|
+
async #execute(fn) {
|
|
57
|
+
const db = useDatabase();
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
if (db.isTransaction) {
|
|
61
|
+
await this.#setAuditParameters(db);
|
|
62
|
+
return await fn(db);
|
|
63
|
+
} else {
|
|
64
|
+
return await db.transaction().execute(async (transaction) => {
|
|
65
|
+
await this.#setAuditParameters(transaction);
|
|
66
|
+
return fn(transaction);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} catch (e) {
|
|
70
|
+
throw new DatabaseError(e);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// setAuditParameters sets audit context data to configuration parameters
|
|
75
|
+
// in the database so that they can be read by the triggered auditing function.
|
|
76
|
+
async #setAuditParameters(transaction) {
|
|
77
|
+
const audit = getAuditContext();
|
|
78
|
+
const statements = [];
|
|
79
|
+
|
|
80
|
+
if (audit.identityId) {
|
|
81
|
+
statements.push(`CALL set_identity_id('${audit.identityId}');`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (audit.traceId) {
|
|
85
|
+
statements.push(`CALL set_trace_id('${audit.traceId}');`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (statements.length > 0) {
|
|
89
|
+
const stmt = statements.join("");
|
|
90
|
+
await sql.raw(stmt).execute(transaction);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
50
94
|
async create(values) {
|
|
51
95
|
const name = tracing.spanNameForModelAPI(this._modelName, "create");
|
|
52
|
-
const db = useDatabase();
|
|
53
96
|
|
|
54
97
|
return tracing.withSpan(name, async (span) => {
|
|
55
|
-
|
|
98
|
+
return await this.#execute(async (db) => {
|
|
56
99
|
const query = db
|
|
57
100
|
.insertInto(this._tableName)
|
|
58
101
|
.values(
|
|
@@ -64,11 +107,8 @@ class ModelAPI {
|
|
|
64
107
|
|
|
65
108
|
span.setAttribute("sql", query.compile().sql);
|
|
66
109
|
const row = await query.executeTakeFirstOrThrow();
|
|
67
|
-
|
|
68
110
|
return camelCaseObject(row);
|
|
69
|
-
}
|
|
70
|
-
throw new DatabaseError(e);
|
|
71
|
-
}
|
|
111
|
+
});
|
|
72
112
|
});
|
|
73
113
|
}
|
|
74
114
|
|
|
@@ -155,48 +195,49 @@ class ModelAPI {
|
|
|
155
195
|
|
|
156
196
|
async update(where, values) {
|
|
157
197
|
const name = tracing.spanNameForModelAPI(this._modelName, "update");
|
|
158
|
-
const db = useDatabase();
|
|
159
198
|
|
|
160
199
|
return tracing.withSpan(name, async (span) => {
|
|
161
|
-
|
|
200
|
+
return await this.#execute(async (db) => {
|
|
201
|
+
let builder = db.updateTable(this._tableName).returningAll();
|
|
162
202
|
|
|
163
|
-
|
|
203
|
+
builder = builder.set(snakeCaseObject(values));
|
|
164
204
|
|
|
165
|
-
|
|
205
|
+
const context = new QueryContext(
|
|
206
|
+
[this._tableName],
|
|
207
|
+
this._tableConfigMap
|
|
208
|
+
);
|
|
166
209
|
|
|
167
|
-
|
|
168
|
-
|
|
210
|
+
// TODO: support joins for update
|
|
211
|
+
builder = applyWhereConditions(context, builder, where);
|
|
169
212
|
|
|
170
|
-
|
|
213
|
+
span.setAttribute("sql", builder.compile().sql);
|
|
171
214
|
|
|
172
|
-
try {
|
|
173
215
|
const row = await builder.executeTakeFirstOrThrow();
|
|
174
216
|
return camelCaseObject(row);
|
|
175
|
-
}
|
|
176
|
-
throw new DatabaseError(e);
|
|
177
|
-
}
|
|
217
|
+
});
|
|
178
218
|
});
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
async delete(where) {
|
|
182
222
|
const name = tracing.spanNameForModelAPI(this._modelName, "delete");
|
|
183
|
-
const db = useDatabase();
|
|
184
223
|
|
|
185
224
|
return tracing.withSpan(name, async (span) => {
|
|
186
|
-
|
|
225
|
+
return await this.#execute(async (db) => {
|
|
226
|
+
let builder = db.deleteFrom(this._tableName).returning(["id"]);
|
|
187
227
|
|
|
188
|
-
|
|
228
|
+
const context = new QueryContext(
|
|
229
|
+
[this._tableName],
|
|
230
|
+
this._tableConfigMap
|
|
231
|
+
);
|
|
189
232
|
|
|
190
|
-
|
|
191
|
-
|
|
233
|
+
// TODO: support joins for delete
|
|
234
|
+
builder = applyWhereConditions(context, builder, where);
|
|
235
|
+
|
|
236
|
+
span.setAttribute("sql", builder.compile().sql);
|
|
192
237
|
|
|
193
|
-
span.setAttribute("sql", builder.compile().sql);
|
|
194
|
-
try {
|
|
195
238
|
const row = await builder.executeTakeFirstOrThrow();
|
|
196
239
|
return row.id;
|
|
197
|
-
}
|
|
198
|
-
throw new DatabaseError(e);
|
|
199
|
-
}
|
|
240
|
+
});
|
|
200
241
|
});
|
|
201
242
|
}
|
|
202
243
|
|
package/src/auditing.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const { AsyncLocalStorage } = require("async_hooks");
|
|
2
|
+
const TraceParent = require("traceparent");
|
|
3
|
+
const { sql, SelectionNode } = require("kysely");
|
|
4
|
+
|
|
5
|
+
const auditContextStorage = new AsyncLocalStorage();
|
|
6
|
+
|
|
7
|
+
// withAuditContext creates the audit context from the runtime request body
|
|
8
|
+
// and sets it to in AsyncLocalStorage so that this data is available to the
|
|
9
|
+
// ModelAPI during the execution of actions, jobs and subscribers.
|
|
10
|
+
async function withAuditContext(request, cb) {
|
|
11
|
+
let audit = {};
|
|
12
|
+
|
|
13
|
+
if (request.meta?.identity) {
|
|
14
|
+
audit.identityId = request.meta.identity.id;
|
|
15
|
+
}
|
|
16
|
+
if (request.meta?.tracing?.traceparent) {
|
|
17
|
+
audit.traceId = TraceParent.fromString(
|
|
18
|
+
request.meta.tracing.traceparent
|
|
19
|
+
)?.traceId;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return await auditContextStorage.run(audit, () => {
|
|
23
|
+
return cb();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// getAuditContext retrieves the audit context from AsyncLocalStorage.
|
|
28
|
+
function getAuditContext() {
|
|
29
|
+
let auditStore = auditContextStorage.getStore();
|
|
30
|
+
return {
|
|
31
|
+
identityId: auditStore?.identityId,
|
|
32
|
+
traceId: auditStore?.traceId,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// AuditContextPlugin is a Kysely plugin which ensures that the audit context data
|
|
37
|
+
// is written to Postgres configuration parameters in the same execution as a query.
|
|
38
|
+
// It does this by calling the set_identity_id() and set_trace_id() functions as a
|
|
39
|
+
// clause in the returning statement. It then subsequently drops these from the actual result.
|
|
40
|
+
// This ensures that these parameters are set when the tables' AFTER trigger function executes.
|
|
41
|
+
class AuditContextPlugin {
|
|
42
|
+
constructor() {
|
|
43
|
+
this.identityIdAlias = "__keel_identity_id";
|
|
44
|
+
this.traceIdAlias = "__keel_trace_id";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#setIdentityClause(value) {
|
|
48
|
+
return `set_identity_id('${value}')`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#setTraceIdClause(value) {
|
|
52
|
+
return `set_trace_id('${value}')`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Appends set_identity_id() and set_trace_id() function calls to the returning statement
|
|
56
|
+
// of INSERT, UPDATE and DELETE operations.
|
|
57
|
+
transformQuery(args) {
|
|
58
|
+
switch (args.node.kind) {
|
|
59
|
+
case "InsertQueryNode":
|
|
60
|
+
case "UpdateQueryNode":
|
|
61
|
+
case "DeleteQueryNode":
|
|
62
|
+
const returning = {
|
|
63
|
+
kind: "ReturningNode",
|
|
64
|
+
selections: [],
|
|
65
|
+
};
|
|
66
|
+
if (args.node.returning) {
|
|
67
|
+
returning.selections.push(...args.node.returning.selections);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Retrieve the audit context from async storage.
|
|
71
|
+
const audit = getAuditContext();
|
|
72
|
+
|
|
73
|
+
if (audit.identityId) {
|
|
74
|
+
const rawNode = sql
|
|
75
|
+
.raw(
|
|
76
|
+
this.#setIdentityClause(audit.identityId, this.identityIdAlias)
|
|
77
|
+
)
|
|
78
|
+
.as(this.identityIdAlias)
|
|
79
|
+
.toOperationNode();
|
|
80
|
+
|
|
81
|
+
returning.selections.push(SelectionNode.create(rawNode));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (audit.traceId) {
|
|
85
|
+
const rawNode = sql
|
|
86
|
+
.raw(this.#setTraceIdClause(audit.traceId))
|
|
87
|
+
.as(this.traceIdAlias)
|
|
88
|
+
.toOperationNode();
|
|
89
|
+
|
|
90
|
+
returning.selections.push(SelectionNode.create(rawNode));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
...args.node,
|
|
95
|
+
returning: returning,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
...args.node,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Drops the set_identity_id() and set_trace_id() fields from the result.
|
|
105
|
+
transformResult(args) {
|
|
106
|
+
if (args.result?.rows) {
|
|
107
|
+
for (let i = 0; i < args.result.rows.length; i++) {
|
|
108
|
+
delete args.result.rows[i][this.identityIdAlias];
|
|
109
|
+
delete args.result.rows[i][this.traceIdAlias];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return args.result;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports.withAuditContext = withAuditContext;
|
|
118
|
+
module.exports.getAuditContext = getAuditContext;
|
|
119
|
+
module.exports.AuditContextPlugin = AuditContextPlugin;
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { test, expect, beforeEach } from "vitest";
|
|
2
|
+
const { ModelAPI } = require("./ModelAPI");
|
|
3
|
+
const { PROTO_ACTION_TYPES } = require("./consts");
|
|
4
|
+
const { sql } = require("kysely");
|
|
5
|
+
const { useDatabase, withDatabase } = require("./database");
|
|
6
|
+
const KSUID = require("ksuid");
|
|
7
|
+
const TraceParent = require("traceparent");
|
|
8
|
+
const { withAuditContext } = require("./auditing");
|
|
9
|
+
|
|
10
|
+
let personAPI;
|
|
11
|
+
const db = useDatabase();
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
await sql`
|
|
15
|
+
|
|
16
|
+
DROP TABLE IF EXISTS post;
|
|
17
|
+
DROP TABLE IF EXISTS person;
|
|
18
|
+
DROP TABLE IF EXISTS author;
|
|
19
|
+
|
|
20
|
+
CREATE TABLE person(
|
|
21
|
+
id text PRIMARY KEY,
|
|
22
|
+
name text UNIQUE
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE OR REPLACE FUNCTION set_identity_id(id VARCHAR)
|
|
26
|
+
RETURNS TEXT AS $$
|
|
27
|
+
BEGIN
|
|
28
|
+
RETURN set_config('audit.identity_id', id, true);
|
|
29
|
+
END
|
|
30
|
+
$$ LANGUAGE plpgsql;
|
|
31
|
+
|
|
32
|
+
CREATE OR REPLACE FUNCTION set_trace_id(id VARCHAR)
|
|
33
|
+
RETURNS TEXT AS $$
|
|
34
|
+
BEGIN
|
|
35
|
+
RETURN set_config('audit.trace_id', id, true);
|
|
36
|
+
END
|
|
37
|
+
$$ LANGUAGE plpgsql;
|
|
38
|
+
`.execute(db);
|
|
39
|
+
|
|
40
|
+
personAPI = new ModelAPI("person", undefined, {});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
async function identityIdFromConfigParam(database, nonLocal = true) {
|
|
44
|
+
const result =
|
|
45
|
+
await sql`SELECT NULLIF(current_setting('audit.identity_id', ${sql.literal(
|
|
46
|
+
nonLocal
|
|
47
|
+
)}), '') AS id`.execute(database);
|
|
48
|
+
return result.rows[0].id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function traceIdFromConfigParam(database, nonLocal = true) {
|
|
52
|
+
const result =
|
|
53
|
+
await sql`SELECT NULLIF(current_setting('audit.trace_id', ${sql.literal(
|
|
54
|
+
nonLocal
|
|
55
|
+
)}), '') AS id`.execute(database);
|
|
56
|
+
return result.rows[0].id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("auditing - capturing identity id in transaction", async () => {
|
|
60
|
+
const request = {
|
|
61
|
+
meta: {
|
|
62
|
+
identity: { id: KSUID.randomSync().string },
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const identityId = request.meta.identity.id;
|
|
67
|
+
|
|
68
|
+
const row = await withDatabase(
|
|
69
|
+
db,
|
|
70
|
+
PROTO_ACTION_TYPES.CREATE, // CREATE will ensure a transaction is opened
|
|
71
|
+
async ({ transaction }) => {
|
|
72
|
+
const row = withAuditContext(request, async () => {
|
|
73
|
+
return await personAPI.create({
|
|
74
|
+
id: KSUID.randomSync().string,
|
|
75
|
+
name: "James",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
|
|
80
|
+
expect(await identityIdFromConfigParam(db)).toBeNull();
|
|
81
|
+
|
|
82
|
+
return row;
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(row.name).toEqual("James");
|
|
87
|
+
expect(await identityIdFromConfigParam(db)).toBeNull();
|
|
88
|
+
expect(await identityIdFromConfigParam(db, false)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("auditing - capturing tracing in transaction", async () => {
|
|
92
|
+
const request = {
|
|
93
|
+
meta: {
|
|
94
|
+
tracing: {
|
|
95
|
+
traceparent: "00-80e1afed08e019fc1110464cfa66635c-7a085853722dc6d2-01",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const traceId = TraceParent.fromString(
|
|
101
|
+
request.meta.tracing.traceparent
|
|
102
|
+
).traceId;
|
|
103
|
+
|
|
104
|
+
const row = await withDatabase(
|
|
105
|
+
db,
|
|
106
|
+
PROTO_ACTION_TYPES.CREATE, // CREATE will ensure a transaction is opened
|
|
107
|
+
async ({ transaction }) => {
|
|
108
|
+
const row = withAuditContext(request, async () => {
|
|
109
|
+
return await personAPI.create({
|
|
110
|
+
id: KSUID.randomSync().string,
|
|
111
|
+
name: "Jim",
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
|
|
116
|
+
expect(await traceIdFromConfigParam(db)).toBeNull();
|
|
117
|
+
|
|
118
|
+
return row;
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(row.name).toEqual("Jim");
|
|
123
|
+
expect(await traceIdFromConfigParam(db)).toBeNull();
|
|
124
|
+
expect(await traceIdFromConfigParam(db, false)).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("auditing - capturing identity id without transaction", async () => {
|
|
128
|
+
const request = {
|
|
129
|
+
meta: {
|
|
130
|
+
identity: { id: KSUID.randomSync().string },
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const row = await withDatabase(
|
|
135
|
+
db,
|
|
136
|
+
PROTO_ACTION_TYPES.GET, // GET will _not_ open a transaction
|
|
137
|
+
async ({ sDb }) => {
|
|
138
|
+
const row = withAuditContext(request, async () => {
|
|
139
|
+
return await personAPI.create({
|
|
140
|
+
id: KSUID.randomSync().string,
|
|
141
|
+
name: "James",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(await identityIdFromConfigParam(sDb)).toBeNull();
|
|
146
|
+
expect(await identityIdFromConfigParam(db)).toBeNull();
|
|
147
|
+
|
|
148
|
+
return row;
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(row.name).toEqual("James");
|
|
153
|
+
expect(await identityIdFromConfigParam(db)).toBeNull();
|
|
154
|
+
expect(await identityIdFromConfigParam(db, false)).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("auditing - capturing tracing without transaction", async () => {
|
|
158
|
+
const request = {
|
|
159
|
+
meta: {
|
|
160
|
+
tracing: {
|
|
161
|
+
traceparent: "00-80e1afed08e019fc1110464cfa66635c-7a085853722dc6d2-01",
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const row = await withDatabase(
|
|
167
|
+
db,
|
|
168
|
+
PROTO_ACTION_TYPES.GET, // GET will _not_ open a transaction
|
|
169
|
+
async ({ sDb }) => {
|
|
170
|
+
const row = withAuditContext(request, async () => {
|
|
171
|
+
return await personAPI.create({
|
|
172
|
+
id: KSUID.randomSync().string,
|
|
173
|
+
name: "Jim",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(await traceIdFromConfigParam(sDb)).toBeNull();
|
|
178
|
+
expect(await traceIdFromConfigParam(db)).toBeNull();
|
|
179
|
+
|
|
180
|
+
return row;
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
expect(row.name).toEqual("Jim");
|
|
185
|
+
expect(await traceIdFromConfigParam(db)).toBeNull();
|
|
186
|
+
expect(await traceIdFromConfigParam(db, false)).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("auditing - no audit context", async () => {
|
|
190
|
+
const row = await withDatabase(
|
|
191
|
+
db,
|
|
192
|
+
PROTO_ACTION_TYPES.CREATE,
|
|
193
|
+
async ({ transaction }) => {
|
|
194
|
+
const row = withAuditContext({}, async () => {
|
|
195
|
+
return await personAPI.create({
|
|
196
|
+
id: KSUID.randomSync().string,
|
|
197
|
+
name: "Jake",
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(await identityIdFromConfigParam(transaction)).toBeNull();
|
|
202
|
+
expect(await identityIdFromConfigParam(db)).toBeNull();
|
|
203
|
+
expect(await traceIdFromConfigParam(transaction)).toBeNull();
|
|
204
|
+
expect(await traceIdFromConfigParam(db)).toBeNull();
|
|
205
|
+
return row;
|
|
206
|
+
}
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(KSUID.parse(row.id).string).toEqual(row.id);
|
|
210
|
+
expect(row.name).toEqual("Jake");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("auditing - ModelAPI.create", async () => {
|
|
214
|
+
const request = {
|
|
215
|
+
meta: {
|
|
216
|
+
identity: { id: KSUID.randomSync().string },
|
|
217
|
+
tracing: {
|
|
218
|
+
traceparent: "00-80e1afed08e019fc1110464cfa66635c-7a085853722dc6d2-01",
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const identityId = request.meta.identity.id;
|
|
224
|
+
const traceId = TraceParent.fromString(
|
|
225
|
+
request.meta.tracing.traceparent
|
|
226
|
+
).traceId;
|
|
227
|
+
|
|
228
|
+
const row = await withDatabase(
|
|
229
|
+
db,
|
|
230
|
+
PROTO_ACTION_TYPES.CREATE,
|
|
231
|
+
async ({ transaction }) => {
|
|
232
|
+
const row = withAuditContext(request, async () => {
|
|
233
|
+
return await personAPI.create({
|
|
234
|
+
id: KSUID.randomSync().string,
|
|
235
|
+
name: "Jake",
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
|
|
240
|
+
expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
|
|
241
|
+
|
|
242
|
+
return row;
|
|
243
|
+
}
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
expect(row.name).toEqual("Jake");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("auditing - ModelAPI.update", async () => {
|
|
250
|
+
const request = {
|
|
251
|
+
meta: {
|
|
252
|
+
identity: { id: KSUID.randomSync().string },
|
|
253
|
+
tracing: {
|
|
254
|
+
traceparent: "00-80e1afed08e019fc1110464cfa66635c-7a085853722dc6d2-01",
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const identityId = request.meta.identity.id;
|
|
260
|
+
const traceId = TraceParent.fromString(
|
|
261
|
+
request.meta.tracing.traceparent
|
|
262
|
+
).traceId;
|
|
263
|
+
|
|
264
|
+
const created = await personAPI.create({
|
|
265
|
+
id: KSUID.randomSync().string,
|
|
266
|
+
name: "Jake",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const row = await withDatabase(
|
|
270
|
+
db,
|
|
271
|
+
PROTO_ACTION_TYPES.CREATE,
|
|
272
|
+
async ({ transaction }) => {
|
|
273
|
+
const row = withAuditContext(request, async () => {
|
|
274
|
+
return await personAPI.update({ id: created.id }, { name: "Jim" });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
|
|
278
|
+
expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
|
|
279
|
+
|
|
280
|
+
return row;
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
expect(row.name).toEqual("Jim");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("auditing - ModelAPI.delete", async () => {
|
|
288
|
+
const request = {
|
|
289
|
+
meta: {
|
|
290
|
+
identity: { id: KSUID.randomSync().string },
|
|
291
|
+
tracing: {
|
|
292
|
+
traceparent: "00-80e1afed08e019fc1110464cfa66635c-7a085853722dc6d2-01",
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const identityId = request.meta.identity.id;
|
|
298
|
+
const traceId = TraceParent.fromString(
|
|
299
|
+
request.meta.tracing.traceparent
|
|
300
|
+
).traceId;
|
|
301
|
+
|
|
302
|
+
const created = await personAPI.create({
|
|
303
|
+
id: KSUID.randomSync().string,
|
|
304
|
+
name: "Jake",
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const row = await withDatabase(
|
|
308
|
+
db,
|
|
309
|
+
PROTO_ACTION_TYPES.CREATE,
|
|
310
|
+
async ({ transaction }) => {
|
|
311
|
+
const row = withAuditContext(request, async () => {
|
|
312
|
+
return await personAPI.delete({ id: created.id });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
|
|
316
|
+
expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
|
|
317
|
+
|
|
318
|
+
return row;
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(row).toEqual(created.id);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("auditing - identity id and trace id fields dropped from result", async () => {
|
|
326
|
+
const request = {
|
|
327
|
+
meta: {
|
|
328
|
+
identity: { id: KSUID.randomSync().string },
|
|
329
|
+
tracing: {
|
|
330
|
+
traceparent: "00-80e1afed08e019fc1110464cfa66635c-7a085853722dc6d2-01",
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const identityId = request.meta.identity.id;
|
|
336
|
+
const traceId = TraceParent.fromString(
|
|
337
|
+
request.meta.tracing.traceparent
|
|
338
|
+
).traceId;
|
|
339
|
+
|
|
340
|
+
const row = await withDatabase(
|
|
341
|
+
db,
|
|
342
|
+
PROTO_ACTION_TYPES.CREATE,
|
|
343
|
+
async ({ transaction }) => {
|
|
344
|
+
const row = withAuditContext(request, async () => {
|
|
345
|
+
return await personAPI.create({
|
|
346
|
+
id: KSUID.randomSync().string,
|
|
347
|
+
name: "Jake",
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(await identityIdFromConfigParam(transaction)).toEqual(identityId);
|
|
352
|
+
expect(await traceIdFromConfigParam(transaction)).toEqual(traceId);
|
|
353
|
+
|
|
354
|
+
return row;
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
expect(row.name).toEqual("Jake");
|
|
359
|
+
expect(row.keelIdentityId).toBeUndefined();
|
|
360
|
+
expect(row.keelTraceId).toBeUndefined();
|
|
361
|
+
expect(Object.keys(row).length).toEqual(2);
|
|
362
|
+
});
|
package/src/database.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { Kysely, PostgresDialect, CamelCasePlugin } = require("kysely");
|
|
2
2
|
const { AsyncLocalStorage } = require("async_hooks");
|
|
3
|
+
const { AuditContextPlugin } = require("./auditing");
|
|
3
4
|
const pg = require("pg");
|
|
4
5
|
const { PROTO_ACTION_TYPES } = require("./consts");
|
|
5
6
|
const { withSpan } = require("./tracing");
|
|
@@ -15,6 +16,7 @@ async function withDatabase(db, actionType, cb) {
|
|
|
15
16
|
let requiresTransaction = true;
|
|
16
17
|
|
|
17
18
|
switch (actionType) {
|
|
19
|
+
case PROTO_ACTION_TYPES.SUBSCRIBER:
|
|
18
20
|
case PROTO_ACTION_TYPES.JOB:
|
|
19
21
|
case PROTO_ACTION_TYPES.GET:
|
|
20
22
|
case PROTO_ACTION_TYPES.LIST:
|
|
@@ -78,6 +80,8 @@ function getDatabaseClient() {
|
|
|
78
80
|
db = new Kysely({
|
|
79
81
|
dialect: getDialect(),
|
|
80
82
|
plugins: [
|
|
83
|
+
// ensures that the audit context data is written to Postgres configuration parameters
|
|
84
|
+
new AuditContextPlugin(),
|
|
81
85
|
// allows users to query using camelCased versions of the database column names, which
|
|
82
86
|
// should match the names we use in our schema.
|
|
83
87
|
// https://kysely-org.github.io/kysely/classes/CamelCasePlugin.html
|
|
@@ -119,14 +123,13 @@ class InstrumentedClient extends pg.Client {
|
|
|
119
123
|
const sql = args[0];
|
|
120
124
|
|
|
121
125
|
let sqlAttribute = false;
|
|
122
|
-
|
|
123
126
|
let spanName = txStatements[sql.toLowerCase()];
|
|
124
127
|
if (!spanName) {
|
|
125
128
|
spanName = "Database Query";
|
|
126
129
|
sqlAttribute = true;
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
return withSpan(spanName, function (span) {
|
|
132
|
+
return await withSpan(spanName, function (span) {
|
|
130
133
|
if (sqlAttribute) {
|
|
131
134
|
span.setAttribute("sql", args[0]);
|
|
132
135
|
}
|
|
@@ -358,3 +358,113 @@ describe("ModelAPI error handling", () => {
|
|
|
358
358
|
});
|
|
359
359
|
});
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
// The following tests assert on the various
|
|
363
|
+
// jsonrpc responses that *should* happen when a user
|
|
364
|
+
// writes a custom function that inadvertently causes a pg constraint error to occur inside of our ModelAPI class instance.
|
|
365
|
+
describe("ModelAPI error handling", () => {
|
|
366
|
+
let functionConfig;
|
|
367
|
+
let db;
|
|
368
|
+
|
|
369
|
+
beforeEach(async () => {
|
|
370
|
+
process.env.KEEL_DB_CONN_TYPE = "pg";
|
|
371
|
+
process.env.KEEL_DB_CONN = `postgresql://postgres:postgres@localhost:5432/functions-runtime`;
|
|
372
|
+
|
|
373
|
+
db = useDatabase();
|
|
374
|
+
|
|
375
|
+
await sql`
|
|
376
|
+
DROP TABLE IF EXISTS post;
|
|
377
|
+
DROP TABLE IF EXISTS author;
|
|
378
|
+
|
|
379
|
+
CREATE TABLE author(
|
|
380
|
+
"id" text PRIMARY KEY,
|
|
381
|
+
"name" text NOT NULL
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
CREATE TABLE post(
|
|
385
|
+
"id" text PRIMARY KEY,
|
|
386
|
+
"title" text NOT NULL UNIQUE,
|
|
387
|
+
"author_id" text NOT NULL REFERENCES author(id)
|
|
388
|
+
);
|
|
389
|
+
`.execute(db);
|
|
390
|
+
|
|
391
|
+
await sql`
|
|
392
|
+
INSERT INTO author (id, name) VALUES ('adam', 'adam bull')
|
|
393
|
+
`.execute(db);
|
|
394
|
+
|
|
395
|
+
const models = {
|
|
396
|
+
post: new ModelAPI("post", undefined, {
|
|
397
|
+
post: {
|
|
398
|
+
author: {
|
|
399
|
+
relationshipType: "belongsTo",
|
|
400
|
+
foreignKey: "author_id",
|
|
401
|
+
referencesTable: "person",
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
}),
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
functionConfig = {
|
|
408
|
+
permissionFns: {},
|
|
409
|
+
actionTypes: {
|
|
410
|
+
createPost: PROTO_ACTION_TYPES.CREATE,
|
|
411
|
+
deletePost: PROTO_ACTION_TYPES.DELETE,
|
|
412
|
+
},
|
|
413
|
+
functions: {
|
|
414
|
+
createPost: async (ctx, inputs) => {
|
|
415
|
+
new Permissions().allow();
|
|
416
|
+
|
|
417
|
+
const post = await models.post.create({
|
|
418
|
+
id: KSUID.randomSync().string,
|
|
419
|
+
...inputs,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
return post;
|
|
423
|
+
},
|
|
424
|
+
deletePost: async (ctx, inputs) => {
|
|
425
|
+
new Permissions().allow();
|
|
426
|
+
|
|
427
|
+
const deleted = await models.post.delete(inputs);
|
|
428
|
+
|
|
429
|
+
return deleted;
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
createContextAPI: () => ({}),
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("when kysely returns a no result error", async () => {
|
|
437
|
+
// a kysely NoResultError is thrown when attempting to delete/update a non existent record.
|
|
438
|
+
const rpcReq = createJSONRPCRequest("123", "deletePost", {
|
|
439
|
+
id: "non-existent-id",
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
443
|
+
id: "123",
|
|
444
|
+
jsonrpc: "2.0",
|
|
445
|
+
error: {
|
|
446
|
+
code: RuntimeErrors.RecordNotFoundError,
|
|
447
|
+
message: "no result",
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("when there is a not null constraint error", async () => {
|
|
453
|
+
const rpcReq = createJSONRPCRequest("123", "createPost", { title: null });
|
|
454
|
+
|
|
455
|
+
expect(await handleRequest(rpcReq, functionConfig)).toEqual({
|
|
456
|
+
id: "123",
|
|
457
|
+
jsonrpc: "2.0",
|
|
458
|
+
error: {
|
|
459
|
+
code: RuntimeErrors.NotNullConstraintError,
|
|
460
|
+
message: 'null value in column "title" violates not-null constraint',
|
|
461
|
+
data: {
|
|
462
|
+
code: "23502",
|
|
463
|
+
column: "title",
|
|
464
|
+
detail: expect.stringContaining("Failing row contains"),
|
|
465
|
+
table: "post",
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
});
|
package/src/handleSubscriber.js
CHANGED
|
@@ -49,7 +49,7 @@ async function handleSubscriber(request, config) {
|
|
|
49
49
|
const subscriberFunction = subscribers[request.method];
|
|
50
50
|
const actionType = PROTO_ACTION_TYPES.SUBSCRIBER;
|
|
51
51
|
|
|
52
|
-
await tryExecuteSubscriber({ db, actionType }, async () => {
|
|
52
|
+
await tryExecuteSubscriber({ request, db, actionType }, async () => {
|
|
53
53
|
// Return the subscriber function to the containing tryExecuteSubscriber block
|
|
54
54
|
return subscriberFunction(ctx, request.params);
|
|
55
55
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const { withDatabase } = require("./database");
|
|
2
|
+
const { withAuditContext } = require("./auditing");
|
|
2
3
|
const {
|
|
3
4
|
withPermissions,
|
|
4
5
|
PERMISSION_STATE,
|
|
@@ -10,12 +11,15 @@ const { PROTO_ACTION_TYPES } = require("./consts");
|
|
|
10
11
|
// tryExecuteFunction will create a new database transaction around a function call
|
|
11
12
|
// and handle any permissions checks. If a permission check fails, then an Error will be thrown and the catch block will be hit.
|
|
12
13
|
function tryExecuteFunction(
|
|
13
|
-
{ db, permitted, permissionFns, actionType,
|
|
14
|
+
{ request, db, permitted, permissionFns, actionType, ctx },
|
|
14
15
|
cb
|
|
15
16
|
) {
|
|
16
17
|
return withPermissions(permitted, async ({ getPermissionState }) => {
|
|
17
18
|
return withDatabase(db, actionType, async ({ transaction }) => {
|
|
18
|
-
const fnResult = await
|
|
19
|
+
const fnResult = await withAuditContext(request, async () => {
|
|
20
|
+
return cb();
|
|
21
|
+
});
|
|
22
|
+
|
|
19
23
|
// api.permissions maintains an internal state of whether the current function 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).
|
|
20
24
|
// 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
|
|
21
25
|
// and therefore we default to checking the permissions defined in the schema automatically.
|
package/src/tryExecuteJob.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
const { withDatabase } = require("./database");
|
|
2
|
+
const { withAuditContext } = require("./auditing");
|
|
2
3
|
const { withPermissions, PERMISSION_STATE } = require("./permissions");
|
|
3
|
-
|
|
4
4
|
const { PermissionError } = require("./errors");
|
|
5
5
|
|
|
6
6
|
// tryExecuteJob will create a new database transaction around a function call
|
|
7
7
|
// and handle any permissions checks. If a permission check fails, then an Error will be thrown and the catch block will be hit.
|
|
8
8
|
function tryExecuteJob({ db, permitted, actionType, request }, cb) {
|
|
9
9
|
return withPermissions(permitted, async ({ getPermissionState }) => {
|
|
10
|
-
return withDatabase(db, actionType, async (
|
|
11
|
-
await
|
|
10
|
+
return withDatabase(db, actionType, async () => {
|
|
11
|
+
await withAuditContext(request, async () => {
|
|
12
|
+
return cb();
|
|
13
|
+
});
|
|
14
|
+
|
|
12
15
|
// 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).
|
|
13
16
|
// 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
|
|
14
17
|
// and therefore we default to checking the permissions defined in the schema automatically.
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
const { withDatabase } = require("./database");
|
|
2
|
+
const { withAuditContext } = require("./auditing");
|
|
2
3
|
|
|
3
4
|
// tryExecuteSubscriber will create a new database connection and execute the function call.
|
|
4
|
-
function tryExecuteSubscriber({ db, actionType }, cb) {
|
|
5
|
+
function tryExecuteSubscriber({ request, db, actionType }, cb) {
|
|
5
6
|
return withDatabase(db, actionType, async () => {
|
|
6
|
-
await
|
|
7
|
+
await withAuditContext(request, async () => {
|
|
8
|
+
return cb();
|
|
9
|
+
});
|
|
7
10
|
});
|
|
8
11
|
}
|
|
9
12
|
|