letopis 1.2.0 → 1.3.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/sql/60-write.sql CHANGED
@@ -545,6 +545,18 @@ begin
545
545
  return jsonb_build_object('rows', vis, 'others', jsonb_array_length(p_rows) - jsonb_array_length(vis));
546
546
  end $$;
547
547
 
548
+ -- Класс для текста отказа (ревью безопасности №3): наименьший класс среди строк p_rows (строки целиком, to_jsonb), которые
549
+ -- актор видит; не видит ни одной — null, и текст класса не называет. Строки берутся из самих данных, а не из таблицы:
550
+ -- удалённые каскадом в этом же операторе её уже не найдут.
551
+ create or replace function @SCHEMA@.visible_class(p_rows jsonb) returns text
552
+ language sql stable
553
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
554
+ return (select min(r->>'class') from jsonb_array_elements(p_rows) r, (select @SCHEMA@.acl_actor('READ') as a) x
555
+ where x.a is not null
556
+ and @SCHEMA@.row_visible((r->>'tenant')::uuid, r->>'class', (r->>'id')::uuid, (r->>'owner')::uuid,
557
+ array(select jsonb_array_elements_text(r->'tags')), r->'data', r->'links',
558
+ (x.a->>'tenant')::uuid, (x.a->>'account')::uuid, x.a->'plan'));
559
+
548
560
  -- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
549
561
  -- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
550
562
  -- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись),
@@ -585,6 +597,7 @@ declare
585
597
  outv jsonb := '[]'::jsonb;
586
598
  a jsonb := @SCHEMA@.acl_actor('READ');
587
599
  vis boolean;
600
+ ref jsonb;
588
601
  begin
589
602
  for r in select e.* from @SCHEMA@.entity e
590
603
  where e.class = any (p_classes) and (e.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
@@ -610,13 +623,17 @@ begin
610
623
  if problem is null and p_incoming then
611
624
  inc := @SCHEMA@.incoming_conflicts(r.id, r.class, r.tenant);
612
625
  if inc <> '[]'::jsonb then
613
- -- id ссылающейся строки — только если актор её видит (доработки после точки Б)
614
- select @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links,
615
- (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan') into vis
616
- from @SCHEMA@.entity x where x.id = (inc->0->>'id')::uuid;
617
- problem := case when coalesce(vis, false)
618
- then format('на строку ссылается роль %s строки %s, которая не принимает класс', inc->0->>'role', inc->0->>'id')
619
- else format('на строку ссылается роль %s строки, которой актор не видит, и она не принимает класс', inc->0->>'role') end;
626
+ -- роль и id ссылающейся строки — только если актор её видит (доработки после точки Б; ревью безопасности №3:
627
+ -- о невидимой — ни роли, ни id); видимые ссылающиеся строки — первыми, затем по id
628
+ select i.v, coalesce(@SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links,
629
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan'), false) as seen
630
+ into ref, vis
631
+ from jsonb_array_elements(inc) i(v)
632
+ left join @SCHEMA@.entity x on x.id = (i.v->>'id')::uuid
633
+ order by seen desc, i.v->>'id' limit 1;
634
+ problem := case when vis
635
+ then format('на строку ссылается роль %s строки %s, которая не принимает класс', ref->>'role', ref->>'id')
636
+ else 'на строку ссылается строка, которой актор не видит, и она не принимает класс' end;
620
637
  end if;
621
638
  end if;
622
639
  if problem is not null then
@@ -678,14 +695,38 @@ begin
678
695
  end loop;
679
696
  end $$;
680
697
 
681
- -- Есть ли в журнале версии строк класса p_name арендатора p_tenant (у класса System — любого арендатора); записи purge,
682
- -- trim и reset — не версии (доработки после точки Б: удаление и пересоздание класса, остаток Н2 ревью №2).
698
+ -- Есть ли в журнале версии строк класса p_name арендатора p_tenant; записи purge, trim и reset — не версии (доработки
699
+ -- после точки Б: удаление и пересоздание класса, остаток Н2 ревью №2). У класса System — строки любого арендатора, кроме
700
+ -- арендаторов со своим классом с тем же именем: их строки — строки своего класса, как их резолвит class_row (ревью
701
+ -- безопасности №3: история такого класса не мешает администратору создать и удалить класс System)
683
702
  create or replace function @SCHEMA@.class_has_history(p_name text, p_tenant uuid) returns boolean
684
703
  language sql stable
685
704
  set search_path = pg_catalog, @SCHEMA@, pg_temp
686
705
  return exists (select 1 from @SCHEMA@.log l
687
- where l.class = p_name and (l.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
688
- and l.op not in ('purge', 'trim', 'reset'));
706
+ where l.class = p_name and l.op not in ('purge', 'trim', 'reset')
707
+ and (l.tenant = p_tenant
708
+ or p_tenant = '@SYSTEM@'::uuid
709
+ and l.tenant <> all (array(select x.tenant from @SCHEMA@.entity x
710
+ where x.class = 'Class' and x.data->>'name' = p_name
711
+ and x.tenant <> '@SYSTEM@'::uuid))));
712
+
713
+ -- Хранят ли историю строк удалённые потомки класса p_name: надгробия строк Class (последняя версия их id — удаление),
714
+ -- у которых в resolved.ancestors есть p_name (ревью безопасности №3). Правила находят старые версии строк и по алиасам
715
+ -- предков, а надгробие потомка их не записывает: пока такая история есть, предок не меняет алиас ни правкой, ни
716
+ -- удалением и созданием заново. У класса System — кроме арендаторов со своим одноимённым классом, как class_has_history
717
+ create or replace function @SCHEMA@.class_orphan_history(p_name text, p_tenant uuid) returns boolean
718
+ language sql stable
719
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
720
+ return exists (select 1 from @SCHEMA@.log t
721
+ where t.class = 'Class' and t.op = 'delete' and t.data->>'name' <> p_name
722
+ and t.data->'resolved'->'ancestors' ? p_name
723
+ and (t.tenant = p_tenant
724
+ or p_tenant = '@SYSTEM@'::uuid
725
+ and t.tenant <> all (array(select x.tenant from @SCHEMA@.entity x
726
+ where x.class = 'Class' and x.data->>'name' = p_name
727
+ and x.tenant <> '@SYSTEM@'::uuid)))
728
+ and not exists (select 1 from @SCHEMA@.log z where z.id = t.id and z.rev > t.rev)
729
+ and @SCHEMA@.class_has_history(t.data->>'name', t.tenant));
689
730
 
690
731
  -- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
691
732
  -- имена связей против методов цепочки, однозначность имени конца во всей схеме, право на агрегаты,
@@ -705,6 +746,7 @@ declare
705
746
  src text;
706
747
  chk jsonb;
707
748
  m jsonb;
749
+ dkid text;
708
750
  begin
709
751
  if p_tenant <> '@SYSTEM@'::uuid then
710
752
  if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
@@ -755,6 +797,20 @@ begin
755
797
  perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)',
756
798
  data->>'name'));
757
799
  end if;
800
+ -- предок производного класса (ревью безопасности №3): правка предка пересчитывает описание потомка (class_evolve) —
801
+ -- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех) меняет
802
+ -- тоже только администратор
803
+ if prevd is not null and not @SCHEMA@.letopis_allowed('letopis.projector') then
804
+ select x.data->>'name' into dkid from @SCHEMA@.entity x
805
+ where x.class = 'Class' and x.data->>'name' <> data->>'name'
806
+ and (x.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
807
+ and x.data->'resolved'->'ancestors' ? (data->>'name') and x.data->'resolved' ? 'derivedBy'
808
+ order by x.data->>'name' limit 1;
809
+ if dkid is not null then
810
+ perform @SCHEMA@.lt_fail('acl_denied', format('класс %s — предок производного класса %s: его описание задаёт только администратор (право letopis.projector)',
811
+ data->>'name', dkid));
812
+ end if;
813
+ end if;
758
814
  -- агрегат развёрнутого описания (свой или унаследованный), новый или изменённый против прежнего — описание
759
815
  -- агрегата, новый потомок цели, смена parent, — задаёт только тот, кто без условий читает все строки каждого
760
816
  -- класса-источника: класса of и его потомков (agg_classes: классы арендатора, у класса System — классы System).
@@ -762,6 +818,13 @@ begin
762
818
  if data->'resolved' ? 'aggregate' then
763
819
  for e in select key, value from jsonb_each(data->'resolved'->'aggregate') order by key loop
764
820
  continue when prevd->'resolved'->'aggregate'->e.key is not distinct from e.value;
821
+ -- класс-источник должен уже быть (ревью безопасности №3, /security-review): пока его нет, проверять право READ не
822
+ -- на чем, а когда класс заведёт другой пользователь, проверялись бы только его права — и описавший агрегат читал бы
823
+ -- в истории цели вклад каждой строки источника. Поэтому источник описывают раньше агрегата
824
+ if cardinality(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) = 0 then
825
+ perform @SCHEMA@.lt_fail('invalid_class', format('агрегат %s.%s: класса-источника %s и его потомков %s нет — источник описывают раньше агрегата',
826
+ data->>'name', e.key, e.value->>'of', case when p_tenant = '@SYSTEM@'::uuid then 'в System' else 'у арендатора' end));
827
+ end if;
765
828
  -- сам класс of первым, потомки — по имени
766
829
  foreach src in array array(select n from unnest(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) n
767
830
  order by n = (e.value->>'of') desc, n) loop
@@ -793,6 +856,13 @@ begin
793
856
  or prevd->'resolved'->'derivedBy' is distinct from data->'resolved'->'derivedBy') then
794
857
  perform @SCHEMA@.class_acl_admit(data->>'name', p_tenant);
795
858
  end if;
859
+ -- алиас класса, у которого удалённые потомки хранят историю строк (ревью безопасности №3): правила находят их старые
860
+ -- версии и по алиасам предков, а надгробие потомка их не записывает — алиас меняется после purge() их строк
861
+ if prevd is not null and prevd->>'alias' is distinct from data->>'alias'
862
+ and @SCHEMA@.class_orphan_history(data->>'name', p_tenant) then
863
+ perform @SCHEMA@.lt_fail('invalid_class', format('удалённые потомки класса %s хранят историю строк: алиас меняется после purge() их строк',
864
+ data->>'name'));
865
+ end if;
796
866
  -- политика хранения истории (доработки после точки Б): по ней maintain() прореживает журнал строк класса, а
797
867
  -- history: false выключает журнал. Одним правом WRITE на Class автор правки стирал бы аудит строк, на которые у него
798
868
  -- нет прав, поэтому смена развёрнутой history существующего класса требует права READ и DELETE без условий на все
@@ -951,12 +1021,17 @@ begin
951
1021
  -- старую историю под чужие правила. Поэтому, если журнал арендатора хранит версии строк класса с этим именем, класс
952
1022
  -- создаётся только заново и с тем же описанием, что в надгробии его строки Class. Надгробия нет (история осталась от
953
1023
  -- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ: сначала purge() строк
954
- if NEW.class = 'Class' and @SCHEMA@.class_has_history(NEW.data->>'name', NEW.tenant)
1024
+ -- Так же — если историю хранят удалённые потомки класса (их правила находят старые версии и по алиасу предка), и
1025
+ -- политика хранения прежняя: по ней maintain() прореживает старые версии (ревью безопасности №3; у описаний без ключа
1026
+ -- history в resolved — значение по умолчанию true)
1027
+ if NEW.class = 'Class'
1028
+ and (@SCHEMA@.class_has_history(NEW.data->>'name', NEW.tenant) or @SCHEMA@.class_orphan_history(NEW.data->>'name', NEW.tenant))
955
1029
  and not (coalesce(last.op = 'delete', false)
956
1030
  and last.data->'resolved'->'ancestors' is not distinct from NEW.data->'resolved'->'ancestors'
957
1031
  and last.data->>'alias' is not distinct from NEW.data->>'alias'
958
- and last.data->'resolved'->'derivedBy' is not distinct from NEW.data->'resolved'->'derivedBy') then
959
- perform @SCHEMA@.lt_fail('invalid_class', format('журнал хранит историю строк класса %s: создать его можно только заново и с прежними parent, alias и derivedBy (или сначала стереть её purge())',
1032
+ and last.data->'resolved'->'derivedBy' is not distinct from NEW.data->'resolved'->'derivedBy'
1033
+ and coalesce(last.data->'resolved'->'history', 'true'::jsonb) is not distinct from coalesce(NEW.data->'resolved'->'history', 'true'::jsonb)) then
1034
+ perform @SCHEMA@.lt_fail('invalid_class', format('журнал хранит историю строк класса %s или его удалённых потомков: создать его можно только заново и с прежними parent, alias, derivedBy и history (или сначала стереть её purge())',
960
1035
  NEW.data->>'name'));
961
1036
  end if;
962
1037
  ts := @SCHEMA@.now_ts();
@@ -1361,6 +1436,8 @@ declare
1361
1436
  mems jsonb;
1362
1437
  lvl_accs uuid[];
1363
1438
  lvl_mems jsonb;
1439
+ drows jsonb;
1440
+ own uuid[];
1364
1441
  begin
1365
1442
  select * into c from @SCHEMA@.ctx();
1366
1443
  -- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
@@ -1381,9 +1458,30 @@ begin
1381
1458
  if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
1382
1459
  perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)', r.name));
1383
1460
  end if;
1384
- if exists (select 1 from @SCHEMA@.entity x where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid))
1461
+ -- класс-источник агрегата (автоматическое ревью коммита 5290be1): пока его называет агрегат класса того же
1462
+ -- арендатора, класс не удаляется — иначе класс с этим именем завёл бы потом другой пользователь, и право READ
1463
+ -- описавшего агрегат на его строки уже не проверить (класс of проверяется только при описании агрегата). Агрегаты
1464
+ -- классов System описывает системный администратор: он читает всё
1465
+ if r.tenant <> '@SYSTEM@'::uuid then
1466
+ select x.data->>'name' || '.' || a.key into denied
1467
+ from @SCHEMA@.entity x cross join lateral jsonb_each(x.data->'resolved'->'aggregate') a
1468
+ where x.class = 'Class' and x.tenant = r.tenant and x.data->'resolved' ? 'aggregate' and a.value->>'of' = r.name
1469
+ order by 1 limit 1;
1470
+ if denied is not null then
1471
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s — источник агрегата %s: сначала уберите агрегат из описания цели',
1472
+ r.name, denied));
1473
+ end if;
1474
+ end if;
1475
+ -- у класса System — строки и потомки любого арендатора, кроме арендаторов со своим одноимённым классом: их строки и
1476
+ -- потомки относятся к своему классу (class_row; ревью безопасности №3)
1477
+ own := case when r.tenant = '@SYSTEM@'::uuid
1478
+ then array(select x.tenant from @SCHEMA@.entity x
1479
+ where x.class = 'Class' and x.data->>'name' = r.name and x.tenant <> '@SYSTEM@'::uuid)
1480
+ else '{}'::uuid[] end;
1481
+ if exists (select 1 from @SCHEMA@.entity x
1482
+ where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid and x.tenant <> all (own)))
1385
1483
  or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
1386
- and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid)) then
1484
+ and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid and x.tenant <> all (own))) then
1387
1485
  perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
1388
1486
  end if;
1389
1487
  -- и история строк (остаток Н2; доработки после точки Б): удалённый класс с историей, созданный заново с другими
@@ -1439,12 +1537,15 @@ begin
1439
1537
  )
1440
1538
  select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
1441
1539
  coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
1442
- min(d.class) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
1540
+ jsonb_agg(to_jsonb(d)) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
1443
1541
  coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
1444
1542
  coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
1445
- into lvl, lvl_classes, lvl_pairs, denied, lvl_accs, lvl_mems from d;
1446
- if denied is not null then
1447
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
1543
+ into lvl, lvl_classes, lvl_pairs, drows, lvl_accs, lvl_mems from d;
1544
+ -- класс отказавшей строки называется, только если актор её видит (ревью безопасности №3)
1545
+ if drows is not null then
1546
+ denied := @SCHEMA@.visible_class(drows);
1547
+ perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права DELETE на класс %s в каскаде удаления', denied)
1548
+ else 'нет права DELETE на строки в каскаде удаления' end);
1448
1549
  end if;
1449
1550
  accs := accs || lvl_accs;
1450
1551
  mems := mems || lvl_mems;
@@ -1453,7 +1554,9 @@ begin
1453
1554
  -- unset: снять конец, новая версия
1454
1555
  for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
1455
1556
  if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
1456
- perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s: снятие конца при удалении', r.class));
1557
+ perform @SCHEMA@.lt_fail('acl_denied', case when @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null
1558
+ then format('нет права WRITE на класс %s: снятие конца при удалении', r.class)
1559
+ else 'нет права WRITE на ссылающуюся строку: снятие конца при удалении' end);
1457
1560
  end if;
1458
1561
  newlinks := '{}'::jsonb;
1459
1562
  for k in select key, value from jsonb_each(r.links) loop
@@ -1627,7 +1730,10 @@ begin
1627
1730
  -- строки производного класса пишет только проектор: перевод их ссылок мимо него оставил бы проекцию
1628
1731
  -- несогласованной (ревью безопасности №3, №3)
1629
1732
  if (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved' ? 'derivedBy' then
1630
- perform @SCHEMA@.lt_fail('derived_class', format('на строку ссылается строка производного класса %s: её пишет проектор', r.class));
1733
+ perform @SCHEMA@.lt_fail('derived_class',
1734
+ case when p_plan is null or @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null
1735
+ then format('на строку ссылается строка производного класса %s: её пишет проектор', r.class)
1736
+ else 'на строку ссылается строка производного класса: её пишет проектор' end);
1631
1737
  end if;
1632
1738
  if p_plan is not null and not @SCHEMA@.acl_row_ok(p_plan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
1633
1739
  perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на ссылающиеся строки');
@@ -1886,8 +1992,8 @@ begin
1886
1992
  'apply_defaults(jsonb, jsonb)', 'links_shape(jsonb, jsonb)', 'targets_bad(jsonb, boolean, jsonb)', 'check_links(jsonb, jsonb, uuid, boolean, jsonb)', 'key_id(jsonb, text, uuid, jsonb, jsonb)',
1887
1993
  'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
1888
1994
  'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text, jsonb)', 'reclass(uuid, text, jsonb)',
1889
- 'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
1890
- 'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_acl_admit(text, uuid)', 'class_rows_admit(text, uuid, text[], text)', 'class_has_history(text, uuid)', 'class_admit(jsonb, uuid)',
1995
+ 'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'visible_class(jsonb)', 'class_change_safe(jsonb, jsonb)',
1996
+ 'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_acl_admit(text, uuid)', 'class_rows_admit(text, uuid, text[], text)', 'class_has_history(text, uuid)', 'class_orphan_history(text, uuid)', 'class_admit(jsonb, uuid)',
1891
1997
  'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal_token(text, text, bytea)', 'signal_raw(text[])', 'signal(text[], uuid[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
1892
1998
  'trg_entity_bd()', 'trg_entity_ad()', 'owner_to_tenant(uuid, uuid)', 'ref_action(jsonb, jsonb, uuid[])', 'delete_preview(uuid[])', 'merge(jsonb, jsonb)', 'inc(jsonb, text[], numeric, numeric)', 'links_patch(jsonb, jsonb)'] loop
1893
1999
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
@@ -5,8 +5,8 @@
5
5
  -- читает журнал всех арендаторов функцией watch_read и пишет строки производных классов (derivedBy)
6
6
  -- только функцией derived_apply. Чекпоинт — строка secret вида PROJECTOR (account = System, ident —
7
7
  -- имя проектора): курсор журнала со снимком, версия проектора, состояние, выбывшие арендаторы. Курсор
8
- -- сдвигается в той же транзакции, что и запись результатов; писатель один — транзакционная
9
- -- advisory-блокировка (19542, «схема:имя») на каждую пачку. Функции кредов вид PROJECTOR не видят
8
+ -- сдвигается в той же транзакции, что и запись результатов; писатель один — строка-замок secret вида LOCK
9
+ -- («схема:projector:имя», lock_try в 60-write.sql) на каждую пачку. Функции кредов вид PROJECTOR не видят
10
10
  -- (80-auth.sql). Приложению функции файла не выданы: их вызывает только роль владельца.
11
11
 
12
12
  -- Чекпоинт проектора: строка создаётся при первом обращении и блокируется до конца транзакции. Чекпоинт — только
package/sql/90-time.sql CHANGED
@@ -256,6 +256,28 @@ end $$;
256
256
  -- все версии удаляются, а на место версии 1 встаёт запись purge с числом стёртых версий и хэшем
257
257
  -- последней. Право DELETE на класс по последнему снимку. История системных классов — аудит прав и
258
258
  -- схемы: её не стирают.
259
+ -- Проектор источника ещё не прочёл версию (p_tx, p_seq) строки арендатора p_tenant класса из p_classes (ревью
260
+ -- безопасности №3, №5 — purge; хвост ревью №3 — обрезка истории). Проектор касается арендатора, у которого есть его
261
+ -- производный класс (derivedBy — имя проектора): брошенный проектор с удалёнными производными классами не держит.
262
+ -- Курсор «tx:seq~снимок»: транзакции, видимые в снимке, учла перестройка. Чекпоинт без from (до 1.2.0) касается всех
263
+ -- классов.
264
+ create or replace function @SCHEMA@.projector_behind(p_tenant uuid, p_classes text[], p_tx xid8, p_seq bigint) returns boolean
265
+ language sql stable
266
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
267
+ return exists (select 1 from @SCHEMA@.secret s
268
+ cross join lateral (select split_part(s.meta->>'cursor', '~', 1) as head,
269
+ nullif(split_part(s.meta->>'cursor', '~', 2), '') as snap) k
270
+ where s.kind = 'PROJECTOR' and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
271
+ and coalesce(s.meta->>'cursor', '') <> ''
272
+ and exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.tenant = p_tenant
273
+ and x.data->'resolved'->>'derivedBy' = s.ident)
274
+ and (not s.meta ? 'from'
275
+ or exists (select 1 from unnest(p_classes) c
276
+ where coalesce((@SCHEMA@.class_row(c, p_tenant)).data->'resolved'->'ancestors', jsonb_build_array(c))
277
+ ?| array(select jsonb_array_elements_text(s.meta->'from'))))
278
+ and (p_tx, p_seq) > (split_part(k.head, ':', 1)::xid8, split_part(k.head, ':', 2)::bigint)
279
+ and not coalesce(pg_visible_in_snapshot(p_tx, k.snap::pg_snapshot), false));
280
+
259
281
  create or replace function @SCHEMA@.purge(p_id uuid, p_confirm boolean default false) returns jsonb
260
282
  language plpgsql volatile security definer
261
283
  set search_path = pg_catalog, @SCHEMA@, pg_temp
@@ -280,21 +302,8 @@ begin
280
302
  perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s', l.class));
281
303
  end if;
282
304
  -- проектор источника, чей курсор ещё не прошёл надгробие (в том числе надгробие этой транзакции), об удалении не
283
- -- узнал бы — вклад строки остался бы в итогах навсегда (ревью безопасности №3, №5). Проектор касается арендатора, у
284
- -- которого есть его производный класс (derivedBy — имя проектора): брошенный проектор с удалёнными производными
285
- -- классами purge не держит. Курсор «tx:seq~снимок»: транзакции, видимые в снимке, учла перестройка. Чекпоинт без from
286
- -- (до 1.2.0) касается всех классов.
287
- if exists (select 1 from @SCHEMA@.secret s
288
- cross join lateral (select split_part(s.meta->>'cursor', '~', 1) as head,
289
- nullif(split_part(s.meta->>'cursor', '~', 2), '') as snap) k
290
- where s.kind = 'PROJECTOR' and s.account = '@SYSTEM@'::uuid and s.deleted_at is null
291
- and coalesce(s.meta->>'cursor', '') <> ''
292
- and exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.tenant = l.tenant
293
- and x.data->'resolved'->>'derivedBy' = s.ident)
294
- and (not s.meta ? 'from' or coalesce((@SCHEMA@.class_row(l.class, l.tenant)).data->'resolved'->'ancestors',
295
- jsonb_build_array(l.class)) ?| array(select jsonb_array_elements_text(s.meta->'from')))
296
- and (l.tx, l.seq) > (split_part(k.head, ':', 1)::xid8, split_part(k.head, ':', 2)::bigint)
297
- and not coalesce(pg_visible_in_snapshot(l.tx, k.snap::pg_snapshot), false)) then
305
+ -- узнал бы — вклад строки остался бы в итогах навсегда (ревью безопасности №3, №5)
306
+ if @SCHEMA@.projector_behind(l.tenant, array[l.class], l.tx, l.seq) then
298
307
  perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл удаление: purge — после его run()');
299
308
  end if;
300
309
  select count(*)::int into n from @SCHEMA@.log x where x.id = p_id;
@@ -330,6 +339,8 @@ declare
330
339
  dplan jsonb;
331
340
  r @SCHEMA@.log;
332
341
  first @SCHEMA@.log;
342
+ nxt record;
343
+ late record;
333
344
  j int;
334
345
  h bytea;
335
346
  k int;
@@ -374,6 +385,17 @@ begin
374
385
  select x.* into first from @SCHEMA@.log x where x.id = r.id order by x.rev limit 1;
375
386
  -- до момента — только сама запись trim: стирать нечего
376
387
  continue when j = 1 and first.op = 'trim';
388
+ -- проектор источника, чей курсор ещё не прошёл первую оставшуюся версию: читая её, он возьмёт прошлую версию для
389
+ -- before, а её уже не будет — свёртка прибавила бы новую, не вычитая прежнюю (хвост ревью безопасности №3). Класс —
390
+ -- обеих версий у стыка: строка могла уйти из семейства источников сменой класса
391
+ select x.rev, x.class into nxt from @SCHEMA@.log x where x.id = r.id and x.rev > j order by x.rev limit 1;
392
+ -- позиция — самая поздняя по (tx, seq) среди стираемых версий и первой оставшейся: у более поздней версии бывает
393
+ -- меньший xid (автоматическое ревью)
394
+ select x.tx, x.seq into late from @SCHEMA@.log x where x.id = r.id and x.rev <= nxt.rev order by x.tx desc, x.seq desc limit 1;
395
+ if @SCHEMA@.projector_behind(r.tenant, array[(select x.class from @SCHEMA@.log x where x.id = r.id and x.rev = j), nxt.class],
396
+ late.tx, late.seq) then
397
+ perform @SCHEMA@.lt_fail('projector_pending', 'проектор источника ещё не прочёл версии: trimHistory — после его run()');
398
+ end if;
377
399
  select x.hash into h from @SCHEMA@.log x where x.id = r.id and x.rev = j;
378
400
  with d as (delete from @SCHEMA@.log x where x.id = r.id and x.rev <= j returning 1)
379
401
  select count(*)::int into k from d;
@@ -1034,6 +1056,48 @@ begin
1034
1056
  order by l.tx, l.seq limit p_limit;
1035
1057
  end $$;
1036
1058
 
1059
+ -- Уход строки из классов подписки сменой класса без смены id (хвост ревью безопасности №3): версия пишется уже с новым
1060
+ -- классом, и подписка по классам её не видит. Здесь — те же окно, горизонт и снимок, что у watch_read (и верхняя граница
1061
+ -- окна p_upto — последняя строка полной пачки watch_read), а строки — синтетические надгробия прежней версии: класс,
1062
+ -- концы и данные прежние; номер, время, автор, tx и seq — версии смены класса; вид delete; агента и причины нет. Только
1063
+ -- если нового класса нет в p_classes, а прежнюю версию актор видит (row_visible с его планом READ): о строке, которой он
1064
+ -- не читал, подписчик не узнает. Функция владельца: версию с новым классом актору видеть не нужно.
1065
+ create or replace function @SCHEMA@.watch_leaving(p_tx xid8, p_seq bigint, p_classes text[], p_limit int,
1066
+ p_snap pg_snapshot default null, p_upto_tx xid8 default null, p_upto_seq bigint default null)
1067
+ returns setof @SCHEMA@.log
1068
+ language plpgsql stable security definer
1069
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
1070
+ as $$
1071
+ declare
1072
+ horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
1073
+ a jsonb;
1074
+ begin
1075
+ if p_classes is null then
1076
+ return;
1077
+ end if;
1078
+ a := @SCHEMA@.acl_actor('READ');
1079
+ if a is null then
1080
+ return;
1081
+ end if;
1082
+ -- новую версию актор не видит — надгробие при любом p_classes и без автора: перебором классов подписки он узнал бы её
1083
+ -- класс (автоматическое ревью доработок после 1.2.0)
1084
+ return query select l.seq, l.tx, p.id, l.rev, p.class, p.cv, p.tenant, p.owner, p.links, p.data, p.tags, l.at,
1085
+ case when v.seen then l.author end, null::text, 'delete'::text, null::text, null::uuid, null::bytea, null::bytea, null::jsonb
1086
+ from @SCHEMA@.log l
1087
+ cross join lateral (select x.* from @SCHEMA@.log x where x.id = l.id and x.rev < l.rev and x.op not in ('purge', 'trim', 'reset')
1088
+ order by x.rev desc limit 1) p
1089
+ cross join lateral (select @SCHEMA@.row_visible(l.tenant, l.class, l.id, l.owner, l.tags, l.data, l.links,
1090
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan') as seen) v
1091
+ where (l.tx, l.seq) > (p_tx, p_seq) and l.tx < horizon
1092
+ and (p_upto_tx is null or (l.tx, l.seq) <= (p_upto_tx, p_upto_seq))
1093
+ and l.op = 'reclass' and l.moved is null and (not (l.class = any (p_classes)) or not v.seen)
1094
+ and p.class = any (p_classes) and p.op <> 'delete'
1095
+ and (p_snap is null or not pg_visible_in_snapshot(l.tx, p_snap))
1096
+ and @SCHEMA@.row_visible(p.tenant, p.class, p.id, p.owner, p.tags, p.data, p.links,
1097
+ (a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan')
1098
+ order by l.tx, l.seq limit p_limit;
1099
+ end $$;
1100
+
1037
1101
  -- Точка подписки «с этого момента»: курсор — горизонт завершённых транзакций, снимок — чтобы не
1038
1102
  -- отдавать транзакции, зафиксированные до подписки, но ещё не ушедшие за горизонт.
1039
1103
  drop function if exists @SCHEMA@.watch_head();
@@ -1053,10 +1117,10 @@ begin
1053
1117
  foreach f in array array[
1054
1118
  'history_default()', 'history_policy(jsonb)', 'history_iv(text)', 'history_horizon(jsonb)',
1055
1119
  'history_keep(jsonb, timestamp, timestamp, timestamp)', 'admin_role()', 'letopis_allowed(text)', 'session_expires()', 'signal_mode()', 'signal_key()', 'class_stamp()',
1056
- 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
1120
+ 'last_version_visible(uuid, uuid, jsonb)', 'restore(uuid)', 'projector_behind(uuid, text[], xid8, bigint)', 'purge(uuid, boolean)', 'trim_history(uuid[], text[], timestamptz)',
1057
1121
  'rekey(uuid, jsonb, jsonb)', 'rekey_class(text, jsonb)', 'account_purge(uuid)', 'reset_mark(uuid, text, uuid)',
1058
1122
  'reset(text, text, uuid, int)', 'thin_batch(timestamptz, int)', 'maintain(timestamptz, int)', 'verify(boolean)',
1059
- 'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean)', 'watch_head()'] loop
1123
+ 'watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean)', 'watch_leaving(xid8, bigint, text[], int, pg_snapshot, xid8, bigint)', 'watch_head()'] loop
1060
1124
  execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
1061
1125
  execute format('revoke all on function @SCHEMA@.%s from public', f);
1062
1126
  end loop;
@@ -1065,6 +1129,6 @@ end $$;
1065
1129
  grant execute on function @SCHEMA@.restore(uuid), @SCHEMA@.purge(uuid, boolean), @SCHEMA@.trim_history(uuid[], text[], timestamptz),
1066
1130
  @SCHEMA@.rekey(uuid, jsonb, jsonb), @SCHEMA@.rekey_class(text, jsonb), @SCHEMA@.account_purge(uuid),
1067
1131
  @SCHEMA@.reset(text, text, uuid, int), @SCHEMA@.maintain(timestamptz, int), @SCHEMA@.verify(boolean),
1068
- @SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean), @SCHEMA@.watch_head(),
1132
+ @SCHEMA@.session_expires(), @SCHEMA@.watch_read(xid8, bigint, timestamptz, text[], int, pg_snapshot, boolean), @SCHEMA@.watch_leaving(xid8, bigint, text[], int, pg_snapshot, xid8, bigint), @SCHEMA@.watch_head(),
1069
1133
  @SCHEMA@.history_default(), @SCHEMA@.history_policy(jsonb), @SCHEMA@.history_iv(text),
1070
1134
  @SCHEMA@.signal_mode(), @SCHEMA@.signal_key(), @SCHEMA@.class_stamp() to @APP@;
package/sql/README.md CHANGED
@@ -53,7 +53,7 @@
53
53
  | LT020 | `confirm_mismatch` | подтверждение сброса не совпадает с именем схемы (этап 6) |
54
54
  | LT021 | `derived_class` | строки производного класса (`derivedBy`) пишет и удаляет только его проектор; на них не ссылаются концы (план 1.1, §2.4) |
55
55
  | LT022 | `aggregate_field` | агрегатное поле (`aggregate`) считает база: приложение и сырой SQL его не передают и не меняют (план 1.1, §2.4) |
56
- | LT023 | `projector_pending` | `purge` удалённого объекта, чьё надгробие ещё не прочёл проектор его класса (ревью безопасности №3, №5) |
56
+ | LT023 | `projector_pending` | `purge` удалённого объекта, чьё надгробие ещё не прочёл проектор его класса (ревью безопасности №3, №5), или `trim_history`, чью первую оставшуюся версию он ещё не прочёл (хвост ревью №3) |
57
57
 
58
58
  ## Порядок файлов
59
59