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/AGENT-CHEATSHEET.en.md +12 -7
- package/AGENT-CHEATSHEET.md +12 -7
- package/CHANGELOG.md +213 -0
- package/README.en.md +70 -21
- package/README.md +59 -18
- package/dist/index.js +27 -7
- package/dist/project.d.ts +25 -3
- package/dist/project.js +122 -21
- package/dist/sync.js +16 -1
- package/dist/up.d.ts +17 -0
- package/dist/up.js +64 -0
- package/dist/watch.js +28 -3
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/sql/10-core.sql +3 -1
- package/sql/50-acl.sql +5 -1
- package/sql/60-write.sql +166 -25
- package/sql/88-derived.sql +83 -3
- package/sql/90-time.sql +113 -22
- package/sql/99-revision.sql +3 -2
- package/sql/README.md +3 -1
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
|
682
|
-
--
|
|
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
|
|
688
|
-
and l.
|
|
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
|
-
|
|
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'
|
|
959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1446
|
-
|
|
1447
|
-
|
|
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',
|
|
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',
|
|
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);
|
package/sql/88-derived.sql
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
-- читает журнал всех арендаторов функцией watch_read и пишет строки производных классов (derivedBy)
|
|
6
6
|
-- только функцией derived_apply. Чекпоинт — строка secret вида PROJECTOR (account = System, ident —
|
|
7
7
|
-- имя проектора): курсор журнала со снимком, версия проектора, состояние, выбывшие арендаторы. Курсор
|
|
8
|
-
-- сдвигается в той же транзакции, что и запись результатов; писатель один —
|
|
9
|
-
--
|
|
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)', '
|
|
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)',
|