letopis 1.0.1 → 1.2.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 +117 -10
- package/AGENT-CHEATSHEET.md +110 -8
- package/CHANGELOG.md +739 -0
- package/README.en.md +873 -68
- package/README.md +830 -66
- package/dist/admin.d.ts +24 -4
- package/dist/admin.js +39 -9
- package/dist/cache.d.ts +33 -9
- package/dist/cache.js +81 -32
- package/dist/chain.d.ts +29 -7
- package/dist/chain.js +61 -12
- package/dist/cli.js +59 -7
- package/dist/errors.js +4 -2
- package/dist/import.d.ts +6 -0
- package/dist/import.js +196 -175
- package/dist/index.d.ts +27 -5
- package/dist/index.js +191 -29
- package/dist/internal.d.ts +16 -0
- package/dist/internal.js +14 -0
- package/dist/load.d.ts +63 -0
- package/dist/load.js +331 -44
- 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 +309 -0
- package/dist/project.js +1097 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +13 -0
- package/dist/signals.d.ts +73 -0
- package/dist/signals.js +169 -0
- package/dist/sql.d.ts +1 -1
- package/dist/sql.js +26 -7
- package/dist/tx.d.ts +9 -7
- package/dist/tx.js +15 -5
- package/dist/typed.d.ts +21 -6
- package/dist/types.d.ts +77 -4
- package/dist/types.js +8 -3
- package/dist/up.d.ts +16 -2
- package/dist/up.js +34 -4
- package/dist/uuid.js +19 -8
- package/dist/watch.d.ts +23 -2
- package/dist/watch.js +44 -7
- package/dist/write.d.ts +11 -2
- package/dist/write.js +57 -10
- package/llms.txt +9 -2
- package/package.json +13 -2
- package/sql/10-core.sql +13 -2
- package/sql/15-errors.sql +4 -1
- package/sql/40-class.sql +57 -2
- package/sql/60-write.sql +611 -82
- package/sql/70-read.sql +33 -8
- package/sql/75-history.sql +358 -0
- package/sql/80-auth.sql +7 -7
- package/sql/88-derived.sql +1154 -0
- package/sql/90-time.sql +134 -21
- package/sql/95-seed.system.sql +24 -6
- package/sql/99-revision.sql +1 -1
- package/sql/README.md +5 -1
- package/sql/migrate/0006-changed-paths.sql +3 -0
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 $$;
|
|
@@ -260,34 +371,74 @@ language sql stable
|
|
|
260
371
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
261
372
|
return coalesce((@SCHEMA@.class_row(p_class, p_tenant)).data->'resolved'->>'history', 'true') <> 'false';
|
|
262
373
|
|
|
263
|
-
--
|
|
264
|
-
--
|
|
265
|
-
--
|
|
266
|
-
--
|
|
267
|
-
|
|
374
|
+
-- Сигналы (план 1.1, §2.2): канал — имя схемы, нагрузка — по режиму установки '@SIGNALS@'
|
|
375
|
+
-- (up({ signals })): class — имя класса; tenant — «Класс@токен арендатора»; opaque — «токен класса@токен
|
|
376
|
+
-- арендатора»; none — сигналов нет. Сессии — session:<16 hex>, сброс — * (кроме none). Токен — первые
|
|
377
|
+
-- 96 бит HMAC-SHA256 от «назначение:значение» на ключе сигналов (HMAC ключа строки KEY по
|
|
378
|
+
-- 'letopis:signals', его отдаёт signal_key()): без ключа псевдоним не сопоставить с именем, а
|
|
379
|
+
-- нагрузка детерминирована — PostgreSQL склеивает одинаковые сигналы транзакции.
|
|
380
|
+
-- до плана 1.1 сигнал принимал только имена классов
|
|
381
|
+
drop function if exists @SCHEMA@.signal(text[]);
|
|
382
|
+
|
|
383
|
+
create or replace function @SCHEMA@.signal_token(p_purpose text, p_value text, p_key bytea) returns text
|
|
384
|
+
language sql immutable parallel safe
|
|
385
|
+
return left(encode(@PGCRYPTO@.hmac(convert_to(p_purpose || ':' || p_value, 'UTF8'), p_key, 'sha256'), 'hex'), 24);
|
|
386
|
+
|
|
387
|
+
-- Готовые нагрузки: сигналы записи от signal(), сессии и сброс. Транзакция с NOTIFY фиксируется под
|
|
388
|
+
-- общей блокировкой кластера вместе с fsync, поэтому библиотека помечает свои транзакции
|
|
389
|
+
-- letopis.notify = 'deferred': нагрузки копятся в letopis.changed, а библиотека после фиксации
|
|
390
|
+
-- сбрасывает по ним свой кэш и отправляет их одной транзакцией без данных (в режиме none — не
|
|
391
|
+
-- отправляет). Сырой SQL шлёт NOTIFY здесь же, вместе с фиксацией; в режиме none — ничего.
|
|
392
|
+
create or replace function @SCHEMA@.signal_raw(p_payloads text[]) returns void
|
|
268
393
|
language plpgsql volatile
|
|
269
394
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
270
395
|
as $$
|
|
271
396
|
declare
|
|
272
397
|
acc text[];
|
|
273
|
-
|
|
398
|
+
pl text;
|
|
274
399
|
begin
|
|
275
|
-
|
|
400
|
+
p_payloads := array(select distinct x from unnest(p_payloads) x where x <> '' order by x);
|
|
401
|
+
if cardinality(p_payloads) = 0 then
|
|
276
402
|
return;
|
|
277
403
|
end if;
|
|
278
404
|
if coalesce(current_setting('letopis.notify', true), '') = 'deferred' then
|
|
279
405
|
acc := coalesce(string_to_array(nullif(current_setting('letopis.changed', true), ''), ','), '{}');
|
|
280
|
-
if not (acc @>
|
|
406
|
+
if not (acc @> p_payloads) then
|
|
281
407
|
perform set_config('letopis.changed',
|
|
282
|
-
array_to_string(array(select distinct x from unnest(acc ||
|
|
408
|
+
array_to_string(array(select distinct x from unnest(acc || p_payloads) x order by x), ','), true);
|
|
283
409
|
end if;
|
|
284
|
-
|
|
285
|
-
foreach
|
|
286
|
-
perform pg_notify('@SCHEMA_NAME@',
|
|
410
|
+
elsif '@SIGNALS@' <> 'none' then
|
|
411
|
+
foreach pl in array p_payloads loop
|
|
412
|
+
perform pg_notify('@SCHEMA_NAME@', pl);
|
|
287
413
|
end loop;
|
|
288
414
|
end if;
|
|
289
415
|
end $$;
|
|
290
416
|
|
|
417
|
+
-- Сигнал записи: пары «класс, арендатор» изменённых строк (арендатор null — System) → нагрузки по
|
|
418
|
+
-- режиму. Одинаковые пары склеиваются: транзакция, записавшая тысячу строк одного класса одного
|
|
419
|
+
-- арендатора, посылает один сигнал.
|
|
420
|
+
create or replace function @SCHEMA@.signal(p_classes text[], p_tenants uuid[]) returns void
|
|
421
|
+
language plpgsql volatile
|
|
422
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
423
|
+
as $$
|
|
424
|
+
declare
|
|
425
|
+
k bytea;
|
|
426
|
+
begin
|
|
427
|
+
if p_classes is null or cardinality(p_classes) = 0 then
|
|
428
|
+
return;
|
|
429
|
+
end if;
|
|
430
|
+
if '@SIGNALS@' in ('tenant', 'opaque') then
|
|
431
|
+
select @PGCRYPTO@.hmac(convert_to('letopis:signals', 'UTF8'), s.material, 'sha256') into k
|
|
432
|
+
from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY';
|
|
433
|
+
end if;
|
|
434
|
+
perform @SCHEMA@.signal_raw(array(
|
|
435
|
+
select case '@SIGNALS@'
|
|
436
|
+
when 'tenant' then p.c || '@' || @SCHEMA@.signal_token('tenant', p.t::text, k)
|
|
437
|
+
when 'opaque' then @SCHEMA@.signal_token('class', p.c, k) || '@' || @SCHEMA@.signal_token('tenant', p.t::text, k)
|
|
438
|
+
else p.c end
|
|
439
|
+
from (select distinct u.c, coalesce(u.t, '@SYSTEM@'::uuid) as t from unnest(p_classes, p_tenants) u(c, t) where u.c is not null) p));
|
|
440
|
+
end $$;
|
|
441
|
+
|
|
291
442
|
-- Версия одной строки в журнал и сигнал — для функций владельца, которые пишут строку сами
|
|
292
443
|
-- (пересчёт потомков, снятие конца, перенос на новый id). Классы с history: false журнал не ведут,
|
|
293
444
|
-- но сигнал посылают. Обычную запись журналируют триггеры после оператора — одной вставкой.
|
|
@@ -295,12 +446,19 @@ create or replace function @SCHEMA@.log_version(e @SCHEMA@.entity) returns void
|
|
|
295
446
|
language plpgsql volatile
|
|
296
447
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
297
448
|
as $$
|
|
449
|
+
declare
|
|
450
|
+
p @SCHEMA@.log;
|
|
298
451
|
begin
|
|
299
452
|
if @SCHEMA@.history_on(e.class, e.tenant) then
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
453
|
+
-- пути изменения: против предыдущей версии id, у первой версии переноса — против прежнего id (moved)
|
|
454
|
+
select l.* into p from @SCHEMA@.log l
|
|
455
|
+
where l.id = case when e.rev > 1 then e.id else e.moved end and l.op not in ('purge', 'trim', 'reset')
|
|
456
|
+
order by l.rev desc limit 1;
|
|
457
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
458
|
+
values (e.id, e.rev, e.class, e.cv, e.tenant, e.owner, e.links, e.data, e.tags, e.at, e.author, e.agent, e.op, e.reason, e.moved, e.prev_hash,
|
|
459
|
+
case when p.id is not null then @SCHEMA@.change_paths(p.data, p.links, p.tags, p.owner, p.class, e.data, e.links, e.tags, e.owner, e.class) end);
|
|
460
|
+
end if;
|
|
461
|
+
perform @SCHEMA@.signal(array[e.class], array[e.tenant]);
|
|
304
462
|
end $$;
|
|
305
463
|
|
|
306
464
|
-- Новая версия строки от имени владельца (пересчёт описаний потомков, снятие конца при удалении):
|
|
@@ -325,6 +483,11 @@ begin
|
|
|
325
483
|
cv = coalesce((@SCHEMA@.class_row(coalesce(p_class, x.class), x.tenant)).rev, x.cv)
|
|
326
484
|
where x.id = p_id
|
|
327
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;
|
|
328
491
|
perform @SCHEMA@.log_version(e);
|
|
329
492
|
return e;
|
|
330
493
|
end $$;
|
|
@@ -384,12 +547,15 @@ end $$;
|
|
|
384
547
|
|
|
385
548
|
-- Безопасно ли изменение описания класса (этап 1, п. 10): новые необязательные поля и концы,
|
|
386
549
|
-- метаданные, алиас, политика истории, кэш. Всё остальное — ужесточение с проверкой строк, в том числе
|
|
387
|
-
-- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись)
|
|
550
|
+
-- любая правка условий anyOf и oneOf (1.0.1: прежде они не сравнивались, и строки не перепроверялись),
|
|
551
|
+
-- признака производного класса derivedBy и описания агрегатов aggregate (план 1.1, §2.6).
|
|
388
552
|
create or replace function @SCHEMA@.class_change_safe(o jsonb, n jsonb) returns boolean
|
|
389
553
|
language sql immutable
|
|
390
554
|
return
|
|
391
555
|
o->'key' = n->'key'
|
|
392
556
|
and o->'ancestors' = n->'ancestors'
|
|
557
|
+
and o->'derivedBy' is not distinct from n->'derivedBy'
|
|
558
|
+
and o->'aggregate' is not distinct from n->'aggregate'
|
|
393
559
|
and (o->'abstract' = n->'abstract' or n->>'abstract' = 'false')
|
|
394
560
|
and o->'schema'->'anyOf' is not distinct from n->'schema'->'anyOf'
|
|
395
561
|
and o->'schema'->'oneOf' is not distinct from n->'schema'->'oneOf'
|
|
@@ -417,6 +583,8 @@ declare
|
|
|
417
583
|
problem text;
|
|
418
584
|
inc jsonb;
|
|
419
585
|
outv jsonb := '[]'::jsonb;
|
|
586
|
+
a jsonb := @SCHEMA@.acl_actor('READ');
|
|
587
|
+
vis boolean;
|
|
420
588
|
begin
|
|
421
589
|
for r in select e.* from @SCHEMA@.entity e
|
|
422
590
|
where e.class = any (p_classes) and (e.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
|
|
@@ -442,7 +610,13 @@ begin
|
|
|
442
610
|
if problem is null and p_incoming then
|
|
443
611
|
inc := @SCHEMA@.incoming_conflicts(r.id, r.class, r.tenant);
|
|
444
612
|
if inc <> '[]'::jsonb then
|
|
445
|
-
|
|
613
|
+
-- id ссылающейся строки — только если актор её видит (доработки после точки Б)
|
|
614
|
+
select @SCHEMA@.row_visible(x.tenant, x.class, x.id, x.owner, x.tags, x.data, x.links,
|
|
615
|
+
(a->>'tenant')::uuid, (a->>'account')::uuid, a->'plan') into vis
|
|
616
|
+
from @SCHEMA@.entity x where x.id = (inc->0->>'id')::uuid;
|
|
617
|
+
problem := case when coalesce(vis, false)
|
|
618
|
+
then format('на строку ссылается роль %s строки %s, которая не принимает класс', inc->0->>'role', inc->0->>'id')
|
|
619
|
+
else format('на строку ссылается роль %s строки, которой актор не видит, и она не принимает класс', inc->0->>'role') end;
|
|
446
620
|
end if;
|
|
447
621
|
end if;
|
|
448
622
|
if problem is not null then
|
|
@@ -459,8 +633,63 @@ create or replace function @SCHEMA@.reserved_names() returns text[]
|
|
|
459
633
|
language sql immutable parallel safe
|
|
460
634
|
return array[@RESERVED_NAMES@]::text[];
|
|
461
635
|
|
|
636
|
+
-- Правка класса p_class арендатора p_tenant меняет имена, по которым его строки находят правила прав: предков
|
|
637
|
+
-- (parent), алиас или derivedBy (ревью безопасности №2). План прав берёт для класса имена предков и их алиасы
|
|
638
|
+
-- (acl_plan_for), поэтому строки класса и его потомков попали бы под правила READ, WRITE и DELETE нового предка или
|
|
639
|
+
-- алиаса — право WRITE на Class открывало бы чужие строки. Актору нужно право READ, WRITE и DELETE без условий на
|
|
640
|
+
-- все строки самого класса и каждого потомка своего арендатора, иначе acl_denied. Потомков у арендаторов класса
|
|
641
|
+
-- System план прав сессии System не видит — они пропускаются, как у агрегатов (agg_source_admit). Проверка — до
|
|
642
|
+
-- правки (class_admit): план видит прежние имена, по которым строки уже защищены.
|
|
643
|
+
create or replace function @SCHEMA@.class_acl_admit(p_class text, p_tenant uuid) returns void
|
|
644
|
+
language plpgsql stable
|
|
645
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
646
|
+
as $$
|
|
647
|
+
begin
|
|
648
|
+
perform @SCHEMA@.class_rows_admit(p_class, p_tenant, array['READ', 'WRITE', 'DELETE'],
|
|
649
|
+
format('класс %s меняет предков, алиас или derivedBy', p_class));
|
|
650
|
+
end $$;
|
|
651
|
+
|
|
652
|
+
-- Право актора на все строки класса p_class и каждого его потомка арендатора p_tenant: по каждой операции p_ops —
|
|
653
|
+
-- без условий, иначе acl_denied с текстом «<p_what>: нужно право <op> на все строки класса <класс>». Сам класс первым,
|
|
654
|
+
-- потомки — по глубине и имени; потомков у арендаторов класса System план сессии System не видит — они пропускаются.
|
|
655
|
+
-- Зовут class_acl_admit (смена предков, алиаса, derivedBy), ужесточение (class_evolve, rekey_class) и смена политики
|
|
656
|
+
-- хранения истории (class_admit; доработки после точки Б).
|
|
657
|
+
create or replace function @SCHEMA@.class_rows_admit(p_class text, p_tenant uuid, p_ops text[], p_what text) returns void
|
|
658
|
+
language plpgsql stable
|
|
659
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
660
|
+
as $$
|
|
661
|
+
declare
|
|
662
|
+
c text;
|
|
663
|
+
op text;
|
|
664
|
+
chk jsonb;
|
|
665
|
+
begin
|
|
666
|
+
for c in select p_class
|
|
667
|
+
union all
|
|
668
|
+
(select x.data->>'name' from @SCHEMA@.entity x
|
|
669
|
+
where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' <> p_class
|
|
670
|
+
and x.data->'resolved'->'ancestors' ? p_class
|
|
671
|
+
order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.data->>'name') loop
|
|
672
|
+
foreach op in array p_ops loop
|
|
673
|
+
chk := @SCHEMA@.acl_check_data(c, op);
|
|
674
|
+
if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
|
|
675
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('%s: нужно право %s на все строки класса %s', p_what, op, c));
|
|
676
|
+
end if;
|
|
677
|
+
end loop;
|
|
678
|
+
end loop;
|
|
679
|
+
end $$;
|
|
680
|
+
|
|
681
|
+
-- Есть ли в журнале версии строк класса p_name арендатора p_tenant (у класса System — любого арендатора); записи purge,
|
|
682
|
+
-- trim и reset — не версии (доработки после точки Б: удаление и пересоздание класса, остаток Н2 ревью №2).
|
|
683
|
+
create or replace function @SCHEMA@.class_has_history(p_name text, p_tenant uuid) returns boolean
|
|
684
|
+
language sql stable
|
|
685
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
686
|
+
return exists (select 1 from @SCHEMA@.log l
|
|
687
|
+
where l.class = p_name and (l.tenant = p_tenant or p_tenant = '@SYSTEM@'::uuid)
|
|
688
|
+
and l.op not in ('purge', 'trim', 'reset'));
|
|
689
|
+
|
|
462
690
|
-- Проверки строки Class сверх метасхемы (этап 1, п. 6): признак system, имя системного класса,
|
|
463
|
-
-- имена связей против методов цепочки, однозначность имени конца во всей
|
|
691
|
+
-- имена связей против методов цепочки, однозначность имени конца во всей схеме, право на агрегаты,
|
|
692
|
+
-- алиас и право на смену предков, алиаса и derivedBy (ревью безопасности №2).
|
|
464
693
|
create or replace function @SCHEMA@.class_admit(p_data jsonb, p_tenant uuid, out data jsonb, out validator jsonpath)
|
|
465
694
|
language plpgsql volatile
|
|
466
695
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -469,6 +698,13 @@ as $$
|
|
|
469
698
|
declare
|
|
470
699
|
e record;
|
|
471
700
|
o record;
|
|
701
|
+
dname text;
|
|
702
|
+
drole text;
|
|
703
|
+
tc @SCHEMA@.entity;
|
|
704
|
+
prevd jsonb;
|
|
705
|
+
src text;
|
|
706
|
+
chk jsonb;
|
|
707
|
+
m jsonb;
|
|
472
708
|
begin
|
|
473
709
|
if p_tenant <> '@SYSTEM@'::uuid then
|
|
474
710
|
if (p_data->>'name') = any (array['Class', 'Account', 'member', 'Resource', 'rule']) then
|
|
@@ -486,6 +722,85 @@ begin
|
|
|
486
722
|
if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
|
|
487
723
|
perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
|
|
488
724
|
end if;
|
|
725
|
+
-- алиас — второе имя класса и для правил прав (ревью безопасности №2): совпади он с именем другого класса (в том
|
|
726
|
+
-- числе Class и системных) или с чужим алиасом арендатора и System, правила одного класса действовали бы на строки
|
|
727
|
+
-- другого
|
|
728
|
+
if data->>'alias' is not null
|
|
729
|
+
and ((data->>'alias') = any (array['Class', 'Account', 'member', 'Resource', 'rule'])
|
|
730
|
+
or exists (select 1 from @SCHEMA@.entity x
|
|
731
|
+
where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
|
|
732
|
+
and (x.data->>'name' = data->>'alias' or x.data->>'alias' = data->>'alias'))) then
|
|
733
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('алиас %s класса %s совпадает с именем или алиасом другого класса',
|
|
734
|
+
data->>'alias', data->>'name'));
|
|
735
|
+
end if;
|
|
736
|
+
-- и наоборот: имя класса — не чужой алиас арендатора и System, иначе правила, написанные для нового класса, действовали
|
|
737
|
+
-- бы и на строки класса с этим алиасом
|
|
738
|
+
if exists (select 1 from @SCHEMA@.entity x
|
|
739
|
+
where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
|
|
740
|
+
and x.data->>'alias' = data->>'name') then
|
|
741
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('имя класса %s совпадает с алиасом другого класса', data->>'name'));
|
|
742
|
+
end if;
|
|
743
|
+
-- агрегаты (план 1.1, §2.4; ревью коммита этапа 4б): поле считают функции владельца по всем строкам источника
|
|
744
|
+
-- арендатора, без прав чтения, поэтому строки попадают в агрегат только с ведома того, кто читает их все. Пути
|
|
745
|
+
-- владельца (сид, class_evolve, rekey_class) сюда не заходят. Описание до правки — для сравнения (нет — новый класс)
|
|
746
|
+
select x.data into prevd from @SCHEMA@.entity x
|
|
747
|
+
where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' = data->>'name';
|
|
748
|
+
-- производный класс (план 1.1, §2.4) заводит, меняет и удаляет только администратор (/security-review после точки Б):
|
|
749
|
+
-- проектор берёт арендатора в участники по его классам с пометкой и пишет туда итоги по всем строкам источников,
|
|
750
|
+
-- поэтому пользователь с правом WRITE на Class, заведя такой класс сам, читал бы итоги по строкам, которые ему читать
|
|
751
|
+
-- запрещено. Пометка своя или унаследованная, до правки или после; право letopis.projector — у администраторской роли
|
|
752
|
+
-- подключения и по правилам System (системный администратор — маска letopis.*)
|
|
753
|
+
if (data->'resolved' ? 'derivedBy' or coalesce(prevd->'resolved' ? 'derivedBy', false))
|
|
754
|
+
and not @SCHEMA@.letopis_allowed('letopis.projector') then
|
|
755
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)',
|
|
756
|
+
data->>'name'));
|
|
757
|
+
end if;
|
|
758
|
+
-- агрегат развёрнутого описания (свой или унаследованный), новый или изменённый против прежнего — описание
|
|
759
|
+
-- агрегата, новый потомок цели, смена parent, — задаёт только тот, кто без условий читает все строки каждого
|
|
760
|
+
-- класса-источника: класса of и его потомков (agg_classes: классы арендатора, у класса System — классы System).
|
|
761
|
+
-- Неизменённый агрегат не проверяется
|
|
762
|
+
if data->'resolved' ? 'aggregate' then
|
|
763
|
+
for e in select key, value from jsonb_each(data->'resolved'->'aggregate') order by key loop
|
|
764
|
+
continue when prevd->'resolved'->'aggregate'->e.key is not distinct from e.value;
|
|
765
|
+
-- сам класс of первым, потомки — по имени
|
|
766
|
+
foreach src in array array(select n from unnest(@SCHEMA@.agg_classes(e.value->>'of', p_tenant, p_tenant = '@SYSTEM@'::uuid)) n
|
|
767
|
+
order by n = (e.value->>'of') desc, n) loop
|
|
768
|
+
chk := @SCHEMA@.acl_check_data(src, 'READ');
|
|
769
|
+
if not coalesce((chk->>'allow')::boolean, false) or chk ? 'filter' then
|
|
770
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('агрегат %s.%s: нужно право READ на все строки класса %s', data->>'name', e.key, src));
|
|
771
|
+
end if;
|
|
772
|
+
end loop;
|
|
773
|
+
end loop;
|
|
774
|
+
end if;
|
|
775
|
+
-- класс становится источником агрегата (новая метка aggregatedBy — смена parent): в агрегат войдут его строки и строки
|
|
776
|
+
-- потомков — нужно право READ без условий на них до правки, по прежним предкам (здесь), и после (agg_source_admit в
|
|
777
|
+
-- триггере после оператора; новый класс проверяется только там: до вставки план прав его не видит)
|
|
778
|
+
m := @SCHEMA@.agg_new_mark(prevd->'resolved', data->'resolved');
|
|
779
|
+
if m is not null and prevd is not null then
|
|
780
|
+
perform @SCHEMA@.agg_source_admit(data->>'name', m);
|
|
781
|
+
for src in select x.data->>'name' from @SCHEMA@.entity x
|
|
782
|
+
where x.class = 'Class' and x.tenant = p_tenant and x.data->>'name' <> data->>'name'
|
|
783
|
+
and x.data->'resolved'->'ancestors' ? (data->>'name')
|
|
784
|
+
order by jsonb_array_length(x.data->'resolved'->'ancestors'), x.data->>'name' loop
|
|
785
|
+
perform @SCHEMA@.agg_source_admit(src, m);
|
|
786
|
+
end loop;
|
|
787
|
+
end if;
|
|
788
|
+
-- правка меняет предков, алиас или derivedBy (ревью безопасности №2): строки класса и потомков попали бы под правила
|
|
789
|
+
-- других имён — нужно право READ, WRITE и DELETE без условий на все их строки. У нового класса строк ещё нет
|
|
790
|
+
if prevd is not null
|
|
791
|
+
and (prevd->'resolved'->'ancestors' is distinct from data->'resolved'->'ancestors'
|
|
792
|
+
or prevd->>'alias' is distinct from data->>'alias'
|
|
793
|
+
or prevd->'resolved'->'derivedBy' is distinct from data->'resolved'->'derivedBy') then
|
|
794
|
+
perform @SCHEMA@.class_acl_admit(data->>'name', p_tenant);
|
|
795
|
+
end if;
|
|
796
|
+
-- политика хранения истории (доработки после точки Б): по ней maintain() прореживает журнал строк класса, а
|
|
797
|
+
-- history: false выключает журнал. Одним правом WRITE на Class автор правки стирал бы аудит строк, на которые у него
|
|
798
|
+
-- нет прав, поэтому смена развёрнутой history существующего класса требует права READ и DELETE без условий на все
|
|
799
|
+
-- строки класса и его потомков своего арендатора — как trimHistory требует их на каждую цель
|
|
800
|
+
if prevd is not null and prevd->'resolved'->'history' is distinct from data->'resolved'->'history' then
|
|
801
|
+
perform @SCHEMA@.class_rows_admit(data->>'name', p_tenant, array['READ', 'DELETE'],
|
|
802
|
+
format('класс %s меняет политику хранения истории', data->>'name'));
|
|
803
|
+
end if;
|
|
489
804
|
for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
|
|
490
805
|
select x.data->>'name' as name into o
|
|
491
806
|
from @SCHEMA@.entity x
|
|
@@ -498,6 +813,51 @@ begin
|
|
|
498
813
|
perform @SCHEMA@.lt_fail('invalid_class', format('конец %s в классе %s ведёт на другой класс', e.key, o.name));
|
|
499
814
|
end if;
|
|
500
815
|
end loop;
|
|
816
|
+
-- на строки производного класса (план 1.1, §2.4) не ссылаются: проектор удаляет и строит их заново.
|
|
817
|
+
-- Классы целей — под разделяемой блокировкой семейства, а класс, который становится производным, — под
|
|
818
|
+
-- исключительной: описание со ссылкой и пометка derivedBy не проходят параллельно мимо друг друга
|
|
819
|
+
for e in select key, value from jsonb_each(coalesce(data->'resolved'->'ends', '{}'::jsonb)) loop
|
|
820
|
+
for tc in select c.* from jsonb_array_elements_text(@SCHEMA@.class_set(e.value->'class')) n(name)
|
|
821
|
+
cross join lateral @SCHEMA@.class_row(n.name, p_tenant) c where c.id is not null order by c.id loop
|
|
822
|
+
perform @SCHEMA@.family_lock(tc, false);
|
|
823
|
+
end loop;
|
|
824
|
+
dname := null;
|
|
825
|
+
select x.data->>'name' into dname
|
|
826
|
+
from @SCHEMA@.entity x
|
|
827
|
+
where x.class = 'Class' and x.tenant in (p_tenant, '@SYSTEM@'::uuid) and x.data->>'name' <> data->>'name'
|
|
828
|
+
and @SCHEMA@.class_set(e.value->'class') ? (x.data->>'name') and x.data->'resolved' ? 'derivedBy'
|
|
829
|
+
limit 1;
|
|
830
|
+
if dname is null and data->'resolved' ? 'derivedBy' and @SCHEMA@.class_set(e.value->'class') ? (data->>'name') then
|
|
831
|
+
dname := data->>'name';
|
|
832
|
+
end if;
|
|
833
|
+
if dname is not null then
|
|
834
|
+
perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на производный класс %s', e.key, dname));
|
|
835
|
+
end if;
|
|
836
|
+
end loop;
|
|
837
|
+
if data->'resolved' ? 'derivedBy' then
|
|
838
|
+
tc := @SCHEMA@.class_row(data->>'name', p_tenant);
|
|
839
|
+
if tc.id is not null then
|
|
840
|
+
perform @SCHEMA@.family_lock(tc, true);
|
|
841
|
+
end if;
|
|
842
|
+
-- класс становится производным, а на него или его потомков уже ссылаются концы других классов (класс
|
|
843
|
+
-- System видят все арендаторы — его ссылки ищутся во всех)
|
|
844
|
+
select x.data->>'name', k.key into dname, drole
|
|
845
|
+
from @SCHEMA@.entity x
|
|
846
|
+
cross join lateral jsonb_each(coalesce(x.data->'resolved'->'ends', '{}'::jsonb)) k
|
|
847
|
+
where x.class = 'Class' and (x.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
|
|
848
|
+
and x.data->>'name' <> data->>'name'
|
|
849
|
+
and @SCHEMA@.class_set(k.value->'class') ?| (array[data->>'name'] || array(
|
|
850
|
+
select y.data->>'name' from @SCHEMA@.entity y
|
|
851
|
+
where y.class = 'Class' and (y.tenant in (p_tenant, '@SYSTEM@'::uuid) or p_tenant = '@SYSTEM@'::uuid)
|
|
852
|
+
and y.data->'resolved'->'ancestors' ? (data->>'name')))
|
|
853
|
+
limit 1;
|
|
854
|
+
if dname is not null then
|
|
855
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s не может стать производным: на него ссылается конец %s класса %s',
|
|
856
|
+
data->>'name', drole, dname));
|
|
857
|
+
end if;
|
|
858
|
+
end if;
|
|
859
|
+
-- строки-замки семейства (family_lock) — заранее: первая запись в новое семейство их не вставляет
|
|
860
|
+
perform @SCHEMA@.family_rows(p_tenant, data);
|
|
501
861
|
end $$;
|
|
502
862
|
|
|
503
863
|
-- ========================= триггеры =========================
|
|
@@ -539,9 +899,21 @@ begin
|
|
|
539
899
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
540
900
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
|
|
541
901
|
end if;
|
|
902
|
+
if res ? 'derivedBy' then
|
|
903
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор %s', NEW.class, res->>'derivedBy'));
|
|
904
|
+
end if;
|
|
542
905
|
if NEW.class = 'Class' then
|
|
543
906
|
select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(coalesce(NEW.data, '{}'::jsonb), NEW.tenant) a;
|
|
544
907
|
else
|
|
908
|
+
-- агрегатное поле считает база (план 1.1, §2.4): переданное значение — отказ, новое — 0 до умолчаний и
|
|
909
|
+
-- проверки; итог из живых источников ставит триггер после оператора
|
|
910
|
+
if res ? 'aggregate' then
|
|
911
|
+
if coalesce(NEW.data, '{}'::jsonb) ?| @SCHEMA@.agg_fields(res) then
|
|
912
|
+
perform @SCHEMA@.lt_fail('aggregate_field', format('поля %s класса %s считает база (aggregate) — их не передают',
|
|
913
|
+
array_to_string(array(select x from unnest(@SCHEMA@.agg_fields(res)) x where NEW.data ? x), ', '), NEW.class));
|
|
914
|
+
end if;
|
|
915
|
+
NEW.data := @SCHEMA@.agg_zero(res, coalesce(NEW.data, '{}'::jsonb));
|
|
916
|
+
end if;
|
|
545
917
|
NEW.data := @SCHEMA@.apply_defaults(res->'schema', coalesce(NEW.data, '{}'::jsonb));
|
|
546
918
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
|
|
547
919
|
if bad <> '[]'::jsonb then
|
|
@@ -573,7 +945,20 @@ begin
|
|
|
573
945
|
-- воскрешение: номер версии и цепочка хэшей продолжаются от последней записи журнала; запись
|
|
574
946
|
-- блокируется — параллельные purge, обрезка и restore того же id ждут (этап 6), а если purge успел
|
|
575
947
|
-- стереть её, пара (id, rev) столкнётся с его записью purge в журнале, и операция повторится
|
|
576
|
-
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;
|
|
948
|
+
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;
|
|
949
|
+
-- класс с историей строк в журнале (остаток Н2 ревью №2; доработки после точки Б): правила прав находят строки, и
|
|
950
|
+
-- старые версии тоже, по именам класса, предков и алиасов — класс с другим parent, alias или derivedBy подставил бы
|
|
951
|
+
-- старую историю под чужие правила. Поэтому, если журнал арендатора хранит версии строк класса с этим именем, класс
|
|
952
|
+
-- создаётся только заново и с тем же описанием, что в надгробии его строки Class. Надгробия нет (история осталась от
|
|
953
|
+
-- класса System, удалённого раньше, или строку Class стёрли мимо триггеров) — отказ: сначала purge() строк
|
|
954
|
+
if NEW.class = 'Class' and @SCHEMA@.class_has_history(NEW.data->>'name', NEW.tenant)
|
|
955
|
+
and not (coalesce(last.op = 'delete', false)
|
|
956
|
+
and last.data->'resolved'->'ancestors' is not distinct from NEW.data->'resolved'->'ancestors'
|
|
957
|
+
and last.data->>'alias' is not distinct from NEW.data->>'alias'
|
|
958
|
+
and last.data->'resolved'->'derivedBy' is not distinct from NEW.data->'resolved'->'derivedBy') then
|
|
959
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('журнал хранит историю строк класса %s: создать его можно только заново и с прежними parent, alias и derivedBy (или сначала стереть её purge())',
|
|
960
|
+
NEW.data->>'name'));
|
|
961
|
+
end if;
|
|
577
962
|
ts := @SCHEMA@.now_ts();
|
|
578
963
|
NEW.rev := coalesce(last.rev, 0) + 1;
|
|
579
964
|
NEW.prev_hash := last.hash;
|
|
@@ -635,6 +1020,10 @@ begin
|
|
|
635
1020
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
636
1021
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
|
|
637
1022
|
end if;
|
|
1023
|
+
if res ? 'derivedBy' or (NEW.class <> OLD.class and (@SCHEMA@.class_row(OLD.class, OLD.tenant)).data->'resolved' ? 'derivedBy') then
|
|
1024
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки пишет проектор',
|
|
1025
|
+
case when res ? 'derivedBy' then NEW.class else OLD.class end));
|
|
1026
|
+
end if;
|
|
638
1027
|
if NEW.class <> OLD.class then
|
|
639
1028
|
-- смена класса (этап 1, п. 9): только read committed, только для классов без ключа
|
|
640
1029
|
if current_setting('transaction_isolation') <> 'read committed' then
|
|
@@ -647,6 +1036,11 @@ begin
|
|
|
647
1036
|
-- обычный update берёт слабую блокировку; смене класса нужна for update, чтобы дождаться
|
|
648
1037
|
-- транзакций, которые как раз пишут ссылку на эту строку
|
|
649
1038
|
perform 1 from @SCHEMA@.entity x where x.id = OLD.id for update;
|
|
1039
|
+
-- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
|
|
1040
|
+
-- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
|
|
1041
|
+
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
|
|
1042
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', OLD.class));
|
|
1043
|
+
end if;
|
|
650
1044
|
inc := @SCHEMA@.incoming_conflicts(OLD.id, NEW.class, OLD.tenant);
|
|
651
1045
|
if inc <> '[]'::jsonb then
|
|
652
1046
|
perform @SCHEMA@.lt_fail('reclass_denied', format('роли ссылающихся строк не принимают класс %s', NEW.class),
|
|
@@ -657,6 +1051,11 @@ begin
|
|
|
657
1051
|
if NEW.class = 'Class' then
|
|
658
1052
|
select a.data, a.validator into NEW.data, NEW.validator from @SCHEMA@.class_admit(NEW.data, OLD.tenant) a;
|
|
659
1053
|
else
|
|
1054
|
+
-- агрегатное поле (план 1.1, §2.4): изменить его нельзя, без поля (замена данных целиком) — прежнее значение;
|
|
1055
|
+
-- при смене класса — прежнее число или 0, итог посчитает триггер после оператора
|
|
1056
|
+
if res ? 'aggregate' then
|
|
1057
|
+
NEW.data := @SCHEMA@.agg_guard(res, OLD.data, NEW.data, NEW.class <> OLD.class);
|
|
1058
|
+
end if;
|
|
660
1059
|
NEW.data := @SCHEMA@.apply_defaults(res->'schema', NEW.data);
|
|
661
1060
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', NEW.data);
|
|
662
1061
|
if bad <> '[]'::jsonb then
|
|
@@ -684,6 +1083,11 @@ begin
|
|
|
684
1083
|
-- ужесточение: исключительная блокировка корня семейства (и нового корня при смене родителя)
|
|
685
1084
|
perform @SCHEMA@.family_lock(OLD, true);
|
|
686
1085
|
perform @SCHEMA@.family_lock(NEW, true);
|
|
1086
|
+
-- смена агрегатов — ещё семейства источников (план 1.1, §2.4): операция, зафиксированная до пересчёта, в него
|
|
1087
|
+
-- войдёт, а ожидающая допишет вклад уже по новому описанию
|
|
1088
|
+
if OLD.data->'resolved'->'aggregate' is distinct from NEW.data->'resolved'->'aggregate' then
|
|
1089
|
+
perform @SCHEMA@.agg_lock_sources(OLD.data->'resolved', NEW.data->'resolved', OLD.tenant);
|
|
1090
|
+
end if;
|
|
687
1091
|
end if;
|
|
688
1092
|
NEW.rev := OLD.rev + 1;
|
|
689
1093
|
NEW.prev_hash := OLD.hash;
|
|
@@ -704,14 +1108,17 @@ language plpgsql security definer
|
|
|
704
1108
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
705
1109
|
as $$
|
|
706
1110
|
declare
|
|
707
|
-
|
|
1111
|
+
sc text[];
|
|
1112
|
+
st uuid[];
|
|
708
1113
|
bad text;
|
|
709
1114
|
begin
|
|
710
1115
|
if exists (select 1 from added n where n.links <> '{}'::jsonb) then
|
|
711
1116
|
bad := @SCHEMA@.targets_bad((select jsonb_agg(jsonb_build_object('class', n.class, 'tenant', n.tenant, 'links', n.links))
|
|
712
1117
|
from added n where n.links <> '{}'::jsonb), true, @SCHEMA@.acl_actor('READ'));
|
|
713
1118
|
end if;
|
|
714
|
-
if bad
|
|
1119
|
+
if bad like '!%' then
|
|
1120
|
+
perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
|
|
1121
|
+
elsif bad is not null then
|
|
715
1122
|
perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
|
|
716
1123
|
end if;
|
|
717
1124
|
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
@@ -719,11 +1126,21 @@ begin
|
|
|
719
1126
|
from added n
|
|
720
1127
|
join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
|
|
721
1128
|
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1129
|
+
-- сигнал — по паре «класс, арендатор»
|
|
1130
|
+
select array_agg(k.class), array_agg(k.tenant) into sc, st from (select distinct a.class, a.tenant from added a) k;
|
|
1131
|
+
perform @SCHEMA@.signal(sc, st);
|
|
1132
|
+
if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
725
1133
|
perform @SCHEMA@.acl_bump();
|
|
726
1134
|
end if;
|
|
1135
|
+
if 'Class' = any (sc) then
|
|
1136
|
+
-- новый класс — источник агрегата (ревью коммита этапа 4б): нужно право READ без условий на все его строки; план
|
|
1137
|
+
-- прав после acl_bump видит новый класс
|
|
1138
|
+
perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
|
|
1139
|
+
from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
|
|
1140
|
+
-- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
|
|
1141
|
+
perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
|
|
1142
|
+
from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
|
|
1143
|
+
end if;
|
|
727
1144
|
return null;
|
|
728
1145
|
end $$;
|
|
729
1146
|
|
|
@@ -735,7 +1152,8 @@ language plpgsql security definer
|
|
|
735
1152
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
736
1153
|
as $$
|
|
737
1154
|
declare
|
|
738
|
-
|
|
1155
|
+
sc text[];
|
|
1156
|
+
st uuid[];
|
|
739
1157
|
o_row @SCHEMA@.entity;
|
|
740
1158
|
n_row @SCHEMA@.entity;
|
|
741
1159
|
cid uuid;
|
|
@@ -748,24 +1166,34 @@ begin
|
|
|
748
1166
|
from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
749
1167
|
where n.links <> '{}'::jsonb and (n.links <> o.links or n.class <> o.class)), true, @SCHEMA@.acl_actor('READ'));
|
|
750
1168
|
end if;
|
|
751
|
-
if bad
|
|
1169
|
+
if bad like '!%' then
|
|
1170
|
+
perform @SCHEMA@.lt_fail('derived_class', format('конец %s ведёт на строку производного класса', substr(bad, 2)));
|
|
1171
|
+
elsif bad is not null then
|
|
752
1172
|
perform @SCHEMA@.lt_fail('target_not_found', format('цель конца %s не найдена', bad));
|
|
753
1173
|
end if;
|
|
754
|
-
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
755
|
-
select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash
|
|
1174
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
1175
|
+
select n.id, n.rev, n.class, n.cv, n.tenant, n.owner, n.links, n.data, n.tags, n.at, n.author, n.agent, n.op, n.reason, n.moved, n.prev_hash,
|
|
1176
|
+
@SCHEMA@.change_paths(o.data, o.links, o.tags, o.owner, o.class, n.data, n.links, n.tags, n.owner, n.class)
|
|
756
1177
|
from new_rows n
|
|
757
1178
|
join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
758
1179
|
join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
|
|
759
1180
|
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant;
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1181
|
+
-- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
|
|
1182
|
+
-- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
|
|
1183
|
+
select array_agg(k.class), array_agg(k.tenant) into sc, st
|
|
1184
|
+
from (select distinct v.class, v.tenant from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
1185
|
+
cross join lateral (values (n.class, n.tenant), (o.class, o.tenant)) v(class, tenant)) k;
|
|
1186
|
+
perform @SCHEMA@.signal(sc, st);
|
|
1187
|
+
if sc && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
763
1188
|
perform @SCHEMA@.acl_bump();
|
|
764
1189
|
end if;
|
|
765
1190
|
for cid in select n.id from new_rows n join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
766
1191
|
where n.class = 'Class' order by n.id loop
|
|
767
1192
|
select * into o_row from old_rows x where x.id = cid;
|
|
768
1193
|
select * into n_row from new_rows x where x.id = cid;
|
|
1194
|
+
-- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
|
|
1195
|
+
-- и после правки — по новым предкам (до правки проверил class_admit)
|
|
1196
|
+
perform @SCHEMA@.agg_source_admit(n_row.data->>'name', @SCHEMA@.agg_new_mark(o_row.data->'resolved', n_row.data->'resolved'));
|
|
769
1197
|
perform @SCHEMA@.class_evolve(o_row, n_row);
|
|
770
1198
|
end loop;
|
|
771
1199
|
-- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
|
|
@@ -787,7 +1215,15 @@ declare
|
|
|
787
1215
|
classes text[] := array[n.data->>'name'];
|
|
788
1216
|
conflicts jsonb;
|
|
789
1217
|
c record;
|
|
1218
|
+
-- агрегаты (план 1.1, §2.4): источники, чьи метки пересчитываются; цели, поле которых считается заново
|
|
1219
|
+
agg_names text[] := '{}';
|
|
1220
|
+
agg_own text[] := '{}';
|
|
1221
|
+
agg_other text[] := @SCHEMA@.agg_marked(o.data->'resolved', n.data->'resolved');
|
|
790
1222
|
begin
|
|
1223
|
+
if o.data->'resolved'->'aggregate' is distinct from n.data->'resolved'->'aggregate' then
|
|
1224
|
+
agg_names := @SCHEMA@.agg_of(o.data->'resolved') || @SCHEMA@.agg_of(n.data->'resolved');
|
|
1225
|
+
agg_own := agg_own || (n.data->>'name');
|
|
1226
|
+
end if;
|
|
791
1227
|
-- потомки: описание resolved и validator пересчитываются, каждый пересчёт — версия класса в журнале
|
|
792
1228
|
for d in select x.* from @SCHEMA@.entity x
|
|
793
1229
|
where x.class = 'Class' and x.id <> n.id
|
|
@@ -798,9 +1234,37 @@ begin
|
|
|
798
1234
|
if prep.data is distinct from d.data or prep.validator::text is distinct from d.validator::text then
|
|
799
1235
|
perform @SCHEMA@.sys_update(d.id, prep.data, d.links, prep.validator, 'update');
|
|
800
1236
|
end if;
|
|
1237
|
+
-- потомок становится источником вместе с классом (смена parent; ревью коммита этапа 4б) — то же право READ на его
|
|
1238
|
+
-- строки по новым предкам (до правки проверил class_admit). class_evolve зовёт только триггер правки приложения;
|
|
1239
|
+
-- потомков класса System у арендаторов план прав сессии System не видит, их строки считают агрегаты System для
|
|
1240
|
+
-- целей того же арендатора
|
|
1241
|
+
if d.tenant = n.tenant then
|
|
1242
|
+
perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
|
|
1243
|
+
end if;
|
|
801
1244
|
classes := classes || (d.data->>'name');
|
|
1245
|
+
if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
|
|
1246
|
+
agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
|
|
1247
|
+
agg_own := agg_own || (d.data->>'name');
|
|
1248
|
+
end if;
|
|
1249
|
+
agg_other := agg_other || @SCHEMA@.agg_marked(d.data->'resolved', prep.data->'resolved');
|
|
802
1250
|
end loop;
|
|
1251
|
+
-- агрегаты семейства изменились: метки источников заново и поле у всех целей — из живых источников (строки
|
|
1252
|
+
-- проверит ужесточение ниже); у источника сменились предки — заново цели, которые его считают
|
|
1253
|
+
if cardinality(agg_names) > 0 then
|
|
1254
|
+
perform @SCHEMA@.agg_mark(n.tenant, agg_names);
|
|
1255
|
+
end if;
|
|
1256
|
+
if cardinality(agg_own) > 0 then
|
|
1257
|
+
perform @SCHEMA@.agg_refresh(agg_own, n.tenant, false);
|
|
1258
|
+
end if;
|
|
1259
|
+
if cardinality(agg_other) > 0 then
|
|
1260
|
+
perform @SCHEMA@.agg_refresh(agg_other, n.tenant, true);
|
|
1261
|
+
end if;
|
|
803
1262
|
if not @SCHEMA@.class_change_safe(o.data->'resolved', n.data->'resolved') then
|
|
1263
|
+
-- ужесточение проверяет все строки семейства, в том числе невидимые актору, и отвечает их числом: пробное
|
|
1264
|
+
-- ужесточение (maximum, enum, pattern…) было оракулом по чужим строкам (доработки после точки Б). Нужно право READ
|
|
1265
|
+
-- без условий на все строки класса и потомков своего арендатора; строки других арендаторов (класс System) —
|
|
1266
|
+
-- уровень установки, их число остаётся в others
|
|
1267
|
+
perform @SCHEMA@.class_rows_admit(n.data->>'name', n.tenant, array['READ'], format('ужесточение класса %s', n.data->>'name'));
|
|
804
1268
|
conflicts := @SCHEMA@.family_conflicts(classes, n.tenant, o.data->'resolved'->'ancestors' is distinct from n.data->'resolved'->'ancestors');
|
|
805
1269
|
if conflicts <> '[]'::jsonb then
|
|
806
1270
|
select * into c from @SCHEMA@.ctx();
|
|
@@ -883,6 +1347,8 @@ declare
|
|
|
883
1347
|
unsets uuid[];
|
|
884
1348
|
classes text[];
|
|
885
1349
|
lvl_classes text[];
|
|
1350
|
+
pairs text[];
|
|
1351
|
+
lvl_pairs text[];
|
|
886
1352
|
v_author uuid;
|
|
887
1353
|
v_reason text := nullif(current_setting('letopis.reason', true), '');
|
|
888
1354
|
newlinks jsonb;
|
|
@@ -904,25 +1370,45 @@ begin
|
|
|
904
1370
|
if exists (select 1 from @SCHEMA@.entity x join gone g on g.id = x.id) then
|
|
905
1371
|
perform @SCHEMA@.lt_fail('recreated_in_statement', 'id удалён и создан заново одним оператором');
|
|
906
1372
|
end if;
|
|
907
|
-
|
|
1373
|
+
-- строки производного класса удаляет только проектор (каскад функции владельца сюда не доходит)
|
|
1374
|
+
select d.class into denied from (select distinct g.class, g.tenant from gone g) d
|
|
1375
|
+
where (@SCHEMA@.class_row(d.class, d.tenant)).data->'resolved' ? 'derivedBy' limit 1;
|
|
1376
|
+
if denied is not null then
|
|
1377
|
+
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
|
|
1378
|
+
end if;
|
|
1379
|
+
for r in select g.id, g.tenant, g.data->>'name' as name, g.data->'resolved' ? 'derivedBy' as derived from gone g where g.class = 'Class' loop
|
|
1380
|
+
-- производный класс удаляет только администратор (class_admit)
|
|
1381
|
+
if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
|
|
1382
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('класс %s производный: его описание задаёт только администратор (право letopis.projector)', r.name));
|
|
1383
|
+
end if;
|
|
908
1384
|
if exists (select 1 from @SCHEMA@.entity x where x.class = r.name and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid))
|
|
909
1385
|
or exists (select 1 from @SCHEMA@.entity x where x.class = 'Class' and x.data->>'parent' = r.name
|
|
910
1386
|
and (x.tenant = r.tenant or r.tenant = '@SYSTEM@'::uuid)) then
|
|
911
1387
|
perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть строки или потомки', r.name));
|
|
912
1388
|
end if;
|
|
1389
|
+
-- и история строк (остаток Н2; доработки после точки Б): удалённый класс с историей, созданный заново с другими
|
|
1390
|
+
-- предками или алиасом, подставил бы её под чужие правила — сначала purge() удалённых строк
|
|
1391
|
+
if @SCHEMA@.class_has_history(r.name, r.tenant) then
|
|
1392
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('у класса %s есть история строк в журнале: сначала стереть её purge() удалённых строк', r.name));
|
|
1393
|
+
end if;
|
|
913
1394
|
end loop;
|
|
1395
|
+
-- удалена цель агрегатов (план 1.1, §2.4): метки её источников — уже без неё
|
|
1396
|
+
perform @SCHEMA@.agg_mark(g.tenant, @SCHEMA@.agg_of(g.data->'resolved'))
|
|
1397
|
+
from gone g where g.class = 'Class' and g.data->'resolved' ? 'aggregate';
|
|
914
1398
|
v_author := coalesce(c.author, '@SYSTEM@'::uuid);
|
|
915
1399
|
-- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень
|
|
916
|
-
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
1400
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
917
1401
|
select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
|
|
918
|
-
greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash
|
|
1402
|
+
greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
|
|
919
1403
|
from gone g
|
|
920
1404
|
join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
|
|
921
1405
|
where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant;
|
|
1406
|
+
-- пары «класс, арендатор» для сигнала — строкой «класс арендатор» (пробела нет ни в имени, ни в uuid)
|
|
922
1407
|
select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
|
|
1408
|
+
coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}'),
|
|
923
1409
|
coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
|
|
924
1410
|
coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
|
|
925
|
-
into lvl, classes, accs, mems from gone g;
|
|
1411
|
+
into lvl, classes, pairs, accs, mems from gone g;
|
|
926
1412
|
all_ids := lvl;
|
|
927
1413
|
while cardinality(lvl) > 0 loop
|
|
928
1414
|
-- кто ссылается на уровень: действие по правилам концов (общее с превью)
|
|
@@ -944,24 +1430,26 @@ begin
|
|
|
944
1430
|
where x.id in (select y.id from @SCHEMA@.entity y where y.id = any (casc) order by y.id for update)
|
|
945
1431
|
returning x.*
|
|
946
1432
|
), t as (
|
|
947
|
-
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash)
|
|
1433
|
+
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
948
1434
|
select d.id, d.rev + 1, d.class, d.cv, d.tenant, d.owner, d.links, d.data, d.tags,
|
|
949
|
-
greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash
|
|
1435
|
+
greatest(@SCHEMA@.now_ts(), d.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, d.hash, '[]'::jsonb
|
|
950
1436
|
from d
|
|
951
1437
|
join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
|
|
952
1438
|
where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
|
|
953
1439
|
)
|
|
954
1440
|
select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
|
|
1441
|
+
coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
|
|
955
1442
|
min(d.class) filter (where not @SCHEMA@.acl_row_ok(dplan, d.class, d.id, d.owner, d.tenant, d.tags, d.data, d.links)),
|
|
956
1443
|
coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
|
|
957
1444
|
coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
|
|
958
|
-
into lvl, lvl_classes, denied, lvl_accs, lvl_mems from d;
|
|
1445
|
+
into lvl, lvl_classes, lvl_pairs, denied, lvl_accs, lvl_mems from d;
|
|
959
1446
|
if denied is not null then
|
|
960
1447
|
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на класс %s в каскаде удаления', denied));
|
|
961
1448
|
end if;
|
|
962
1449
|
accs := accs || lvl_accs;
|
|
963
1450
|
mems := mems || lvl_mems;
|
|
964
1451
|
classes := classes || lvl_classes;
|
|
1452
|
+
pairs := pairs || lvl_pairs;
|
|
965
1453
|
-- unset: снять конец, новая версия
|
|
966
1454
|
for r in select x.* from @SCHEMA@.entity x where x.id = any (unsets) order by x.id for update loop
|
|
967
1455
|
if not @SCHEMA@.acl_row_ok(wplan, r.class, r.id, r.owner, r.tenant, r.tags, r.data, r.links) then
|
|
@@ -990,7 +1478,8 @@ begin
|
|
|
990
1478
|
perform @SCHEMA@.auth_kill(r.acc, null);
|
|
991
1479
|
update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
|
|
992
1480
|
end loop;
|
|
993
|
-
perform @SCHEMA@.signal(
|
|
1481
|
+
perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
|
|
1482
|
+
from (select distinct p from unnest(pairs) p) x;
|
|
994
1483
|
if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
995
1484
|
perform @SCHEMA@.acl_bump();
|
|
996
1485
|
end if;
|
|
@@ -1102,8 +1591,15 @@ return (
|
|
|
1102
1591
|
-- Перенос строки на новый id (смена класса с ключом, смена ключа): новая строка продолжает журнал
|
|
1103
1592
|
-- нового id и ссылается на старый (moved), ссылки переводятся — если переведённый конец входит
|
|
1104
1593
|
-- в ключ ссылающейся строки, её id тоже меняется, и перевод идёт дальше рекурсивно; старая строка
|
|
1105
|
-
-- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая
|
|
1106
|
-
|
|
1594
|
+
-- удаляется с надгробием, которое ссылается на новый id. Проверки делает вызывающая функция; p_plan — план WRITE
|
|
1595
|
+
-- актора (reclass, rekey, rekey_class): каждая строка, чьи ссылки перевод переписывает, на всех уровнях рекурсии должна
|
|
1596
|
+
-- им проходить (доработки после точки Б: прежде проверялся только первый уровень); null — путь владельца.
|
|
1597
|
+
-- Агрегаты (план 1.1, §2.4): перенос — несколько операторов (новая строка, перевод ссылок, удаление старой), и
|
|
1598
|
+
-- триггер агрегатов на это время только копит их вклады (agg_hold_begin), а в конце применяет одним итогом: сумма
|
|
1599
|
+
-- не проходит через промежуточные значения, новая цель считается целиком из живых источников.
|
|
1600
|
+
drop function if exists @SCHEMA@.move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text);
|
|
1601
|
+
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,
|
|
1602
|
+
p_plan jsonb default null)
|
|
1107
1603
|
returns @SCHEMA@.entity
|
|
1108
1604
|
language plpgsql volatile
|
|
1109
1605
|
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
@@ -1116,6 +1612,7 @@ declare
|
|
|
1116
1612
|
r @SCHEMA@.entity;
|
|
1117
1613
|
nl jsonb;
|
|
1118
1614
|
rid uuid;
|
|
1615
|
+
held boolean := @SCHEMA@.agg_hold_begin('acc');
|
|
1119
1616
|
begin
|
|
1120
1617
|
select * into c from @SCHEMA@.ctx();
|
|
1121
1618
|
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;
|
|
@@ -1127,10 +1624,18 @@ begin
|
|
|
1127
1624
|
returning * into n;
|
|
1128
1625
|
perform @SCHEMA@.log_version(n);
|
|
1129
1626
|
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
|
|
1627
|
+
-- строки производного класса пишет только проектор: перевод их ссылок мимо него оставил бы проекцию
|
|
1628
|
+
-- несогласованной (ревью безопасности №3, №3)
|
|
1629
|
+
if (@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved' ? 'derivedBy' then
|
|
1630
|
+
perform @SCHEMA@.lt_fail('derived_class', format('на строку ссылается строка производного класса %s: её пишет проектор', r.class));
|
|
1631
|
+
end if;
|
|
1632
|
+
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
|
+
perform @SCHEMA@.lt_fail('acl_denied', 'нет права WRITE на ссылающиеся строки');
|
|
1634
|
+
end if;
|
|
1130
1635
|
nl := @SCHEMA@.links_replace(r.links, o.id, p_new_id);
|
|
1131
1636
|
rid := coalesce(@SCHEMA@.key_id((@SCHEMA@.class_row(r.class, r.tenant)).data->'resolved', r.class, r.tenant, r.data, nl), r.id);
|
|
1132
1637
|
if rid <> r.id then
|
|
1133
|
-
perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey');
|
|
1638
|
+
perform @SCHEMA@.move_row(r, rid, r.class, r.data, nl, 'rekey', p_plan);
|
|
1134
1639
|
else
|
|
1135
1640
|
perform @SCHEMA@.sys_update(r.id, r.data, nl, r.validator, 'update');
|
|
1136
1641
|
end if;
|
|
@@ -1138,6 +1643,9 @@ begin
|
|
|
1138
1643
|
delete from @SCHEMA@.entity x where x.id = o.id returning x.* into r;
|
|
1139
1644
|
-- надгробие всегда вида delete (по нему срез на момент отличает удалённый id), moved — новый id
|
|
1140
1645
|
perform @SCHEMA@.tombstone(r, 'delete', p_new_id);
|
|
1646
|
+
if held then
|
|
1647
|
+
perform @SCHEMA@.agg_hold_end(true);
|
|
1648
|
+
end if;
|
|
1141
1649
|
return n;
|
|
1142
1650
|
end $$;
|
|
1143
1651
|
|
|
@@ -1161,6 +1669,7 @@ declare
|
|
|
1161
1669
|
rd jsonb;
|
|
1162
1670
|
wplan jsonb;
|
|
1163
1671
|
denied text;
|
|
1672
|
+
n_denied int;
|
|
1164
1673
|
begin
|
|
1165
1674
|
select * into c from @SCHEMA@.ctx();
|
|
1166
1675
|
if c.account is null then
|
|
@@ -1178,6 +1687,11 @@ begin
|
|
|
1178
1687
|
if not @SCHEMA@.acl_row_ok(wplan, o.class, o.id, o.owner, o.tenant, o.tags, o.data, o.links) then
|
|
1179
1688
|
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на строку класса %s', o.class));
|
|
1180
1689
|
end if;
|
|
1690
|
+
-- строка уходит из своего класса — как удаление из него: нужно право DELETE на прежнюю строку (доработки после
|
|
1691
|
+
-- точки Б; иначе с одним WRITE строку переносили в класс, где есть DELETE, и удаляли там)
|
|
1692
|
+
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
|
|
1693
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('нет права DELETE на строку класса %s: смена класса убирает её из класса', o.class));
|
|
1694
|
+
end if;
|
|
1181
1695
|
-- строки прав и классов функция не трогает
|
|
1182
1696
|
if o.class in ('Class', 'Account', 'member', 'Resource', 'rule') or p_class in ('Class', 'Account', 'member', 'Resource', 'rule') then
|
|
1183
1697
|
perform @SCHEMA@.lt_fail('reclass_denied', 'класс системной строки не меняется');
|
|
@@ -1192,7 +1706,19 @@ begin
|
|
|
1192
1706
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
1193
1707
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
|
|
1194
1708
|
end if;
|
|
1195
|
-
|
|
1709
|
+
if res ? 'derivedBy' or (@SCHEMA@.class_row(o.class, o.tenant)).data->'resolved' ? 'derivedBy' then
|
|
1710
|
+
perform @SCHEMA@.lt_fail('derived_class', 'строки производного класса пишет только его проектор');
|
|
1711
|
+
end if;
|
|
1712
|
+
d := coalesce(p_data, o.data);
|
|
1713
|
+
-- агрегатные поля нового класса считает база (план 1.1, §2.4): в данных их не передают; прежнее число остаётся
|
|
1714
|
+
-- до пересчёта, иначе 0
|
|
1715
|
+
if res ? 'aggregate' then
|
|
1716
|
+
if p_data ?| @SCHEMA@.agg_fields(res) then
|
|
1717
|
+
perform @SCHEMA@.lt_fail('aggregate_field', format('агрегатные поля класса %s считает база — в данных reclass их не передают', p_class));
|
|
1718
|
+
end if;
|
|
1719
|
+
d := @SCHEMA@.agg_zero(res, d);
|
|
1720
|
+
end if;
|
|
1721
|
+
d := @SCHEMA@.apply_defaults(res->'schema', d);
|
|
1196
1722
|
bad := @SCHEMA@.validate_data(res->'rules', cls.validator, res->'vars', d);
|
|
1197
1723
|
if bad <> '[]'::jsonb then
|
|
1198
1724
|
perform @SCHEMA@.lt_fail('invalid_data', format('данные не проходят описание класса %s', p_class), jsonb_build_object('issues', bad));
|
|
@@ -1211,17 +1737,20 @@ begin
|
|
|
1211
1737
|
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на класс %s', p_class));
|
|
1212
1738
|
end if;
|
|
1213
1739
|
if nid <> o.id then
|
|
1214
|
-
-- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE
|
|
1215
|
-
|
|
1740
|
+
-- перевод ссылок пишет ссылающиеся строки: на них тоже нужно право WRITE (глубже первого уровня — move_row);
|
|
1741
|
+
-- класс такой строки называется, только если актор её видит
|
|
1742
|
+
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')),
|
|
1743
|
+
count(*) into denied, n_denied from @SCHEMA@.entity x
|
|
1216
1744
|
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);
|
|
1217
|
-
if
|
|
1218
|
-
perform @SCHEMA@.lt_fail('acl_denied', format('нет права WRITE на ссылающиеся строки класса %s', denied)
|
|
1745
|
+
if n_denied > 0 then
|
|
1746
|
+
perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права WRITE на ссылающиеся строки класса %s', denied)
|
|
1747
|
+
else 'нет права WRITE на ссылающиеся строки' end);
|
|
1219
1748
|
end if;
|
|
1220
1749
|
end if;
|
|
1221
1750
|
if nid = o.id then
|
|
1222
1751
|
perform @SCHEMA@.sys_update(o.id, d, lk.links, null, 'reclass', p_class);
|
|
1223
1752
|
else
|
|
1224
|
-
perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass');
|
|
1753
|
+
perform @SCHEMA@.move_row(o, nid, p_class, d, lk.links, 'reclass', wplan);
|
|
1225
1754
|
end if;
|
|
1226
1755
|
return nid;
|
|
1227
1756
|
end $$;
|
|
@@ -1353,13 +1882,13 @@ do $$
|
|
|
1353
1882
|
declare f text;
|
|
1354
1883
|
begin
|
|
1355
1884
|
foreach f in array array[
|
|
1356
|
-
'now_ts()', 'family_lock(@SCHEMA@.entity, boolean)', 'class_set(jsonb)', 'class_accepts(jsonb, text, uuid)', 'role_accepts(text, uuid, text, text, uuid)',
|
|
1885
|
+
'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)',
|
|
1357
1886
|
'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)',
|
|
1358
1887
|
'log_version(@SCHEMA@.entity)', 'sys_update(uuid, jsonb, jsonb, jsonpath, text, text)', 'tombstone(@SCHEMA@.entity, text, uuid)',
|
|
1359
|
-
'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text)', 'reclass(uuid, text, jsonb)',
|
|
1888
|
+
'links_replace(jsonb, uuid, uuid)', 'move_row(@SCHEMA@.entity, uuid, text, jsonb, jsonb, text, jsonb)', 'reclass(uuid, text, jsonb)',
|
|
1360
1889
|
'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'class_change_safe(jsonb, jsonb)',
|
|
1361
|
-
'family_conflicts(text[], uuid, boolean)', 'reserved_names()', 'class_admit(jsonb, uuid)',
|
|
1362
|
-
'trg_entity_bi()', 'trg_entity_bu()', 'trg_entity_ai()', 'trg_entity_au()', 'history_on(text, uuid)', 'signal(text[])', 'class_evolve(@SCHEMA@.entity, @SCHEMA@.entity)',
|
|
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)',
|
|
1891
|
+
'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)',
|
|
1363
1892
|
'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
|
|
1364
1893
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
1365
1894
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|