kryptheon-night 0.1.22 → 0.1.26

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/schema.js CHANGED
@@ -322,6 +322,82 @@ async function readAnonDefinerFunctions(client, schema, role) {
322
322
  });
323
323
  }
324
324
 
325
+ /**
326
+ * The app's own SQL and PL/pgSQL functions, with their full definitions.
327
+ *
328
+ * Read so the helpers a rule calls can be built inside the copy. Found on a
329
+ * blind test: a rule called has_org_role(org_id, ARRAY[...]::org_role[]). The
330
+ * copy has its own org_role, the helper stayed in the original schema taking
331
+ * the original's, and no rule could be created - the scan stopped before it
332
+ * began. Left in the original, a helper also answers from the original's
333
+ * tables rather than from the copy's.
334
+ */
335
+ async function readFunctions(client, schema) {
336
+ const { rows } = await client.query(
337
+ `SELECT p.proname AS name,
338
+ l.lanname AS language,
339
+ p.prosrc AS src,
340
+ pg_get_functiondef(p.oid) AS def,
341
+ pg_get_function_identity_arguments(p.oid) AS args,
342
+ p.proacl IS NULL AS default_acl,
343
+ (SELECT coalesce(array_agg(g ORDER BY g), '{}'::text[])
344
+ FROM (SELECT DISTINCT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee)::text END AS g
345
+ FROM aclexplode(p.proacl) a
346
+ WHERE a.privilege_type = 'EXECUTE' AND a.grantee <> p.proowner) x) AS executors
347
+ FROM pg_proc p
348
+ JOIN pg_namespace n ON n.oid = p.pronamespace
349
+ JOIN pg_language l ON l.oid = p.prolang
350
+ WHERE n.nspname = $1
351
+ AND p.prokind = 'f'
352
+ AND p.prorettype <> 'pg_catalog.trigger'::regtype
353
+ ORDER BY p.proname, p.oid`,
354
+ [schema],
355
+ );
356
+ return rows;
357
+ }
358
+
359
+ /**
360
+ * Supabase Storage's buckets: whether each is public, and the rules on
361
+ * storage.objects that say who may read it (a rule naming the bucket's id).
362
+ *
363
+ * The settings and the rules only - never a file, never a file's name. A
364
+ * database without Supabase Storage, or one this connection cannot read it in,
365
+ * has none. Mirrors read_buckets in engine.sql, query for query.
366
+ *
367
+ * Asked first rather than left to fail: a failed query inside a caller's
368
+ * transaction would abort everything after it.
369
+ */
370
+ async function readBuckets(client) {
371
+ try {
372
+ const here = await client.query(
373
+ `SELECT to_regclass('storage.buckets') IS NOT NULL
374
+ AND EXISTS (SELECT 1 FROM information_schema.columns
375
+ WHERE table_schema = 'storage' AND table_name = 'buckets' AND column_name = 'public') AS found`,
376
+ );
377
+ if (!here.rows[0].found) return [];
378
+ const { rows } = await client.query(BUCKETS_QUERY);
379
+ return rows[0].buckets;
380
+ } catch (err) {
381
+ return [];
382
+ }
383
+ }
384
+
385
+ const BUCKETS_QUERY =
386
+ `SELECT coalesce(jsonb_agg(jsonb_build_object(
387
+ 'id', b.id::text,
388
+ 'name', b.name::text,
389
+ 'public', coalesce(b.public, false),
390
+ 'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
391
+ 'name', p.policyname::text,
392
+ 'roles', to_jsonb(p.roles::text[]),
393
+ 'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
394
+ FROM pg_policies p
395
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
396
+ AND p.cmd IN ('SELECT', 'ALL')
397
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
398
+ ) ORDER BY b.id), '[]'::jsonb) AS buckets
399
+ FROM storage.buckets b`;
400
+
325
401
  async function readPolicies(client, schema) {
326
402
  const { rows } = await client.query(
327
403
  `SELECT tablename AS table_name,
@@ -597,6 +673,8 @@ async function readSchema(client, schema) {
597
673
  viewGrants: await readViewGrants(client, schema),
598
674
  external: external,
599
675
  anonFunctions: await readAnonDefinerFunctions(client, schema),
676
+ functions: await readFunctions(client, schema),
677
+ buckets: await readBuckets(client),
600
678
  unsupported: unsupported,
601
679
  };
602
680
  }
@@ -656,18 +734,19 @@ function rewriteSchemaRefs(expr, fromSchema, toSchema) {
656
734
  * since source and copy printed the same words.
657
735
  *
658
736
  * Not rewriteSchemaRefs, which moves every "kn_app." it finds: a rule calling
659
- * one of the app's functions (kn_app.whoami()) has to keep calling it,
660
- * because functions are not copied, and pointing it at the copy makes the
661
- * CREATE POLICY fail. Only names that are tables or views of this app move.
662
- * A name followed by more identifier characters is a different name -
663
- * members_log is not members - so the match stops at a word boundary.
737
+ * one of the app's functions that is not copied (kn_app.whoami() in another
738
+ * language) has to keep calling it, and pointing it at the copy makes the
739
+ * CREATE POLICY fail. Only tables, views, and the functions the copy was
740
+ * given (copiedFunctions) move. A name followed by more identifier characters
741
+ * is a different name - members_log is not members - so the match stops at a
742
+ * word boundary.
664
743
  */
665
- function rewriteOwnTableRefs(expr, plan, target) {
744
+ function rewriteOwnTableRefs(expr, plan, target, copiedFunctions) {
666
745
  if (expr === null || expr === undefined) return expr;
667
746
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
668
747
  const simple = (s) => /^[a-z_][a-z0-9_$]*$/.test(s);
669
748
  const schemaForms = [quote(plan.schema)].concat(simple(plan.schema) ? [plan.schema] : []);
670
- const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name));
749
+ const own = (plan.tables || []).map((t) => t.name).concat((plan.views || []).map((v) => v.name), copiedFunctions || []);
671
750
  let out = String(expr);
672
751
  for (const name of own) {
673
752
  const nameForms = [quote(name)].concat(simple(name) ? [name] : []);
@@ -681,6 +760,76 @@ function rewriteOwnTableRefs(expr, plan, target) {
681
760
  return out;
682
761
  }
683
762
 
763
+ /**
764
+ * Which of the app's functions the copy needs: every SQL or PL/pgSQL function
765
+ * a rule or a view calls, and every one those call in turn. Matched by name
766
+ * followed by "(", bare or schema-qualified; every overload of a name comes
767
+ * along. Functions in other languages stay where they are.
768
+ */
769
+ function functionsToCopy(plan) {
770
+ const copyable = (plan.functions || []).filter((f) => f.language === 'sql' || f.language === 'plpgsql');
771
+ const names = Array.from(new Set(copyable.map((f) => f.name)));
772
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
773
+ const calls = (text, name) => new RegExp('(^|[^A-Za-z0-9_$])"?' + escape(name) + '"?\\s*\\(').test(String(text || ''));
774
+ const seeds = []
775
+ .concat((plan.policies || []).map((p) => String(p.qual || '') + ' ' + String(p.with_check || '')))
776
+ .concat((plan.views || []).map((v) => v.definition));
777
+ const wanted = new Set(names.filter((name) => seeds.some((text) => calls(text, name))));
778
+ // And whatever those call, until nothing new turns up.
779
+ let grew = true;
780
+ while (grew) {
781
+ grew = false;
782
+ for (const f of copyable) {
783
+ if (!wanted.has(f.name)) continue;
784
+ for (const name of names) {
785
+ if (!wanted.has(name) && calls(f.src, name)) {
786
+ wanted.add(name);
787
+ grew = true;
788
+ }
789
+ }
790
+ }
791
+ }
792
+ return copyable.filter((f) => wanted.has(f.name));
793
+ }
794
+
795
+ /**
796
+ * One of the app's functions, rebuilt in the copy: the same body and settings,
797
+ * its name in the copy, its references to the app's own tables, views and
798
+ * copied functions pointed at the copy, and a pinned search_path that looks in
799
+ * the copy first and the original second (for what the copy does not have,
800
+ * such as an extension's functions).
801
+ */
802
+ function copyFunctionStatement(fn, plan, target, copiedNames) {
803
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
804
+ const schemaForms = [quote(plan.schema), plan.schema].map(escape).join('|');
805
+ let def = String(fn.def).replace(
806
+ new RegExp('^CREATE OR REPLACE FUNCTION\\s+(?:' + schemaForms + ')\\.'),
807
+ () => 'CREATE FUNCTION ' + quote(target) + '.',
808
+ );
809
+ // The settings line, e.g. SET search_path TO 'public', 'storage'
810
+ const pinned = /^\s*SET search_path (?:TO|=) /m.test(def);
811
+ def = def.replace(/^(\s*SET search_path (?:TO|=) )(.*)$/m, (all, head, list) =>
812
+ head + list.split(',').map((part) => {
813
+ const bare = part.trim().replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
814
+ return bare === plan.schema ? "'" + target + "', " + part.trim() : part.trim();
815
+ }).join(', '));
816
+ // None pinned, so a bare name in the body is looked up on the caller's path
817
+ // when it runs - and the caller's path has never heard of the copy. Found on
818
+ // a blind test (OrbitDesk): member_of and has_role read `workspace_members`
819
+ // bare, so the copy's helpers read the original's members, with the
820
+ // original's workspace_role, against the copy's. Every rule calling has_role
821
+ // failed with "operator does not exist", 13 attacks never ran, and a loop
822
+ // through member_of never fired because the original's table was empty.
823
+ // So the copy's helper is pinned the way the copy itself was built: the copy
824
+ // first, then where the original's bare names would have gone. A BEGIN
825
+ // ATOMIC body has no AS line and needs none - it was bound when it was made.
826
+ if (!pinned) {
827
+ const path = Array.from(new Set([target, plan.schema, 'public', 'extensions']));
828
+ def = def.replace(/\nAS /, '\n SET search_path TO ' + path.map((s) => "'" + s + "'").join(', ') + '\nAS ');
829
+ }
830
+ return rewriteOwnTableRefs(def, plan, target, copiedNames);
831
+ }
832
+
684
833
  /**
685
834
  * Points a reference with no schema on it at the copy.
686
835
  *
@@ -940,6 +1089,29 @@ async function writeSchema(client, plan, target) {
940
1089
  statements.push(rewriteSchemaRefs(index.definition, plan.schema, target));
941
1090
  }
942
1091
 
1092
+ // The helpers the rules and views call, after the tables their bodies read
1093
+ // and before anything that calls them.
1094
+ const copied = functionsToCopy(plan);
1095
+ const copiedNames = Array.from(new Set(copied.map((f) => f.name)));
1096
+ // The app's own types move with them: a copied helper taking app.role_t[]
1097
+ // has to take the copy's role_t, or the copy's columns cannot be compared
1098
+ // with its arguments.
1099
+ const movedNames = copiedNames.concat((plan.types || []).map((t) => t.name));
1100
+ for (const fn of copied) {
1101
+ statements.push(copyFunctionStatement(fn, plan, target, movedNames));
1102
+ // Who may call it, as in the original. Found by the blocked-read check: a
1103
+ // helper the original had closed to anon and authenticated came out of
1104
+ // the copy open to everyone, and a rule that cannot be evaluated was
1105
+ // reported as evaluated.
1106
+ if (!fn.default_acl) {
1107
+ const signature = quote(target) + '.' + quote(fn.name) + '(' + rewriteOwnTableRefs(fn.args, plan, target, movedNames) + ')';
1108
+ statements.push('REVOKE ALL ON FUNCTION ' + signature + ' FROM PUBLIC');
1109
+ for (const who of fn.executors || []) {
1110
+ statements.push('GRANT EXECUTE ON FUNCTION ' + signature + ' TO ' + (who === 'PUBLIC' ? 'PUBLIC' : quote(who)));
1111
+ }
1112
+ }
1113
+ }
1114
+
943
1115
  // Views last, because they read from the tables above. Copied rather than
944
1116
  // skipped because a view is a way into a table: it runs with its creator
945
1117
  // rights unless it says security_invoker, so a view over a protected table
@@ -1011,8 +1183,8 @@ async function writeSchema(client, plan, target) {
1011
1183
  'FOR ' + policy.cmd,
1012
1184
  'TO ' + roles,
1013
1185
  ];
1014
- if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target) + ')');
1015
- if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target) + ')');
1186
+ if (policy.qual) parts.push('USING (' + rewriteOwnTableRefs(policy.qual, plan, target, movedNames) + ')');
1187
+ if (policy.with_check) parts.push('WITH CHECK (' + rewriteOwnTableRefs(policy.with_check, plan, target, movedNames) + ')');
1016
1188
  statements.push(parts.join(' '));
1017
1189
  }
1018
1190
 
@@ -1269,6 +1441,7 @@ module.exports = {
1269
1441
  writeSchema: writeSchema,
1270
1442
  diffSchemas: diffSchemas,
1271
1443
  readPolicies: readPolicies,
1444
+ readBuckets: readBuckets,
1272
1445
  readTypes: readTypes,
1273
1446
  readSequenceGrants: readSequenceGrants,
1274
1447
  readIndexes: readIndexes,
@@ -1277,6 +1450,8 @@ module.exports = {
1277
1450
  referenceIn: referenceIn,
1278
1451
  rewriteSchemaRefs: rewriteSchemaRefs,
1279
1452
  qualifyOwnRefs: qualifyOwnRefs,
1453
+ functionsToCopy: functionsToCopy,
1454
+ copyFunctionStatement: copyFunctionStatement,
1280
1455
  stubNameFor: stubNameFor,
1281
1456
  IDENTITIES: IDENTITIES,
1282
1457
  quote: quote,
package/sqlengine.js CHANGED
@@ -114,6 +114,15 @@ async function tamper(client, target, into, tables, seeded, policies) {
114
114
  return rows[0].answer;
115
115
  }
116
116
 
117
+ /** What the least trusted member of a team can change. Rolled back inside the engine. */
118
+ async function teammate(client, target, into, tables, seeded) {
119
+ const { rows } = await client.query(
120
+ 'SELECT ' + quote(target) + '.teammate($1, $2::jsonb, $3::jsonb) AS answer',
121
+ [into, JSON.stringify(tables), JSON.stringify(seeded)],
122
+ );
123
+ return rows[0].answer;
124
+ }
125
+
117
126
  /** Can a half-finished row survive? */
118
127
  async function orphan(client, target, into, tables, seeded) {
119
128
  const { rows } = await client.query(
@@ -141,7 +150,7 @@ async function collide(client, target, into, tables, indexes) {
141
150
  }
142
151
 
143
152
  /**
144
- * The same six operations `scan.js` asks of an engine, done in SQL.
153
+ * The same seven operations `scan.js` asks of an engine, done in SQL.
145
154
  *
146
155
  * Handed an engine schema that is already installed, because installing and
147
156
  * removing it is the caller's business - `withEngine` below - and a scan
@@ -162,6 +171,7 @@ function adapterFor(target) {
162
171
  tamper: (client, into, tables, sown, policies) =>
163
172
  tamper(client, target, into, tables, sown, policies),
164
173
  orphan: (client, into, tables, sown) => orphan(client, target, into, tables, sown),
174
+ teammate: (client, into, tables, sown) => teammate(client, target, into, tables, sown),
165
175
  };
166
176
  }
167
177
 
@@ -194,6 +204,7 @@ module.exports = {
194
204
  impersonate: impersonate,
195
205
  tamper: tamper,
196
206
  orphan: orphan,
207
+ teammate: teammate,
197
208
  collide: collide,
198
209
  adapterFor: adapterFor,
199
210
  withEngine: withEngine,
package/tamper.js CHANGED
@@ -47,9 +47,13 @@ const ACTORS = [
47
47
  * The rollback is not a tidy-up, it is the safety property. Nothing this
48
48
  * attack does survives the statement that did it.
49
49
  */
50
- async function tryWrite(client, role, identity, statement, values) {
50
+ async function tryWrite(client, role, identity, statement, values, first) {
51
51
  await client.query('BEGIN');
52
52
  try {
53
+ // Something the copy's owner does first, inside the same transaction, so
54
+ // it is undone with the write: putting a person into a team for one
55
+ // attempt (see teammate below).
56
+ if (first) await client.query(first.statement, first.values || []);
53
57
  await client.query("SET LOCAL statement_timeout = '15s'");
54
58
  await client.query('SET LOCAL role TO ' + role);
55
59
  await client.query('SELECT set_config($1, $2, true)', [
@@ -217,6 +221,215 @@ function firstWritable(table) {
217
221
  return (plain || usable[0] || { name: 'id' }).name;
218
222
  }
219
223
 
224
+ /*
225
+ * What the least trusted member of a team can change.
226
+ *
227
+ * Everything above is one customer against another. Found on two blind tests
228
+ * (OrbitDesk, AtlasPay): a rule written "members of this workspace may change
229
+ * it" lets a viewer change it too, and an UPDATE rule that checks the role is
230
+ * undone by a second one beside it that does not - Postgres lets a write
231
+ * through if any permissive rule allows it. Five planted holes of this shape
232
+ * went unreported, because both fake people were the owner of their own team
233
+ * and nobody else was ever in it.
234
+ *
235
+ * So one of them joins the other's team with the lowest role there is, for
236
+ * one attempt at a time, and tries to change what is in it. Whether a viewer
237
+ * may edit is the owner's decision, so this is reported as something to
238
+ * check, never as a confirmed break.
239
+ */
240
+
241
+ // Labels that say "may only look", tried in this order before falling back
242
+ // to the last label that is not plainly in charge.
243
+ const LEAST_TRUSTED = [/view/i, /read/i, /guest/i, /observ/i, /audit/i];
244
+ const IN_CHARGE = /owner|admin|super|root/i;
245
+ // Owner columns that name who made a row rather than who is in it.
246
+ const CREATOR = /^(created_by|author_id|owner|owner_id)$/i;
247
+
248
+ /** The role a viewer has, out of a team's list of roles, or null. */
249
+ function lowestRole(labels) {
250
+ for (const pattern of LEAST_TRUSTED) {
251
+ const found = labels.find((label) => pattern.test(label));
252
+ if (found) return found;
253
+ }
254
+ const plain = labels.filter((label) => !IN_CHARGE.test(label));
255
+ return plain.length ? plain[plain.length - 1] : null;
256
+ }
257
+
258
+ /**
259
+ * The column holding a member's role, and its lowest value: named for a role,
260
+ * with a fixed list of values (an enum, or a CHECK listing them).
261
+ */
262
+ function roleColumnOf(table) {
263
+ for (const column of table.columns || []) {
264
+ if (!/role/i.test(column.name)) continue;
265
+ const labels = Array.isArray(column.enum_labels) && column.enum_labels.length
266
+ ? column.enum_labels
267
+ : attack.allowedByCheck(table, column.name);
268
+ if (labels.length < 2) continue;
269
+ const lowest = lowestRole(labels);
270
+ if (lowest) return { column: column.name, lowest: lowest };
271
+ }
272
+ return null;
273
+ }
274
+
275
+ /** Whether a column is a key on its own: one row per person, at most. */
276
+ function aloneUnique(table, column) {
277
+ return (table.constraints || []).some((constraint) => {
278
+ if (constraint.kind !== 'p' && constraint.kind !== 'u') return false;
279
+ const match = /(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)/i.exec(String(constraint.definition || ''));
280
+ if (!match) return false;
281
+ const columns = match[1].split(',').map((name) => name.trim().split('"').join(''));
282
+ return columns.length === 1 && columns[0] === column;
283
+ });
284
+ }
285
+
286
+ /**
287
+ * The tables saying who is in which team, and as what: a person, a role, and
288
+ * a key to a table of the app's own that is not the person. A table where the
289
+ * person can appear only once (profiles.org_id) is not one: nobody joins a
290
+ * second team there, so there is no one to add.
291
+ */
292
+ function memberships(tables) {
293
+ const found = [];
294
+ const names = new Set((tables || []).map((table) => table.name));
295
+ for (const table of tables || []) {
296
+ const person = attack.ownerColumn(table);
297
+ // Whoever made the row is not who is in the team. Found on a blind test:
298
+ // workspace_invites (created_by, role, workspace_id) was taken for the
299
+ // members table, and an invitation made nobody a member of anything.
300
+ if (!person || CREATOR.test(person)) continue;
301
+ const role = roleColumnOf(table);
302
+ if (!role) continue;
303
+ const keys = attack.foreignKeys(table).concat(attack.impliedKeys(table, tables))
304
+ .filter((key) => key.refTable !== table.name && names.has(key.refTable) && !key.columns.includes(person));
305
+ if (!keys.length || aloneUnique(table, person)) continue;
306
+ found.push({ table: table, person: person, role: role.column, lowest: role.lowest, keys: keys });
307
+ }
308
+ // A table named for members first: it is the one a team rule asks.
309
+ const named = (m) => (/member/i.test(m.table.name) ? 0 : 1);
310
+ return found.map((m, i) => [m, i]).sort((a, b) => named(a[0]) - named(b[0]) || a[1] - b[1]).map((pair) => pair[0]);
311
+ }
312
+
313
+ /**
314
+ * For every team table: can a viewer change it?
315
+ *
316
+ * The other fake person joins the first one's team as its lowest role, and
317
+ * every write is tried twice - before joining and after - so only what joining
318
+ * added is counted: a hole open to every customer is already reported above,
319
+ * and a person changing their own rows is the feature. Both rolled back, the
320
+ * joining with them.
321
+ */
322
+ async function teammate(client, schema, tables, seeded) {
323
+ const findings = [];
324
+ const completed = [];
325
+ const blocked = [];
326
+ const shapeFor = new Map((seeded || []).map((entry) => [entry.table, entry.attempt || 0]));
327
+ const done = new Set();
328
+
329
+ for (const member of memberships(tables)) {
330
+ if (!shapeFor.has(member.table.name)) continue;
331
+ const at = quote(schema) + '.' + quote(member.table.name);
332
+ let theirs = null;
333
+ try {
334
+ theirs = (await client.query('SELECT * FROM ' + at + ' WHERE ' + quote(member.person) + ' = $1 LIMIT 1',
335
+ [attack.USER_A])).rows[0] || null;
336
+ } catch (err) {
337
+ theirs = null;
338
+ }
339
+ if (!theirs) continue;
340
+
341
+ // Their team, as the first person's own membership names it.
342
+ const overrides = {};
343
+ overrides[member.role] = member.lowest;
344
+ for (const key of member.keys) {
345
+ key.columns.forEach((name) => { if (name !== member.person) overrides[name] = theirs[name]; });
346
+ }
347
+ const parent = member.keys[0].refTable;
348
+ const as = 'as a "' + member.lowest + '" in ' + member.table.name;
349
+ let join = null;
350
+ let refused = null;
351
+ try {
352
+ const row = await attack.rowFor(client, schema, member.table, attack.USER_B, 9, overrides, shapeFor.get(member.table.name));
353
+ join = {
354
+ statement: 'INSERT INTO ' + at + ' (' + row.columns.map(quote).join(', ') + ') VALUES (' +
355
+ row.values.map((_, i) => '$' + (i + 1)).join(', ') + ')',
356
+ values: row.values,
357
+ };
358
+ // Whether they can be put in the team at all, tried once on its own.
359
+ await client.query('BEGIN');
360
+ try {
361
+ await client.query(join.statement, join.values);
362
+ } finally {
363
+ await client.query('ROLLBACK').catch(() => {});
364
+ }
365
+ } catch (err) {
366
+ refused = err.message;
367
+ }
368
+ if (refused) {
369
+ // Said once, on the table that would not take them: every team table
370
+ // went untested for the same reason.
371
+ blocked.push({ table: member.table.name, key: 'role:' + member.table.name + ':' + member.lowest,
372
+ why: as + ': I could not add one: ' + refused });
373
+ continue;
374
+ }
375
+
376
+ for (const table of tables) {
377
+ if (!shapeFor.has(table.name) || done.has(table.name)) continue;
378
+ done.add(table.name);
379
+ const key = 'role:' + table.name + ':' + member.lowest;
380
+ const owner = attack.ownerColumn(table);
381
+ // Never their own rows: changing what you made yourself is not a role
382
+ // question, and the row that put them in the team is theirs.
383
+ const notTheirs = owner ? ' WHERE ' + quote(owner) + " IS DISTINCT FROM '" + attack.USER_B + "'" : '';
384
+ const target = quote(schema) + '.' + quote(table.name);
385
+ const column = quote(firstWritable(table));
386
+ const moves = [
387
+ { what: 'change', statement: 'UPDATE ' + target + ' SET ' + column + ' = ' + column + notTheirs },
388
+ { what: 'delete', statement: 'DELETE FROM ' + target + notTheirs },
389
+ ];
390
+ const can = [];
391
+ const changed = {};
392
+ let stuck = null;
393
+ for (const move of moves) {
394
+ const before = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, []);
395
+ const after = await tryWrite(client, 'authenticated', attack.USER_B, move.statement, [], join);
396
+ const counted = (result) => {
397
+ const outcome = whatHappened(result);
398
+ return outcome === 'untested' ? null : outcome === 'got through' ? result.count : 0;
399
+ };
400
+ const was = counted(before);
401
+ const now = counted(after);
402
+ if (was === null || now === null) {
403
+ stuck = now === null ? after.why : before.why;
404
+ } else if (now > was) {
405
+ can.push(move.what);
406
+ changed[move.what] = now - was;
407
+ }
408
+ }
409
+ if (!can.length && stuck) {
410
+ blocked.push({ table: table.name, key: key, why: as + ': ' + stuck });
411
+ continue;
412
+ }
413
+ completed.push(key);
414
+ if (can.length) {
415
+ findings.push({
416
+ kind: 'role',
417
+ table: table.name,
418
+ who: member.lowest,
419
+ via: member.table.name,
420
+ roleColumn: member.role,
421
+ parent: parent,
422
+ can: can,
423
+ changed: changed,
424
+ columns: (table.columns || []).map((c) => c.name),
425
+ rlsEnabled: table.rlsEnabled,
426
+ });
427
+ }
428
+ }
429
+ }
430
+ return { findings: findings, completed: completed, blocked: blocked };
431
+ }
432
+
220
433
  module.exports = {
221
434
  ACTORS: ACTORS,
222
435
  tryWrite: tryWrite,
@@ -224,4 +437,8 @@ module.exports = {
224
437
  firstWritable: firstWritable,
225
438
  writeAsFor: writeAsFor,
226
439
  tamper: tamper,
440
+ lowestRole: lowestRole,
441
+ roleColumnOf: roleColumnOf,
442
+ memberships: memberships,
443
+ teammate: teammate,
227
444
  };
package/trouble.js CHANGED
@@ -365,7 +365,7 @@ function supabaseDirectRef(raw) {
365
365
  * survives is the stack: the person cannot use it and it makes the tool look
366
366
  * like it broke rather than like it has something to tell them.
367
367
  */
368
- function explain(err, raw) {
368
+ function explain(err, raw, options) {
369
369
  const code = err && err.code ? String(err.code) : '';
370
370
 
371
371
  // Checked before the general ENOTFOUND, because for this one address the
@@ -448,6 +448,18 @@ function explain(err, raw) {
448
448
 
449
449
  // Nothing matched. Say what happened and where, without pretending to know
450
450
  // why, and without the stack.
451
+ //
452
+ // After the connection was made, "I could not connect" is untrue - found on
453
+ // a blind test, where it was printed over a copy that could not be built.
454
+ if (options && options.connected) {
455
+ return [
456
+ 'I connected, but the check stopped part-way.',
457
+ '',
458
+ 'The server said: ' + message,
459
+ '',
460
+ 'Nothing was left behind: the temporary copy is deleted whenever a check stops.',
461
+ ];
462
+ }
451
463
  const lines = [
452
464
  'I could not connect to the database.',
453
465
  '',