letopis 1.1.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 +113 -16
- package/AGENT-CHEATSHEET.md +106 -14
- package/CHANGELOG.md +702 -0
- package/README.en.md +798 -74
- package/README.md +756 -68
- package/dist/admin.d.ts +20 -4
- package/dist/admin.js +33 -9
- package/dist/cli.js +53 -4
- package/dist/errors.js +4 -2
- package/dist/index.d.ts +16 -2
- package/dist/index.js +94 -23
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.js +42 -11
- package/dist/mcp.d.ts +28 -0
- package/dist/mcp.js +810 -0
- package/dist/model.d.ts +34 -0
- package/dist/model.js +17 -0
- package/dist/project.d.ts +318 -0
- package/dist/project.js +1142 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/sql.js +9 -5
- package/dist/sync.js +11 -2
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +12 -0
- package/dist/watch.js +48 -4
- package/dist/write.d.ts +1 -1
- package/dist/write.js +25 -5
- package/llms.txt +6 -2
- package/package.json +13 -2
- package/sql/10-core.sql +8 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +629 -56
- package/sql/70-read.sql +33 -8
- package/sql/80-auth.sql +4 -4
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +154 -13
- package/sql/95-seed.system.sql +12 -2
- package/sql/README.md +4 -1
package/sql/60-write.sql
CHANGED
|
@@ -10,25 +10,123 @@ create or replace function @SCHEMA@.now_ts() returns timestamptz
|
|
|
10
10
|
language sql volatile
|
|
11
11
|
return clock_timestamp();
|
|
12
12
|
|
|
13
|
-
--
|
|
14
|
-
--
|
|
15
|
-
--
|
|
16
|
-
--
|
|
17
|
-
--
|
|
18
|
-
--
|
|
13
|
+
-- Внутренние блокировки (семейства, проектор, обслуживание) — строки-замки: строки вида LOCK таблицы secret, которой у
|
|
14
|
+
-- приложения нет, а не advisory-блокировки. Advisory-блокировку берёт любая роль базы, а её ключ виден всем в pg_locks:
|
|
15
|
+
-- посторонняя роль занимала бы её и останавливала запись семейства, проектор или обслуживание для всех (ревью
|
|
16
|
+
-- безопасности №3, №2). id строки-замка — lock_id имени; строки семейств заводятся заранее (class_admit, seed_system),
|
|
17
|
+
-- иначе строку вставляет первое обращение — и параллельное ждёт его транзакцию.
|
|
18
|
+
-- lock_id — первые 16 байт sha256 имени: его считает каждая строка записи, а v5_id собирает jsonb и зовёт pgcrypto.
|
|
19
|
+
create or replace function @SCHEMA@.lock_id(p_name text) returns uuid
|
|
20
|
+
language sql immutable parallel safe
|
|
21
|
+
return encode(substring(sha256(convert_to('letopis:lock:' || p_name, 'UTF8')) from 1 for 16), 'hex')::uuid;
|
|
22
|
+
|
|
23
|
+
create or replace function @SCHEMA@.lock_row(p_name text) returns uuid
|
|
24
|
+
language plpgsql volatile
|
|
25
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
26
|
+
as $$
|
|
27
|
+
declare
|
|
28
|
+
v uuid := @SCHEMA@.lock_id(p_name);
|
|
29
|
+
begin
|
|
30
|
+
if not exists (select 1 from @SCHEMA@.secret s where s.id = v) then
|
|
31
|
+
-- имя — в meta, а не в ident: уникальный индекс ident сравнивает без регистра, и строка-замок семейства, чей корень
|
|
32
|
+
-- отличается от другого только регистром, не вставлялась — его блокировка молча не бралась (/security-review после
|
|
33
|
+
-- ревью безопасности №3). Строки нет и после вставки — ошибка, а не запись без блокировки.
|
|
34
|
+
insert into @SCHEMA@.secret (id, account, kind, meta) values (v, '@SYSTEM@'::uuid, 'LOCK', jsonb_build_object('name', p_name))
|
|
35
|
+
on conflict do nothing;
|
|
36
|
+
if not exists (select 1 from @SCHEMA@.secret s where s.id = v) then
|
|
37
|
+
raise exception using errcode = 'XX000', message = format('строка-замок %s не создана', p_name);
|
|
38
|
+
end if;
|
|
39
|
+
end if;
|
|
40
|
+
return v;
|
|
41
|
+
end $$;
|
|
42
|
+
|
|
43
|
+
-- Строки вида LOCK других аккаунтов — не строки-замки: в 1.1 вид LOCK был открыт функциям кредов. При накате они
|
|
44
|
+
-- помечаются удалёнными; повторный накат их уже не находит.
|
|
45
|
+
update @SCHEMA@.secret s set deleted_at = clock_timestamp()
|
|
46
|
+
where s.kind = 'LOCK' and s.account <> '@SYSTEM@'::uuid and s.deleted_at is null;
|
|
47
|
+
|
|
48
|
+
-- Строка-замок без ожидания (обслуживание и проектор — один исполнитель): true — взята до конца транзакции, false — её
|
|
49
|
+
-- держит другая транзакция. Блокировка — отдельным оператором: строку, которую вставила lock_row, видит уже он.
|
|
50
|
+
create or replace function @SCHEMA@.lock_try(p_name text) returns boolean
|
|
51
|
+
language plpgsql volatile
|
|
52
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
53
|
+
as $$
|
|
54
|
+
declare
|
|
55
|
+
k uuid := @SCHEMA@.lock_row(p_name);
|
|
56
|
+
begin
|
|
57
|
+
perform 1 from @SCHEMA@.secret s where s.id = k for update skip locked;
|
|
58
|
+
return found;
|
|
59
|
+
end $$;
|
|
60
|
+
|
|
61
|
+
-- Блокировки семейства (этап 1, п. 10): писатели и загрузчик берут разделяемую блокировку корня семейства, ужесточение
|
|
62
|
+
-- описания и rekeyClass — исключительную. Имя — арендатор корневого класса и имя корня (итоговое ревью безопасности):
|
|
63
|
+
-- ужесточение или rekeyClass одноимённого класса в другом арендаторе запись сюда не останавливает; корень из System
|
|
64
|
+
-- (общий для всех арендаторов) — арендатор System. p_data — описание класса с resolved.
|
|
65
|
+
create or replace function @SCHEMA@.family_name(p_tenant uuid, p_data jsonb) returns text
|
|
66
|
+
language sql stable
|
|
67
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
68
|
+
return '@SCHEMA_NAME@:family:' || (case when p_tenant = '@SYSTEM@'::uuid or p_data->>'name' = p_data->'resolved'->'ancestors'->>-1 then p_tenant
|
|
69
|
+
else coalesce((@SCHEMA@.class_row(p_data->'resolved'->'ancestors'->>-1, p_tenant)).tenant, p_tenant) end)::text
|
|
70
|
+
|| ':' || (p_data->'resolved'->'ancestors'->>-1);
|
|
71
|
+
|
|
72
|
+
-- Строки-замки семейства класса — заранее, при записи строки Class и в seed_system: первая запись в семейство их не вставляет.
|
|
73
|
+
create or replace function @SCHEMA@.family_rows(p_tenant uuid, p_data jsonb) returns void
|
|
74
|
+
language plpgsql volatile
|
|
75
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
76
|
+
as $$
|
|
77
|
+
declare
|
|
78
|
+
n text := @SCHEMA@.family_name(p_tenant, p_data);
|
|
79
|
+
begin
|
|
80
|
+
perform @SCHEMA@.lock_row(n), @SCHEMA@.lock_row(n || ':gate');
|
|
81
|
+
end $$;
|
|
82
|
+
|
|
83
|
+
-- p_cls — строка Class класса семейства. Писатели — for key share строки-замка семейства, исключительная — for update:
|
|
84
|
+
-- писатели друг другу не мешают. Строковые блокировки, в отличие от advisory, очереди не держат: for key share проходит
|
|
85
|
+
-- мимо ждущего for update, и поток записей не дал бы исключительной случиться никогда. Поэтому у семейства есть шлюз —
|
|
86
|
+
-- вторая строка-замок: исключительная занимает его первым, а писатель проходит шлюз в подтранзакции — ждёт, пока шлюз
|
|
87
|
+
-- занят, и сразу отпускает. Шлюз писатель проходит один раз за транзакцию: тот, кто уже держит строку-замок, у шлюза
|
|
88
|
+
-- ждал бы исключительную, а та — его самого. Справка letopis.family_locks (подтранзакция, откат которой снимает
|
|
89
|
+
-- блокировку, откатывает и её) пропускает только шлюз, строку-замок функция берёт всегда — повторно взятая своя
|
|
90
|
+
-- блокировка не ждёт: справку выставит и клиент, а без блокировки запись прошла бы мимо ужесточения (автоматическое
|
|
91
|
+
-- ревью коммита 1bc6584).
|
|
19
92
|
create or replace function @SCHEMA@.family_lock(p_cls @SCHEMA@.entity, p_exclusive boolean) returns void
|
|
20
93
|
language plpgsql volatile
|
|
21
94
|
as $$
|
|
22
95
|
declare
|
|
23
|
-
|
|
24
|
-
k
|
|
96
|
+
n text := @SCHEMA@.family_name(p_cls.tenant, p_cls.data);
|
|
97
|
+
k uuid := @SCHEMA@.lock_id(n);
|
|
98
|
+
held text := coalesce(current_setting('letopis.family_locks', true), '');
|
|
99
|
+
g uuid;
|
|
25
100
|
begin
|
|
26
|
-
k := hashtext('@SCHEMA_NAME@:' || (case when p_cls.tenant = '@SYSTEM@'::uuid or p_cls.data->>'name' = root then p_cls.tenant
|
|
27
|
-
else coalesce((@SCHEMA@.class_row(root, p_cls.tenant)).tenant, p_cls.tenant) end)::text || ':' || root);
|
|
28
101
|
if p_exclusive then
|
|
29
|
-
|
|
102
|
+
g := @SCHEMA@.lock_row(n || ':gate');
|
|
103
|
+
perform 1 from @SCHEMA@.secret s where s.id = g for update;
|
|
104
|
+
perform @SCHEMA@.lock_row(n);
|
|
105
|
+
perform 1 from @SCHEMA@.secret s where s.id = k for update;
|
|
106
|
+
if not found then
|
|
107
|
+
raise exception using errcode = 'XX000', message = format('строка-замок %s не найдена', n);
|
|
108
|
+
end if;
|
|
30
109
|
else
|
|
31
|
-
|
|
110
|
+
if strpos(held, '|' || n || '|') = 0 then
|
|
111
|
+
g := @SCHEMA@.lock_row(n || ':gate');
|
|
112
|
+
begin
|
|
113
|
+
perform 1 from @SCHEMA@.secret s where s.id = g for key share;
|
|
114
|
+
raise exception 'шлюз пройден';
|
|
115
|
+
exception when raise_exception then
|
|
116
|
+
null;
|
|
117
|
+
end;
|
|
118
|
+
end if;
|
|
119
|
+
perform 1 from @SCHEMA@.secret s where s.id = k for key share;
|
|
120
|
+
if not found then
|
|
121
|
+
perform @SCHEMA@.lock_row(n);
|
|
122
|
+
perform 1 from @SCHEMA@.secret s where s.id = k for key share;
|
|
123
|
+
if not found then
|
|
124
|
+
raise exception using errcode = 'XX000', message = format('строка-замок %s не найдена', n);
|
|
125
|
+
end if;
|
|
126
|
+
end if;
|
|
127
|
+
end if;
|
|
128
|
+
if strpos(held, '|' || n || '|') = 0 then
|
|
129
|
+
perform set_config('letopis.family_locks', held || '|' || n || '|', true);
|
|
32
130
|
end if;
|
|
33
131
|
end $$;
|
|
34
132
|
-- до итогового ревью безопасности ключ был без арендатора
|
|
@@ -169,7 +267,8 @@ end $$;
|
|
|
169
267
|
-- При p_acl ({tenant, account, plan} — план READ актора) цель ещё и должна быть видна актору (этап 5,
|
|
170
268
|
-- п. 6): строку, которую он не видит, нельзя использовать как цель — ответ тот же, что для
|
|
171
269
|
-- несуществующей. Цели-аккаунты (Account) не проверяются: на них ссылаются членства и владельцы.
|
|
172
|
-
-- Возвращает роль первой непрошедшей цели или null
|
|
270
|
+
-- Возвращает роль первой непрошедшей цели или null; цель — строка производного класса: «!роль»
|
|
271
|
+
-- (вызывающий отвечает derived_class).
|
|
173
272
|
create or replace function @SCHEMA@.targets_bad(p_rows jsonb, p_lock boolean, p_acl jsonb default null) returns text
|
|
174
273
|
language plpgsql volatile
|
|
175
274
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -200,21 +299,31 @@ begin
|
|
|
200
299
|
left join lateral jsonb_array_elements(case jsonb_typeof(k.value) when 'array' then k.value else '[]'::jsonb end) a on true
|
|
201
300
|
where jsonb_typeof(k.value) = 'string' or a.value is not null
|
|
202
301
|
)
|
|
203
|
-
select
|
|
204
|
-
from
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
302
|
+
select case when x.miss then x.role else '!' || x.role end into bad
|
|
303
|
+
from (
|
|
304
|
+
select p.role,
|
|
305
|
+
t.id is null
|
|
306
|
+
or t.tenant not in (p.tenant, '@SYSTEM@'::uuid)
|
|
307
|
+
or not coalesce(@SCHEMA@.class_set(c.ends->p.role->'class') ? t.class
|
|
308
|
+
or @SCHEMA@.class_set(c.ends->p.role->'class') ?| array(select jsonb_array_elements_text(kr.res->'ancestors')), false)
|
|
309
|
+
or (p_acl is not null and t.class <> 'Account'
|
|
310
|
+
and not @SCHEMA@.row_visible(t.tenant, t.class, t.id, t.owner, t.tags, t.data, t.links,
|
|
311
|
+
(p_acl->>'tenant')::uuid, (p_acl->>'account')::uuid, p_acl->'plan')) as miss,
|
|
312
|
+
coalesce(kr.res ? 'derivedBy', false) as derived
|
|
313
|
+
from p
|
|
314
|
+
join c on c.cls = p.cls and c.tenant = p.tenant and c.ends is not distinct from coalesce(p.ends, c.ends)
|
|
315
|
+
left join @SCHEMA@.entity t on t.id = p.tid
|
|
316
|
+
-- описание класса цели — только когда класс не назван в конце прямо: тогда нужны его предки, а цель
|
|
317
|
+
-- производного класса (конец, названный предком; план 1.1, §2.4) не проходит — прямо назвать производный
|
|
318
|
+
-- класс в конце не даёт описание (class_admit)
|
|
319
|
+
left join lateral (
|
|
320
|
+
select case when t.id is null or @SCHEMA@.class_set(c.ends->p.role->'class') ? t.class then null
|
|
321
|
+
else (select k.data->'resolved' from @SCHEMA@.entity k
|
|
322
|
+
where k.class = 'Class' and k.data->>'name' = t.class and k.tenant in (t.tenant, '@SYSTEM@'::uuid)
|
|
323
|
+
order by k.tenant = '@SYSTEM@'::uuid limit 1) end as res
|
|
324
|
+
) kr on true
|
|
325
|
+
) x
|
|
326
|
+
where x.miss or x.derived
|
|
218
327
|
limit 1;
|
|
219
328
|
return bad;
|
|
220
329
|
end $$;
|
|
@@ -239,7 +348,9 @@ begin
|
|
|
239
348
|
end if;
|
|
240
349
|
bad := @SCHEMA@.targets_bad(jsonb_build_array(jsonb_build_object(
|
|
241
350
|
'class', p_res->>'name', 'tenant', p_tenant, 'links', links, 'ends', coalesce(p_res->'ends', '{}'::jsonb))), p_lock, p_acl);
|
|
242
|
-
if bad
|
|
351
|
+
if bad like '!%' then
|
|
352
|
+
code := 'derived_class'; msg := format('конец %s ведёт на строку производного класса', substr(bad, 2));
|
|
353
|
+
elsif bad is not null then
|
|
243
354
|
code := 'target_not_found'; msg := format('цель конца %s не найдена', bad);
|
|
244
355
|
end if;
|
|
245
356
|
end $$;
|
|
@@ -372,6 +483,11 @@ begin
|
|
|
372
483
|
cv = coalesce((@SCHEMA@.class_row(coalesce(p_class, x.class), x.tenant)).rev, x.cv)
|
|
373
484
|
where x.id = p_id
|
|
374
485
|
returning x.* into e;
|
|
486
|
+
-- смена класса: агрегатные поля новой цели триггер после правки посчитал в эту же версию (план 1.1, §2.4) —
|
|
487
|
+
-- в журнал идёт строка таблицы, а не returning
|
|
488
|
+
if p_class is not null then
|
|
489
|
+
select x.* into e from @SCHEMA@.entity x where x.id = p_id;
|
|
490
|
+
end if;
|
|
375
491
|
perform @SCHEMA@.log_version(e);
|
|
376
492
|
return e;
|
|
377
493
|
end $$;
|
|
@@ -429,14 +545,29 @@ begin
|
|
|
429
545
|
return jsonb_build_object('rows', vis, 'others', jsonb_array_length(p_rows) - jsonb_array_length(vis));
|
|
430
546
|
end $$;
|
|
431
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
|
+
|
|
432
560
|
-- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
|
|
433
561
|
-- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
|
|
434
|
-
-- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись)
|
|
562
|
+
-- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись),
|
|
563
|
+
-- признака производного класса derivedBy и описания агрегатов aggregate (план 1.1, §2.6).
|
|
435
564
|
create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
|
|
436
565
|
language sql immutable
|
|
437
566
|
return
|
|
438
567
|
o->'key' = n->'key'
|
|
439
568
|
and o->'ancestors' = n->'ancestors'
|
|
569
|
+
and o->'derivedBy' is not distinct from n->'derivedBy'
|
|
570
|
+
and o->'aggregate' is not distinct from n->'aggregate'
|
|
440
571
|
and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
|
|
441
572
|
and o->'schema'->'anyOf' is not distinct from n->'schema'->'anyOf'
|
|
442
573
|
and o->'schema'->'oneOf' is not distinct from n->'schema'->'oneOf'
|
|
@@ -464,6 +595,9 @@ declare
|
|
|
464
595
|
problem text;
|
|
465
596
|
inc jsonb;
|
|
466
597
|
outv jsonb := '[]'::jsonb;
|
|
598
|
+
a jsonb := @SCHEMA@.acl_actor('READ');
|
|
599
|
+
vis boolean;
|
|
600
|
+
ref jsonb;
|
|
467
601
|
begin
|
|
468
602
|
for r in select e.* from @SCHEMA@.entity e
|
|
469
603
|
where e.class = any (p_classes) and (e.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
|
|
@@ -489,7 +623,17 @@ begin
|
|
|
489
623
|
if problem is null and p_incoming then
|
|
490
624
|
inc := @SCHEMA@.incoming_conflicts(r.id, r.class, r.tenant);
|
|
491
625
|
if inc <> '[]'::jsonb then
|
|
492
|
-
|
|
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;
|
|
493
637
|
end if;
|
|
494
638
|
end if;
|
|
495
639
|
if problem is not null then
|
|
@@ -506,8 +650,87 @@ create or replace function @SCHEMA@.reserved_names() returns text[]
|
|
|
506
650
|
language sql immutable parallel safe
|
|
507
651
|
return array[@RESERVED_NAMES@]::text[];
|
|
508
652
|
|
|
653
|
+
-- Правка класса p_class арендатора p_tenant меняет имена, по которым его строки находят правила прав: предков
|
|
654
|
+
-- (parent), алиас или derivedBy (ревью безопасности №2). План прав берёт для класса имена предков и их алиасы
|
|
655
|
+
-- (acl_plan_for), поэтому строки класса и его потомков попали бы под правила READ, WRITE и DELETE нового предка или
|
|
656
|
+
-- алиаса — право WRITE на Class открывало бы чужие строки. Актору нужно право READ, WRITE и DELETE без условий на
|
|
657
|
+
-- все строки самого класса и каждого потомка своего арендатора, иначе acl_denied. Потомков у арендаторов класса
|
|
658
|
+
-- System план прав сессии System не видит — они пропускаются, как у агрегатов (agg_source_admit). Проверка — до
|
|
659
|
+
-- правки (class_admit): план видит прежние имена, по которым строки уже защищены.
|
|
660
|
+
create or replace function @SCHEMA@.class_acl_admit(p_class text, p_tenant uuid) returns void
|
|
661
|
+
language plpgsql stable
|
|
662
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
663
|
+
as $$
|
|
664
|
+
begin
|
|
665
|
+
perform @SCHEMA@.class_rows_admit(p_class, p_tenant, array['READ', 'WRITE', 'DELETE'],
|
|
666
|
+
format('класс %s меняет предков, алиас или derivedBy', p_class));
|
|
667
|
+
end $$;
|
|
668
|
+
|
|
669
|
+
-- Право актора на все строки класса p_class и каждого его потомка арендатора p_tenant: по каждой операции p_ops —
|
|
670
|
+
-- без условий, иначе acl_denied с текстом «<p_what>: нужно право <op> на все строки класса <класс>». Сам класс первым,
|
|
671
|
+
-- потомки — по глубине и имени; потомков у арендаторов класса System план сессии System не видит — они пропускаются.
|
|
672
|
+
-- Зовут class_acl_admit (смена предков, алиаса, derivedBy), ужесточение (class_evolve, rekey_class) и смена политики
|
|
673
|
+
-- хранения истории (class_admit; доработки после точки Б).
|
|
674
|
+
create or replace function @SCHEMA@.class_rows_admit(p_class text, p_tenant uuid, p_ops text[], p_what text) returns void
|
|
675
|
+
language plpgsql stable
|
|
676
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
677
|
+
as $$
|
|
678
|
+
declare
|
|
679
|
+
c text;
|
|
680
|
+
op text;
|
|
681
|
+
chk jsonb;
|
|
682
|
+
begin
|
|
683
|
+
for c in select p_class
|
|
684
|
+
union all
|
|
685
|
+
(select x.data->>'name' from @SCHEMA@.entity x
|
|
686
|
+
where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' <> p_class
|
|
687
|
+
and x.data->'resolved'->'ancestors' ? p_class
|
|
688
|
+
order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.data->>'name') loop
|
|
689
|
+
foreach op in array p_ops loop
|
|
690
|
+
chk := @SCHEMA@.acl_check_data(c, op);
|
|
691
|
+
if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
|
|
692
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('%s: нужно право %s на все строки класса %s', p_what, op, c));
|
|
693
|
+
end if;
|
|
694
|
+
end loop;
|
|
695
|
+
end loop;
|
|
696
|
+
end $$;
|
|
697
|
+
|
|
698
|
+
-- Есть ли в журнале версии строк класса p_name арендатора p_tenant; записи purge, trim и reset — не версии (доработки
|
|
699
|
+
-- после точки Б: удаление и пересоздание класса, остаток Н2 ревью №2). У класса System — строки любого арендатора, кроме
|
|
700
|
+
-- арендаторов со своим классом с тем же именем: их строки — строки своего класса, как их резолвит class_row (ревью
|
|
701
|
+
-- безопасности №3: история такого класса не мешает администратору создать и удалить класс System)
|
|
702
|
+
create or replace function @SCHEMA@.class_has_history(p_name text, p_tenant uuid) returns boolean
|
|
703
|
+
language sql stable
|
|
704
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
705
|
+
return exists (select 1 from @SCHEMA@.log 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));
|
|
730
|
+
|
|
509
731
|
-- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
|
|
510
|
-
-- имена связей против методов цепочки, однозначность имени конца во всей
|
|
732
|
+
-- имена связей против методов цепочки, однозначность имени конца во всей схеме, право на агрегаты,
|
|
733
|
+
-- алиас и право на смену предков, алиаса и derivedBy (ревью безопасности №2).
|
|
511
734
|
create or replace function @SCHEMA@.class_admit(p_data jsonb, p_tenant uuid, out data jsonb, out validator jsonpath)
|
|
512
735
|
language plpgsql volatile
|
|
513
736
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -516,6 +739,14 @@ as $$
|
|
|
516
739
|
declare
|
|
517
740
|
e record;
|
|
518
741
|
o record;
|
|
742
|
+
dname text;
|
|
743
|
+
drole text;
|
|
744
|
+
tc @SCHEMA@.entity;
|
|
745
|
+
prevd jsonb;
|
|
746
|
+
src text;
|
|
747
|
+
chk jsonb;
|
|
748
|
+
m jsonb;
|
|
749
|
+
dkid text;
|
|
519
750
|
begin
|
|
520
751
|
if p_tenant <> '@SYSTEM@'::uuid then
|
|
521
752
|
if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
|
|
@@ -533,6 +764,113 @@ begin
|
|
|
533
764
|
if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
|
|
534
765
|
perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
|
|
535
766
|
end if;
|
|
767
|
+
-- алиас — второе имя класса и для правил прав (ревью безопасности №2): совпади он с именем другого класса (в том
|
|
768
|
+
-- числе Class и системных) или с чужим алиасом арендатора и System, правила одного класса действовали бы на строки
|
|
769
|
+
-- другого
|
|
770
|
+
if data->>'alias' is not null
|
|
771
|
+
and ((data->>'alias') = any (array['Class', 'Account', 'member', 'Resource', 'rule'])
|
|
772
|
+
or exists (select 1 from @SCHEMA@.entity x
|
|
773
|
+
where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
|
|
774
|
+
and (x.data->>'name' = data->>'alias' or x.data->>'alias' = data->>'alias'))) then
|
|
775
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('алиас %s класса %s совпадает с именем или алиасом другого класса',
|
|
776
|
+
data->>'alias', data->>'name'));
|
|
777
|
+
end if;
|
|
778
|
+
-- и наоборот: имя класса — не чужой алиас арендатора и System, иначе правила, написанные для нового класса, действовали
|
|
779
|
+
-- бы и на строки класса с этим алиасом
|
|
780
|
+
if exists (select 1 from @SCHEMA@.entity x
|
|
781
|
+
where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
|
|
782
|
+
and x.data->>'alias' = data->>'name') then
|
|
783
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('имя класса %s совпадает с алиасом другого класса', data->>'name'));
|
|
784
|
+
end if;
|
|
785
|
+
-- агрегаты (план 1.1, §2.4; ревью коммита этапа 4б): поле считают функции владельца по всем строкам источника
|
|
786
|
+
-- арендатора, без прав чтения, поэтому строки попадают в агрегат только с ведома того, кто читает их все. Пути
|
|
787
|
+
-- владельца (сид, class_evolve, rekey_class) сюда не заходят. Описание до правки — для сравнения (нет — новый класс)
|
|
788
|
+
select x.data into prevd from @SCHEMA@.entity x
|
|
789
|
+
where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' = data->>'name';
|
|
790
|
+
-- производный класс (план 1.1, §2.4) заводит, меняет и удаляет только администратор (/security-review после точки Б):
|
|
791
|
+
-- проектор берёт арендатора в участники по его классам с пометкой и пишет туда итоги по всем строкам источников,
|
|
792
|
+
-- поэтому пользователь с правом WRITE на Class, заведя такой класс сам, читал бы итоги по строкам, которые ему читать
|
|
793
|
+
-- запрещено. Пометка своя или унаследованная, до правки или после; право letopis.projector — у администраторской роли
|
|
794
|
+
-- подключения и по правилам System (системный администратор — маска letopis.*)
|
|
795
|
+
if (data->'resolved' ? 'derivedBy' or coalesce(prevd->'resolved' ? 'derivedBy', false))
|
|
796
|
+
and not @SCHEMA@.letopis_allowed('letopis.projector') then
|
|
797
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)',
|
|
798
|
+
data->>'name'));
|
|
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;
|
|
814
|
+
-- агрегат развёрнутого описания (свой или унаследованный), новый или изменённый против прежнего — описание
|
|
815
|
+
-- агрегата, новый потомок цели, смена parent, — задаёт только тот, кто без условий читает все строки каждого
|
|
816
|
+
-- класса-источника: класса of и его потомков (agg_classes: классы арендатора, у класса System — классы System).
|
|
817
|
+
-- Неизменённый агрегат не проверяется
|
|
818
|
+
if data->'resolved' ? 'aggregate' then
|
|
819
|
+
for e in select key, value from jsonb_each(data->'resolved'->'aggregate') order by key loop
|
|
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;
|
|
828
|
+
-- сам класс of первым, потомки — по имени
|
|
829
|
+
foreach src in array array(select n from unnest(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) n
|
|
830
|
+
order by n = (e.value->>'of') desc, n) loop
|
|
831
|
+
chk := @SCHEMA@.acl_check_data(src, 'READ');
|
|
832
|
+
if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
|
|
833
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('агрегат %s.%s: нужно право READ на все строки класса %s', data->>'name', e.key, src));
|
|
834
|
+
end if;
|
|
835
|
+
end loop;
|
|
836
|
+
end loop;
|
|
837
|
+
end if;
|
|
838
|
+
-- класс становится источником агрегата (новая метка aggregatedBy — смена parent): в агрегат войдут его строки и строки
|
|
839
|
+
-- потомков — нужно право READ без условий на них до правки, по прежним предкам (здесь), и после (agg_source_admit в
|
|
840
|
+
-- триггере после оператора; новый класс проверяется только там: до вставки план прав его не видит)
|
|
841
|
+
m := @SCHEMA@.agg_new_mark(prevd->'resolved', data->'resolved');
|
|
842
|
+
if m is not null and prevd is not null then
|
|
843
|
+
perform @SCHEMA@.agg_source_admit(data->>'name', m);
|
|
844
|
+
for src in select x.data->>'name' from @SCHEMA@.entity x
|
|
845
|
+
where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' <> data->>'name'
|
|
846
|
+
and x.data->'resolved'->'ancestors' ? (data->>'name')
|
|
847
|
+
order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.data->>'name' loop
|
|
848
|
+
perform @SCHEMA@.agg_source_admit(src, m);
|
|
849
|
+
end loop;
|
|
850
|
+
end if;
|
|
851
|
+
-- правка меняет предков, алиас или derivedBy (ревью безопасности №2): строки класса и потомков попали бы под правила
|
|
852
|
+
-- других имён — нужно право READ, WRITE и DELETE без условий на все их строки. У нового класса строк ещё нет
|
|
853
|
+
if prevd is not null
|
|
854
|
+
and (prevd->'resolved'->'ancestors' is distinct from data->'resolved'->'ancestors'
|
|
855
|
+
or prevd->>'alias' is distinct from data->>'alias'
|
|
856
|
+
or prevd->'resolved'->'derivedBy' is distinct from data->'resolved'->'derivedBy') then
|
|
857
|
+
perform @SCHEMA@.class_acl_admit(data->>'name', p_tenant);
|
|
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;
|
|
866
|
+
-- политика хранения истории (доработки после точки Б): по ней maintain() прореживает журнал строк класса, а
|
|
867
|
+
-- history: false выключает журнал. Одним правом WRITE на Class автор правки стирал бы аудит строк, на которые у него
|
|
868
|
+
-- нет прав, поэтому смена развёрнутой history существующего класса требует права READ и DELETE без условий на все
|
|
869
|
+
-- строки класса и его потомков своего арендатора — как trimHistory требует их на каждую цель
|
|
870
|
+
if prevd is not null and prevd->'resolved'->'history' is distinct from data->'resolved'->'history' then
|
|
871
|
+
perform @SCHEMA@.class_rows_admit(data->>'name', p_tenant, array['READ', 'DELETE'],
|
|
872
|
+
format('класс %s меняет политику хранения истории', data->>'name'));
|
|
873
|
+
end if;
|
|
536
874
|
for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
|
|
537
875
|
select x.data->>'name' as name into o
|
|
538
876
|
from @SCHEMA@.entity x
|
|
@@ -545,6 +883,51 @@ begin
|
|
|
545
883
|
perform @SCHEMA@.lt_fail('invalid_class', format('конец %s в классе %s ведёт на другой класс', e.key, o.name));
|
|
546
884
|
end if;
|
|
547
885
|
end loop;
|
|
886
|
+
-- на строки производного класса (план 1.1, §2.4) не ссылаются: проектор удаляет и строит их заново.
|
|
887
|
+
-- Классы целей — под разделяемой блокировкой семейства, а класс, который становится производным, — под
|
|
888
|
+
-- исключительной: описание со ссылкой и пометка derivedBy не проходят параллельно мимо друг друга
|
|
889
|
+
for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
|
|
890
|
+
for tc in select c.* from jsonb_array_elements_text(@SCHEMA@.class_set(e.value->'class')) n(name)
|
|
891
|
+
cross join lateral @SCHEMA@.class_row(n.name, p_tenant) c where c.id is not null order by c.id loop
|
|
892
|
+
perform @SCHEMA@.family_lock(tc, false);
|
|
893
|
+
end loop;
|
|
894
|
+
dname := null;
|
|
895
|
+
select x.data->>'name' into dname
|
|
896
|
+
from @SCHEMA@.entity x
|
|
897
|
+
where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
|
|
898
|
+
and @SCHEMA@.class_set(e.value->'class') ? (x.data->>'name') and x.data->'resolved' ? 'derivedBy'
|
|
899
|
+
limit 1;
|
|
900
|
+
if dname is null and data->'resolved' ? 'derivedBy' and @SCHEMA@.class_set(e.value->'class') ? (data->>'name') then
|
|
901
|
+
dname := data->>'name';
|
|
902
|
+
end if;
|
|
903
|
+
if dname is not null then
|
|
904
|
+
perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на производный класс %s', e.key, dname));
|
|
905
|
+
end if;
|
|
906
|
+
end loop;
|
|
907
|
+
if data->'resolved' ? 'derivedBy' then
|
|
908
|
+
tc := @SCHEMA@.class_row(data->>'name', p_tenant);
|
|
909
|
+
if tc.id is not null then
|
|
910
|
+
perform @SCHEMA@.family_lock(tc, true);
|
|
911
|
+
end if;
|
|
912
|
+
-- класс становится производным, а на него или его потомков уже ссылаются концы других классов (класс
|
|
913
|
+
-- System видят все арендаторы — его ссылки ищутся во всех)
|
|
914
|
+
select x.data->>'name', k.key into dname, drole
|
|
915
|
+
from @SCHEMA@.entity x
|
|
916
|
+
cross join lateral jsonb_each(coalesce(x.data->'resolved'->'ends', '{}'::jsonb)) k
|
|
917
|
+
where x.class = 'Class' and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
|
|
918
|
+
and x.data->>'name' <> data->>'name'
|
|
919
|
+
and @SCHEMA@.class_set(k.value->'class') ?| (array[data->>'name'] || array(
|
|
920
|
+
select y.data->>'name' from @SCHEMA@.entity y
|
|
921
|
+
where y.class = 'Class' and (y.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
|
|
922
|
+
and y.data->'resolved'->'ancestors' ? (data->>'name')))
|
|
923
|
+
limit 1;
|
|
924
|
+
if dname is not null then
|
|
925
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s не может стать производным: на него ссылается конец %s класса %s',
|
|
926
|
+
data->>'name', drole, dname));
|
|
927
|
+
end if;
|
|
928
|
+
end if;
|
|
929
|
+
-- строки-замки семейства (family_lock) — заранее: первая запись в новое семейство их не вставляет
|
|
930
|
+
perform @SCHEMA@.family_rows(p_tenant, data);
|
|
548
931
|
end $$;
|
|
549
932
|
|
|
550
933
|
-- ========================= триггеры =========================
|
|
@@ -586,9 +969,21 @@ begin
|
|
|
586
969
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
587
970
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
|
|
588
971
|
end if;
|
|
972
|
+
if res ? 'derivedBy' then
|
|
973
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', NEW.class, res->>'derivedBy'));
|
|
974
|
+
end if;
|
|
589
975
|
if NEW.class = 'Class' then
|
|
590
976
|
select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(coalesce(NEW.data, '{}'::jsonb), NEW.tenant) a;
|
|
591
977
|
else
|
|
978
|
+
-- агрегатное поле считает база (план 1.1, §2.4): переданное значение — отказ, новое — 0 до умолчаний и
|
|
979
|
+
-- проверки; итог из живых источников ставит триггер после оператора
|
|
980
|
+
if res ? 'aggregate' then
|
|
981
|
+
if coalesce(NEW.data, '{}'::jsonb) ?| @SCHEMA@.agg_fields(res) then
|
|
982
|
+
perform @SCHEMA@.lt_fail('aggregate_field', format('поля %s класса %s считает база (aggregate) — их не передают',
|
|
983
|
+
array_to_string(array(select x from unnest(@SCHEMA@.agg_fields(res)) x where NEW.data ? x), ', '), NEW.class));
|
|
984
|
+
end if;
|
|
985
|
+
NEW.data := @SCHEMA@.agg_zero(res, coalesce(NEW.data, '{}'::jsonb));
|
|
986
|
+
end if;
|
|
592
987
|
NEW.data := @SCHEMA@.apply_defaults(res->'schema', coalesce(NEW.data, '{}'::jsonb));
|
|
593
988
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
|
|
594
989
|
if bad <> '[]'::jsonb then
|
|
@@ -620,7 +1015,25 @@ begin
|
|
|
620
1015
|
-- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала; запись
|
|
621
1016
|
-- блокируется — параллельные purge, обрезка и restore того же id ждут (этап 6), а если purge успел
|
|
622
1017
|
-- стереть её, пара (id, rev) столкнётся с его записью purge в журнале, и операция повторится
|
|
623
|
-
select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = NEW.id order by l.rev desc limit 1 for update;
|
|
1018
|
+
select l.rev, l.hash, l.at, l.op, l.data into last from @SCHEMA@.log l where l.id = NEW.id order by l.rev desc limit 1 for update;
|
|
1019
|
+
-- класс с историей строк в журнале (остаток Н2 ревью №2; доработки после точки Б): правила прав находят строки, и
|
|
1020
|
+
-- старые версии тоже, по именам класса, предков и алиасов — класс с другим parent, alias или derivedBy подставил бы
|
|
1021
|
+
-- старую историю под чужие правила. Поэтому, если журнал арендатора хранит версии строк класса с этим именем, класс
|
|
1022
|
+
-- создаётся только заново и с тем же описанием, что в надгробии его строки Class. Надгробия нет (история осталась от
|
|
1023
|
+
-- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ: сначала purge() строк
|
|
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))
|
|
1029
|
+
and not (coalesce(last.op = 'delete', false)
|
|
1030
|
+
and last.data->'resolved'->'ancestors' is not distinct from NEW.data->'resolved'->'ancestors'
|
|
1031
|
+
and last.data->>'alias' is not distinct from NEW.data->>'alias'
|
|
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())',
|
|
1035
|
+
NEW.data->>'name'));
|
|
1036
|
+
end if;
|
|
624
1037
|
ts := @SCHEMA@.now_ts();
|
|
625
1038
|
NEW.rev := coalesce(last.rev, 0) + 1;
|
|
626
1039
|
NEW.prev_hash := last.hash;
|
|
@@ -682,6 +1095,10 @@ begin
|
|
|
682
1095
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
683
1096
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
|
|
684
1097
|
end if;
|
|
1098
|
+
if res ? 'derivedBy' or (NEW.class <> OLD.class and (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved' ? 'derivedBy') then
|
|
1099
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор',
|
|
1100
|
+
case when res ? 'derivedBy' then NEW.class else OLD.class end));
|
|
1101
|
+
end if;
|
|
685
1102
|
if NEW.class <> OLD.class then
|
|
686
1103
|
-- смена класса (этап 1, п. 9): только read committed, только для классов без ключа
|
|
687
1104
|
if current_setting('transaction_isolation') <> 'read committed' then
|
|
@@ -694,6 +1111,11 @@ begin
|
|
|
694
1111
|
-- обычный update берёт слабую блокировку; смене класса нужна for update, чтобы дождаться
|
|
695
1112
|
-- транзакций, которые как раз пишут ссылку на эту строку
|
|
696
1113
|
perform 1 from @SCHEMA@.entity x where x.id = OLD.id for update;
|
|
1114
|
+
-- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
|
|
1115
|
+
-- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
|
|
1116
|
+
if not @SCHEMA@.acl_row_ok(@SCHEMA@.acl_plan('DELETE'), OLD.class, OLD.id, OLD.owner, OLD.tenant, OLD.tags, OLD.data, OLD.links) then
|
|
1117
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', OLD.class));
|
|
1118
|
+
end if;
|
|
697
1119
|
inc := @SCHEMA@.incoming_conflicts(OLD.id, NEW.class, OLD.tenant);
|
|
698
1120
|
if inc <> '[]'::jsonb then
|
|
699
1121
|
perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', NEW.class),
|
|
@@ -704,6 +1126,11 @@ begin
|
|
|
704
1126
|
if NEW.class = 'Class' then
|
|
705
1127
|
select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(NEW.data, OLD.tenant) a;
|
|
706
1128
|
else
|
|
1129
|
+
-- агрегатное поле (план 1.1, §2.4): изменить его нельзя, без поля (замена данных целиком) — прежнее значение;
|
|
1130
|
+
-- при смене класса — прежнее число или 0, итог посчитает триггер после оператора
|
|
1131
|
+
if res ? 'aggregate' then
|
|
1132
|
+
NEW.data := @SCHEMA@.agg_guard(res, OLD.data, NEW.data, NEW.class <> OLD.class);
|
|
1133
|
+
end if;
|
|
707
1134
|
NEW.data := @SCHEMA@.apply_defaults(res->'schema', NEW.data);
|
|
708
1135
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
|
|
709
1136
|
if bad <> '[]'::jsonb then
|
|
@@ -731,6 +1158,11 @@ begin
|
|
|
731
1158
|
-- ужесточение: исключительная блокировка корня семейства (и нового корня при смене родителя)
|
|
732
1159
|
perform @SCHEMA@.family_lock(OLD, true);
|
|
733
1160
|
perform @SCHEMA@.family_lock(NEW, true);
|
|
1161
|
+
-- смена агрегатов — ещё семейства источников (план 1.1, §2.4): операция, зафиксированная до пересчёта, в него
|
|
1162
|
+
-- войдёт, а ожидающая допишет вклад уже по новому описанию
|
|
1163
|
+
if OLD.data->'resolved'->'aggregate' is distinct from NEW.data->'resolved'->'aggregate' then
|
|
1164
|
+
perform @SCHEMA@.agg_lock_sources(OLD.data->'resolved', NEW.data->'resolved', OLD.tenant);
|
|
1165
|
+
end if;
|
|
734
1166
|
end if;
|
|
735
1167
|
NEW.rev := OLD.rev + 1;
|
|
736
1168
|
NEW.prev_hash := OLD.hash;
|
|
@@ -759,7 +1191,9 @@ begin
|
|
|
759
1191
|
bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
|
|
760
1192
|
from added n where n.links <> '{}'::jsonb), true, @SCHEMA@.acl_actor('READ'));
|
|
761
1193
|
end if;
|
|
762
|
-
if bad
|
|
1194
|
+
if bad like '!%' then
|
|
1195
|
+
perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
|
|
1196
|
+
elsif bad is not null then
|
|
763
1197
|
perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
|
|
764
1198
|
end if;
|
|
765
1199
|
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
@@ -773,6 +1207,15 @@ begin
|
|
|
773
1207
|
if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
774
1208
|
perform @SCHEMA@.acl_bump();
|
|
775
1209
|
end if;
|
|
1210
|
+
if 'Class' = any (sc) then
|
|
1211
|
+
-- новый класс — источник агрегата (ревью коммита этапа 4б): нужно право READ без условий на все его строки; план
|
|
1212
|
+
-- прав после acl_bump видит новый класс
|
|
1213
|
+
perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
|
|
1214
|
+
from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
|
|
1215
|
+
-- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
|
|
1216
|
+
perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
|
|
1217
|
+
from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
|
|
1218
|
+
end if;
|
|
776
1219
|
return null;
|
|
777
1220
|
end $$;
|
|
778
1221
|
|
|
@@ -798,7 +1241,9 @@ begin
|
|
|
798
1241
|
from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
799
1242
|
where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)), true, @SCHEMA@.acl_actor('READ'));
|
|
800
1243
|
end if;
|
|
801
|
-
if bad
|
|
1244
|
+
if bad like '!%' then
|
|
1245
|
+
perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
|
|
1246
|
+
elsif bad is not null then
|
|
802
1247
|
perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
|
|
803
1248
|
end if;
|
|
804
1249
|
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
@@ -821,6 +1266,9 @@ begin
|
|
|
821
1266
|
where n.class = 'Class' order by n.id loop
|
|
822
1267
|
select * into o_row from old_rows x where x.id = cid;
|
|
823
1268
|
select * into n_row from new_rows x where x.id = cid;
|
|
1269
|
+
-- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
|
|
1270
|
+
-- и после правки — по новым предкам (до правки проверил class_admit)
|
|
1271
|
+
perform @SCHEMA@.agg_source_admit(n_row.data->>'name', @SCHEMA@.agg_new_mark(o_row.data->'resolved', n_row.data->'resolved'));
|
|
824
1272
|
perform @SCHEMA@.class_evolve(o_row, n_row);
|
|
825
1273
|
end loop;
|
|
826
1274
|
-- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
|
|
@@ -842,7 +1290,15 @@ declare
|
|
|
842
1290
|
classes text[] := array[n.data->>'name'];
|
|
843
1291
|
conflicts jsonb;
|
|
844
1292
|
c record;
|
|
1293
|
+
-- агрегаты (план 1.1, §2.4): источники, чьи метки пересчитываются; цели, поле которых считается заново
|
|
1294
|
+
agg_names text[] := '{}';
|
|
1295
|
+
agg_own text[] := '{}';
|
|
1296
|
+
agg_other text[] := @SCHEMA@.agg_marked(o.data->'resolved', n.data->'resolved');
|
|
845
1297
|
begin
|
|
1298
|
+
if o.data->'resolved'->'aggregate' is distinct from n.data->'resolved'->'aggregate' then
|
|
1299
|
+
agg_names := @SCHEMA@.agg_of(o.data->'resolved') || @SCHEMA@.agg_of(n.data->'resolved');
|
|
1300
|
+
agg_own := agg_own || (n.data->>'name');
|
|
1301
|
+
end if;
|
|
846
1302
|
-- потомки: описание resolved и validator пересчитываются, каждый пересчёт — версия класса в журнале
|
|
847
1303
|
for d in select x.* from @SCHEMA@.entity x
|
|
848
1304
|
where x.class = 'Class' and x.id <> n.id
|
|
@@ -853,9 +1309,37 @@ begin
|
|
|
853
1309
|
if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
|
|
854
1310
|
perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
|
|
855
1311
|
end if;
|
|
1312
|
+
-- потомок становится источником вместе с классом (смена parent; ревью коммита этапа 4б) — то же право READ на его
|
|
1313
|
+
-- строки по новым предкам (до правки проверил class_admit). class_evolve зовёт только триггер правки приложения;
|
|
1314
|
+
-- потомков класса System у арендаторов план прав сессии System не видит, их строки считают агрегаты System для
|
|
1315
|
+
-- целей того же арендатора
|
|
1316
|
+
if d.tenant = n.tenant then
|
|
1317
|
+
perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
|
|
1318
|
+
end if;
|
|
856
1319
|
classes := classes || (d.data->>'name');
|
|
1320
|
+
if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
|
|
1321
|
+
agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
|
|
1322
|
+
agg_own := agg_own || (d.data->>'name');
|
|
1323
|
+
end if;
|
|
1324
|
+
agg_other := agg_other || @SCHEMA@.agg_marked(d.data->'resolved', prep.data->'resolved');
|
|
857
1325
|
end loop;
|
|
1326
|
+
-- агрегаты семейства изменились: метки источников заново и поле у всех целей — из живых источников (строки
|
|
1327
|
+
-- проверит ужесточение ниже); у источника сменились предки — заново цели, которые его считают
|
|
1328
|
+
if cardinality(agg_names) > 0 then
|
|
1329
|
+
perform @SCHEMA@.agg_mark(n.tenant, agg_names);
|
|
1330
|
+
end if;
|
|
1331
|
+
if cardinality(agg_own) > 0 then
|
|
1332
|
+
perform @SCHEMA@.agg_refresh(agg_own, n.tenant, false);
|
|
1333
|
+
end if;
|
|
1334
|
+
if cardinality(agg_other) > 0 then
|
|
1335
|
+
perform @SCHEMA@.agg_refresh(agg_other, n.tenant, true);
|
|
1336
|
+
end if;
|
|
858
1337
|
if not @SCHEMA@.class_change_safe(o.data->'resolved', n.data->'resolved') then
|
|
1338
|
+
-- ужесточение проверяет все строки семейства, в том числе невидимые актору, и отвечает их числом: пробное
|
|
1339
|
+
-- ужесточение (maximum, enum, pattern…) было оракулом по чужим строкам (доработки после точки Б). Нужно право READ
|
|
1340
|
+
-- без условий на все строки класса и потомков своего арендатора; строки других арендаторов (класс System) —
|
|
1341
|
+
-- уровень установки, их число остаётся в others
|
|
1342
|
+
perform @SCHEMA@.class_rows_admit(n.data->>'name', n.tenant, array['READ'], format('ужесточение класса %s', n.data->>'name'));
|
|
859
1343
|
conflicts := @SCHEMA@.family_conflicts(classes, n.tenant, o.data->'resolved'->'ancestors' is distinct from n.data->'resolved'->'ancestors');
|
|
860
1344
|
if conflicts <> '[]'::jsonb then
|
|
861
1345
|
select * into c from @SCHEMA@.ctx();
|
|
@@ -952,6 +1436,8 @@ declare
|
|
|
952
1436
|
mems jsonb;
|
|
953
1437
|
lvl_accs uuid[];
|
|
954
1438
|
lvl_mems jsonb;
|
|
1439
|
+
drows jsonb;
|
|
1440
|
+
own uuid[];
|
|
955
1441
|
begin
|
|
956
1442
|
select * into c from @SCHEMA@.ctx();
|
|
957
1443
|
-- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
|
|
@@ -961,13 +1447,52 @@ begin
|
|
|
961
1447
|
if exists (select 1 from @SCHEMA@.entity x join gone g on g.id = x.id) then
|
|
962
1448
|
perform @SCHEMA@.lt_fail('recreated_in_statement', 'id удалён и создан заново одним оператором');
|
|
963
1449
|
end if;
|
|
964
|
-
|
|
965
|
-
|
|
1450
|
+
-- строки производного класса удаляет только проектор (каскад функции владельца сюда не доходит)
|
|
1451
|
+
select d.class into denied from (select distinct g.class, g.tenant from gone g) d
|
|
1452
|
+
where (@SCHEMA@.class_row(d.class, d.tenant)).data->'resolved' ? 'derivedBy' limit 1;
|
|
1453
|
+
if denied is not null then
|
|
1454
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
|
|
1455
|
+
end if;
|
|
1456
|
+
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
|
|
1457
|
+
-- производный класс удаляет только администратор (class_admit)
|
|
1458
|
+
if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
|
|
1459
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)', r.name));
|
|
1460
|
+
end if;
|
|
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)))
|
|
966
1483
|
or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
|
|
967
|
-
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
|
|
968
1485
|
perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
|
|
969
1486
|
end if;
|
|
1487
|
+
-- и история строк (остаток Н2; доработки после точки Б): удалённый класс с историей, созданный заново с другими
|
|
1488
|
+
-- предками или алиасом, подставил бы её под чужие правила — сначала purge() удалённых строк
|
|
1489
|
+
if @SCHEMA@.class_has_history(r.name, r.tenant) then
|
|
1490
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть история строк в журнале: сначала стереть её purge() удалённых строк', r.name));
|
|
1491
|
+
end if;
|
|
970
1492
|
end loop;
|
|
1493
|
+
-- удалена цель агрегатов (план 1.1, §2.4): метки её источников — уже без неё
|
|
1494
|
+
perform @SCHEMA@.agg_mark(g.tenant, @SCHEMA@.agg_of(g.data->'resolved'))
|
|
1495
|
+
from gone g where g.class = 'Class' and g.data->'resolved' ? 'aggregate';
|
|
971
1496
|
v_author := coalesce(c.author, '@SYSTEM@'::uuid);
|
|
972
1497
|
-- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
|
|
973
1498
|
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
@@ -1012,12 +1537,15 @@ begin
|
|
|
1012
1537
|
)
|
|
1013
1538
|
select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
|
|
1014
1539
|
coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
|
|
1015
|
-
|
|
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)),
|
|
1016
1541
|
coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
|
|
1017
1542
|
coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
|
|
1018
|
-
into lvl, lvl_classes, lvl_pairs,
|
|
1019
|
-
|
|
1020
|
-
|
|
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);
|
|
1021
1549
|
end if;
|
|
1022
1550
|
accs := accs || lvl_accs;
|
|
1023
1551
|
mems := mems || lvl_mems;
|
|
@@ -1026,7 +1554,9 @@ begin
|
|
|
1026
1554
|
-- unset: снять конец, новая версия
|
|
1027
1555
|
for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
|
|
1028
1556
|
if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
|
|
1029
|
-
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);
|
|
1030
1560
|
end if;
|
|
1031
1561
|
newlinks := '{}'::jsonb;
|
|
1032
1562
|
for k in select key, value from jsonb_each(r.links) loop
|
|
@@ -1164,8 +1694,15 @@ return (
|
|
|
1164
1694
|
-- Перенос строки на новый id (смена класса с ключом, смена ключа): новая строка продолжает журнал
|
|
1165
1695
|
-- нового id и ссылается на старый (moved), ссылки переводятся — если переведённый конец входит
|
|
1166
1696
|
-- в ключ ссылающейся строки, её id тоже меняется, и перевод идёт дальше рекурсивно; старая строка
|
|
1167
|
-
-- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая
|
|
1168
|
-
|
|
1697
|
+
-- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция; p_plan — план WRITE
|
|
1698
|
+
-- актора (reclass, rekey, rekey_class): каждая строка, чьи ссылки перевод переписывает, на всех уровнях рекурсии должна
|
|
1699
|
+
-- им проходить (доработки после точки Б: прежде проверялся только первый уровень); null — путь владельца.
|
|
1700
|
+
-- Агрегаты (план 1.1, §2.4): перенос — несколько операторов (новая строка, перевод ссылок, удаление старой), и
|
|
1701
|
+
-- триггер агрегатов на это время только копит их вклады (agg_hold_begin), а в конце применяет одним итогом: сумма
|
|
1702
|
+
-- не проходит через промежуточные значения, новая цель считается целиком из живых источников.
|
|
1703
|
+
drop function if exists @SCHEMA@.move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text);
|
|
1704
|
+
create or replace function @SCHEMA@.move_row(o @SCHEMA@.entity, p_new_id uuid, p_class text, p_data jsonb, p_links jsonb, p_op text,
|
|
1705
|
+
p_plan jsonb default null)
|
|
1169
1706
|
returns @SCHEMA@.entity
|
|
1170
1707
|
language plpgsql volatile
|
|
1171
1708
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -1178,6 +1715,7 @@ declare
|
|
|
1178
1715
|
r @SCHEMA@.entity;
|
|
1179
1716
|
nl jsonb;
|
|
1180
1717
|
rid uuid;
|
|
1718
|
+
held boolean := @SCHEMA@.agg_hold_begin('acc');
|
|
1181
1719
|
begin
|
|
1182
1720
|
select * into c from @SCHEMA@.ctx();
|
|
1183
1721
|
select l.rev, l.hash, l.at into last from @SCHEMA@.log l where l.id = p_new_id order by l.rev desc limit 1 for update;
|
|
@@ -1189,10 +1727,21 @@ begin
|
|
|
1189
1727
|
returning * into n;
|
|
1190
1728
|
perform @SCHEMA@.log_version(n);
|
|
1191
1729
|
for r in select x.* from @SCHEMA@.entity x where x.ends @> array[o.id] and x.id <> p_new_id order by x.id for update loop
|
|
1730
|
+
-- строки производного класса пишет только проектор: перевод их ссылок мимо него оставил бы проекцию
|
|
1731
|
+
-- несогласованной (ревью безопасности №3, №3)
|
|
1732
|
+
if (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved' ? 'derivedBy' then
|
|
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);
|
|
1737
|
+
end if;
|
|
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
|
|
1739
|
+
perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на ссылающиеся строки');
|
|
1740
|
+
end if;
|
|
1192
1741
|
nl := @SCHEMA@.links_replace(r.links, o.id, p_new_id);
|
|
1193
1742
|
rid := coalesce(@SCHEMA@.key_id((@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved', r.class, r.tenant, r.data, nl), r.id);
|
|
1194
1743
|
if rid <> r.id then
|
|
1195
|
-
perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey');
|
|
1744
|
+
perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey', p_plan);
|
|
1196
1745
|
else
|
|
1197
1746
|
perform @SCHEMA@.sys_update(r.id, r.data, nl, r.validator, 'update');
|
|
1198
1747
|
end if;
|
|
@@ -1200,6 +1749,9 @@ begin
|
|
|
1200
1749
|
delete from @SCHEMA@.entity x where x.id = o.id returning x.* into r;
|
|
1201
1750
|
-- надгробие всегда вида delete (по нему срез на момент отличает удалённый id), moved — новый id
|
|
1202
1751
|
perform @SCHEMA@.tombstone(r, 'delete', p_new_id);
|
|
1752
|
+
if held then
|
|
1753
|
+
perform @SCHEMA@.agg_hold_end(true);
|
|
1754
|
+
end if;
|
|
1203
1755
|
return n;
|
|
1204
1756
|
end $$;
|
|
1205
1757
|
|
|
@@ -1223,6 +1775,7 @@ declare
|
|
|
1223
1775
|
rd jsonb;
|
|
1224
1776
|
wplan jsonb;
|
|
1225
1777
|
denied text;
|
|
1778
|
+
n_denied int;
|
|
1226
1779
|
begin
|
|
1227
1780
|
select * into c from @SCHEMA@.ctx();
|
|
1228
1781
|
if c.account is null then
|
|
@@ -1240,6 +1793,11 @@ begin
|
|
|
1240
1793
|
if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
|
|
1241
1794
|
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
|
|
1242
1795
|
end if;
|
|
1796
|
+
-- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
|
|
1797
|
+
-- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
|
|
1798
|
+
if not @SCHEMA@.acl_row_ok(@SCHEMA@.acl_plan('DELETE'), o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
|
|
1799
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', o.class));
|
|
1800
|
+
end if;
|
|
1243
1801
|
-- строки прав и классов функция не трогает
|
|
1244
1802
|
if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') or p_class in ('Class', 'Account', 'member', 'Resource', 'rule') then
|
|
1245
1803
|
perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
|
|
@@ -1254,7 +1812,19 @@ begin
|
|
|
1254
1812
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
1255
1813
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
|
|
1256
1814
|
end if;
|
|
1257
|
-
|
|
1815
|
+
if res ? 'derivedBy' or (@SCHEMA@.class_row(o.class, o.tenant)).data->'resolved' ? 'derivedBy' then
|
|
1816
|
+
perform @SCHEMA@.lt_fail('derived_class', 'строки производного класса пишет только его проектор');
|
|
1817
|
+
end if;
|
|
1818
|
+
d := coalesce(p_data, o.data);
|
|
1819
|
+
-- агрегатные поля нового класса считает база (план 1.1, §2.4): в данных их не передают; прежнее число остаётся
|
|
1820
|
+
-- до пересчёта, иначе 0
|
|
1821
|
+
if res ? 'aggregate' then
|
|
1822
|
+
if p_data ?| @SCHEMA@.agg_fields(res) then
|
|
1823
|
+
perform @SCHEMA@.lt_fail('aggregate_field', format('агрегатные поля класса %s считает база — в данных reclass их не передают', p_class));
|
|
1824
|
+
end if;
|
|
1825
|
+
d := @SCHEMA@.agg_zero(res, d);
|
|
1826
|
+
end if;
|
|
1827
|
+
d := @SCHEMA@.apply_defaults(res->'schema', d);
|
|
1258
1828
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
|
|
1259
1829
|
if bad <> '[]'::jsonb then
|
|
1260
1830
|
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', p_class), jsonb_build_object('issues', bad));
|
|
@@ -1273,17 +1843,20 @@ begin
|
|
|
1273
1843
|
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', p_class));
|
|
1274
1844
|
end if;
|
|
1275
1845
|
if nid <> o.id then
|
|
1276
|
-
-- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
|
|
1277
|
-
|
|
1846
|
+
-- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE (глубже первого уровня — move_row);
|
|
1847
|
+
-- класс такой строки называется, только если актор её видит
|
|
1848
|
+
select min(x.class) filter (where @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links, c.tenant, c.account, rd->'plan')),
|
|
1849
|
+
count(*) into denied, n_denied from @SCHEMA@.entity x
|
|
1278
1850
|
where x.ends @> array[o.id] and not @SCHEMA@.acl_row_ok(wplan, x.class, x.id, x.owner, x.tenant, x.tags, x.data, x.links);
|
|
1279
|
-
if
|
|
1280
|
-
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на ссылающиеся строки класса %s', denied)
|
|
1851
|
+
if n_denied > 0 then
|
|
1852
|
+
perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права WRITE на ссылающиеся строки класса %s', denied)
|
|
1853
|
+
else 'нет права WRITE на ссылающиеся строки' end);
|
|
1281
1854
|
end if;
|
|
1282
1855
|
end if;
|
|
1283
1856
|
if nid = o.id then
|
|
1284
1857
|
perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'reclass', p_class);
|
|
1285
1858
|
else
|
|
1286
|
-
perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass');
|
|
1859
|
+
perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass', wplan);
|
|
1287
1860
|
end if;
|
|
1288
1861
|
return nid;
|
|
1289
1862
|
end $$;
|
|
@@ -1415,12 +1988,12 @@ do $$
|
|
|
1415
1988
|
declare f text;
|
|
1416
1989
|
begin
|
|
1417
1990
|
foreach f in array array[
|
|
1418
|
-
'now_ts()', 'family_lock(@SCHEMA@.entity, boolean)', 'class_set(jsonb)', 'class_accepts(jsonb, text, uuid)', 'role_accepts(text, uuid, text, text, uuid)',
|
|
1991
|
+
'now_ts()', 'lock_id(text)', 'lock_row(text)', 'lock_try(text)', 'family_name(uuid, jsonb)', 'family_rows(uuid, jsonb)', 'family_lock(@SCHEMA@.entity, boolean)', 'class_set(jsonb)', 'class_accepts(jsonb, text, uuid)', 'role_accepts(text, uuid, text, text, uuid)',
|
|
1419
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)',
|
|
1420
1993
|
'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
|
|
1421
|
-
'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
|
|
1422
|
-
'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
|
|
1423
|
-
'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
|
|
1994
|
+
'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text, jsonb)', 'reclass(uuid, text, jsonb)',
|
|
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)',
|
|
1424
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)',
|
|
1425
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
|
|
1426
1999
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|