kryptheon-night 0.1.26 → 0.1.29

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
@@ -1625,6 +1625,54 @@ END $$;
1625
1625
  * and `implied`, the keys the app never declared, so a `workspace_id` with no
1626
1626
  * foreign key still names a workspace that is there.
1627
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
+
1628
1676
  CREATE OR REPLACE FUNCTION __KN__.row_for(
1629
1677
  source text, tab jsonb, person text, distinct_ text, overrides jsonb, attempt integer,
1630
1678
  seeding jsonb DEFAULT NULL)
@@ -1636,6 +1684,10 @@ DECLARE
1636
1684
  values_ jsonb := '[]'::jsonb;
1637
1685
  borrowed jsonb := '{}'::jsonb;
1638
1686
  part_of_key jsonb := '[]'::jsonb;
1687
+ only_one jsonb := '{}'::jsonb;
1688
+ group_ jsonb;
1689
+ kept text;
1690
+ must_fill boolean;
1639
1691
  key jsonb;
1640
1692
  row_ jsonb;
1641
1693
  col jsonb;
@@ -1664,6 +1716,22 @@ BEGIN
1664
1716
  part_of_key := part_of_key || (key->'columns');
1665
1717
  END LOOP;
1666
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
+
1667
1735
  FOR col IN SELECT * FROM jsonb_array_elements(tab->'columns') LOOP
1668
1736
  name := col->>'name';
1669
1737
 
@@ -1673,6 +1741,13 @@ BEGIN
1673
1741
  CONTINUE;
1674
1742
  END IF;
1675
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
+
1676
1751
  IF name = owner THEN
1677
1752
  columns := columns || to_jsonb(name);
1678
1753
  values_ := values_ || jsonb_build_array(to_jsonb(person));
@@ -1712,9 +1787,11 @@ BEGIN
1712
1787
  values_ := values_ || jsonb_build_array(borrowed->name);
1713
1788
  CONTINUE;
1714
1789
  END IF;
1715
- -- 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.
1716
1793
  CONTINUE WHEN col->>'default_expr' IS NOT NULL;
1717
- CONTINUE WHEN NOT (col->>'not_null')::boolean;
1794
+ CONTINUE WHEN NOT (col->>'not_null')::boolean AND NOT must_fill;
1718
1795
 
1719
1796
  columns := columns || to_jsonb(name);
1720
1797
  -- A CHECK that lists what it will accept beats anything invented here.
@@ -2414,12 +2491,36 @@ BEGIN
2414
2491
  CONTINUE WHEN labels IS NULL OR jsonb_array_length(labels) < 2;
2415
2492
  lowest := __KN__.lowest_role(labels);
2416
2493
  IF lowest IS NOT NULL THEN
2417
- RETURN jsonb_build_object('column', col->>'name', 'lowest', lowest);
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', 'type', col->>'type', '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));
2418
2501
  END IF;
2419
2502
  END LOOP;
2420
2503
  RETURN NULL;
2421
2504
  END $$;
2422
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
+
2423
2524
  /* Whether a column is a key on its own: one row per person, at most. */
2424
2525
  CREATE OR REPLACE FUNCTION __KN__.alone_unique(tab jsonb, column_name text)
2425
2526
  RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
@@ -2461,7 +2562,8 @@ BEGIN
2461
2562
  AND NOT (k->'columns' ? person);
2462
2563
  CONTINUE WHEN jsonb_array_length(keys) = 0 OR __KN__.alone_unique(tab, person);
2463
2564
  found := found || jsonb_build_array(jsonb_build_object(
2464
- 'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest', 'keys', keys));
2565
+ 'table', tab, 'person', person, 'role', role_->>'column', 'lowest', role_->>'lowest',
2566
+ 'roleType', role_->>'type', 'ladder', role_->'ladder', 'keys', keys));
2465
2567
  END LOOP;
2466
2568
  -- A table named for members first: it is the one a team rule asks.
2467
2569
  SELECT coalesce(jsonb_agg(m ORDER BY (m->'table'->>'name') !~* 'member', at), '[]'::jsonb) INTO found
@@ -2521,7 +2623,49 @@ RETURNS bigint LANGUAGE sql IMMUTABLE AS $$
2521
2623
  ELSE 0 END;
2522
2624
  $$;
2523
2625
 
2524
- CREATE OR REPLACE FUNCTION __KN__.teammate(source text, tables jsonb, seeded jsonb)
2626
+ /*
2627
+ * Whether the app's own rules name this role among those allowed to make
2628
+ * this kind of write: the role in a list in a permissive rule for that
2629
+ * command, or in a list inside a helper that rule calls. A rule written for
2630
+ * one role on its own (role = 'analyst', LaunchRail) is not a list of who may
2631
+ * write. Mirrors allowedByRules in tamper.js.
2632
+ */
2633
+ CREATE OR REPLACE FUNCTION __KN__.allowed_by_rules(rules jsonb, table_name text, what text, role_ text, type_ text)
2634
+ RETURNS boolean LANGUAGE plpgsql IMMUTABLE AS $$
2635
+ DECLARE
2636
+ esc constant text := '([.*+?^${}()|\[\]\\])';
2637
+ label text := '''' || regexp_replace(replace(role_, '''', ''''''), esc, '\\\1', 'g') || '''';
2638
+ type_name text := regexp_replace(replace(split_part(coalesce(type_, 'text'), '.', -1), '"', ''), esc, '\\\1', 'g');
2639
+ in_rule_list text := 'ARRAY\[[^]]*' || label || '::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?';
2640
+ -- The same list written as an array literal, kept as '{...}'::role[].
2641
+ in_rule_literal text := '''\{([^}'']*,)?"?' || regexp_replace(role_, esc, '\\\1', 'g') ||
2642
+ '"?(,[^}'']*)?\}''::("?[A-Za-z0-9_$]+"?\.)?"?' || type_name || '"?\[\]';
2643
+ in_helper_list text := '(\mIN\s*\(|ARRAY\[)[^])]*' || label;
2644
+ command text := CASE what WHEN 'add' THEN 'INSERT' WHEN 'change' THEN 'UPDATE' WHEN 'delete' THEN 'DELETE' END;
2645
+ policy jsonb;
2646
+ body text;
2647
+ BEGIN
2648
+ FOR policy IN SELECT * FROM jsonb_array_elements(coalesce(rules->'policies', '[]'::jsonb)) LOOP
2649
+ CONTINUE WHEN policy->>'table_name' <> table_name;
2650
+ CONTINUE WHEN upper(coalesce(policy->>'permissive', 'PERMISSIVE')) <> 'PERMISSIVE';
2651
+ CONTINUE WHEN upper(coalesce(policy->>'cmd', 'ALL')) NOT IN ('ALL', command);
2652
+ body := coalesce(policy->>'qual', '') || ' ' || coalesce(policy->>'with_check', '');
2653
+ IF body ~* in_rule_list OR body ~* in_rule_literal THEN RETURN true; END IF;
2654
+ IF EXISTS (
2655
+ SELECT 1 FROM jsonb_array_elements(coalesce(rules->'functions', '[]'::jsonb)) f
2656
+ WHERE __KN__.calls_function(body, f->>'name')
2657
+ AND coalesce(f->>'src', '') ~* in_helper_list) THEN
2658
+ RETURN true;
2659
+ END IF;
2660
+ END LOOP;
2661
+ RETURN false;
2662
+ END $$;
2663
+
2664
+ -- The three-argument teammate of 0.1.26-0.1.28, dropped so an engine
2665
+ -- upgraded in place keeps one version and every call stays unambiguous.
2666
+ DROP FUNCTION IF EXISTS __KN__.teammate(text, jsonb, jsonb);
2667
+
2668
+ CREATE OR REPLACE FUNCTION __KN__.teammate(source text, tables jsonb, seeded jsonb, rules jsonb)
2525
2669
  RETURNS jsonb LANGUAGE plpgsql AS $$
2526
2670
  DECLARE
2527
2671
  findings jsonb := '[]'::jsonb;
@@ -2533,20 +2677,27 @@ DECLARE
2533
2677
  shape integer;
2534
2678
  at text;
2535
2679
  theirs jsonb;
2680
+ team jsonb;
2536
2681
  overrides jsonb;
2682
+ joins jsonb;
2537
2683
  key jsonb;
2538
2684
  name text;
2539
2685
  parent text;
2540
- as_ text;
2686
+ parent_key jsonb;
2687
+ to_team jsonb;
2688
+ pointed jsonb;
2541
2689
  row_ jsonb;
2542
2690
  join_ text;
2543
2691
  refused text;
2544
2692
  owner text;
2693
+ where_ text[];
2545
2694
  not_theirs text;
2546
2695
  target text;
2547
2696
  column_ text;
2548
2697
  finding_key text;
2549
- move record;
2698
+ read_key text;
2699
+ moves jsonb;
2700
+ move jsonb;
2550
2701
  was bigint;
2551
2702
  now_ bigint;
2552
2703
  before_ jsonb;
@@ -2554,6 +2705,14 @@ DECLARE
2554
2705
  can jsonb;
2555
2706
  changed jsonb;
2556
2707
  stuck text;
2708
+ secrets jsonb;
2709
+ look text;
2710
+ role_ text;
2711
+ tried jsonb;
2712
+ i integer;
2713
+ before_all jsonb;
2714
+ below jsonb;
2715
+ listed boolean;
2557
2716
  BEGIN
2558
2717
  FOR member IN SELECT * FROM jsonb_array_elements(__KN__.memberships(tables)) LOOP
2559
2718
  SELECT (s->>'attempt')::integer INTO shape
@@ -2571,37 +2730,52 @@ BEGIN
2571
2730
  CONTINUE WHEN theirs IS NULL;
2572
2731
 
2573
2732
  -- Their team, as the first person's own membership names it.
2574
- overrides := jsonb_build_object(member->>'role', member->>'lowest');
2733
+ team := '{}'::jsonb;
2575
2734
  FOR key IN SELECT * FROM jsonb_array_elements(member->'keys') LOOP
2576
2735
  FOR name IN SELECT jsonb_array_elements_text(key->'columns') LOOP
2577
2736
  IF name <> member->>'person' THEN
2578
- overrides := jsonb_set(overrides, ARRAY[name], coalesce(theirs->name, 'null'::jsonb));
2737
+ team := jsonb_set(team, ARRAY[name], coalesce(theirs->name, 'null'::jsonb));
2579
2738
  END IF;
2580
2739
  END LOOP;
2581
2740
  END LOOP;
2582
- parent := member->'keys'->0->>'refTable';
2583
- as_ := 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name');
2741
+ parent_key := member->'keys'->0;
2742
+ parent := parent_key->>'refTable';
2584
2743
 
2585
- -- Whether they can be put in the team at all, tried once on its own.
2744
+ -- Putting the other person in that team, as each role in turn. Built up
2745
+ -- front, before anybody changes role.
2746
+ joins := '{}'::jsonb;
2586
2747
  refused := NULL;
2587
- BEGIN
2588
- row_ := __KN__.row_for(source, member->'table', __KN__.user_b(), '9', overrides, shape);
2589
- join_ := __KN__.insert_statement(source, member->'table'->>'name', row_);
2748
+ FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
2749
+ BEGIN
2750
+ overrides := jsonb_set(team, ARRAY[member->>'role'], to_jsonb(role_));
2751
+ row_ := __KN__.row_for(source, member->'table', __KN__.user_b(), '9', overrides, shape);
2752
+ joins := jsonb_set(joins, ARRAY[role_],
2753
+ to_jsonb(__KN__.insert_statement(source, member->'table'->>'name', row_)));
2754
+ EXCEPTION WHEN OTHERS THEN
2755
+ IF role_ = member->>'lowest' THEN refused := SQLERRM; END IF;
2756
+ END;
2757
+ END LOOP;
2758
+ join_ := joins->>(member->>'lowest');
2759
+
2760
+ -- Whether they can be put in the team at all, tried once on its own.
2761
+ IF refused IS NULL THEN
2590
2762
  BEGIN
2591
2763
  EXECUTE join_;
2592
2764
  RAISE EXCEPTION 'kryptheon: undoing the join' USING ERRCODE = 'KN001';
2593
- EXCEPTION WHEN SQLSTATE 'KN001' THEN
2594
- NULL;
2765
+ EXCEPTION
2766
+ WHEN SQLSTATE 'KN001' THEN
2767
+ NULL;
2768
+ WHEN OTHERS THEN
2769
+ refused := SQLERRM;
2595
2770
  END;
2596
- EXCEPTION WHEN OTHERS THEN
2597
- refused := SQLERRM;
2598
- END;
2771
+ END IF;
2599
2772
  IF refused IS NOT NULL THEN
2600
2773
  -- Said once, on the table that would not take them.
2601
2774
  blocked := blocked || jsonb_build_array(jsonb_build_object(
2602
2775
  'table', member->'table'->>'name',
2603
2776
  'key', 'role:' || (member->'table'->>'name') || ':' || (member->>'lowest'),
2604
- 'why', as_ || ': I could not add one: ' || refused));
2777
+ 'why', 'as a "' || (member->>'lowest') || '" in ' || (member->'table'->>'name') ||
2778
+ ': I could not add one: ' || refused));
2605
2779
  CONTINUE;
2606
2780
  END IF;
2607
2781
 
@@ -2610,54 +2784,172 @@ BEGIN
2610
2784
  WHERE s->>'table' = tab->>'name');
2611
2785
  CONTINUE WHEN done ? (tab->>'name');
2612
2786
  done := done || to_jsonb(tab->>'name');
2613
- finding_key := 'role:' || (tab->>'name') || ':' || (member->>'lowest');
2787
+ SELECT (s->>'attempt')::integer INTO shape
2788
+ FROM jsonb_array_elements(coalesce(seeded, '[]'::jsonb)) s
2789
+ WHERE s->>'table' = tab->>'name';
2790
+ shape := coalesce(shape, 0);
2614
2791
  owner := __KN__.owner_column(tab);
2615
- -- Never their own rows: changing what you made yourself is not a role
2616
- -- question, and the row that put them in the team is theirs.
2617
- not_theirs := CASE WHEN owner IS NULL THEN ''
2618
- ELSE ' WHERE ' || __KN__.always_quote(owner) || ' IS DISTINCT FROM ' || quote_literal(__KN__.user_b()) END;
2792
+
2793
+ -- A table that names the team itself, and the team it is to name.
2794
+ SELECT k INTO to_team
2795
+ FROM jsonb_array_elements(__KN__.foreign_keys(tab) || __KN__.implied_keys(tab, tables))
2796
+ WITH ORDINALITY AS e(k, pos)
2797
+ WHERE k->>'refTable' = parent
2798
+ AND jsonb_array_length(k->'columns') = jsonb_array_length(parent_key->'refColumns')
2799
+ ORDER BY pos LIMIT 1;
2800
+ IF NOT FOUND THEN to_team := NULL; END IF;
2801
+ pointed := '{}'::jsonb;
2802
+ IF to_team IS NOT NULL THEN
2803
+ FOR i IN 0 .. jsonb_array_length(to_team->'columns') - 1 LOOP
2804
+ pointed := jsonb_set(pointed, ARRAY[to_team->'columns'->>i], coalesce(team->(
2805
+ SELECT parent_key->'columns'->>((p.pos - 1)::integer)
2806
+ FROM jsonb_array_elements_text(parent_key->'refColumns') WITH ORDINALITY AS p(c, pos)
2807
+ WHERE p.c = to_team->'refColumns'->>i), 'null'::jsonb));
2808
+ END LOOP;
2809
+ END IF;
2810
+
2811
+ -- Never their own rows, and only the team they joined, where the table
2812
+ -- says which team a row is in.
2813
+ where_ := ARRAY[]::text[];
2814
+ IF owner IS NOT NULL THEN
2815
+ where_ := where_ || (__KN__.always_quote(owner) || ' IS DISTINCT FROM ' || quote_literal(__KN__.user_b()));
2816
+ END IF;
2817
+ FOR name IN SELECT jsonb_object_keys(pointed) ORDER BY 1 LOOP
2818
+ where_ := where_ || (__KN__.always_quote(name) || ' = ' || quote_literal(pointed->>name));
2819
+ END LOOP;
2820
+ not_theirs := CASE WHEN array_length(where_, 1) IS NULL THEN ''
2821
+ ELSE ' WHERE ' || array_to_string(where_, ' AND ') END;
2619
2822
  target := __KN__.always_quote(source) || '.' || __KN__.always_quote(tab->>'name');
2620
2823
  column_ := __KN__.always_quote(__KN__.first_writable(tab));
2621
- can := '[]'::jsonb;
2622
- changed := '{}'::jsonb;
2623
- stuck := NULL;
2624
- FOR move IN
2625
- SELECT * FROM (VALUES
2626
- (1, 'change', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
2627
- (2, 'delete', 'DELETE FROM ' || target || not_theirs)) AS m(n, what, statement)
2628
- ORDER BY n
2629
- LOOP
2630
- before_ := __KN__.try_write('authenticated', __KN__.user_b(), move.statement);
2631
- after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move.statement, join_);
2632
- was := __KN__.rows_moved(before_);
2824
+ moves := jsonb_build_array(
2825
+ jsonb_build_object('what', 'change',
2826
+ 'statement', 'UPDATE ' || target || ' SET ' || column_ || ' = ' || column_ || not_theirs),
2827
+ jsonb_build_object('what', 'delete', 'statement', 'DELETE FROM ' || target || not_theirs));
2828
+
2829
+ -- Adding a row to the team, said only of a role whose own name says it
2830
+ -- may only look, and written under the other person's name: a rule that
2831
+ -- lets a member add a row only as themselves is them speaking for
2832
+ -- themselves.
2833
+ IF to_team IS NOT NULL AND EXISTS (
2834
+ SELECT 1 FROM jsonb_array_elements_text(member->'ladder') r WHERE __KN__.look_only(r)) THEN
2835
+ BEGIN
2836
+ row_ := __KN__.row_for(source, tab, __KN__.user_a(), '11', pointed, shape);
2837
+ moves := jsonb_build_array(jsonb_build_object('what', 'add', 'lookOnly', true,
2838
+ 'statement', __KN__.insert_statement(source, tab->>'name', row_))) || moves;
2839
+ EXCEPTION WHEN OTHERS THEN
2840
+ NULL;
2841
+ END;
2842
+ END IF;
2843
+
2844
+ -- Every role not in charge, from the lowest up, the way reads are tried
2845
+ -- (LaunchRail: production gave "analyst" its own UPDATE rule and only
2846
+ -- "viewer" was tried). The first role that can write where the app's own
2847
+ -- rules do not list it is the one named. What each write did before
2848
+ -- joining is asked once.
2849
+ before_all := '{}'::jsonb;
2850
+ FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
2851
+ before_all := jsonb_set(before_all, ARRAY[move->>'what'],
2852
+ __KN__.try_write('authenticated', __KN__.user_b(), move->>'statement'));
2853
+ END LOOP;
2854
+ below := '[]'::jsonb;
2855
+ FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
2856
+ finding_key := 'role:' || (tab->>'name') || ':' || role_;
2857
+ IF NOT (joins ? role_) THEN
2858
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2859
+ 'table', tab->>'name', 'key', finding_key,
2860
+ 'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': I could not add one'));
2861
+ EXIT;
2862
+ END IF;
2863
+ can := '[]'::jsonb;
2864
+ changed := '{}'::jsonb;
2865
+ stuck := NULL;
2866
+ listed := false;
2867
+ FOR move IN SELECT * FROM jsonb_array_elements(moves) LOOP
2868
+ CONTINUE WHEN coalesce((move->>'lookOnly')::boolean, false) AND NOT __KN__.look_only(role_);
2869
+ -- A write the rules list this role for is not asked: whatever it did
2870
+ -- would not be reported, and a key refusing it left the table untested.
2871
+ IF __KN__.allowed_by_rules(rules, tab->>'name', move->>'what', role_, member->>'roleType') THEN
2872
+ listed := true;
2873
+ CONTINUE;
2874
+ END IF;
2875
+ before_ := before_all->(move->>'what');
2876
+ after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), move->>'statement', joins->>role_);
2877
+ was := __KN__.rows_moved(before_);
2878
+ now_ := __KN__.rows_moved(after_);
2879
+ IF was IS NULL OR now_ IS NULL THEN
2880
+ stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
2881
+ ELSIF now_ > was THEN
2882
+ can := can || to_jsonb(move->>'what');
2883
+ changed := jsonb_set(changed, ARRAY[move->>'what'], to_jsonb(now_ - was));
2884
+ END IF;
2885
+ END LOOP;
2886
+
2887
+ IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
2888
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2889
+ 'table', tab->>'name', 'key', finding_key,
2890
+ 'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': ' || stuck));
2891
+ EXIT;
2892
+ END IF;
2893
+ completed := completed || to_jsonb(finding_key);
2894
+ IF jsonb_array_length(can) > 0 THEN
2895
+ findings := findings || jsonb_build_array(jsonb_build_object(
2896
+ 'kind', 'role',
2897
+ 'table', tab->>'name',
2898
+ 'who', role_,
2899
+ 'via', member->'table'->>'name',
2900
+ 'roleColumn', member->>'role',
2901
+ 'parent', parent,
2902
+ 'can', can,
2903
+ 'changed', changed,
2904
+ 'below', below,
2905
+ 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2906
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
2907
+ EXIT;
2908
+ END IF;
2909
+ IF NOT listed THEN below := below || to_jsonb(role_); END IF;
2910
+ END LOOP;
2911
+
2912
+ -- Reading what a member should not see, said only of a table holding a
2913
+ -- secret. Every role not in charge, from the lowest up; the first that
2914
+ -- can read is the one named.
2915
+ secrets := __KN__.secret_columns(tab);
2916
+ CONTINUE WHEN jsonb_array_length(secrets) = 0;
2917
+ look := 'SELECT 1 FROM ' || target || not_theirs;
2918
+ was := __KN__.rows_moved(__KN__.try_write('authenticated', __KN__.user_b(), look));
2919
+ tried := '[]'::jsonb;
2920
+ FOR role_ IN SELECT jsonb_array_elements_text(member->'ladder') LOOP
2921
+ read_key := 'teamread:' || (tab->>'name') || ':' || role_;
2922
+ IF joins ? role_ THEN
2923
+ after_ := __KN__.try_write_joined('authenticated', __KN__.user_b(), look, joins->>role_);
2924
+ ELSE
2925
+ after_ := jsonb_build_object('ok', false, 'count', 0, 'why', 'I could not add one');
2926
+ END IF;
2633
2927
  now_ := __KN__.rows_moved(after_);
2634
2928
  IF was IS NULL OR now_ IS NULL THEN
2635
- stuck := CASE WHEN now_ IS NULL THEN after_->>'why' ELSE before_->>'why' END;
2636
- ELSIF now_ > was THEN
2637
- can := can || to_jsonb(move.what);
2638
- changed := jsonb_set(changed, ARRAY[move.what], to_jsonb(now_ - was));
2929
+ blocked := blocked || jsonb_build_array(jsonb_build_object(
2930
+ 'table', tab->>'name', 'key', read_key,
2931
+ 'why', 'as a "' || role_ || '" in ' || (member->'table'->>'name') || ': ' ||
2932
+ CASE WHEN now_ IS NULL THEN after_->>'why' ELSE 'reading it failed before joining' END));
2933
+ EXIT;
2639
2934
  END IF;
2935
+ completed := completed || to_jsonb(read_key);
2936
+ IF now_ > was THEN
2937
+ findings := findings || jsonb_build_array(jsonb_build_object(
2938
+ 'kind', 'teamread',
2939
+ 'table', tab->>'name',
2940
+ 'who', role_,
2941
+ 'via', member->'table'->>'name',
2942
+ 'roleColumn', member->>'role',
2943
+ 'parent', parent,
2944
+ 'readable', now_ - was,
2945
+ 'secrets', secrets,
2946
+ 'below', tried,
2947
+ 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2948
+ 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
2949
+ EXIT;
2950
+ END IF;
2951
+ tried := tried || to_jsonb(role_);
2640
2952
  END LOOP;
2641
-
2642
- IF jsonb_array_length(can) = 0 AND stuck IS NOT NULL THEN
2643
- blocked := blocked || jsonb_build_array(jsonb_build_object(
2644
- 'table', tab->>'name', 'key', finding_key, 'why', as_ || ': ' || stuck));
2645
- CONTINUE;
2646
- END IF;
2647
- completed := completed || to_jsonb(finding_key);
2648
- IF jsonb_array_length(can) > 0 THEN
2649
- findings := findings || jsonb_build_array(jsonb_build_object(
2650
- 'kind', 'role',
2651
- 'table', tab->>'name',
2652
- 'who', member->>'lowest',
2653
- 'via', member->'table'->>'name',
2654
- 'roleColumn', member->>'role',
2655
- 'parent', parent,
2656
- 'can', can,
2657
- 'changed', changed,
2658
- 'columns', coalesce((SELECT jsonb_agg(c->>'name') FROM jsonb_array_elements(tab->'columns') c), '[]'::jsonb),
2659
- 'rlsEnabled', coalesce((tab->>'rlsEnabled')::boolean, false)));
2660
- END IF;
2661
2953
  END LOOP;
2662
2954
  END LOOP;
2663
2955
 
@@ -3006,11 +3298,20 @@ DECLARE
3006
3298
  tab jsonb;
3007
3299
  col jsonb;
3008
3300
  expectation text;
3301
+ outbound boolean;
3009
3302
  BEGIN
3010
3303
  FOR tab IN SELECT * FROM jsonb_array_elements(tables) LOOP
3304
+ -- A table keeping another service's credentials, so this app can call
3305
+ -- it: a token there opens nothing in this app, and two rows sharing one
3306
+ -- is not a way in. Found on a blind test (LaunchRail), where
3307
+ -- integration_credentials.access_token was reported as "can exist twice".
3308
+ outbound := tab->>'name' ~* '(^|_)(integrations?|providers?|oauth|connections?|connectors?|external|third_party)(_|$)'
3309
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) c
3310
+ WHERE c->>'name' ~* '^provider$');
3011
3311
  FOR col IN SELECT * FROM jsonb_array_elements(coalesce(tab->'columns', '[]'::jsonb)) LOOP
3012
3312
  expectation := __KN__.must_be_unique(col->>'name', tab->>'name');
3013
3313
  CONTINUE WHEN expectation IS NULL;
3314
+ CONTINUE WHEN expectation = 'credential' AND outbound;
3014
3315
  found := found || jsonb_build_array(jsonb_build_object(
3015
3316
  'table', tab->>'name',
3016
3317
  'column', col->>'name',
@@ -3178,7 +3479,8 @@ BEGIN
3178
3479
 
3179
3480
  seen := __KN__.impersonate(copy_name, theirs || views);
3180
3481
  wrote := __KN__.tamper(copy_name, theirs, sown->'seeded', copy_plan->'policies');
3181
- ranked := __KN__.teammate(copy_name, theirs, sown->'seeded');
3482
+ ranked := __KN__.teammate(copy_name, theirs, sown->'seeded',
3483
+ jsonb_build_object('policies', copy_plan->'policies', 'functions', copy_plan->'functions'));
3182
3484
  stranded := __KN__.orphan(copy_name, theirs, sown->'seeded');
3183
3485
  raced := __KN__.collide(copy_name, theirs, copy_plan->'indexes');
3184
3486