kryptheon-night 0.1.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/attack.js ADDED
@@ -0,0 +1,555 @@
1
+ // The impersonation attack.
2
+ //
3
+ // Sign in as one person, ask for another person's data, and see what comes
4
+ // back. This is the attack a recorded browser flow can never find, because the
5
+ // frontend never even tries to ask for somebody else's rows - it only ever
6
+ // requests what the signed-in person is supposed to have.
7
+ //
8
+ // It runs the way a real request runs: become the role PostgREST becomes, put
9
+ // the caller's identity where PostgREST puts it, then read. Verified against a
10
+ // real database in probe-rls.js before any of this was written.
11
+ //
12
+ // Two fake people are seeded and nothing else is ever inserted, so a finding is
13
+ // always about rows this tool created. No real customer is involved at any
14
+ // point, which is the promise the whole product is sold on.
15
+
16
+ const { quote } = require('./schema.js');
17
+
18
+ const USER_A = '11111111-1111-4111-8111-111111111111';
19
+ const USER_B = '22222222-2222-4222-8222-222222222222';
20
+
21
+ // Two more who own nothing at all. The seeded pair already hold a row each,
22
+ // so an attack that inserts under their name collides with the row the seeder
23
+ // put there - on a table keyed by the person, that is a primary key clash, and
24
+ // it was being read as the app refusing the attack. These are for any attack
25
+ // that has to add a row of its own.
26
+ const USER_C = '55555555-5555-4555-8555-555555555555';
27
+ const USER_D = '66666666-6666-4666-8666-666666666666';
28
+
29
+ // The names an owner column is actually given, in the order they are worth
30
+ // believing. `id` is last and only counts on a table that looks like a profile,
31
+ // where the row id IS the person.
32
+ const OWNER_NAMES = ['user_id', 'owner_id', 'owner', 'profile_id', 'account_id', 'created_by', 'author_id'];
33
+
34
+ /** The column that says who a row belongs to, or null if nothing does. */
35
+ function ownerColumn(table) {
36
+ const uuids = table.columns.filter((c) => c.type === 'uuid');
37
+ for (const name of OWNER_NAMES) {
38
+ const found = uuids.find((c) => c.name === name);
39
+ if (found) return found.name;
40
+ }
41
+ // A profiles table keyed by the person themselves.
42
+ if (/profile|user|account|member/i.test(table.name)) {
43
+ const id = uuids.find((c) => c.name === 'id');
44
+ if (id) return id.name;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /** Keeps a generated value inside a declared width like varchar(20). */
50
+ function fitTo(text, type) {
51
+ const match = /^[a-z ]*\((\d+)\)$/.exec(String(type).trim());
52
+ if (!match) return text;
53
+ const limit = Number(match[1]);
54
+ return text.length > limit ? text.slice(0, limit) : text;
55
+ }
56
+
57
+ /**
58
+ * Something valid to put in a column, so a row can exist at all.
59
+ *
60
+ * `distinct` is what keeps two seeded rows from being identical. Without it
61
+ * every row carried the same text, so a table with a unique email column
62
+ * refused the second insert and the whole table was reported as "not checked"
63
+ * - a well-built app treated as an unknown one. The tag goes at the front
64
+ * because a narrow varchar truncates the end, and two rows truncated to the
65
+ * same string is that bug all over again.
66
+ */
67
+ function valueFor(column, owner, distinct, attempt) {
68
+ // A domain is somebody's own type with a rule bolted on. The rule cannot be
69
+ // guessed at from here, but the type underneath it can be filled in.
70
+ const type = String(column.base_type || column.type).toLowerCase();
71
+ const tag = distinct === undefined || distinct === null ? '' : String(distinct);
72
+ const step = Number(tag) || 0;
73
+
74
+ // An enum accepts one of a fixed list and nothing else. Every generated
75
+ // string was rejected, and the table went down as "not checked".
76
+ //
77
+ // Insisting on a real array rather than accepting anything with a length:
78
+ // the labels arrived once as the string "{new,paid,shipped}", whose first
79
+ // element is the character "{", and that is a value the database rejects
80
+ // just as firmly while looking like the code is working.
81
+ if (Array.isArray(column.enum_labels) && column.enum_labels.length) return column.enum_labels[0];
82
+ // Anything at all is allowed in an empty array, whatever the element type.
83
+ if (column.is_array || /\[\]$/.test(type)) return '{}';
84
+
85
+ if (type === 'uuid') return owner;
86
+ if (/^(integer|bigint|smallint|numeric|decimal|real|double|money)/.test(type)) return 1 + step;
87
+ if (/^bool/.test(type)) return true;
88
+ if (/^(timestamp|date)/.test(type)) return new Date().toISOString();
89
+ if (/^time/.test(type)) return '12:00:00';
90
+ if (/^interval/.test(type)) return '1 day';
91
+ if (/^json/.test(type)) return '{}';
92
+ if (/^(inet|cidr)/.test(type)) return '192.0.2.' + (1 + step);
93
+ if (/^macaddr8/.test(type)) return '08:00:2b:01:02:03:04:0' + (5 + step);
94
+ if (/^macaddr/.test(type)) return '08:00:2b:01:02:0' + (3 + step);
95
+ if (/^(tsvector|tsquery)/.test(type)) return 'kryptheon';
96
+ if (/^bytea/.test(type)) return Buffer.from('kryptheon');
97
+ if (/^xml/.test(type)) return '<kryptheon/>';
98
+ if (/^bit/.test(type)) return '0';
99
+ if (/^(point|line|lseg|box|path|polygon|circle)/.test(type)) return '(0,0)';
100
+
101
+ // Text is where the rules live that cannot be read: a domain that insists on
102
+ // an @, a CHECK on a length, a regex for a product code. Rather than pretend
103
+ // to understand them, the seeder works down a short ladder of shapes and
104
+ // keeps whichever one the database accepts.
105
+ const shapes = [
106
+ tag ? tag + ' kryptheon test' : 'kryptheon test',
107
+ 'kryptheon' + (tag || '') + '@example.com',
108
+ 'KN' + (tag || '1'),
109
+ String(step + 1),
110
+ 'https://example.com/kryptheon',
111
+ ];
112
+ return fitTo(shapes[Math.min(Math.max(attempt || 0, 0), shapes.length - 1)], type);
113
+ }
114
+
115
+ /**
116
+ * Values a CHECK constraint will actually accept for one column.
117
+ *
118
+ * `status text CHECK (status IN ('open','closed'))` is one of the most common
119
+ * things anyone writes, and Postgres stores it as
120
+ * `CHECK ((status = ANY (ARRAY['open'::text, 'closed'::text])))`. Reading the
121
+ * literals back out turns a table that could never be seeded into one that can.
122
+ *
123
+ * Only this one shape is understood, deliberately. A CHECK can contain
124
+ * anything, and pretending to satisfy an arbitrary one would mean inventing
125
+ * rows that the app itself would reject.
126
+ */
127
+ function allowedByCheck(table, columnName) {
128
+ const found = [];
129
+ const escaped = String(columnName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
130
+ // Both spellings of the same rule. On a text column Postgres writes
131
+ // state = ANY (ARRAY['open'::text, ...])
132
+ // and on a varchar column it writes
133
+ // (state)::text = ANY ((ARRAY['open'::character varying, ...])::text[])
134
+ // which the first pattern could not cross: it stopped at the bracket that
135
+ // closes the cast. A varchar column with an IN list is an ordinary thing to
136
+ // write, and every table with one was seeded with an invented value, refused
137
+ // by its own constraint, and reported as a table that could not be checked.
138
+ //
139
+ // What may sit between the column and the = is spelled out rather than left
140
+ // to a negated class: anything looser reaches across the next AND and hands
141
+ // one column's allowed values to another.
142
+ const anyOf = new RegExp(
143
+ '\\b' + escaped + '\\b\\)?(?:::[a-z ]+)?\\s*=\\s*ANY\\s*\\(+\\s*ARRAY\\[(.*?)\\]',
144
+ 'i',
145
+ );
146
+ for (const constraint of table.constraints || []) {
147
+ if (constraint.kind !== 'c') continue;
148
+ const match = anyOf.exec(String(constraint.definition));
149
+ if (!match) continue;
150
+ // Picked out one at a time rather than split on commas, which came apart
151
+ // in the middle of any value that had a comma in it.
152
+ const literals = /'((?:[^']|'')*)'/g;
153
+ let literal;
154
+ while ((literal = literals.exec(match[1])) !== null) {
155
+ found.push(literal[1].split("''").join("'"));
156
+ }
157
+ }
158
+ return found;
159
+ }
160
+
161
+ /**
162
+ * The foreign keys on a table, read out of Postgres's own wording.
163
+ *
164
+ * Every column of the key, not just the first. A key over (org_id, cart_id)
165
+ * used to be read as though it were only org_id: the second column got an
166
+ * invented value, the pair pointed at no row that existed, and the table was
167
+ * reported as one that could not be checked.
168
+ */
169
+ function foreignKeys(table) {
170
+ const keys = [];
171
+ const unquote = (text) => text.trim().split('"').join('');
172
+ for (const constraint of table.constraints || []) {
173
+ if (constraint.kind !== 'f') continue;
174
+ const match = /FOREIGN KEY \(([^)]+)\) REFERENCES ([^(]+)\(([^)]+)\)/i.exec(constraint.definition);
175
+ if (!match) continue;
176
+ keys.push({
177
+ columns: match[1].split(',').map(unquote),
178
+ refTable: unquote(match[2].split('.').pop()),
179
+ refColumns: match[3].split(',').map(unquote),
180
+ });
181
+ }
182
+ return keys;
183
+ }
184
+
185
+ /**
186
+ * Parents before children.
187
+ *
188
+ * Tables come back in alphabetical order, which put `orders` before `profiles`
189
+ * and made every insert fail on the foreign key - on the first real app it was
190
+ * pointed at, because every app has one of these. A cycle is left in whatever
191
+ * order it arrived: it cannot be satisfied anyway, and the table that fails is
192
+ * reported rather than dropped.
193
+ */
194
+ function dependencyOrder(tables) {
195
+ const byName = new Map(tables.map((t) => [t.name, t]));
196
+ const ordered = [];
197
+ const done = new Set();
198
+ const visiting = new Set();
199
+
200
+ const visit = (table) => {
201
+ if (done.has(table.name) || visiting.has(table.name)) return;
202
+ visiting.add(table.name);
203
+ for (const key of foreignKeys(table)) {
204
+ const parent = byName.get(key.refTable);
205
+ if (parent && parent.name !== table.name) visit(parent);
206
+ }
207
+ visiting.delete(table.name);
208
+ done.add(table.name);
209
+ ordered.push(table);
210
+ };
211
+
212
+ for (const table of tables) visit(table);
213
+ return ordered;
214
+ }
215
+
216
+ /**
217
+ * One row that is really in the parent table, so a foreign key is satisfied.
218
+ *
219
+ * The whole row, not one column of it. A composite key has to point at a pair
220
+ * that exists together: borrowing each column from a separate row would build
221
+ * a combination the parent never had.
222
+ */
223
+ async function existingRow(client, schema, key) {
224
+ try {
225
+ const columns = key.refColumns.map((name, i) => quote(name) + ' AS v' + i).join(', ');
226
+ const { rows } = await client.query(
227
+ 'SELECT ' + columns + ' FROM ' + quote(schema) + '.' + quote(key.refTable) + ' LIMIT 1',
228
+ );
229
+ if (!rows.length) return null;
230
+ return key.refColumns.map((name, i) => rows[0]['v' + i]);
231
+ } catch (err) {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * A row that will actually go in.
238
+ *
239
+ * Owner column set to the person, foreign keys pointing at rows that really
240
+ * exist, every NOT NULL column filled, and anything with a default left to
241
+ * supply its own value.
242
+ *
243
+ * `overrides` is what the Collision attack needs: it forces one column to a
244
+ * chosen value while everything else stays distinct, so when two inserts race
245
+ * they collide on that column and on nothing else. Without it a second unique
246
+ * column elsewhere in the table would refuse the insert, and the refusal would
247
+ * be read as the app defending itself.
248
+ */
249
+ async function rowFor(client, schema, table, person, distinct, overrides, attempt) {
250
+ const forced = overrides || {};
251
+ const owner = ownerColumn(table);
252
+ const columns = [];
253
+ const values = [];
254
+
255
+ // Every column that takes part in a foreign key, and the value it has to
256
+ // hold. Resolved one key at a time so that all of a composite key's columns
257
+ // come from the same parent row.
258
+ const borrowed = new Map();
259
+ for (const key of foreignKeys(table)) {
260
+ const row = await existingRow(client, schema, key);
261
+ if (!row) continue;
262
+ key.columns.forEach((name, i) => {
263
+ if (!borrowed.has(name)) borrowed.set(name, row[i]);
264
+ });
265
+ }
266
+ const partOfKey = new Set();
267
+ for (const key of foreignKeys(table)) key.columns.forEach((name) => partOfKey.add(name));
268
+
269
+ for (const column of table.columns) {
270
+ if (Object.prototype.hasOwnProperty.call(forced, column.name)) {
271
+ columns.push(column.name);
272
+ values.push(forced[column.name]);
273
+ continue;
274
+ }
275
+ if (column.name === owner) {
276
+ columns.push(column.name);
277
+ values.push(person);
278
+ continue;
279
+ }
280
+ // A column pointing at another table has to hold something that is
281
+ // actually there, whatever its type would otherwise suggest.
282
+ if (partOfKey.has(column.name)) {
283
+ if (borrowed.has(column.name)) {
284
+ columns.push(column.name);
285
+ values.push(borrowed.get(column.name));
286
+ continue;
287
+ }
288
+ if (column.not_null) throw new Error('nothing to point ' + column.name + ' at');
289
+ continue;
290
+ }
291
+ // A generated column computes itself and refuses to be written to at all.
292
+ //
293
+ // Two halves, and only one of them can be observed. An identity column
294
+ // carries no default_expr, so without the identity half the seeder writes
295
+ // to it and Postgres refuses the row - both engines are caught doing it.
296
+ // A stored generated column keeps its expression IN default_expr, so the
297
+ // line below skips it whether or not this one does: removing the generated
298
+ // half changes nothing any fixture could see. It stays because that is a
299
+ // fact about how readColumns fills the shape, not about Postgres, and the
300
+ // day it changes this is the only thing standing in the way.
301
+ if (column.generated || column.identity) continue;
302
+ // Anything with a default can supply its own value.
303
+ if (column.default_expr) continue;
304
+ if (!column.not_null) continue;
305
+ columns.push(column.name);
306
+ // A CHECK that lists what it will accept beats anything invented here.
307
+ const allowed = allowedByCheck(table, column.name);
308
+ values.push(
309
+ allowed.length
310
+ ? allowed[Math.min(Math.max(attempt || 0, 0), allowed.length - 1)]
311
+ : valueFor(column, person, distinct, attempt),
312
+ );
313
+ }
314
+
315
+ return { columns: columns, values: values };
316
+ }
317
+
318
+ /** Puts a built row in. Separate so the same row can be raced against itself. */
319
+ function insertRow(client, schema, table, row) {
320
+ const where = 'INSERT INTO ' + quote(schema) + '.' + quote(table);
321
+ // A table of nothing but an id and its defaults leaves no columns to name,
322
+ // and "INSERT INTO t () VALUES ()" is a syntax error. Postgres has a spelling
323
+ // for exactly this, and without it every settings and flags table in the
324
+ // world came back as one that could not be checked.
325
+ if (!row.columns.length) return client.query(where + ' DEFAULT VALUES');
326
+ const placeholders = row.values.map((_, i) => '$' + (i + 1));
327
+ return client.query(
328
+ where + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' + placeholders.join(', ') + ')',
329
+ row.values,
330
+ );
331
+ }
332
+
333
+ // How many differently-shaped values to try before giving up on a table. The
334
+ // rules that reject the first attempt - a domain insisting on an @, a CHECK on
335
+ // a length - cannot be read out of the catalogue, so the only honest way to
336
+ // satisfy them is to offer something else and see.
337
+ const SHAPES_TO_TRY = 5;
338
+
339
+ /**
340
+ * Two rows per table: one belonging to each fake person.
341
+ *
342
+ * Inserted as the owner of the schema, deliberately - seeding is not the
343
+ * attack, and a policy that blocked the seed would leave nothing to attack.
344
+ *
345
+ * A table that cannot be seeded is recorded rather than skipped quietly. An
346
+ * empty table reads as a safe table, so silently failing here would report an
347
+ * app as secure because the tool could not get a row into it.
348
+ */
349
+ async function seed(client, schema, tables) {
350
+ const seeded = [];
351
+ const skipped = [];
352
+ for (const table of dependencyOrder(tables)) {
353
+ const owner = ownerColumn(table);
354
+
355
+ // A table with nobody's name on it still gets a row. Without one, an open
356
+ // door cannot be told from an empty room: a logged-out stranger reads zero
357
+ // rows either way, and the tool reports the app as safe. Settings tables,
358
+ // waitlists and contact forms are exactly this shape, and exactly the ones
359
+ // that get left open.
360
+ const people = owner ? [USER_A, USER_B] : [USER_A];
361
+
362
+ // Each attempt offers a differently-shaped set of values. A table whose
363
+ // rules reject all of them is reported, never quietly passed over.
364
+ let refused = null;
365
+ let landed = false;
366
+ let worked = 0;
367
+ for (let attempt = 0; attempt < SHAPES_TO_TRY && !landed; attempt++) {
368
+ try {
369
+ let nth = 0;
370
+ for (const person of people) {
371
+ nth += 1;
372
+ const row = await rowFor(client, schema, table, person, nth, null, attempt);
373
+ await insertRow(client, schema, table.name, row);
374
+ }
375
+ landed = true;
376
+ worked = attempt;
377
+ } catch (err) {
378
+ refused = err.message;
379
+ // A half-seeded table would make the next attempt collide with its own
380
+ // first row, so anything that did go in is taken back out.
381
+ await client
382
+ .query('DELETE FROM ' + quote(schema) + '.' + quote(table.name))
383
+ .catch(() => {});
384
+ }
385
+ }
386
+
387
+ if (landed) {
388
+ // The shape that worked is kept: any later attack that has to insert into
389
+ // this table can use the same one instead of rediscovering it, and be
390
+ // sure a refusal is the app defending itself rather than a CHECK it
391
+ // never satisfied.
392
+ seeded.push({ table: table.name, owner: owner, attempt: worked });
393
+ } else {
394
+ // Recorded, never swallowed. The report has to say this table was not
395
+ // checked rather than let an empty table pass for a safe one.
396
+ skipped.push({ table: table.name, why: refused });
397
+ }
398
+ }
399
+ return { seeded: seeded, skipped: skipped };
400
+ }
401
+
402
+ /** Reads a table the way a request would, as whoever is asking. */
403
+ async function readAs(client, schema, table, role, userId) {
404
+ await client.query('BEGIN');
405
+ try {
406
+ await client.query('SET LOCAL role TO ' + role);
407
+ // A logged-out visitor is not "no claims". Supabase hands PostgREST the
408
+ // anon key, which is itself a JWT, so request.jwt.claims arrives as a real
409
+ // JSON object that simply has no `sub` in it.
410
+ //
411
+ // Sending an empty string instead made auth.uid() throw on the cast, and a
412
+ // read that throws returns no rows - which is exactly what a properly
413
+ // secured table returns. Every table whose policy calls auth.uid(), which
414
+ // is nearly every table anyone writes, came back looking safe without the
415
+ // rule ever being evaluated.
416
+ await client.query('SELECT set_config($1, $2, true)', [
417
+ 'request.jwt.claims',
418
+ JSON.stringify(userId ? { sub: userId, role: role } : { role: role }),
419
+ ]);
420
+ const result = await client.query('SELECT * FROM ' + quote(schema) + '.' + quote(table));
421
+ return result.rows;
422
+ } catch (err) {
423
+ // A refusal is an answer: the table is not reachable by this caller at all.
424
+ return { blocked: err.message };
425
+ } finally {
426
+ await client.query('ROLLBACK');
427
+ }
428
+ }
429
+
430
+ function rowsOwnedBy(rows, owner, person) {
431
+ if (!Array.isArray(rows)) return 0;
432
+ return rows.filter((r) => String(r[owner]) === person).length;
433
+ }
434
+
435
+ /**
436
+ * A refusal is only an answer when it is the right refusal.
437
+ *
438
+ * "permission denied for table orders" means this caller cannot reach the
439
+ * table at all. That is the attack being defeated, and it is good news worth
440
+ * recording as a pass.
441
+ *
442
+ * Everything else - a schema the policy needs and the role cannot use, a
443
+ * function the policy calls that is not there, a timeout - means the rule was
444
+ * never evaluated. No verdict exists. Both come back as an error and return
445
+ * zero rows, and zero rows is exactly what a perfectly secured table returns,
446
+ * so telling them apart is the difference between "you are safe" and "I could
447
+ * not tell", which is the difference the whole product rests on.
448
+ */
449
+ function refusalMeans(message) {
450
+ // The multi-word kinds come first. Postgres says "permission denied for
451
+ // materialized view hits", and an alternation that tried `view` first would
452
+ // never reach it - so a matview nobody had granted was filed as untested
453
+ // rather than as the attack being beaten, and a correct app collected a
454
+ // warning it had not earned.
455
+ const denied = /permission denied for (materialized view|foreign table|partitioned table|table|relation|view|sequence)/i;
456
+ return denied.test(String(message)) ? 'unreachable' : 'untested';
457
+ }
458
+
459
+ /**
460
+ * What each table gives away, and to whom.
461
+ *
462
+ * Two separate findings, because they are two different conversations with the
463
+ * person who has to fix it:
464
+ *
465
+ * exposed - a logged-out stranger can read the table. This is the one that
466
+ * ends up on a news site.
467
+ * crossed - a signed-in customer can read another customer's rows. Quieter,
468
+ * and the one that breaks trust with the people already paying.
469
+ */
470
+ async function impersonate(client, schema, tables) {
471
+ const findings = [];
472
+ const completed = [];
473
+ const blocked = [];
474
+
475
+ /** Did this read produce a verdict, and if not, why not? */
476
+ const settle = (key, table, answer, as) => {
477
+ if (Array.isArray(answer)) {
478
+ completed.push(key);
479
+ return true;
480
+ }
481
+ if (refusalMeans(answer.blocked) === 'unreachable') {
482
+ // Refused outright. The attack ran and lost, which is the result we want
483
+ // for a table that is properly closed.
484
+ completed.push(key);
485
+ return false;
486
+ }
487
+ blocked.push({ table: table, key: key, why: as + ': ' + answer.blocked });
488
+ return false;
489
+ };
490
+
491
+ for (const table of tables) {
492
+ const owner = ownerColumn(table);
493
+ const anon = await readAs(client, schema, table.name, 'anon', null);
494
+ const asA = await readAs(client, schema, table.name, 'authenticated', USER_A);
495
+
496
+ if (settle('exposed:' + table.name, table.name, anon, 'as a logged-out visitor') && anon.length > 0) {
497
+ findings.push({
498
+ kind: 'exposed',
499
+ table: table.name,
500
+ readable: anon.length,
501
+ columns: Object.keys(anon[0] || {}),
502
+ rlsEnabled: table.rlsEnabled,
503
+ isView: Boolean(table.isView),
504
+ });
505
+ }
506
+
507
+ // Crossed is only ever looked for where a row says who it belongs to, so
508
+ // on a table with no owner there is no attack to record either way.
509
+ if (owner && settle('crossed:' + table.name, table.name, asA, 'as a signed-in customer')) {
510
+ const theirs = rowsOwnedBy(asA, owner, USER_B);
511
+ if (theirs > 0) {
512
+ findings.push({
513
+ kind: 'crossed',
514
+ table: table.name,
515
+ owner: owner,
516
+ readable: theirs,
517
+ columns: Object.keys(asA[0] || {}),
518
+ rlsEnabled: table.rlsEnabled,
519
+ });
520
+ }
521
+ }
522
+ }
523
+
524
+ return { findings: findings, completed: completed, blocked: blocked };
525
+ }
526
+
527
+ /** A stable shape for comparing one run against another. */
528
+ function summarise(result) {
529
+ const findings = Array.isArray(result) ? result : (result && result.findings) || [];
530
+ return findings
531
+ .map((f) => f.kind + ':' + f.table + ':' + f.readable)
532
+ .sort()
533
+ .join(' | ');
534
+ }
535
+
536
+ module.exports = {
537
+ USER_A: USER_A,
538
+ USER_B: USER_B,
539
+ USER_C: USER_C,
540
+ USER_D: USER_D,
541
+ refusalMeans: refusalMeans,
542
+ ownerColumn: ownerColumn,
543
+ fitTo: fitTo,
544
+ foreignKeys: foreignKeys,
545
+ dependencyOrder: dependencyOrder,
546
+ valueFor: valueFor,
547
+ allowedByCheck: allowedByCheck,
548
+ rowFor: rowFor,
549
+ existingRow: existingRow,
550
+ insertRow: insertRow,
551
+ seed: seed,
552
+ readAs: readAs,
553
+ impersonate: impersonate,
554
+ summarise: summarise,
555
+ };