letopis 1.2.0 → 1.4.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
@@ -718,6 +760,14 @@ begin
718
760
  perform @SCHEMA@.lt_fail('invalid_class', format('имя %s занято системным классом', p_data->>'name'));
719
761
  end if;
720
762
  end if;
763
+ -- родитель — под разделяемой блокировкой своего семейства (хвост ревью доработок после 1.2.0): удаление класса берёт
764
+ -- исключительную, поэтому родитель не удалится мимо нового потомка, а удалённый, пока ждали, class_prepare не найдёт
765
+ if p_data->>'parent' is not null then
766
+ tc := @SCHEMA@.class_row(p_data->>'parent', p_tenant);
767
+ if tc.id is not null then
768
+ perform @SCHEMA@.family_lock(tc, false);
769
+ end if;
770
+ end if;
721
771
  select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
722
772
  if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
723
773
  perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
@@ -755,6 +805,23 @@ begin
755
805
  perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)',
756
806
  data->>'name'));
757
807
  end if;
808
+ -- предок производного класса (ревью безопасности №3): правка предка пересчитывает описание потомка (class_evolve) —
809
+ -- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех, кроме
810
+ -- арендаторов со своим одноимённым классом: их потомки наследуют свой класс) меняет тоже только администратор
811
+ if prevd is not null and not @SCHEMA@.letopis_allowed('letopis.projector') then
812
+ select x.data->>'name' into dkid from @SCHEMA@.entity x
813
+ where x.class = 'Class' and x.data->>'name' <> data->>'name'
814
+ and (x.tenant = p_tenant
815
+ or p_tenant = '@SYSTEM@'::uuid
816
+ and x.tenant <> all (array(select y.tenant from @SCHEMA@.entity y
817
+ where y.class = 'Class' and y.data->>'name' = data->>'name' and y.tenant <> '@SYSTEM@'::uuid)))
818
+ and x.data->'resolved'->'ancestors' ? (data->>'name') and x.data->'resolved' ? 'derivedBy'
819
+ order by x.data->>'name' limit 1;
820
+ if dkid is not null then
821
+ perform @SCHEMA@.lt_fail('acl_denied', format('класс %s — предок производного класса %s: его описание задаёт только администратор (право letopis.projector)',
822
+ data->>'name', dkid));
823
+ end if;
824
+ end if;
758
825
  -- агрегат развёрнутого описания (свой или унаследованный), новый или изменённый против прежнего — описание
759
826
  -- агрегата, новый потомок цели, смена parent, — задаёт только тот, кто без условий читает все строки каждого
760
827
  -- класса-источника: класса of и его потомков (agg_classes: классы арендатора, у класса System — классы System).
@@ -762,6 +829,13 @@ begin
762
829
  if data->'resolved' ? 'aggregate' then
763
830
  for e in select key, value from jsonb_each(data->'resolved'->'aggregate') order by key loop
764
831
  continue when prevd->'resolved'->'aggregate'->e.key is not distinct from e.value;
832
+ -- класс-источник должен уже быть (ревью безопасности №3, /security-review): пока его нет, проверять право READ не
833
+ -- на чем, а когда класс заведёт другой пользователь, проверялись бы только его права — и описавший агрегат читал бы
834
+ -- в истории цели вклад каждой строки источника. Поэтому источник описывают раньше агрегата
835
+ if cardinality(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) = 0 then
836
+ perform @SCHEMA@.lt_fail('invalid_class', format('агрегат %s.%s: класса-источника %s и его потомков %s нет — источник описывают раньше агрегата',
837
+ data->>'name', e.key, e.value->>'of', case when p_tenant = '@SYSTEM@'::uuid then 'в System' else 'у арендатора' end));
838
+ end if;
765
839
  -- сам класс of первым, потомки — по имени
766
840
  foreach src in array array(select n from unnest(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) n
767
841
  order by n = (e.value->>'of') desc, n) loop
@@ -793,6 +867,13 @@ begin
793
867
  or prevd->'resolved'->'derivedBy' is distinct from data->'resolved'->'derivedBy') then
794
868
  perform @SCHEMA@.class_acl_admit(data->>'name', p_tenant);
795
869
  end if;
870
+ -- алиас класса, у которого удалённые потомки хранят историю строк (ревью безопасности №3): правила находят их старые
871
+ -- версии и по алиасам предков, а надгробие потомка их не записывает — алиас меняется после purge() их строк
872
+ if prevd is not null and prevd->>'alias' is distinct from data->>'alias'
873
+ and @SCHEMA@.class_orphan_history(data->>'name', p_tenant) then
874
+ perform @SCHEMA@.lt_fail('invalid_class', format('удалённые потомки класса %s хранят историю строк: алиас меняется после purge() их строк',
875
+ data->>'name'));
876
+ end if;
796
877
  -- политика хранения истории (доработки после точки Б): по ней maintain() прореживает журнал строк класса, а
797
878
  -- history: false выключает журнал. Одним правом WRITE на Class автор правки стирал бы аудит строк, на которые у него
798
879
  -- нет прав, поэтому смена развёрнутой history существующего класса требует права READ и DELETE без условий на все
@@ -892,9 +973,12 @@ begin
892
973
  if cls.id is null then
893
974
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
894
975
  end if;
895
- -- разделяемая блокировка корня семейства, затем описание перечитывается под ней
976
+ -- разделяемая блокировка корня семейства, затем описание перечитывается под ней (класс могли удалить, пока ждали)
896
977
  perform @SCHEMA@.family_lock(cls, false);
897
978
  cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
979
+ if cls.id is null then
980
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
981
+ end if;
898
982
  res := cls.data->'resolved';
899
983
  if coalesce((res->>'abstract')::boolean, false) then
900
984
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
@@ -951,12 +1035,17 @@ begin
951
1035
  -- старую историю под чужие правила. Поэтому, если журнал арендатора хранит версии строк класса с этим именем, класс
952
1036
  -- создаётся только заново и с тем же описанием, что в надгробии его строки Class. Надгробия нет (история осталась от
953
1037
  -- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ: сначала purge() строк
954
- if NEW.class = 'Class' and @SCHEMA@.class_has_history(NEW.data->>'name', NEW.tenant)
1038
+ -- Так же — если историю хранят удалённые потомки класса (их правила находят старые версии и по алиасу предка), и
1039
+ -- политика хранения прежняя: по ней maintain() прореживает старые версии (ревью безопасности №3; у описаний без ключа
1040
+ -- history в resolved — значение по умолчанию true)
1041
+ if NEW.class = 'Class'
1042
+ and (@SCHEMA@.class_has_history(NEW.data->>'name', NEW.tenant) or @SCHEMA@.class_orphan_history(NEW.data->>'name', NEW.tenant))
955
1043
  and not (coalesce(last.op = 'delete', false)
956
1044
  and last.data->'resolved'->'ancestors' is not distinct from NEW.data->'resolved'->'ancestors'
957
1045
  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())',
1046
+ and last.data->'resolved'->'derivedBy' is not distinct from NEW.data->'resolved'->'derivedBy'
1047
+ and coalesce(last.data->'resolved'->'history', 'true'::jsonb) is not distinct from coalesce(NEW.data->'resolved'->'history', 'true'::jsonb)) then
1048
+ perform @SCHEMA@.lt_fail('invalid_class', format('журнал хранит историю строк класса %s или его удалённых потомков: создать его можно только заново и с прежними parent, alias, derivedBy и history (или сначала стереть её purge())',
960
1049
  NEW.data->>'name'));
961
1050
  end if;
962
1051
  ts := @SCHEMA@.now_ts();
@@ -1016,6 +1105,9 @@ begin
1016
1105
  end if;
1017
1106
  perform @SCHEMA@.family_lock(cls, false);
1018
1107
  cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
1108
+ if cls.id is null then
1109
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
1110
+ end if;
1019
1111
  res := cls.data->'resolved';
1020
1112
  if coalesce((res->>'abstract')::boolean, false) then
1021
1113
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
@@ -1137,6 +1229,10 @@ begin
1137
1229
  -- прав после acl_bump видит новый класс
1138
1230
  perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
1139
1231
  from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1232
+ -- и описавшему агрегат (хвост ревью доработок после 1.2.0): заведя потомка источника, читающий всё пользователь
1233
+ -- иначе открыл бы его строки тому, кто описал агрегат без права на них
1234
+ perform @SCHEMA@.agg_author_admit(n.data->>'name', n.tenant, null, n.data->'resolved')
1235
+ from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
1140
1236
  -- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
1141
1237
  perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
1142
1238
  from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
@@ -1194,6 +1290,7 @@ begin
1194
1290
  -- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
1195
1291
  -- и после правки — по новым предкам (до правки проверил class_admit)
1196
1292
  perform @SCHEMA@.agg_source_admit(n_row.data->>'name', @SCHEMA@.agg_new_mark(o_row.data->'resolved', n_row.data->'resolved'));
1293
+ perform @SCHEMA@.agg_author_admit(n_row.data->>'name', n_row.tenant, o_row.data->'resolved', n_row.data->'resolved');
1197
1294
  perform @SCHEMA@.class_evolve(o_row, n_row);
1198
1295
  end loop;
1199
1296
  -- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
@@ -1241,6 +1338,8 @@ begin
1241
1338
  if d.tenant = n.tenant then
1242
1339
  perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
1243
1340
  end if;
1341
+ -- и описавшему агрегат — в объёме описания (agg_author_admit сам пропускает класс арендатора под агрегатом System)
1342
+ perform @SCHEMA@.agg_author_admit(d.data->>'name', d.tenant, d.data->'resolved', prep.data->'resolved');
1244
1343
  classes := classes || (d.data->>'name');
1245
1344
  if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
1246
1345
  agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
@@ -1361,6 +1460,9 @@ declare
1361
1460
  mems jsonb;
1362
1461
  lvl_accs uuid[];
1363
1462
  lvl_mems jsonb;
1463
+ drows jsonb;
1464
+ own uuid[];
1465
+ cl @SCHEMA@.entity;
1364
1466
  begin
1365
1467
  select * into c from @SCHEMA@.ctx();
1366
1468
  -- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
@@ -1376,14 +1478,42 @@ begin
1376
1478
  if denied is not null then
1377
1479
  perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
1378
1480
  end if;
1481
+ -- удаление класса (хвост ревью безопасности доработок после 1.2.0): проверки ниже — нет строк, потомков и истории —
1482
+ -- не видят строку, которую писатель ещё не зафиксировал, и она осталась бы без класса, а с ней история удалённого
1483
+ -- класса, от которой зависит смена алиаса предка. Писатели строк и создание потомка держат разделяемую блокировку
1484
+ -- семейства: исключительная ждёт их, и следующие операторы проверок видят зафиксированное
1485
+ for cl in select g.* from gone g where g.class = 'Class' order by @SCHEMA@.family_name(g.tenant, g.data), g.id loop
1486
+ perform @SCHEMA@.family_lock(cl, true);
1487
+ end loop;
1379
1488
  for r in select g.id, g.tenant, g.data->>'name' as name, g.data->'resolved' ? 'derivedBy' as derived from gone g where g.class = 'Class' loop
1380
1489
  -- производный класс удаляет только администратор (class_admit)
1381
1490
  if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
1382
1491
  perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)', r.name));
1383
1492
  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))
1493
+ -- класс-источник агрегата (автоматическое ревью коммита 5290be1): пока его называет агрегат класса того же
1494
+ -- арендатора, класс не удаляется — иначе класс с этим именем завёл бы потом другой пользователь, и право READ
1495
+ -- описавшего агрегат на его строки уже не проверить (класс of проверяется только при описании агрегата). Агрегаты
1496
+ -- классов System описывает системный администратор: он читает всё
1497
+ if r.tenant <> '@SYSTEM@'::uuid then
1498
+ select x.data->>'name' || '.' || a.key into denied
1499
+ from @SCHEMA@.entity x cross join lateral jsonb_each(x.data->'resolved'->'aggregate') a
1500
+ where x.class = 'Class' and x.tenant = r.tenant and x.data->'resolved' ? 'aggregate' and a.value->>'of' = r.name
1501
+ order by 1 limit 1;
1502
+ if denied is not null then
1503
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s — источник агрегата %s: сначала уберите агрегат из описания цели',
1504
+ r.name, denied));
1505
+ end if;
1506
+ end if;
1507
+ -- у класса System — строки и потомки любого арендатора, кроме арендаторов со своим одноимённым классом: их строки и
1508
+ -- потомки относятся к своему классу (class_row; ревью безопасности №3)
1509
+ own := case when r.tenant = '@SYSTEM@'::uuid
1510
+ then array(select x.tenant from @SCHEMA@.entity x
1511
+ where x.class = 'Class' and x.data->>'name' = r.name and x.tenant <> '@SYSTEM@'::uuid)
1512
+ else '{}'::uuid[] end;
1513
+ if exists (select 1 from @SCHEMA@.entity x
1514
+ where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid and x.tenant <> all (own)))
1385
1515
  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
1516
+ and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid and x.tenant <> all (own))) then
1387
1517
  perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
1388
1518
  end if;
1389
1519
  -- и история строк (остаток Н2; доработки после точки Б): удалённый класс с историей, созданный заново с другими
@@ -1439,12 +1569,15 @@ begin
1439
1569
  )
1440
1570
  select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
1441
1571
  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)),
1572
+ 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
1573
  coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
1444
1574
  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));
1575
+ into lvl, lvl_classes, lvl_pairs, drows, lvl_accs, lvl_mems from d;
1576
+ -- класс отказавшей строки называется, только если актор её видит (ревью безопасности №3)
1577
+ if drows is not null then
1578
+ denied := @SCHEMA@.visible_class(drows);
1579
+ perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права DELETE на класс %s в каскаде удаления', denied)
1580
+ else 'нет права DELETE на строки в каскаде удаления' end);
1448
1581
  end if;
1449
1582
  accs := accs || lvl_accs;
1450
1583
  mems := mems || lvl_mems;
@@ -1453,7 +1586,9 @@ begin
1453
1586
  -- unset: снять конец, новая версия
1454
1587
  for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
1455
1588
  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));
1589
+ perform @SCHEMA@.lt_fail('acl_denied', case when @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null
1590
+ then format('нет права WRITE на класс %s: снятие конца при удалении', r.class)
1591
+ else 'нет права WRITE на ссылающуюся строку: снятие конца при удалении' end);
1457
1592
  end if;
1458
1593
  newlinks := '{}'::jsonb;
1459
1594
  for k in select key, value from jsonb_each(r.links) loop
@@ -1627,7 +1762,10 @@ begin
1627
1762
  -- строки производного класса пишет только проектор: перевод их ссылок мимо него оставил бы проекцию
1628
1763
  -- несогласованной (ревью безопасности №3, №3)
1629
1764
  if (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved' ? 'derivedBy' then
1630
- perform @SCHEMA@.lt_fail('derived_class', format('на строку ссылается строка производного класса %s: её пишет проектор', r.class));
1765
+ perform @SCHEMA@.lt_fail('derived_class',
1766
+ case when p_plan is null or @SCHEMA@.visible_class(jsonb_build_array(to_jsonb(r))) is not null
1767
+ then format('на строку ссылается строка производного класса %s: её пишет проектор', r.class)
1768
+ else 'на строку ссылается строка производного класса: её пишет проектор' end);
1631
1769
  end if;
1632
1770
  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
1771
  perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на ссылающиеся строки');
@@ -1702,6 +1840,9 @@ begin
1702
1840
  end if;
1703
1841
  perform @SCHEMA@.family_lock(cls, false);
1704
1842
  cls := @SCHEMA@.class_row(p_class, o.tenant);
1843
+ if cls.id is null then
1844
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', p_class));
1845
+ end if;
1705
1846
  res := cls.data->'resolved';
1706
1847
  if coalesce((res->>'abstract')::boolean, false) then
1707
1848
  perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
@@ -1886,8 +2027,8 @@ begin
1886
2027
  '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
2028
  'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
1888
2029
  '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)',
2030
+ 'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'visible_class(jsonb)', 'class_change_safe(jsonb, jsonb)',
2031
+ '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
2032
  '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
2033
  '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
2034
  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
  -- Чекпоинт проектора: строка создаётся при первом обращении и блокируется до конца транзакции. Чекпоинт — только
@@ -143,6 +143,9 @@ begin
143
143
  -- как у писателей приложения: разделяемая блокировка семейства, описание — свежее под ней
144
144
  perform @SCHEMA@.family_lock(cls, false);
145
145
  cls := @SCHEMA@.class_row(cname, tid);
146
+ if cls.id is null then
147
+ perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', cname) || w);
148
+ end if;
146
149
  res := cls.data->'resolved';
147
150
  if res->>'derivedBy' is distinct from p_name then
148
151
  perform @SCHEMA@.lt_fail('derived_class', format('класс %s не производный проектора %s', cname, p_name) || w);
@@ -581,6 +584,82 @@ begin
581
584
  end if;
582
585
  end $$;
583
586
 
587
+ -- Кто описал агрегат p_field цели p_target (строка Class): автор версии строки Class, с которой развёрнутое описание
588
+ -- агрегата (своё или унаследованное) стало нынешним; надгробие обрывает описание — пересоздавший класс описал агрегат
589
+ -- заново. Журнал строк Class не прореживается и не стирается (purge и обрезка их не берут). Сид и пути владельца без
590
+ -- сессии пишут автора System.
591
+ create or replace function @SCHEMA@.agg_author(p_target @SCHEMA@.entity, p_field text) returns uuid
592
+ language sql stable
593
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
594
+ return (select l.author from @SCHEMA@.log l
595
+ where l.id = p_target.id and l.op not in ('purge', 'trim', 'reset', 'delete')
596
+ and l.data->'resolved'->'aggregate'->p_field is not distinct from p_target.data->'resolved'->'aggregate'->p_field
597
+ and (select case when p.op = 'delete' then null else p.data->'resolved'->'aggregate'->p_field end
598
+ from @SCHEMA@.log p
599
+ where p.id = l.id and p.rev < l.rev and p.op not in ('purge', 'trim', 'reset')
600
+ order by p.rev desc limit 1) is distinct from p_target.data->'resolved'->'aggregate'->p_field
601
+ order by l.rev desc limit 1);
602
+
603
+ -- Аккаунт p_account читает без условий все строки класса p_class арендатора p_tenant: план READ — как у acl_subject
604
+ -- (аккаунт включён и лежит в System, арендатор — сам аккаунт или по членству), но без отказа: нет аккаунта или
605
+ -- членства — права нет.
606
+ create or replace function @SCHEMA@.agg_author_reads(p_account uuid, p_tenant uuid, p_class text) returns boolean
607
+ language plpgsql stable
608
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
609
+ as $$
610
+ declare
611
+ a jsonb;
612
+ m jsonb;
613
+ begin
614
+ select e.data into a from @SCHEMA@.entity e
615
+ where e.id = p_account and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
616
+ if a is null then
617
+ return false;
618
+ end if;
619
+ if p_tenant <> p_account then
620
+ select x.data into m from @SCHEMA@.entity x
621
+ where x.id = @SCHEMA@.v5_id(p_tenant, 'member', array[p_account::text, p_tenant::text]) and x.class = 'member' and x.tenant = p_tenant;
622
+ if m is null then
623
+ return false;
624
+ end if;
625
+ end if;
626
+ return coalesce(@SCHEMA@.acl_plan_for(p_account, p_tenant,
627
+ array(select jsonb_array_elements_text(coalesce(a->'categories', '[]'::jsonb))),
628
+ case when p_tenant = p_account then array['owner']
629
+ else array(select jsonb_array_elements_text(coalesce(m->'roles', '[]'::jsonb))) end,
630
+ 'READ') -> p_class = '{}'::jsonb, false);
631
+ end $$;
632
+
633
+ -- Класс p_class арендатора p_tenant становится источником агрегатов — новые метки aggregatedBy описания p_new против
634
+ -- p_old (хвост ревью безопасности доработок после 1.2.0, решение владельца): его строки войдут в агрегат, и описавший
635
+ -- агрегат читал бы в истории цели вклад каждой из них, — поэтому право READ без условий на все строки класса нужно и
636
+ -- описавшему, иначе acl_denied. Объём — тот же, что при описании (class_admit, agg_classes): у агрегата класса
637
+ -- арендатора — классы арендатора, у агрегата класса System — классы System. Класс арендатора под агрегатом System
638
+ -- описавший не проверял и при описании: его, как и прежде, проверяет только тот, кто заводит (agg_source_admit).
639
+ -- Описавший System (сид, путь владельца без сессии) не проверяется.
640
+ create or replace function @SCHEMA@.agg_author_admit(p_class text, p_tenant uuid, p_old jsonb, p_new jsonb) returns void
641
+ language plpgsql stable
642
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
643
+ as $$
644
+ declare
645
+ mk jsonb;
646
+ tc @SCHEMA@.entity;
647
+ who uuid;
648
+ begin
649
+ for mk in select x from jsonb_array_elements(coalesce(p_new->'aggregatedBy', '[]'::jsonb)) x
650
+ where not coalesce(p_old->'aggregatedBy', '[]'::jsonb) @> jsonb_build_array(x)
651
+ order by x->>'class', x->>'field' loop
652
+ tc := @SCHEMA@.class_row(mk->>'class', p_tenant);
653
+ continue when tc.id is null or (tc.tenant = '@SYSTEM@'::uuid and p_tenant <> '@SYSTEM@'::uuid);
654
+ who := @SCHEMA@.agg_author(tc, mk->>'field');
655
+ continue when who is null or who = '@SYSTEM@'::uuid;
656
+ if not @SCHEMA@.agg_author_reads(who, tc.tenant, p_class) then
657
+ perform @SCHEMA@.lt_fail('acl_denied', format('класс %s становится источником агрегата %s.%s, а описавший агрегат не читает все его строки (право READ без условий): агрегат описывает заново тот, кто их читает',
658
+ p_class, mk->>'class', mk->>'field'));
659
+ end if;
660
+ end loop;
661
+ end $$;
662
+
584
663
  -- Метки источников заново: классы арендатора p_tenant (System — всех), в предках которых есть одно из имён p_names;
585
664
  -- изменившееся описание — новой версией строки Class (журнал, сигнал).
586
665
  create or replace function @SCHEMA@.agg_mark(p_tenant uuid, p_names text[]) returns void
@@ -1142,7 +1221,8 @@ begin
1142
1221
  foreach f in array array['agg_fields(jsonb)', 'agg_of(jsonb)', 'agg_zero(jsonb, jsonb)', 'agg_num(jsonb)', 'agg_where_ok(jsonb, jsonb)',
1143
1222
  'agg_guard(jsonb, jsonb, jsonb, boolean)', 'agg_keep(text, uuid, jsonb, jsonb)', 'agg_check_source(jsonb, jsonb, text, jsonb)',
1144
1223
  'agg_cycle(uuid, jsonb)', 'agg_prepare(jsonb, jsonb, uuid)', 'agg_source(jsonb, uuid)', 'agg_marked(jsonb, jsonb)',
1145
- 'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
1224
+ 'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_author(@SCHEMA@.entity, text)',
1225
+ 'agg_author_reads(uuid, uuid, text)', 'agg_author_admit(text, uuid, jsonb, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
1146
1226
  'agg_classes(text, uuid, boolean)', 'agg_compute(jsonb, uuid, uuid)',
1147
1227
  'agg_check(text, uuid, jsonb)', 'agg_write(@SCHEMA@.entity, jsonb, boolean)', 'agg_token(text)', 'agg_held()',
1148
1228
  'agg_hold_begin(text)', 'agg_hold_end(boolean)', 'agg_merge(jsonb, jsonb)', 'agg_flush(jsonb)', 'agg_kind(text, uuid)',