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/AGENT-CHEATSHEET.en.md +9 -7
- package/AGENT-CHEATSHEET.md +9 -7
- package/CHANGELOG.md +125 -0
- package/README.en.md +48 -20
- package/README.md +40 -17
- package/dist/index.js +20 -7
- package/dist/project.d.ts +11 -2
- package/dist/project.js +63 -18
- package/dist/sync.js +11 -2
- 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/60-write.sql +130 -24
- package/sql/88-derived.sql +2 -2
- package/sql/90-time.sql +82 -18
- package/sql/README.md +1 -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
|
|
@@ -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
|
-
|
|
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'
|
|
959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1446
|
-
|
|
1447
|
-
|
|
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',
|
|
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',
|
|
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);
|
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
|
-- Чекпоинт проектора: строка создаётся при первом обращении и блокируется до конца транзакции. Чекпоинт — только
|
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
|
-
|
|
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
|
|