@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamkeel/functions-runtime",
3
- "version": "0.365.18",
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
- try {
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
- } catch (e) {
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
- let builder = db.updateTable(this._tableName).returningAll();
200
+ return await this.#execute(async (db) => {
201
+ let builder = db.updateTable(this._tableName).returningAll();
162
202
 
163
- builder = builder.set(snakeCaseObject(values));
203
+ builder = builder.set(snakeCaseObject(values));
164
204
 
165
- const context = new QueryContext([this._tableName], this._tableConfigMap);
205
+ const context = new QueryContext(
206
+ [this._tableName],
207
+ this._tableConfigMap
208
+ );
166
209
 
167
- // TODO: support joins for update
168
- builder = applyWhereConditions(context, builder, where);
210
+ // TODO: support joins for update
211
+ builder = applyWhereConditions(context, builder, where);
169
212
 
170
- span.setAttribute("sql", builder.compile().sql);
213
+ span.setAttribute("sql", builder.compile().sql);
171
214
 
172
- try {
173
215
  const row = await builder.executeTakeFirstOrThrow();
174
216
  return camelCaseObject(row);
175
- } catch (e) {
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
- let builder = db.deleteFrom(this._tableName).returning(["id"]);
225
+ return await this.#execute(async (db) => {
226
+ let builder = db.deleteFrom(this._tableName).returning(["id"]);
187
227
 
188
- const context = new QueryContext([this._tableName], this._tableConfigMap);
228
+ const context = new QueryContext(
229
+ [this._tableName],
230
+ this._tableConfigMap
231
+ );
189
232
 
190
- // TODO: support joins for delete
191
- builder = applyWhereConditions(context, builder, where);
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
- } catch (e) {
198
- throw new DatabaseError(e);
199
- }
240
+ });
200
241
  });
201
242
  }
202
243
 
@@ -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
+ });
@@ -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, request, ctx },
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 cb();
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.
@@ -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 ({ transaction }) => {
11
- await cb();
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 cb();
7
+ await withAuditContext(request, async () => {
8
+ return cb();
9
+ });
7
10
  });
8
11
  }
9
12