kryptheon-night 0.1.25 → 0.1.27

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/engine.sql CHANGED
@@ -335,6 +335,38 @@ RETURNS jsonb LANGUAGE sql STABLE AS $$
335
335
  ) rows;
336
336
  $$;
337
337
 
338
+ /*
339
+ * Supabase Storage's buckets: whether each is public, and the rules on
340
+ * storage.objects that say who may read it. The settings and the rules only,
341
+ * never a file. Run through EXECUTE because a database without Supabase
342
+ * Storage has no storage.buckets to name, and that failure is caught below
343
+ * and read as no buckets. Mirrors readBuckets in schema.js.
344
+ */
345
+ CREATE OR REPLACE FUNCTION __KN__.read_buckets()
346
+ RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
347
+ DECLARE
348
+ found jsonb;
349
+ BEGIN
350
+ EXECUTE $q$
351
+ SELECT coalesce(jsonb_agg(jsonb_build_object(
352
+ 'id', b.id::text,
353
+ 'name', b.name::text,
354
+ 'public', coalesce(b.public, false),
355
+ 'readRules', (SELECT coalesce(jsonb_agg(jsonb_build_object(
356
+ 'name', p.policyname::text,
357
+ 'roles', to_jsonb(p.roles::text[]),
358
+ 'qual', p.qual) ORDER BY p.policyname), '[]'::jsonb)
359
+ FROM pg_policies p
360
+ WHERE p.schemaname = 'storage' AND p.tablename = 'objects'
361
+ AND p.cmd IN ('SELECT', 'ALL')
362
+ AND position(quote_literal(b.id::text) IN coalesce(p.qual, '')) > 0)
363
+ ) ORDER BY b.id), '[]'::jsonb)
364
+ FROM storage.buckets b $q$ INTO found;
365
+ RETURN found;
366
+ EXCEPTION WHEN OTHERS THEN
367
+ RETURN '[]'::jsonb;
368
+ END $$;
369
+
338
370
  /* Everything needed to rebuild a schema, in one shape. */
339
371
  CREATE OR REPLACE FUNCTION __KN__.read_schema(source text)
340
372
  RETURNS jsonb LANGUAGE plpgsql STABLE AS $$
@@ -362,6 +394,7 @@ BEGIN
362
394
  'views', __KN__.read_views(source),
363
395
  'viewGrants', __KN__.read_view_grants(source),
364
396
  'functions', __KN__.read_functions(source),
397
+ 'buckets', __KN__.read_buckets(),
365
398
  -- A foreign key pointing at something whose shape could not be read is
366
399
  -- not something to guess at. The caller stops rather than attacking a
367
400
  -- copy that is missing a piece.
@@ -642,6 +675,19 @@ BEGIN
642
675
  parts := parts || CASE WHEN bare = src THEN '''' || target || ''', ' || part ELSE part END;
643
676
  END LOOP;
644
677
  def := replace(def, m[1] || m[2], m[1] || array_to_string(parts, ', '));
678
+ ELSE
679
+ -- None pinned, so a bare name in the body is looked up on the caller's
680
+ -- path when it runs, and that path has never heard of the copy. Found on
681
+ -- a blind test (OrbitDesk): the copy's has_role read the original's
682
+ -- members with the original's enum, and every rule calling it failed with
683
+ -- "operator does not exist". Pinned the way the copy was built: the copy
684
+ -- first, then where the original's bare names would have gone. A BEGIN
685
+ -- ATOMIC body has no AS line and needs none.
686
+ SELECT string_agg('''' || s || '''', ', ' ORDER BY first) INTO head
687
+ FROM (SELECT s, min(at) AS first
688
+ FROM unnest(ARRAY[target, src, 'public', 'extensions']) WITH ORDINALITY AS u(s, at)
689
+ GROUP BY s) p;
690
+ def := regexp_replace(def, E'\nAS ', E'\n SET search_path TO ' || head || E'\nAS ');
645
691
  END IF;
646
692
  RETURN __KN__.rewrite_own_table_refs(def, plan, target, copied);
647
693
  END $$;
@@ -1579,6 +1625,54 @@ END $$;
1579
1625
  * and `implied`, the keys the app never declared, so a `workspace_id` with no
1580
1626
  * foreign key still names a workspace that is there.
1581
1627
  */
1628
+ /*
1629
+ * Sets of columns where exactly one may be filled in. Mirrors exactlyOne in
1630
+ * attack.js: (a IS NOT NULL)::int + (b IS NOT NULL)::int = 1, and
1631
+ * num_nonnulls(a, b) = 1, read only when the constraint says nothing else.
1632
+ * Found on a blind test (HelixOps), where documents pointed at a work order
1633
+ * or an inspection, the seeder pointed it at both, and it was never tested.
1634
+ */
1635
+ CREATE OR REPLACE FUNCTION __KN__.exactly_one(tab jsonb)
1636
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
1637
+ DECLARE
1638
+ name_ constant text := '("(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)';
1639
+ summed text := '\(\(' || name_ || ' IS NOT NULL\)\)::integer';
1640
+ groups jsonb := '[]'::jsonb;
1641
+ con jsonb;
1642
+ definition text;
1643
+ rest text;
1644
+ counted text[];
1645
+ columns jsonb;
1646
+ found text;
1647
+ BEGIN
1648
+ FOR con IN SELECT * FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) LOOP
1649
+ CONTINUE WHEN con->>'kind' <> 'c';
1650
+ definition := coalesce(con->>'definition', '');
1651
+ columns := '[]'::jsonb;
1652
+ FOR found IN SELECT (m)[1] FROM regexp_matches(definition, summed, 'gi') m LOOP
1653
+ columns := columns || to_jsonb(found);
1654
+ END LOOP;
1655
+ rest := replace(regexp_replace(definition, summed, '', 'gi'), '+', '');
1656
+ IF jsonb_array_length(columns) = 0 THEN
1657
+ counted := regexp_match(definition, 'num_nonnulls\(([^()]*)\)', 'i');
1658
+ IF counted IS NOT NULL THEN
1659
+ FOR found IN SELECT (m)[1] FROM regexp_matches(counted[1], name_, 'g') m LOOP
1660
+ columns := columns || to_jsonb(found);
1661
+ END LOOP;
1662
+ rest := regexp_replace(rest, 'num_nonnulls\([^()]*\)', '', 'i');
1663
+ END IF;
1664
+ END IF;
1665
+ -- Nothing may be left but the CHECK itself, brackets and "= 1".
1666
+ CONTINUE WHEN jsonb_array_length(columns) < 2
1667
+ OR upper(regexp_replace(rest, '[()\s]', '', 'g')) <> 'CHECK=1';
1668
+ groups := groups || jsonb_build_array((
1669
+ SELECT jsonb_agg(CASE WHEN left(c, 1) = '"' THEN replace(substr(c, 2, length(c) - 2), '""', '"') ELSE c END
1670
+ ORDER BY at)
1671
+ FROM jsonb_array_elements_text(columns) WITH ORDINALITY AS e(c, at)));
1672
+ END LOOP;
1673
+ RETURN groups;
1674
+ END $$;
1675
+
1582
1676
  CREATE OR REPLACE FUNCTION __KN__.row_for(
1583
1677
  source text, tab jsonb, person text, distinct_ text, overrides jsonb, attempt integer,
1584
1678
  seeding jsonb DEFAULT NULL)
@@ -1590,6 +1684,10 @@ DECLARE
1590
1684
  values_ jsonb := '[]'::jsonb;
1591
1685
  borrowed jsonb := '{}'::jsonb;
1592
1686
  part_of_key jsonb := '[]'::jsonb;
1687
+ only_one jsonb := '{}'::jsonb;
1688
+ group_ jsonb;
1689
+ kept text;
1690
+ must_fill boolean;
1593
1691
  key jsonb;
1594
1692
  row_ jsonb;
1595
1693
  col jsonb;
@@ -1618,6 +1716,22 @@ BEGIN
1618
1716
  part_of_key := part_of_key || (key->'columns');
1619
1717
  END LOOP;
1620
1718
 
1719
+ -- Where exactly one of a set may be filled in: the one already chosen, or
1720
+ -- else the first that can be - a key needs a parent row to point at. The
1721
+ -- rest are written as nothing, whatever their default.
1722
+ FOR group_ IN SELECT * FROM jsonb_array_elements(__KN__.exactly_one(tab)) LOOP
1723
+ SELECT c INTO kept FROM jsonb_array_elements_text(group_) WITH ORDINALITY AS e(c, pos)
1724
+ WHERE forced ? c ORDER BY pos LIMIT 1;
1725
+ IF kept IS NULL THEN
1726
+ SELECT c INTO kept FROM jsonb_array_elements_text(group_) WITH ORDINALITY AS e(c, pos)
1727
+ WHERE NOT (part_of_key ? c) OR borrowed ? c ORDER BY pos LIMIT 1;
1728
+ END IF;
1729
+ FOR name IN SELECT jsonb_array_elements_text(group_) LOOP
1730
+ only_one := jsonb_set(only_one, ARRAY[name], to_jsonb(name = kept));
1731
+ END LOOP;
1732
+ kept := NULL;
1733
+ END LOOP;
1734
+
1621
1735
  FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
1622
1736
  name := col->>'name';
1623
1737
 
@@ -1627,6 +1741,13 @@ BEGIN
1627
1741
  CONTINUE;
1628
1742
  END IF;
1629
1743
 
1744
+ IF only_one->name = 'false'::jsonb THEN
1745
+ columns := columns || to_jsonb(name);
1746
+ values_ := values_ || jsonb_build_array('null'::jsonb);
1747
+ CONTINUE;
1748
+ END IF;
1749
+ must_fill := coalesce(only_one->name = 'true'::jsonb, false);
1750
+
1630
1751
  IF name = owner THEN
1631
1752
  columns := columns || to_jsonb(name);
1632
1753
  values_ := values_ || jsonb_build_array(to_jsonb(person));
@@ -1666,9 +1787,11 @@ BEGIN
1666
1787
  values_ := values_ || jsonb_build_array(borrowed->name);
1667
1788
  CONTINUE;
1668
1789
  END IF;
1669
- -- Anything with a default can supply its own value.
1790
+ -- Anything with a default can supply its own value. A column that may be
1791
+ -- empty is left empty - unless it is the one of a set that has to be
1792
+ -- filled in.
1670
1793
  CONTINUE WHEN col->>'default_expr' IS NOT NULL;
1671
- CONTINUE WHEN NOT (col->>'not_null')::boolean;
1794
+ CONTINUE WHEN NOT (col->>'not_null')::boolean AND NOT must_fill;
1672
1795
 
1673
1796
  columns := columns || to_jsonb(name);
1674
1797
  -- A CHECK that lists what it will accept beats anything invented here.
@@ -2316,6 +2439,447 @@ BEGIN
2316
2439
  RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
2317
2440
  END $$;
2318
2441
 
2442
+ -- --------------------------------------------------------------------------
2443
+ -- What the least trusted member of a team can change.
2444
+ --
2445
+ -- Mirrors teammate in tamper.js. One fake person joins the other's team with
2446
+ -- the lowest role there is, for one attempt at a time, and every write is
2447
+ -- tried before joining and after, so only what joining added is counted.
2448
+ -- Reported as something to check: whether a viewer may edit is the owner's
2449
+ -- decision. Found on two blind tests, where five holes of this shape - "any
2450
+ -- member may change it", or an UPDATE rule that checks the role beside one
2451
+ -- that does not - went unreported.
2452
+ -- --------------------------------------------------------------------------
2453
+
2454
+ /* The role a viewer has, out of a team's list of roles, or null. */
2455
+ CREATE OR REPLACE FUNCTION __KN__.lowest_role(labels jsonb)
2456
+ RETURNS text LANGUAGE plpgsql IMMUTABLE AS $$
2457
+ DECLARE
2458
+ pattern text;
2459
+ found text;
2460
+ BEGIN
2461
+ -- Labels that say "may only look", tried in this order before falling back
2462
+ -- to the last label that is not plainly in charge.
2463
+ FOREACH pattern IN ARRAY ARRAY['view', 'read', 'guest', 'observ', 'audit'] LOOP
2464
+ SELECT l INTO found FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, at)
2465
+ WHERE l ~* pattern ORDER BY at LIMIT 1;
2466
+ IF found IS NOT NULL THEN RETURN found; END IF;
2467
+ END LOOP;
2468
+ SELECT l INTO found FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, at)
2469
+ WHERE l !~* 'owner|admin|super|root' ORDER BY at DESC LIMIT 1;
2470
+ RETURN found;
2471
+ END $$;
2472
+
2473
+ /*
2474
+ * The column holding a member's role, and its lowest value: named for a role,
2475
+ * with a fixed list of values.
2476
+ */
2477
+ CREATE OR REPLACE FUNCTION __KN__.role_column_of(tab jsonb)
2478
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
2479
+ DECLARE
2480
+ col jsonb;
2481
+ labels jsonb;
2482
+ lowest text;
2483
+ BEGIN
2484
+ FOR col IN SELECT * FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) LOOP
2485
+ CONTINUE WHEN (col->>'name') !~* 'role';
2486
+ IF jsonb_typeof(col->'enum_labels') = 'array' AND jsonb_array_length(col->'enum_labels') > 0 THEN
2487
+ labels := col->'enum_labels';
2488
+ ELSE
2489
+ labels := to_jsonb(__KN__.allowed_by_check(tab, col->>'name'));
2490
+ END IF;
2491
+ CONTINUE WHEN labels IS NULL OR jsonb_array_length(labels) < 2;
2492
+ lowest := __KN__.lowest_role(labels);
2493
+ IF lowest IS NOT NULL THEN
2494
+ -- Every role not plainly in charge, the lowest first and then upwards:
2495
+ -- the order reads are tried in.
2496
+ RETURN jsonb_build_object('column', col->>'name', 'lowest', lowest, 'ladder',
2497
+ jsonb_build_array(lowest) || coalesce((
2498
+ SELECT jsonb_agg(l ORDER BY pos DESC)
2499
+ FROM jsonb_array_elements_text(labels) WITH ORDINALITY AS e(l, pos)
2500
+ WHERE l <> lowest AND l !~* 'owner|admin|super|root'), '[]'::jsonb));
2501
+ END IF;
2502
+ END LOOP;
2503
+ RETURN NULL;
2504
+ END $$;
2505
+
2506
+ /*
2507
+ * The columns of a table that look like they hold a secret: a token, a secret
2508
+ * or a key, a password, or the payload a webhook carried. Mirrors
2509
+ * secretColumns in tamper.js. Found on a blind test (HelixOps).
2510
+ */
2511
+ CREATE OR REPLACE FUNCTION __KN__.secret_columns(tab jsonb)
2512
+ RETURNS jsonb LANGUAGE sql IMMUTABLE AS $$
2513
+ SELECT coalesce(jsonb_agg(c->>'name' ORDER BY pos), '[]'::jsonb)
2514
+ FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) WITH ORDINALITY AS e(c, pos)
2515
+ WHERE (c->>'name') ~* '(^|_)(token|secret|password|passwd|pwd|payload|credentials?|(api|private|access|client|signing|service|encryption|secret|license)_?key|(request|response)_body)(_|$)';
2516
+ $$;
2517
+
2518
+ /* Whether a role's own name says it may only look. */
2519
+ CREATE OR REPLACE FUNCTION __KN__.look_only(role_ text)
2520
+ RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
2521
+ SELECT role_ ~* 'view' OR role_ ~* 'read' OR role_ ~* 'guest' OR role_ ~* 'observ' OR role_ ~* 'audit';
2522
+ $$;
2523
+
2524
+ /* Whether a column is a key on its own: one row per person, at most. */
2525
+ CREATE OR REPLACE FUNCTION __KN__.alone_unique(tab jsonb, column_name text)
2526
+ RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
2527
+ SELECT EXISTS (
2528
+ SELECT 1
2529
+ FROM jsonb_array_elements(coalesce(tab->'constraints', '[]'::jsonb)) c,
2530
+ LATERAL regexp_match(c->>'definition', '(?:PRIMARY KEY|UNIQUE) \(([^)]+)\)', 'i') m
2531
+ WHERE c->>'kind' IN ('p', 'u')
2532
+ AND array_length(string_to_array(m[1], ','), 1) = 1
2533
+ AND replace(trim(m[1]), '"', '') = column_name);
2534
+ $$;
2535
+
2536
+ /*
2537
+ * The tables saying who is in which team, and as what: a person, a role, and
2538
+ * a key to a table of the app's own that is not the person. A table where the
2539
+ * person can appear only once is not one: nobody joins a second team there.
2540
+ */
2541
+ CREATE OR REPLACE FUNCTION __KN__.memberships(tables jsonb)
2542
+ RETURNS jsonb LANGUAGE plpgsql IMMUTABLE AS $$
2543
+ DECLARE
2544
+ found jsonb := '[]'::jsonb;
2545
+ tab jsonb;
2546
+ person text;
2547
+ role_ jsonb;
2548
+ keys jsonb;
2549
+ BEGIN
2550
+ FOR tab IN SELECT * FROM jsonb_array_elements(coalesce(tables, '[]'::jsonb)) LOOP
2551
+ person := __KN__.owner_column(tab);
2552
+ -- Whoever made the row is not who is in the team: an invitation's
2553
+ -- created_by makes nobody a member of anything.
2554
+ CONTINUE WHEN person IS NULL OR person ~* '^(created_by|author_id|owner|owner_id)$';
2555
+ role_ := __KN__.role_column_of(tab);
2556
+ CONTINUE WHEN role_ IS NULL;
2557
+ SELECT coalesce(jsonb_agg(k ORDER BY at), '[]'::jsonb) INTO keys
2558
+ FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables))
2559
+ WITH ORDINALITY AS e(k, at)
2560
+ WHERE k->>'refTable' <> tab->>'name'
2561
+ AND EXISTS (SELECT 1 FROM jsonb_array_elements(tables) t WHERE t->>'name' = k->>'refTable')
2562
+ AND NOT (k->'columns' ? person);
2563
+ CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
2564
+ found := found || jsonb_build_array(jsonb_build_object(
2565
+ 'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
2566
+ 'ladder', role_->'ladder', 'keys', keys));
2567
+ END LOOP;
2568
+ -- A table named for members first: it is the one a team rule asks.
2569
+ SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
2570
+ FROM jsonb_array_elements(found) WITH ORDINALITY AS e(m, at);
2571
+ RETURN found;
2572
+ END $$;
2573
+
2574
+ /*
2575
+ * try_write, with something the copy's owner does first inside the same
2576
+ * subtransaction - putting a person into a team - so it is undone with the
2577
+ * write. A function of its own rather than a fourth argument: an installed
2578
+ * engine upgraded in place would keep the old three-argument one beside it,
2579
+ * and every call would become ambiguous.
2580
+ */
2581
+ CREATE OR REPLACE FUNCTION __KN__.try_write_joined(role_ text, identity text, statement text, join_ text)
2582
+ RETURNS jsonb LANGUAGE plpgsql AS $$
2583
+ DECLARE
2584
+ claims text := CASE WHEN identity IS NULL
2585
+ THEN json_build_object('role', role_)::text
2586
+ ELSE json_build_object('sub', identity, 'role', role_)::text END;
2587
+ moved bigint := 0;
2588
+ worked boolean := false;
2589
+ why text := NULL;
2590
+ BEGIN
2591
+ BEGIN
2592
+ EXECUTE join_;
2593
+ EXECUTE 'SET LOCAL statement_timeout = ' || quote_literal('15s');
2594
+ EXECUTE 'SET LOCAL ROLE ' || quote_ident(role_);
2595
+ PERFORM set_config('request.jwt.claims', claims, true);
2596
+
2597
+ EXECUTE statement;
2598
+ GET DIAGNOSTICS moved = ROW_COUNT;
2599
+ worked := true;
2600
+
2601
+ RAISE EXCEPTION 'kryptheon: undoing the write' USING ERRCODE = 'KN001';
2602
+ EXCEPTION
2603
+ WHEN SQLSTATE 'KN001' THEN
2604
+ NULL;
2605
+ WHEN OTHERS THEN
2606
+ worked := false;
2607
+ moved := 0;
2608
+ why := SQLERRM;
2609
+ END;
2610
+
2611
+ RESET ROLE;
2612
+ PERFORM set_config('request.jwt.claims', '', true);
2613
+ PERFORM set_config('statement_timeout', '0', true);
2614
+ RETURN jsonb_build_object('ok', worked, 'count', moved, 'why', why);
2615
+ END $$;
2616
+
2617
+ /* A write's rows, or null when it decided nothing. */
2618
+ CREATE OR REPLACE FUNCTION __KN__.rows_moved(result jsonb)
2619
+ RETURNS bigint LANGUAGE sql IMMUTABLE AS $$
2620
+ SELECT CASE __KN__.what_happened(result)
2621
+ WHEN 'untested' THEN NULL
2622
+ WHEN 'got through' THEN (result->>'count')::bigint
2623
+ ELSE 0 END;
2624
+ $$;
2625
+
2626
+ CREATE OR REPLACE FUNCTION __KN__.teammate(source text, tables jsonb, seeded jsonb)
2627
+ RETURNS jsonb LANGUAGE plpgsql AS $$
2628
+ DECLARE
2629
+ findings jsonb := '[]'::jsonb;
2630
+ completed jsonb := '[]'::jsonb;
2631
+ blocked jsonb := '[]'::jsonb;
2632
+ done jsonb := '[]'::jsonb;
2633
+ member jsonb;
2634
+ tab jsonb;
2635
+ shape integer;
2636
+ at text;
2637
+ theirs jsonb;
2638
+ team jsonb;
2639
+ overrides jsonb;
2640
+ joins jsonb;
2641
+ key jsonb;
2642
+ name text;
2643
+ parent text;
2644
+ parent_key jsonb;
2645
+ to_team jsonb;
2646
+ pointed jsonb;
2647
+ row_ jsonb;
2648
+ join_ text;
2649
+ refused text;
2650
+ owner text;
2651
+ where_ text[];
2652
+ not_theirs text;
2653
+ target text;
2654
+ column_ text;
2655
+ finding_key text;
2656
+ read_key text;
2657
+ moves jsonb;
2658
+ move jsonb;
2659
+ was bigint;
2660
+ now_ bigint;
2661
+ before_ jsonb;
2662
+ after_ jsonb;
2663
+ can jsonb;
2664
+ changed jsonb;
2665
+ stuck text;
2666
+ secrets jsonb;
2667
+ look text;
2668
+ role_ text;
2669
+ tried jsonb;
2670
+ i integer;
2671
+ BEGIN
2672
+ FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
2673
+ SELECT (s->>'attempt')::integer INTO shape
2674
+ FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
2675
+ WHERE s->>'table' = member->'table'->>'name';
2676
+ CONTINUE WHEN NOT FOUND;
2677
+ shape := coalesce(shape, 0);
2678
+ at := __KN__.always_quote(source) || '.' || __KN__.always_quote(member->'table'->>'name');
2679
+ BEGIN
2680
+ EXECUTE 'SELECT to_jsonb(t) FROM ' || at || ' t WHERE ' || __KN__.always_quote(member->>'person') ||
2681
+ ' = ' || quote_literal(__KN__.user_a()) || ' LIMIT 1' INTO theirs;
2682
+ EXCEPTION WHEN OTHERS THEN
2683
+ theirs := NULL;
2684
+ END;
2685
+ CONTINUE WHEN theirs IS NULL;
2686
+
2687
+ -- Their team, as the first person's own membership names it.
2688
+ team := '{}'::jsonb;
2689
+ FOR key IN SELECT * FROM jsonb_array_elements(member->'keys') LOOP
2690
+ FOR name IN SELECT jsonb_array_elements_text(key->'columns') LOOP
2691
+ IF name <> member->>'person' THEN
2692
+ team := jsonb_set(team, ARRAY[name], coalesce(theirs->name, 'null'::jsonb));
2693
+ END IF;
2694
+ END LOOP;
2695
+ END LOOP;
2696
+ parent_key := member->'keys'->0;
2697
+ parent := parent_key->>'refTable';
2698
+
2699
+ -- Putting the other person in that team, as each role in turn. Built up
2700
+ -- front, before anybody changes role.
2701
+ joins := '{}'::jsonb;
2702
+ refused := NULL;
2703
+ FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
2704
+ BEGIN
2705
+ overrides := jsonb_set(team, ARRAY[member->>'role'], to_jsonb(role_));
2706
+ row_ := __KN__.row_for(source, member->'table', __KN__.user_b(), '9', overrides, shape);
2707
+ joins := jsonb_set(joins, ARRAY[role_],
2708
+ to_jsonb(__KN__.insert_statement(source, member->'table'->>'name', row_)));
2709
+ EXCEPTION WHEN OTHERS THEN
2710
+ IF role_ = member->>'lowest' THEN refused := SQLERRM; END IF;
2711
+ END;
2712
+ END LOOP;
2713
+ join_ := joins->>(member->>'lowest');
2714
+
2715
+ -- Whether they can be put in the team at all, tried once on its own.
2716
+ IF refused IS NULL THEN
2717
+ BEGIN
2718
+ EXECUTE join_;
2719
+ RAISE EXCEPTION 'kryptheon: undoing the join' USING ERRCODE = 'KN001';
2720
+ EXCEPTION
2721
+ WHEN SQLSTATE 'KN001' THEN
2722
+ NULL;
2723
+ WHEN OTHERS THEN
2724
+ refused := SQLERRM;
2725
+ END;
2726
+ END IF;
2727
+ IF refused IS NOT NULL THEN
2728
+ -- Said once, on the table that would not take them.
2729
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2730
+ 'table', member->'table'->>'name',
2731
+ 'key', 'role:' || (member->'table'->>'name') || ':' || (member->>'lowest'),
2732
+ 'why', 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name') ||
2733
+ ': I could not add one: ' || refused));
2734
+ CONTINUE;
2735
+ END IF;
2736
+
2737
+ FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
2738
+ CONTINUE WHEN NOT EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
2739
+ WHERE s->>'table' = tab->>'name');
2740
+ CONTINUE WHEN done ? (tab->>'name');
2741
+ done := done || to_jsonb(tab->>'name');
2742
+ SELECT (s->>'attempt')::integer INTO shape
2743
+ FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
2744
+ WHERE s->>'table' = tab->>'name';
2745
+ shape := coalesce(shape, 0);
2746
+ finding_key := 'role:' || (tab->>'name') || ':' || (member->>'lowest');
2747
+ owner := __KN__.owner_column(tab);
2748
+
2749
+ -- A table that names the team itself, and the team it is to name.
2750
+ SELECT k INTO to_team
2751
+ FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables))
2752
+ WITH ORDINALITY AS e(k, pos)
2753
+ WHERE k->>'refTable' = parent
2754
+ AND jsonb_array_length(k->'columns') = jsonb_array_length(parent_key->'refColumns')
2755
+ ORDER BY pos LIMIT 1;
2756
+ IF NOT FOUND THEN to_team := NULL; END IF;
2757
+ pointed := '{}'::jsonb;
2758
+ IF to_team IS NOT NULL THEN
2759
+ FOR i IN 0 .. jsonb_array_length(to_team->'columns') - 1 LOOP
2760
+ pointed := jsonb_set(pointed, ARRAY[to_team->'columns'->>i], coalesce(team->(
2761
+ SELECT parent_key->'columns'->>((p.pos - 1)::integer)
2762
+ FROM jsonb_array_elements_text(parent_key->'refColumns') WITH ORDINALITY AS p(c, pos)
2763
+ WHERE p.c = to_team->'refColumns'->>i), 'null'::jsonb));
2764
+ END LOOP;
2765
+ END IF;
2766
+
2767
+ -- Never their own rows, and only the team they joined, where the table
2768
+ -- says which team a row is in.
2769
+ where_ := ARRAY[]::text[];
2770
+ IF owner IS NOT NULL THEN
2771
+ where_ := where_ || (__KN__.always_quote(owner) || ' IS DISTINCT FROM ' || quote_literal(__KN__.user_b()));
2772
+ END IF;
2773
+ FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
2774
+ where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
2775
+ END LOOP;
2776
+ not_theirs := CASE WHEN array_length(where_, 1) IS NULL THEN ''
2777
+ ELSE ' WHERE ' || array_to_string(where_, ' AND ') END;
2778
+ target := __KN__.always_quote(source) || '.' || __KN__.always_quote(tab->>'name');
2779
+ column_ := __KN__.always_quote(__KN__.first_writable(tab));
2780
+ moves := jsonb_build_array(
2781
+ jsonb_build_object('what', 'change',
2782
+ 'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
2783
+ jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
2784
+
2785
+ -- Adding a row to the team, said only of a role whose own name says it
2786
+ -- may only look, and written under the other person's name: a rule that
2787
+ -- lets a member add a row only as themselves is them speaking for
2788
+ -- themselves.
2789
+ IF __KN__.look_only(member->>'lowest') AND to_team IS NOT NULL THEN
2790
+ BEGIN
2791
+ row_ := __KN__.row_for(source, tab, __KN__.user_a(), '11', pointed, shape);
2792
+ moves := jsonb_build_array(jsonb_build_object('what', 'add',
2793
+ 'statement', __KN__.insert_statement(source, tab->>'name', row_))) || moves;
2794
+ EXCEPTION WHEN OTHERS THEN
2795
+ NULL;
2796
+ END;
2797
+ END IF;
2798
+
2799
+ can := '[]'::jsonb;
2800
+ changed := '{}'::jsonb;
2801
+ stuck := NULL;
2802
+ FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
2803
+ before_ := __KN__.try_write('authenticated', __KN__.user_b(), move->>'statement');
2804
+ after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move->>'statement', join_);
2805
+ was := __KN__.rows_moved(before_);
2806
+ now_ := __KN__.rows_moved(after_);
2807
+ IF was IS NULL OR now_ IS NULL THEN
2808
+ stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
2809
+ ELSIF now_ > was THEN
2810
+ can := can || to_jsonb(move->>'what');
2811
+ changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb(now_ - was));
2812
+ END IF;
2813
+ END LOOP;
2814
+
2815
+ IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
2816
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2817
+ 'table', tab->>'name', 'key', finding_key,
2818
+ 'why', 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name') || ': ' || stuck));
2819
+ ELSE
2820
+ completed := completed || to_jsonb(finding_key);
2821
+ IF jsonb_array_length(can) > 0 THEN
2822
+ findings := findings || jsonb_build_array(jsonb_build_object(
2823
+ 'kind', 'role',
2824
+ 'table', tab->>'name',
2825
+ 'who', member->>'lowest',
2826
+ 'via', member->'table'->>'name',
2827
+ 'roleColumn', member->>'role',
2828
+ 'parent', parent,
2829
+ 'can', can,
2830
+ 'changed', changed,
2831
+ 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2832
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
2833
+ END IF;
2834
+ END IF;
2835
+
2836
+ -- Reading what a member should not see, said only of a table holding a
2837
+ -- secret. Every role not in charge, from the lowest up; the first that
2838
+ -- can read is the one named.
2839
+ secrets := __KN__.secret_columns(tab);
2840
+ CONTINUE WHEN jsonb_array_length(secrets) = 0;
2841
+ look := 'SELECT 1 FROM ' || target || not_theirs;
2842
+ was := __KN__.rows_moved(__KN__.try_write('authenticated', __KN__.user_b(), look));
2843
+ tried := '[]'::jsonb;
2844
+ FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
2845
+ read_key := 'teamread:' || (tab->>'name') || ':' || role_;
2846
+ IF joins ? role_ THEN
2847
+ after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), look, joins->>role_);
2848
+ ELSE
2849
+ after_ := jsonb_build_object('ok', false, 'count', 0, 'why', 'I could not add one');
2850
+ END IF;
2851
+ now_ := __KN__.rows_moved(after_);
2852
+ IF was IS NULL OR now_ IS NULL THEN
2853
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2854
+ 'table', tab->>'name', 'key', read_key,
2855
+ 'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': ' ||
2856
+ CASE WHEN now_ IS NULL THEN after_->>'why' ELSE 'reading it failed before joining' END));
2857
+ EXIT;
2858
+ END IF;
2859
+ completed := completed || to_jsonb(read_key);
2860
+ IF now_ > was THEN
2861
+ findings := findings || jsonb_build_array(jsonb_build_object(
2862
+ 'kind', 'teamread',
2863
+ 'table', tab->>'name',
2864
+ 'who', role_,
2865
+ 'via', member->'table'->>'name',
2866
+ 'roleColumn', member->>'role',
2867
+ 'parent', parent,
2868
+ 'readable', now_ - was,
2869
+ 'secrets', secrets,
2870
+ 'below', tried,
2871
+ 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2872
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
2873
+ EXIT;
2874
+ END IF;
2875
+ tried := tried || to_jsonb(role_);
2876
+ END LOOP;
2877
+ END LOOP;
2878
+ END LOOP;
2879
+
2880
+ RETURN jsonb_build_object('findings', findings, 'completed', completed, 'blocked', blocked);
2881
+ END $$;
2882
+
2319
2883
  -- --------------------------------------------------------------------------
2320
2884
  -- The interruption attack: can a half-finished write survive?
2321
2885
  --
@@ -2776,6 +3340,7 @@ DECLARE
2776
3340
  sown jsonb;
2777
3341
  seen jsonb;
2778
3342
  wrote jsonb;
3343
+ ranked jsonb;
2779
3344
  stranded jsonb;
2780
3345
  raced jsonb;
2781
3346
  findings jsonb := '[]'::jsonb;
@@ -2829,10 +3394,11 @@ BEGIN
2829
3394
 
2830
3395
  seen := __KN__.impersonate(copy_name, theirs || views);
2831
3396
  wrote := __KN__.tamper(copy_name, theirs, sown->'seeded', copy_plan->'policies');
3397
+ ranked := __KN__.teammate(copy_name, theirs, sown->'seeded');
2832
3398
  stranded := __KN__.orphan(copy_name, theirs, sown->'seeded');
2833
3399
  raced := __KN__.collide(copy_name, theirs, copy_plan->'indexes');
2834
3400
 
2835
- findings := (seen->'findings') || (wrote->'findings') || (stranded->'findings');
3401
+ findings := (seen->'findings') || (wrote->'findings') || (ranked->'findings') || (stranded->'findings');
2836
3402
 
2837
3403
  -- A rule that looks itself up breaks the app for whoever it fails for.
2838
3404
  -- One finding per table at fault, however many reads ran into it; the
@@ -2851,6 +3417,7 @@ BEGIN
2851
3417
  -- fixed from one whose attack simply did not run this time.
2852
3418
  attempted := coalesce(seen->'completed', '[]'::jsonb)
2853
3419
  || coalesce(wrote->'completed', '[]'::jsonb)
3420
+ || coalesce(ranked->'completed', '[]'::jsonb)
2854
3421
  || coalesce(stranded->'completed', '[]'::jsonb);
2855
3422
 
2856
3423
  -- And everything that was not. Said out loud, never left out: a table
@@ -2858,6 +3425,7 @@ BEGIN
2858
3425
  not_checked := coalesce(sown->'skipped', '[]'::jsonb)
2859
3426
  || coalesce(seen->'blocked', '[]'::jsonb)
2860
3427
  || coalesce(wrote->'blocked', '[]'::jsonb)
3428
+ || coalesce(ranked->'blocked', '[]'::jsonb)
2861
3429
  || coalesce(stranded->'notTried', '[]'::jsonb)
2862
3430
  || coalesce(raced->'notTried', '[]'::jsonb);
2863
3431