letopis 1.3.0 → 1.4.1
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 +14 -6
- package/AGENT-CHEATSHEET.md +18 -10
- package/CHANGELOG.md +247 -0
- package/README.en.md +146 -58
- package/README.md +129 -51
- package/dist/acl.d.ts +11 -5
- package/dist/admin.d.ts +30 -14
- package/dist/admin.js +42 -16
- package/dist/auth.d.ts +61 -17
- package/dist/auth.js +16 -2
- package/dist/cache.d.ts +5 -1
- package/dist/chain.d.ts +91 -15
- package/dist/chain.js +47 -4
- package/dist/cli.js +6 -0
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +5 -5
- package/dist/import.js +7 -0
- package/dist/index.d.ts +74 -45
- package/dist/index.js +18 -4
- package/dist/load.d.ts +10 -3
- package/dist/load.js +19 -4
- package/dist/mcp.d.ts +20 -11
- package/dist/mcp.js +4 -4
- package/dist/model.d.ts +4 -1
- package/dist/ops.d.ts +58 -18
- package/dist/ops.js +58 -18
- package/dist/project.d.ts +44 -12
- package/dist/project.js +90 -8
- package/dist/registry.d.ts +4 -1
- package/dist/registry.js +4 -1
- package/dist/sql.d.ts +4 -1
- package/dist/sql.js +12 -4
- package/dist/sync.js +11 -5
- package/dist/tx.js +10 -1
- package/dist/types.d.ts +14 -7
- package/dist/types.js +13 -6
- package/dist/up.d.ts +65 -25
- package/dist/up.js +152 -40
- package/dist/validate.d.ts +9 -2
- package/dist/validate.js +19 -3
- package/dist/watch.d.ts +17 -5
- package/dist/watch.js +40 -8
- package/dist/write.d.ts +3 -2
- package/dist/write.js +29 -10
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/sql/10-core.sql +6 -1
- package/sql/50-acl.sql +5 -1
- package/sql/60-write.sql +77 -22
- package/sql/80-auth.sql +39 -2
- package/sql/88-derived.sql +82 -1
- package/sql/90-time.sql +75 -38
- package/sql/95-seed.system.sql +6 -3
- package/sql/99-revision.sql +3 -2
- package/sql/README.md +2 -0
- package/sql/migrate/0007-secret-account-fk.sql +12 -0
package/dist/write.js
CHANGED
|
@@ -29,8 +29,9 @@ import { applyDefaults } from './validate.js';
|
|
|
29
29
|
/** Метка приращения; Symbol.for — маркер узнаёт и вторая копия пакета в процессе. */
|
|
30
30
|
export const INC = Symbol.for('letopis.inc');
|
|
31
31
|
/**
|
|
32
|
-
* Приращение в update
|
|
33
|
-
*
|
|
32
|
+
* Приращение числового поля в `update()`: `db.Wallet(w).update({ balance: inc(-300) })`. Сложение делает база над
|
|
33
|
+
* текущим значением поля в момент записи, поэтому одновременные приращения не теряются. Если поля ещё нет, `{ start }`
|
|
34
|
+
* задаёт, с чего начать (поле получит `start + by`), а без него — ошибка `invalid_data`.
|
|
34
35
|
*/
|
|
35
36
|
export function inc(by, opts = {}) {
|
|
36
37
|
return Object.freeze({ [INC]: true, by, ...(opts.start !== undefined ? { start: opts.start } : {}) });
|
|
@@ -377,7 +378,11 @@ function applySlotsLocal(links, ops) {
|
|
|
377
378
|
}
|
|
378
379
|
return out;
|
|
379
380
|
}
|
|
380
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Теги, которые пишет глагол: строка или список из `.tags(…)` (перед update они же и отбирают цели). Оператор
|
|
383
|
+
* (has, hasAny, hasAll) только отбирает — теги целей не меняются; create и upsert его не принимают (precheck).
|
|
384
|
+
*/
|
|
385
|
+
const tagsValue = (s) => typeof s.tagsFilter === 'string' ? [s.tagsFilter] : Array.isArray(s.tagsFilter) ? s.tagsFilter : null;
|
|
381
386
|
/** «Уже существует»: id вычисляет библиотека (idOf), а не разбирает текст ошибки сервера (§2.5). */
|
|
382
387
|
function existsError(w, cls, data, links, given, cause) {
|
|
383
388
|
let id = given;
|
|
@@ -440,12 +445,23 @@ async function explainMissing(w, missing, rev) {
|
|
|
440
445
|
}
|
|
441
446
|
return fail('acl_denied', `строки видны, но изменить их нельзя: ${seen.map((r) => r.id).join(', ')}`, { ids: seen.map((r) => r.id) });
|
|
442
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Строки ответа глагола — в порядке целей (`targetIds`: по `sort()` до глагола, иначе по id). `update … returning`
|
|
450
|
+
* отдаёт их в порядке обхода таблицы, и `first()` после глагола брал бы случайную из исправленных строк.
|
|
451
|
+
*/
|
|
452
|
+
function inTargetOrder(ids, rows) {
|
|
453
|
+
const pos = new Map(ids.map((id, i) => [id, i]));
|
|
454
|
+
return rows.sort((a, b) => (pos.get(a.id) ?? 0) - (pos.get(b.id) ?? 0));
|
|
455
|
+
}
|
|
443
456
|
/** Цели update/delete/anonymize/reclass: id из пути (limit и sort сужают); data.id — если у шага нет фильтра. */
|
|
444
457
|
async function targetIds(w, steps, opStep, dataId) {
|
|
445
458
|
const path = steps.map(bare);
|
|
446
459
|
if (opStep.filter === undefined && dataId !== undefined)
|
|
447
460
|
path[path.length - 1] = { ...path[path.length - 1], filter: dataId };
|
|
448
|
-
|
|
461
|
+
const ids = await readIds(w, path, opStep.opMods ?? {});
|
|
462
|
+
// без явного порядка — по id: глаголы по списку целей (reclass, rekey и др.) пишут журнал по очереди, и порядок
|
|
463
|
+
// записей не должен зависеть от плана чтения
|
|
464
|
+
return opStep.opMods?.order ? ids : [...ids].sort();
|
|
449
465
|
}
|
|
450
466
|
async function updateOp(w, steps, opStep) {
|
|
451
467
|
const op = opStep.op;
|
|
@@ -456,7 +472,7 @@ async function updateOp(w, steps, opStep) {
|
|
|
456
472
|
const ids = await targetIds(w, steps, opStep, useDataId ? dataId : undefined);
|
|
457
473
|
if (op.rev !== undefined) {
|
|
458
474
|
if (ids.length > 1)
|
|
459
|
-
fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей —
|
|
475
|
+
fail('rev_ambiguous', `update(patch, { rev }): у шага ${opStep.name} ${ids.length} целей — проверить версию { rev } можно только у одной цели`);
|
|
460
476
|
if (!ids.length)
|
|
461
477
|
fail('target_not_found', `update(patch, { rev }): у шага ${opStep.name} нет цели`);
|
|
462
478
|
}
|
|
@@ -481,7 +497,7 @@ async function updateOp(w, steps, opStep) {
|
|
|
481
497
|
const got = new Set(res.map((r) => r.row.id));
|
|
482
498
|
await explainMissing(w, ids.filter((x) => !got.has(x)), op.rev);
|
|
483
499
|
}
|
|
484
|
-
return res.map((r) => toRow(r.row));
|
|
500
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
485
501
|
}
|
|
486
502
|
async function deleteOp(w, steps, opStep) {
|
|
487
503
|
const op = opStep.op;
|
|
@@ -510,9 +526,11 @@ async function deleteOp(w, steps, opStep) {
|
|
|
510
526
|
}
|
|
511
527
|
const tombs = await query(w, `select ${rowJson('l')} as row from ${S}.log l where l.tx = pg_current_xact_id() and l.seq > $1::bigint and l.op = 'delete' order by l.seq`, [mark]);
|
|
512
528
|
const out = tombs.map((r) => toRow(r.row));
|
|
513
|
-
// строки классов без журнала (history: false) надгробий не оставляют — берём их из returning
|
|
529
|
+
// строки классов без журнала (history: false) надгробий не оставляют — берём их из returning, по id (порядок
|
|
530
|
+
// returning зависит от плана, а итог delete() — нет: надгробия идут по seq, триггер пишет их в порядке id)
|
|
514
531
|
const have = new Set(out.map((r) => r.id));
|
|
515
|
-
|
|
532
|
+
const byId = (a, b) => String(a.row.id) < String(b.row.id) ? -1 : String(a.row.id) > String(b.row.id) ? 1 : 0;
|
|
533
|
+
for (const g of [...gone].sort(byId)) {
|
|
516
534
|
if (have.has(g.row.id))
|
|
517
535
|
continue;
|
|
518
536
|
const row = toRow(g.row);
|
|
@@ -534,7 +552,7 @@ async function anonymizeOp(w, steps, opStep) {
|
|
|
534
552
|
const got = new Set(res.map((r) => r.row.id));
|
|
535
553
|
await explainMissing(w, ids.filter((x) => !got.has(x)));
|
|
536
554
|
}
|
|
537
|
-
return res.map((r) => toRow(r.row));
|
|
555
|
+
return inTargetOrder(ids, res.map((r) => toRow(r.row)));
|
|
538
556
|
}
|
|
539
557
|
async function reclassOp(w, steps, opStep) {
|
|
540
558
|
const op = opStep.op;
|
|
@@ -554,7 +572,8 @@ async function deletedTargets(w, steps, opStep, verb) {
|
|
|
554
572
|
const dead = rows.filter((r) => r.$deleted);
|
|
555
573
|
if (!dead.length && rows.length)
|
|
556
574
|
fail('not_deleted', `объект не удалён: ${verb}() — только для удалённых${verb === 'purge' ? ', сначала delete()' : ''}`, { ids: rows.map((r) => r.id) });
|
|
557
|
-
|
|
575
|
+
// без явного порядка — по id: restore и purge пишут журнал по очереди целей
|
|
576
|
+
return opStep.opMods?.order ? dead : [...dead].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
558
577
|
}
|
|
559
578
|
/** Восстановление (этап 6, п. 2): последний снимок новой версией; база перепроверяет его по текущему описанию. */
|
|
560
579
|
async function restoreOp(w, steps, opStep) {
|
package/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Versioned entity store for Node.js on PostgreSQL 18. Every change is a version in a journal (history, reads as of a date, cursor subscriptions). Integrity, tenant isolation and access rules are enforced by the database itself (triggers and row-level security), so they hold for raw SQL too. Data is read and written with chains like `db.Shop(shop).Product().rows()`.
|
|
4
4
|
|
|
5
|
-
Key facts for writing code with letopis 1.
|
|
5
|
+
Key facts for writing code with letopis 1.4:
|
|
6
6
|
|
|
7
7
|
- ESM package, Node 22+, PostgreSQL 18+ with pgcrypto, database encoding UTF8. Runtime dependency: `postgres`. Zod 4 is an optional peer dependency needed only by `letopis/model`; `@modelcontextprotocol/server`, only by `letopis/mcp`.
|
|
8
8
|
- Install a schema with `up({ dsn, schema })` from an admin role; the schema is named `v2.<name>`. The application connects with a LOGIN role that is a member of `letopis_app`: `connect({ dsn, schema: 'v2.<name>', token | apiKey })`. A role that bypasses RLS is refused (`bypass_rls`) unless `allowBypassRls: true`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "letopis",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Letopis (летопись): versioned entity store on PostgreSQL 18 — integrity, history, tenants and access control enforced by the database itself, dot-notation chains on top",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"postgres",
|
package/sql/10-core.sql
CHANGED
|
@@ -107,9 +107,12 @@ create index if not exists log_ends on @SCHEMA@.log using gin (@SCHEMA@.link_ids
|
|
|
107
107
|
-- журнал часто прореживается (§2.11): чаще чистим
|
|
108
108
|
alter table @SCHEMA@.log set (autovacuum_vacuum_scale_factor = 0.02, autovacuum_analyze_scale_factor = 0.02);
|
|
109
109
|
|
|
110
|
+
-- account — строка entity (аккаунт; у служебных строк KEY, LOCK, PROJECTOR — System): удалённый аккаунт уносит свои строки
|
|
111
|
+
-- каскадом внешнего ключа — сессии, хэши паролей и ключей, секреты TOTP, внешние учётные записи (ревизия 7; в схему
|
|
112
|
+
-- прежней ревизии ключ добавляет миграция 0007). Строка Account у System не удаляется (trg_entity_bd_account, 60-write.sql)
|
|
110
113
|
create table if not exists @SCHEMA@.secret (
|
|
111
114
|
id uuid primary key default gen_random_uuid(),
|
|
112
|
-
account uuid not null,
|
|
115
|
+
account uuid not null constraint secret_account_fkey references @SCHEMA@.entity (id) on delete cascade,
|
|
113
116
|
kind text not null, -- PASSWORD, APIKEY, KEYSECRET, OTP, TOTP, IDENTITY, SESSION
|
|
114
117
|
ident text, -- email, телефон, префикс ключа, внешний id
|
|
115
118
|
material bytea, -- хэш пароля, ключа, кода или токена; секрет TOTP
|
|
@@ -121,6 +124,8 @@ create table if not exists @SCHEMA@.secret (
|
|
|
121
124
|
create unique index if not exists secret_kind_ident on @SCHEMA@.secret (kind, casefold(ident) collate pg_unicode_fast)
|
|
122
125
|
where deleted_at is null and ident is not null;
|
|
123
126
|
create index if not exists secret_material on @SCHEMA@.secret (material) where kind = 'SESSION' and deleted_at is null;
|
|
127
|
+
-- строки аккаунта: каскад внешнего ключа при удалении аккаунта и гашение его сессий (auth_kill) — без перебора таблицы
|
|
128
|
+
create index if not exists secret_account on @SCHEMA@.secret (account);
|
|
124
129
|
|
|
125
130
|
alter table @SCHEMA@.entity owner to @OWNER@;
|
|
126
131
|
alter table @SCHEMA@.log owner to @OWNER@;
|
package/sql/50-acl.sql
CHANGED
|
@@ -228,9 +228,13 @@ begin
|
|
|
228
228
|
and jsonb_typeof(x.pattern->'class') <> 'string', false) as bad) m
|
|
229
229
|
where x.deny or not m.bad
|
|
230
230
|
), cl as (
|
|
231
|
-
|
|
231
|
+
-- один класс на имя — тот, что строки арендатора находят по имени (class_row): свой, иначе System. Одноимённый
|
|
232
|
+
-- класс System, заведённый после класса арендатора, в план не входит: его предки и алиас давали бы права на строки
|
|
233
|
+
-- класса арендатора (хвост ревью доработок после 1.2.0)
|
|
234
|
+
select distinct on (c.data->>'name') c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
|
|
232
235
|
from @SCHEMA@.entity c
|
|
233
236
|
where c.class = 'Class' and c.tenant in (p_tenant, '@SYSTEM@'::uuid)
|
|
237
|
+
order by c.data->>'name', c.tenant = '@SYSTEM@'::uuid
|
|
234
238
|
), k as (
|
|
235
239
|
select cl.name,
|
|
236
240
|
array(select jsonb_array_elements_text(cl.anc))
|
package/sql/60-write.sql
CHANGED
|
@@ -760,6 +760,14 @@ begin
|
|
|
760
760
|
perform @SCHEMA@.lt_fail('invalid_class', format('имя %s занято системным классом', p_data->>'name'));
|
|
761
761
|
end if;
|
|
762
762
|
end if;
|
|
763
|
+
-- родитель — под разделяемой блокировкой своего семейства (хвост ревью доработок после 1.2.0): удаление класса берёт
|
|
764
|
+
-- исключительную, поэтому родитель не удалится мимо нового потомка, а удалённый, пока ждали, class_prepare не найдёт
|
|
765
|
+
if p_data->>'parent' is not null then
|
|
766
|
+
tc := @SCHEMA@.class_row(p_data->>'parent', p_tenant);
|
|
767
|
+
if tc.id is not null then
|
|
768
|
+
perform @SCHEMA@.family_lock(tc, false);
|
|
769
|
+
end if;
|
|
770
|
+
end if;
|
|
763
771
|
select c.data, c.validator into data, validator from @SCHEMA@.class_prepare(p_data, p_tenant) c;
|
|
764
772
|
if data->>'kind' = 'link' and (data->>'name') = any (@SCHEMA@.reserved_names()) then
|
|
765
773
|
perform @SCHEMA@.lt_fail('invalid_class', format('имя связи %s совпадает с методом цепочки', data->>'name'));
|
|
@@ -798,12 +806,15 @@ begin
|
|
|
798
806
|
data->>'name'));
|
|
799
807
|
end if;
|
|
800
808
|
-- предок производного класса (ревью безопасности №3): правка предка пересчитывает описание потомка (class_evolve) —
|
|
801
|
-
-- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System —
|
|
802
|
-
-- тоже только администратор
|
|
809
|
+
-- поля, ключ, history, — поэтому класс с производными потомками (своего арендатора, у класса System — всех, кроме
|
|
810
|
+
-- арендаторов со своим одноимённым классом: их потомки наследуют свой класс) меняет тоже только администратор
|
|
803
811
|
if prevd is not null and not @SCHEMA@.letopis_allowed('letopis.projector') then
|
|
804
812
|
select x.data->>'name' into dkid from @SCHEMA@.entity x
|
|
805
813
|
where x.class = 'Class' and x.data->>'name' <> data->>'name'
|
|
806
|
-
and (x.tenant = p_tenant
|
|
814
|
+
and (x.tenant = p_tenant
|
|
815
|
+
or p_tenant = '@SYSTEM@'::uuid
|
|
816
|
+
and x.tenant <> all (array(select y.tenant from @SCHEMA@.entity y
|
|
817
|
+
where y.class = 'Class' and y.data->>'name' = data->>'name' and y.tenant <> '@SYSTEM@'::uuid)))
|
|
807
818
|
and x.data->'resolved'->'ancestors' ? (data->>'name') and x.data->'resolved' ? 'derivedBy'
|
|
808
819
|
order by x.data->>'name' limit 1;
|
|
809
820
|
if dkid is not null then
|
|
@@ -962,9 +973,12 @@ begin
|
|
|
962
973
|
if cls.id is null then
|
|
963
974
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s не найден', NEW.class));
|
|
964
975
|
end if;
|
|
965
|
-
-- разделяемая блокировка корня семейства, затем описание перечитывается под ней
|
|
976
|
+
-- разделяемая блокировка корня семейства, затем описание перечитывается под ней (класс могли удалить, пока ждали)
|
|
966
977
|
perform @SCHEMA@.family_lock(cls, false);
|
|
967
978
|
cls := @SCHEMA@.class_row(NEW.class, NEW.tenant);
|
|
979
|
+
if cls.id is null then
|
|
980
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
|
|
981
|
+
end if;
|
|
968
982
|
res := cls.data->'resolved';
|
|
969
983
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
970
984
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
|
|
@@ -1091,6 +1105,9 @@ begin
|
|
|
1091
1105
|
end if;
|
|
1092
1106
|
perform @SCHEMA@.family_lock(cls, false);
|
|
1093
1107
|
cls := @SCHEMA@.class_row(NEW.class, OLD.tenant);
|
|
1108
|
+
if cls.id is null then
|
|
1109
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', NEW.class));
|
|
1110
|
+
end if;
|
|
1094
1111
|
res := cls.data->'resolved';
|
|
1095
1112
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
1096
1113
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', NEW.class));
|
|
@@ -1200,7 +1217,8 @@ begin
|
|
|
1200
1217
|
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
|
|
1201
1218
|
from added n
|
|
1202
1219
|
join (select k.class, k.tenant from (select distinct a.class, a.tenant from added a) k
|
|
1203
|
-
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
|
|
1220
|
+
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
|
|
1221
|
+
order by n.at, n.id; -- порядок seq — порядок входа, а не плана соединения
|
|
1204
1222
|
-- сигнал — по паре «класс, арендатор»
|
|
1205
1223
|
select array_agg(k.class), array_agg(k.tenant) into sc, st from (select distinct a.class, a.tenant from added a) k;
|
|
1206
1224
|
perform @SCHEMA@.signal(sc, st);
|
|
@@ -1212,6 +1230,10 @@ begin
|
|
|
1212
1230
|
-- прав после acl_bump видит новый класс
|
|
1213
1231
|
perform @SCHEMA@.agg_source_admit(n.data->>'name', @SCHEMA@.agg_new_mark(null, n.data->'resolved'))
|
|
1214
1232
|
from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
|
|
1233
|
+
-- и описавшему агрегат (хвост ревью доработок после 1.2.0): заведя потомка источника, читающий всё пользователь
|
|
1234
|
+
-- иначе открыл бы его строки тому, кто описал агрегат без права на них
|
|
1235
|
+
perform @SCHEMA@.agg_author_admit(n.data->>'name', n.tenant, null, n.data->'resolved')
|
|
1236
|
+
from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregatedBy';
|
|
1215
1237
|
-- новая цель агрегатов (план 1.1, §2.4): метки её источников (aggregatedBy) — заново
|
|
1216
1238
|
perform @SCHEMA@.agg_mark(n.tenant, @SCHEMA@.agg_of(n.data->'resolved'))
|
|
1217
1239
|
from added n where n.class = 'Class' and n.data->'resolved' ? 'aggregate';
|
|
@@ -1252,7 +1274,8 @@ begin
|
|
|
1252
1274
|
from new_rows n
|
|
1253
1275
|
join old_rows o on o.id = n.id and o.rev <> n.rev
|
|
1254
1276
|
join (select k.class, k.tenant from (select distinct a.class, a.tenant from new_rows a) k
|
|
1255
|
-
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
|
|
1277
|
+
where @SCHEMA@.history_on(k.class, k.tenant)) h on h.class = n.class and h.tenant = n.tenant
|
|
1278
|
+
order by n.id; -- порядок seq не зависит от плана: правка многих строк пишет версии в порядке id
|
|
1256
1279
|
-- сигнал — по парам нового и прежнего класса: при смене класса без ключа ответы прежнего класса в
|
|
1257
1280
|
-- кэше других процессов устарели бы (в 1.0.1 сигнал шёл только по новому)
|
|
1258
1281
|
select array_agg(k.class), array_agg(k.tenant) into sc, st
|
|
@@ -1269,6 +1292,7 @@ begin
|
|
|
1269
1292
|
-- класс становится источником агрегата (смена parent; ревью коммита этапа 4б): право READ без условий на его строки
|
|
1270
1293
|
-- и после правки — по новым предкам (до правки проверил class_admit)
|
|
1271
1294
|
perform @SCHEMA@.agg_source_admit(n_row.data->>'name', @SCHEMA@.agg_new_mark(o_row.data->'resolved', n_row.data->'resolved'));
|
|
1295
|
+
perform @SCHEMA@.agg_author_admit(n_row.data->>'name', n_row.tenant, o_row.data->'resolved', n_row.data->'resolved');
|
|
1272
1296
|
perform @SCHEMA@.class_evolve(o_row, n_row);
|
|
1273
1297
|
end loop;
|
|
1274
1298
|
-- отключённый аккаунт: сессии гаснут (этап 5, п. 2), каждая — сигнал кэшу
|
|
@@ -1316,6 +1340,8 @@ begin
|
|
|
1316
1340
|
if d.tenant = n.tenant then
|
|
1317
1341
|
perform @SCHEMA@.agg_source_admit(d.data->>'name', @SCHEMA@.agg_new_mark(d.data->'resolved', prep.data->'resolved'));
|
|
1318
1342
|
end if;
|
|
1343
|
+
-- и описавшему агрегат — в объёме описания (agg_author_admit сам пропускает класс арендатора под агрегатом System)
|
|
1344
|
+
perform @SCHEMA@.agg_author_admit(d.data->>'name', d.tenant, d.data->'resolved', prep.data->'resolved');
|
|
1319
1345
|
classes := classes || (d.data->>'name');
|
|
1320
1346
|
if d.data->'resolved'->'aggregate' is distinct from prep.data->'resolved'->'aggregate' then
|
|
1321
1347
|
agg_names := agg_names || @SCHEMA@.agg_of(d.data->'resolved') || @SCHEMA@.agg_of(prep.data->'resolved');
|
|
@@ -1361,6 +1387,24 @@ begin
|
|
|
1361
1387
|
return null;
|
|
1362
1388
|
end $$;
|
|
1363
1389
|
|
|
1390
|
+
-- Перед удалением строки Account (ревизия 7, решение владельца). Строки secret аккаунта — сессии, хэши паролей и ключей,
|
|
1391
|
+
-- секреты TOTP, внешние учётные записи — уносит каскад внешнего ключа secret.account в конце оператора, раньше триггера
|
|
1392
|
+
-- после него, поэтому сессии гаснут с сигналом session:<хэш> здесь: процессы сбрасывают кэш ответов этих сессий.
|
|
1393
|
+
-- Срабатывает для всех ролей, в том числе владельца: аккаунты удаляют и функции владельца — каскад trg_entity_ad и
|
|
1394
|
+
-- account_purge. Строка Account у System не удаляется: ей принадлежат служебные строки secret (KEY — ключ прав и
|
|
1395
|
+
-- сигналов, LOCK — строки-замки, PROJECTOR — чекпоинты проекторов), которые каскад стёр бы, а без них установка не пишет.
|
|
1396
|
+
create or replace function @SCHEMA@.trg_entity_bd_account() returns trigger
|
|
1397
|
+
language plpgsql security definer
|
|
1398
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
1399
|
+
as $$
|
|
1400
|
+
begin
|
|
1401
|
+
if OLD.id = '@SYSTEM@'::uuid then
|
|
1402
|
+
perform @SCHEMA@.lt_fail('acl_denied', 'аккаунт System не удаляется: ему принадлежат служебные строки установки');
|
|
1403
|
+
end if;
|
|
1404
|
+
perform @SCHEMA@.auth_kill(OLD.id, null);
|
|
1405
|
+
return OLD;
|
|
1406
|
+
end $$;
|
|
1407
|
+
|
|
1364
1408
|
-- Действие со строкой, которая ссылается на удаляемые id (p_ids), по правилам её концов (p_ends):
|
|
1365
1409
|
-- cascade сильнее restrict, restrict сильнее unset; null — строка на них не ссылается. Общее правило
|
|
1366
1410
|
-- для триггера удаления и превью delete_preview() (этап 4, п. 6): превью не расходится с удалением.
|
|
@@ -1432,12 +1476,11 @@ declare
|
|
|
1432
1476
|
dplan jsonb;
|
|
1433
1477
|
wplan jsonb;
|
|
1434
1478
|
denied text;
|
|
1435
|
-
accs uuid[];
|
|
1436
1479
|
mems jsonb;
|
|
1437
|
-
lvl_accs uuid[];
|
|
1438
1480
|
lvl_mems jsonb;
|
|
1439
1481
|
drows jsonb;
|
|
1440
1482
|
own uuid[];
|
|
1483
|
+
cl @SCHEMA@.entity;
|
|
1441
1484
|
begin
|
|
1442
1485
|
select * into c from @SCHEMA@.ctx();
|
|
1443
1486
|
-- права в замыкании (этап 5, п. 6): DELETE на каждую удаляемую каскадом строку, WRITE — на строку,
|
|
@@ -1453,6 +1496,13 @@ begin
|
|
|
1453
1496
|
if denied is not null then
|
|
1454
1497
|
perform @SCHEMA@.lt_fail('derived_class', format('класс %s производный: его строки удаляет проектор', denied));
|
|
1455
1498
|
end if;
|
|
1499
|
+
-- удаление класса (хвост ревью безопасности доработок после 1.2.0): проверки ниже — нет строк, потомков и истории —
|
|
1500
|
+
-- не видят строку, которую писатель ещё не зафиксировал, и она осталась бы без класса, а с ней история удалённого
|
|
1501
|
+
-- класса, от которой зависит смена алиаса предка. Писатели строк и создание потомка держат разделяемую блокировку
|
|
1502
|
+
-- семейства: исключительная ждёт их, и следующие операторы проверок видят зафиксированное
|
|
1503
|
+
for cl in select g.* from gone g where g.class = 'Class' order by @SCHEMA@.family_name(g.tenant, g.data), g.id loop
|
|
1504
|
+
perform @SCHEMA@.family_lock(cl, true);
|
|
1505
|
+
end loop;
|
|
1456
1506
|
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
1507
|
-- производный класс удаляет только администратор (class_admit)
|
|
1458
1508
|
if r.derived and not @SCHEMA@.letopis_allowed('letopis.projector') then
|
|
@@ -1494,19 +1544,21 @@ begin
|
|
|
1494
1544
|
perform @SCHEMA@.agg_mark(g.tenant, @SCHEMA@.agg_of(g.data->'resolved'))
|
|
1495
1545
|
from gone g where g.class = 'Class' and g.data->'resolved' ? 'aggregate';
|
|
1496
1546
|
v_author := coalesce(c.author, '@SYSTEM@'::uuid);
|
|
1497
|
-
-- надгробия: последний снимок следующей версией с видом delete — одной вставкой на
|
|
1547
|
+
-- надгробия: последний снимок следующей версией с видом delete — одной вставкой на уровень, в порядке id: без
|
|
1548
|
+
-- order by порядок seq (а с ним итог delete() и время at) зависел от плана соединения и менялся от прогона к
|
|
1549
|
+
-- прогону; время now_ts() (volatile) планировщик вычисляет уже после сортировки
|
|
1498
1550
|
insert into @SCHEMA@.log (id, rev, class, cv, tenant, owner, links, data, tags, at, author, agent, op, reason, moved, prev_hash, changed)
|
|
1499
1551
|
select g.id, g.rev + 1, g.class, g.cv, g.tenant, g.owner, g.links, g.data, g.tags,
|
|
1500
1552
|
greatest(@SCHEMA@.now_ts(), g.at + interval '1 microsecond'), v_author, c.agent, 'delete', v_reason, null, g.hash, '[]'::jsonb
|
|
1501
1553
|
from gone g
|
|
1502
1554
|
join (select d.class, d.tenant from (select distinct x.class, x.tenant from gone x) d
|
|
1503
|
-
where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
|
|
1555
|
+
where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
|
|
1556
|
+
order by g.id;
|
|
1504
1557
|
-- пары «класс, арендатор» для сигнала — строкой «класс арендатор» (пробела нет ни в имени, ни в uuid)
|
|
1505
1558
|
select coalesce(array_agg(g.id), '{}'), coalesce(array_agg(distinct g.class), '{}'),
|
|
1506
1559
|
coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}'),
|
|
1507
|
-
coalesce(array_agg(g.id) filter (where g.class = 'Account'), '{}'),
|
|
1508
1560
|
coalesce(jsonb_agg(jsonb_build_object('tenant', g.tenant, 'user', g.links->>'User')) filter (where g.class = 'member'), '[]'::jsonb)
|
|
1509
|
-
into lvl, classes, pairs,
|
|
1561
|
+
into lvl, classes, pairs, mems from gone g;
|
|
1510
1562
|
all_ids := lvl;
|
|
1511
1563
|
while cardinality(lvl) > 0 loop
|
|
1512
1564
|
-- кто ссылается на уровень: действие по правилам концов (общее с превью)
|
|
@@ -1534,20 +1586,19 @@ begin
|
|
|
1534
1586
|
from d
|
|
1535
1587
|
join (select dd.class, dd.tenant from (select distinct x.class, x.tenant from d x) dd
|
|
1536
1588
|
where @SCHEMA@.history_on(dd.class, dd.tenant)) h on h.class = d.class and h.tenant = d.tenant
|
|
1589
|
+
order by d.id -- надгробия уровня в порядке id, как и на уровне 0
|
|
1537
1590
|
)
|
|
1538
1591
|
select coalesce(array_agg(d.id), '{}'), coalesce(array_agg(distinct d.class), '{}'),
|
|
1539
1592
|
coalesce(array_agg(distinct d.class || ' ' || d.tenant::text), '{}'),
|
|
1540
1593
|
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)),
|
|
1541
|
-
coalesce(array_agg(d.id) filter (where d.class = 'Account'), '{}'),
|
|
1542
1594
|
coalesce(jsonb_agg(jsonb_build_object('tenant', d.tenant, 'user', d.links->>'User')) filter (where d.class = 'member'), '[]'::jsonb)
|
|
1543
|
-
into lvl, lvl_classes, lvl_pairs, drows,
|
|
1595
|
+
into lvl, lvl_classes, lvl_pairs, drows, lvl_mems from d;
|
|
1544
1596
|
-- класс отказавшей строки называется, только если актор её видит (ревью безопасности №3)
|
|
1545
1597
|
if drows is not null then
|
|
1546
1598
|
denied := @SCHEMA@.visible_class(drows);
|
|
1547
1599
|
perform @SCHEMA@.lt_fail('acl_denied', case when denied is not null then format('нет права DELETE на класс %s в каскаде удаления', denied)
|
|
1548
1600
|
else 'нет права DELETE на строки в каскаде удаления' end);
|
|
1549
1601
|
end if;
|
|
1550
|
-
accs := accs || lvl_accs;
|
|
1551
1602
|
mems := mems || lvl_mems;
|
|
1552
1603
|
classes := classes || lvl_classes;
|
|
1553
1604
|
pairs := pairs || lvl_pairs;
|
|
@@ -1573,14 +1624,11 @@ begin
|
|
|
1573
1624
|
end loop;
|
|
1574
1625
|
-- выход из членства (и удаление аккаунта, которое снимает его членства): строки арендатора, которыми
|
|
1575
1626
|
-- владел участник, переходят арендатору (§2.10) — новой версией
|
|
1576
|
-
for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m loop
|
|
1627
|
+
for r in select distinct (m->>'tenant')::uuid as tenant, (m->>'user')::uuid as usr from jsonb_array_elements(mems) m order by 1, 2 loop
|
|
1577
1628
|
perform @SCHEMA@.owner_to_tenant(r.tenant, r.usr);
|
|
1578
1629
|
end loop;
|
|
1579
|
-
-- удалённый
|
|
1580
|
-
|
|
1581
|
-
perform @SCHEMA@.auth_kill(r.acc, null);
|
|
1582
|
-
update @SCHEMA@.secret x set deleted_at = clock_timestamp() where x.account = r.acc and x.deleted_at is null;
|
|
1583
|
-
end loop;
|
|
1630
|
+
-- удалённый аккаунт (ревизия 7): сессии погасил с сигналом триггер до удаления (trg_entity_bd_account), его строки
|
|
1631
|
+
-- secret стёр каскад внешнего ключа secret.account — идентификаторы входа свободны
|
|
1584
1632
|
perform @SCHEMA@.signal(array_agg(split_part(x.p, ' ', 1)), array_agg(split_part(x.p, ' ', 2)::uuid))
|
|
1585
1633
|
from (select distinct p from unnest(pairs) p) x;
|
|
1586
1634
|
if classes && array['rule', 'Resource', 'member', 'Account', 'Class'] then
|
|
@@ -1668,6 +1716,9 @@ create or replace trigger entity_au after update on @SCHEMA@.entity referencing
|
|
|
1668
1716
|
for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_au();
|
|
1669
1717
|
create or replace trigger entity_bd before delete on @SCHEMA@.entity
|
|
1670
1718
|
for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_bd();
|
|
1719
|
+
-- удаление аккаунта — для всех ролей, в том числе владельца (каскад trg_entity_ad, account_purge): ревизия 7
|
|
1720
|
+
create or replace trigger entity_bd_account before delete on @SCHEMA@.entity
|
|
1721
|
+
for each row when (OLD.class = 'Account') execute function @SCHEMA@.trg_entity_bd_account();
|
|
1671
1722
|
create or replace trigger entity_ad after delete on @SCHEMA@.entity referencing old table as gone
|
|
1672
1723
|
for each statement when (current_user <> '@OWNER_NAME@') execute function @SCHEMA@.trg_entity_ad();
|
|
1673
1724
|
-- в режиме репликации (session_replication_role = replica) триггеры тоже работают
|
|
@@ -1676,6 +1727,7 @@ alter table @SCHEMA@.entity enable always trigger entity_bu;
|
|
|
1676
1727
|
alter table @SCHEMA@.entity enable always trigger entity_ai;
|
|
1677
1728
|
alter table @SCHEMA@.entity enable always trigger entity_au;
|
|
1678
1729
|
alter table @SCHEMA@.entity enable always trigger entity_bd;
|
|
1730
|
+
alter table @SCHEMA@.entity enable always trigger entity_bd_account;
|
|
1679
1731
|
alter table @SCHEMA@.entity enable always trigger entity_ad;
|
|
1680
1732
|
|
|
1681
1733
|
-- ========================= смена класса с ключом =========================
|
|
@@ -1808,6 +1860,9 @@ begin
|
|
|
1808
1860
|
end if;
|
|
1809
1861
|
perform @SCHEMA@.family_lock(cls, false);
|
|
1810
1862
|
cls := @SCHEMA@.class_row(p_class, o.tenant);
|
|
1863
|
+
if cls.id is null then
|
|
1864
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', p_class));
|
|
1865
|
+
end if;
|
|
1811
1866
|
res := cls.data->'resolved';
|
|
1812
1867
|
if coalesce((res->>'abstract')::boolean, false) then
|
|
1813
1868
|
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s абстрактный', p_class));
|
|
@@ -1995,7 +2050,7 @@ begin
|
|
|
1995
2050
|
'incoming_conflicts(uuid, text, uuid)', 'visible_detail(jsonb, uuid)', 'visible_class(jsonb)', 'class_change_safe(jsonb, jsonb)',
|
|
1996
2051
|
'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)',
|
|
1997
2052
|
'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)',
|
|
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
|
|
2053
|
+
'trg_entity_bd()', 'trg_entity_bd_account()', '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
|
|
1999
2054
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
2000
2055
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
2001
2056
|
end loop;
|
package/sql/80-auth.sql
CHANGED
|
@@ -795,6 +795,43 @@ begin
|
|
|
795
795
|
where m.class = 'member' and m.links->>'User' = c.account::text and @SCHEMA@.auth_enabled(m.tenant);
|
|
796
796
|
end $$;
|
|
797
797
|
|
|
798
|
+
-- Сведения о сессии по токену (db.auth.session): сервис, у которого есть токен пользователя (например, из
|
|
799
|
+
-- cookie), узнаёт, чья это сессия, жива ли она и в каком арендаторе работает, не подключаясь по этому токену.
|
|
800
|
+
-- Право auth.session, как у issue_session. Неверный, погашенный или истёкший токен и отключённый аккаунт — null:
|
|
801
|
+
-- причина не различается, как в ctx(). Сессия ищется только среди SESSION: ключ API или код с тем же sha256 не
|
|
802
|
+
-- подойдут. member — проверка членства из ctx() без исключения: членство в рабочем арендаторе цело и арендатор
|
|
803
|
+
-- включён; собственный арендатор — всегда true.
|
|
804
|
+
create or replace function @SCHEMA@.auth_session_info(p_token text) returns jsonb
|
|
805
|
+
language plpgsql stable security definer
|
|
806
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
807
|
+
as $$
|
|
808
|
+
declare
|
|
809
|
+
c record;
|
|
810
|
+
s record;
|
|
811
|
+
t uuid;
|
|
812
|
+
begin
|
|
813
|
+
select * into c from @SCHEMA@.ctx();
|
|
814
|
+
perform @SCHEMA@.auth_session_required(c.account);
|
|
815
|
+
perform @SCHEMA@.acl_require('auth.session');
|
|
816
|
+
select x.account, x.meta, x.expires_at into s from @SCHEMA@.secret x
|
|
817
|
+
where x.kind = 'SESSION' and x.deleted_at is null and x.material = sha256(convert_to(coalesce(p_token, ''), 'UTF8'))
|
|
818
|
+
and (x.expires_at is null or x.expires_at > clock_timestamp());
|
|
819
|
+
if s.account is null or not @SCHEMA@.auth_enabled(s.account) then
|
|
820
|
+
return null;
|
|
821
|
+
end if;
|
|
822
|
+
t := coalesce((s.meta->>'tenant')::uuid, s.account);
|
|
823
|
+
return jsonb_build_object(
|
|
824
|
+
'account', s.account,
|
|
825
|
+
'tenant', t,
|
|
826
|
+
'member', t = s.account or (
|
|
827
|
+
exists (select 1 from @SCHEMA@.entity x
|
|
828
|
+
where x.id = @SCHEMA@.v5_id(t, 'member', array[s.account::text, t::text]) and x.class = 'member' and x.tenant = t)
|
|
829
|
+
and @SCHEMA@.auth_enabled(t)),
|
|
830
|
+
'expiresAt', s.expires_at,
|
|
831
|
+
'loginAt', s.meta->'login_at',
|
|
832
|
+
'methods', coalesce(s.meta->'methods', '[]'::jsonb));
|
|
833
|
+
end $$;
|
|
834
|
+
|
|
798
835
|
do $$
|
|
799
836
|
declare f text;
|
|
800
837
|
begin
|
|
@@ -807,7 +844,7 @@ begin
|
|
|
807
844
|
'login_otp(text, text, text)', 'totp_code(bytea, bigint)', 'auth_enroll_totp(uuid)', 'auth_verify_totp(uuid, text, int)',
|
|
808
845
|
'auth_totp_enabled(uuid)', 'auth_link(uuid, text, text, jsonb, boolean)', 'auth_lookup(text, text, boolean)',
|
|
809
846
|
'auth_credentials(uuid)', 'auth_revoke_credential(uuid, boolean)', 'auth_set_flags(uuid, boolean, boolean)',
|
|
810
|
-
'auth_refresh(interval)', 'auth_logout()', 'auth_logout_all(uuid)', 'auth_switch(uuid)', 'auth_tenants()'] loop
|
|
847
|
+
'auth_refresh(interval)', 'auth_logout()', 'auth_logout_all(uuid)', 'auth_switch(uuid)', 'auth_tenants()', 'auth_session_info(text)'] loop
|
|
811
848
|
execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
|
|
812
849
|
execute format('revoke all on function @SCHEMA@.%s from public', f);
|
|
813
850
|
end loop;
|
|
@@ -824,4 +861,4 @@ grant execute on function
|
|
|
824
861
|
@SCHEMA@.auth_totp_enabled(uuid), @SCHEMA@.auth_link(uuid, text, text, jsonb, boolean), @SCHEMA@.auth_lookup(text, text, boolean),
|
|
825
862
|
@SCHEMA@.auth_credentials(uuid), @SCHEMA@.auth_revoke_credential(uuid, boolean), @SCHEMA@.auth_set_flags(uuid, boolean, boolean),
|
|
826
863
|
@SCHEMA@.auth_refresh(interval), @SCHEMA@.auth_logout(), @SCHEMA@.auth_logout_all(uuid), @SCHEMA@.auth_switch(uuid),
|
|
827
|
-
@SCHEMA@.auth_tenants() to @APP@;
|
|
864
|
+
@SCHEMA@.auth_tenants(), @SCHEMA@.auth_session_info(text) to @APP@;
|
package/sql/88-derived.sql
CHANGED
|
@@ -143,6 +143,9 @@ begin
|
|
|
143
143
|
-- как у писателей приложения: разделяемая блокировка семейства, описание — свежее под ней
|
|
144
144
|
perform @SCHEMA@.family_lock(cls, false);
|
|
145
145
|
cls := @SCHEMA@.class_row(cname, tid);
|
|
146
|
+
if cls.id is null then
|
|
147
|
+
perform @SCHEMA@.lt_fail('invalid_class', format('класс %s удалён', cname) || w);
|
|
148
|
+
end if;
|
|
146
149
|
res := cls.data->'resolved';
|
|
147
150
|
if res->>'derivedBy' is distinct from p_name then
|
|
148
151
|
perform @SCHEMA@.lt_fail('derived_class', format('класс %s не производный проектора %s', cname, p_name) || w);
|
|
@@ -269,6 +272,7 @@ begin
|
|
|
269
272
|
from g
|
|
270
273
|
join (select d.class, d.tenant from (select distinct y.class, y.tenant from g y) d
|
|
271
274
|
where @SCHEMA@.history_on(d.class, d.tenant)) h on h.class = g.class and h.tenant = g.tenant
|
|
275
|
+
order by g.id -- надгробия в порядке id, как в trg_entity_ad
|
|
272
276
|
)
|
|
273
277
|
select count(*)::int, coalesce(array_agg(distinct g.class || ' ' || g.tenant::text), '{}') into n, pairs from g;
|
|
274
278
|
if n > 0 then
|
|
@@ -581,6 +585,82 @@ begin
|
|
|
581
585
|
end if;
|
|
582
586
|
end $$;
|
|
583
587
|
|
|
588
|
+
-- Кто описал агрегат p_field цели p_target (строка Class): автор версии строки Class, с которой развёрнутое описание
|
|
589
|
+
-- агрегата (своё или унаследованное) стало нынешним; надгробие обрывает описание — пересоздавший класс описал агрегат
|
|
590
|
+
-- заново. Журнал строк Class не прореживается и не стирается (purge и обрезка их не берут). Сид и пути владельца без
|
|
591
|
+
-- сессии пишут автора System.
|
|
592
|
+
create or replace function @SCHEMA@.agg_author(p_target @SCHEMA@.entity, p_field text) returns uuid
|
|
593
|
+
language sql stable
|
|
594
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
595
|
+
return (select l.author from @SCHEMA@.log l
|
|
596
|
+
where l.id = p_target.id and l.op not in ('purge', 'trim', 'reset', 'delete')
|
|
597
|
+
and l.data->'resolved'->'aggregate'->p_field is not distinct from p_target.data->'resolved'->'aggregate'->p_field
|
|
598
|
+
and (select case when p.op = 'delete' then null else p.data->'resolved'->'aggregate'->p_field end
|
|
599
|
+
from @SCHEMA@.log p
|
|
600
|
+
where p.id = l.id and p.rev < l.rev and p.op not in ('purge', 'trim', 'reset')
|
|
601
|
+
order by p.rev desc limit 1) is distinct from p_target.data->'resolved'->'aggregate'->p_field
|
|
602
|
+
order by l.rev desc limit 1);
|
|
603
|
+
|
|
604
|
+
-- Аккаунт p_account читает без условий все строки класса p_class арендатора p_tenant: план READ — как у acl_subject
|
|
605
|
+
-- (аккаунт включён и лежит в System, арендатор — сам аккаунт или по членству), но без отказа: нет аккаунта или
|
|
606
|
+
-- членства — права нет.
|
|
607
|
+
create or replace function @SCHEMA@.agg_author_reads(p_account uuid, p_tenant uuid, p_class text) returns boolean
|
|
608
|
+
language plpgsql stable
|
|
609
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
610
|
+
as $$
|
|
611
|
+
declare
|
|
612
|
+
a jsonb;
|
|
613
|
+
m jsonb;
|
|
614
|
+
begin
|
|
615
|
+
select e.data into a from @SCHEMA@.entity e
|
|
616
|
+
where e.id = p_account and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
|
|
617
|
+
if a is null then
|
|
618
|
+
return false;
|
|
619
|
+
end if;
|
|
620
|
+
if p_tenant <> p_account then
|
|
621
|
+
select x.data into m from @SCHEMA@.entity x
|
|
622
|
+
where x.id = @SCHEMA@.v5_id(p_tenant, 'member', array[p_account::text, p_tenant::text]) and x.class = 'member' and x.tenant = p_tenant;
|
|
623
|
+
if m is null then
|
|
624
|
+
return false;
|
|
625
|
+
end if;
|
|
626
|
+
end if;
|
|
627
|
+
return coalesce(@SCHEMA@.acl_plan_for(p_account, p_tenant,
|
|
628
|
+
array(select jsonb_array_elements_text(coalesce(a->'categories', '[]'::jsonb))),
|
|
629
|
+
case when p_tenant = p_account then array['owner']
|
|
630
|
+
else array(select jsonb_array_elements_text(coalesce(m->'roles', '[]'::jsonb))) end,
|
|
631
|
+
'READ') -> p_class = '{}'::jsonb, false);
|
|
632
|
+
end $$;
|
|
633
|
+
|
|
634
|
+
-- Класс p_class арендатора p_tenant становится источником агрегатов — новые метки aggregatedBy описания p_new против
|
|
635
|
+
-- p_old (хвост ревью безопасности доработок после 1.2.0, решение владельца): его строки войдут в агрегат, и описавший
|
|
636
|
+
-- агрегат читал бы в истории цели вклад каждой из них, — поэтому право READ без условий на все строки класса нужно и
|
|
637
|
+
-- описавшему, иначе acl_denied. Объём — тот же, что при описании (class_admit, agg_classes): у агрегата класса
|
|
638
|
+
-- арендатора — классы арендатора, у агрегата класса System — классы System. Класс арендатора под агрегатом System
|
|
639
|
+
-- описавший не проверял и при описании: его, как и прежде, проверяет только тот, кто заводит (agg_source_admit).
|
|
640
|
+
-- Описавший System (сид, путь владельца без сессии) не проверяется.
|
|
641
|
+
create or replace function @SCHEMA@.agg_author_admit(p_class text, p_tenant uuid, p_old jsonb, p_new jsonb) returns void
|
|
642
|
+
language plpgsql stable
|
|
643
|
+
set search_path = pg_catalog, @SCHEMA@, pg_temp
|
|
644
|
+
as $$
|
|
645
|
+
declare
|
|
646
|
+
mk jsonb;
|
|
647
|
+
tc @SCHEMA@.entity;
|
|
648
|
+
who uuid;
|
|
649
|
+
begin
|
|
650
|
+
for mk in select x from jsonb_array_elements(coalesce(p_new->'aggregatedBy', '[]'::jsonb)) x
|
|
651
|
+
where not coalesce(p_old->'aggregatedBy', '[]'::jsonb) @> jsonb_build_array(x)
|
|
652
|
+
order by x->>'class', x->>'field' loop
|
|
653
|
+
tc := @SCHEMA@.class_row(mk->>'class', p_tenant);
|
|
654
|
+
continue when tc.id is null or (tc.tenant = '@SYSTEM@'::uuid and p_tenant <> '@SYSTEM@'::uuid);
|
|
655
|
+
who := @SCHEMA@.agg_author(tc, mk->>'field');
|
|
656
|
+
continue when who is null or who = '@SYSTEM@'::uuid;
|
|
657
|
+
if not @SCHEMA@.agg_author_reads(who, tc.tenant, p_class) then
|
|
658
|
+
perform @SCHEMA@.lt_fail('acl_denied', format('класс %s становится источником агрегата %s.%s, а описавший агрегат не читает все его строки (право READ без условий): агрегат описывает заново тот, кто их читает',
|
|
659
|
+
p_class, mk->>'class', mk->>'field'));
|
|
660
|
+
end if;
|
|
661
|
+
end loop;
|
|
662
|
+
end $$;
|
|
663
|
+
|
|
584
664
|
-- Метки источников заново: классы арендатора p_tenant (System — всех), в предках которых есть одно из имён p_names;
|
|
585
665
|
-- изменившееся описание — новой версией строки Class (журнал, сигнал).
|
|
586
666
|
create or replace function @SCHEMA@.agg_mark(p_tenant uuid, p_names text[]) returns void
|
|
@@ -1142,7 +1222,8 @@ begin
|
|
|
1142
1222
|
foreach f in array array['agg_fields(jsonb)', 'agg_of(jsonb)', 'agg_zero(jsonb, jsonb)', 'agg_num(jsonb)', 'agg_where_ok(jsonb, jsonb)',
|
|
1143
1223
|
'agg_guard(jsonb, jsonb, jsonb, boolean)', 'agg_keep(text, uuid, jsonb, jsonb)', 'agg_check_source(jsonb, jsonb, text, jsonb)',
|
|
1144
1224
|
'agg_cycle(uuid, jsonb)', 'agg_prepare(jsonb, jsonb, uuid)', 'agg_source(jsonb, uuid)', 'agg_marked(jsonb, jsonb)',
|
|
1145
|
-
'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', '
|
|
1225
|
+
'agg_new_mark(jsonb, jsonb)', 'agg_source_admit(text, jsonb)', 'agg_author(@SCHEMA@.entity, text)',
|
|
1226
|
+
'agg_author_reads(uuid, uuid, text)', 'agg_author_admit(text, uuid, jsonb, jsonb)', 'agg_mark(uuid, text[])', 'agg_lock_sources(jsonb, jsonb, uuid)',
|
|
1146
1227
|
'agg_classes(text, uuid, boolean)', 'agg_compute(jsonb, uuid, uuid)',
|
|
1147
1228
|
'agg_check(text, uuid, jsonb)', 'agg_write(@SCHEMA@.entity, jsonb, boolean)', 'agg_token(text)', 'agg_held()',
|
|
1148
1229
|
'agg_hold_begin(text)', 'agg_hold_end(boolean)', 'agg_merge(jsonb, jsonb)', 'agg_flush(jsonb)', 'agg_kind(text, uuid)',
|