letopis 0.21.0 → 1.0.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.
Files changed (89) hide show
  1. package/AGENT-CHEATSHEET.en.md +368 -0
  2. package/AGENT-CHEATSHEET.md +354 -0
  3. package/CHANGELOG.md +313 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3469
  7. package/dist/acl.d.ts +26 -50
  8. package/dist/acl.js +22 -267
  9. package/dist/admin.d.ts +138 -0
  10. package/dist/admin.js +170 -0
  11. package/dist/auth.d.ts +120 -73
  12. package/dist/auth.js +121 -306
  13. package/dist/cache.d.ts +73 -0
  14. package/dist/cache.js +148 -0
  15. package/dist/chain.d.ts +124 -191
  16. package/dist/chain.js +362 -563
  17. package/dist/cli.d.ts +2 -0
  18. package/dist/cli.js +164 -0
  19. package/dist/demo/booking.d.ts +289 -0
  20. package/dist/demo/booking.js +159 -0
  21. package/dist/errors.d.ts +29 -0
  22. package/dist/errors.js +70 -0
  23. package/dist/import.d.ts +179 -0
  24. package/dist/import.js +792 -0
  25. package/dist/index.d.ts +172 -26
  26. package/dist/index.js +304 -178
  27. package/dist/jsonschema.d.ts +22 -0
  28. package/dist/jsonschema.js +167 -0
  29. package/dist/load.d.ts +76 -0
  30. package/dist/load.js +884 -0
  31. package/dist/model.d.ts +166 -0
  32. package/dist/model.js +224 -0
  33. package/dist/ops.d.ts +7 -6
  34. package/dist/ops.js +7 -51
  35. package/dist/pglite.d.ts +22 -0
  36. package/dist/pglite.js +45 -0
  37. package/dist/registry.d.ts +57 -0
  38. package/dist/registry.js +82 -0
  39. package/dist/sql.d.ts +59 -142
  40. package/dist/sql.js +568 -654
  41. package/dist/sync.d.ts +31 -0
  42. package/dist/sync.js +108 -0
  43. package/dist/tx.d.ts +129 -8
  44. package/dist/tx.js +300 -73
  45. package/dist/typed.d.ts +97 -0
  46. package/dist/typed.js +1 -0
  47. package/dist/types.d.ts +71 -252
  48. package/dist/types.js +27 -108
  49. package/dist/up.d.ts +140 -47
  50. package/dist/up.js +339 -267
  51. package/dist/uuid.d.ts +21 -6
  52. package/dist/uuid.js +48 -64
  53. package/dist/validate.d.ts +24 -0
  54. package/dist/validate.js +251 -0
  55. package/dist/watch.d.ts +62 -0
  56. package/dist/watch.js +168 -0
  57. package/dist/write.d.ts +117 -74
  58. package/dist/write.js +658 -720
  59. package/llms.txt +26 -0
  60. package/package.json +49 -19
  61. package/sql/10-core.sql +136 -0
  62. package/sql/15-errors.sql +60 -0
  63. package/sql/20-context.sql +153 -0
  64. package/sql/30-validate.sql +423 -0
  65. package/sql/40-class.sql +259 -0
  66. package/sql/50-acl.sql +539 -0
  67. package/sql/60-write.sql +1369 -0
  68. package/sql/70-read.sql +245 -0
  69. package/sql/80-auth.sql +827 -0
  70. package/sql/90-time.sql +957 -0
  71. package/sql/95-seed.system.sql +178 -0
  72. package/sql/99-revision.sql +3 -0
  73. package/sql/README.md +56 -0
  74. package/sql/seed.booking.sql +39 -112
  75. package/dist/schema.d.ts +0 -15
  76. package/dist/schema.js +0 -352
  77. package/dist/sessions.d.ts +0 -32
  78. package/dist/sessions.js +0 -114
  79. package/dist/tables.d.ts +0 -105
  80. package/dist/tables.js +0 -248
  81. package/docker/Dockerfile +0 -40
  82. package/docker/start.sh +0 -18
  83. package/scripts/check-docs.mjs +0 -375
  84. package/scripts/gen-api-contract.mjs +0 -226
  85. package/scripts/gen-types.mjs +0 -350
  86. package/scripts/release-notes.mjs +0 -76
  87. package/scripts/schema-sync.mjs +0 -185
  88. package/sql/ddl.sql +0 -685
  89. package/sql/seed.auth.sql +0 -73
package/sql/50-acl.sql ADDED
@@ -0,0 +1,539 @@
1
+ -- letopis 1.0 — права: ACL и политики RLS (план, §2.2, §2.8, этап 5, п. 5).
2
+ --
3
+ -- Модель прав 0.21 (lib/src/acl.ts тега v0.21.0) на строках системных классов:
4
+ -- Resource (хаб, ключ [alias]) — data { alias, category, pattern }; category:
5
+ -- ACCOUNT — группа: pattern { categories: "{A,B}" | "!{A,B}", roles: "{a,b}" | "!{a,b}" }, нет ключа — все;
6
+ -- API — эндпоинт: pattern { endpoint: <маска> }; нет pattern — все эндпоинты;
7
+ -- READ | WRITE | DELETE — строки: pattern { class: <маска с предками>, …условие на строку };
8
+ -- rule (связь Group → Resource, Object → Resource, ключ [Group, Object]) — data { permission, weight,
9
+ -- enabled, code, message }.
10
+ -- Маска: сегменты через точку, {a,b} — альтернативы (вложенные можно), * — хвост из 1+ сегментов.
11
+ -- Маски categories, roles, endpoint и class — только строки (описание класса Resource, 95-seed);
12
+ -- строка, записанная в обход проверки с маской не строкой, разрешений не даёт (в 0.21 совпадала со всеми).
13
+ -- Решение — ровно одно правило: наибольший вес; при равном весе запрет сильнее разрешения; из равных —
14
+ -- объект с меньшим alias; нет правил — запрет.
15
+ --
16
+ -- Где действуют правила (решение этапа 5): правила арендатора — на строки этого арендатора, правила
17
+ -- System — во всех арендаторах. Группа по категориям совпадает по категориям аккаунта (их пишет
18
+ -- только системный администратор: строки Account лежат в System); группа по ролям — по ролям членства
19
+ -- в рабочем арендаторе и только в правилах этого же арендатора: роль, выданная администратором
20
+ -- арендатора, не даёт прав по правилам System. В личном арендаторе (арендатор = аккаунт) роль — owner.
21
+ -- Условие на строку — шаблон колонок id, owner, tenant, tags, data, links (вхождение @>), значения
22
+ -- "$actor" (он же "$account") и "$tenant" подставляются.
23
+ --
24
+ -- Политики RLS: строка арендатора сессии видна (и правима) по плану прав; строки Class арендатора и
25
+ -- System видны любой сессии; собственная строка Account и собственные членства — самому аккаунту.
26
+ -- План прав считается функцией владельца acl_plan() один раз на упоминание таблицы в запросе
27
+ -- (подзапрос (select …) PostgreSQL выполняет один раз); на строку — встраиваемая acl_ok().
28
+ -- Условие видимости продублировано в row_visible(): по нему функции поиска find_* (70-read.sql) и
29
+ -- превью удаления отбирают строки без RLS. Менять оба места вместе; совпадение — тест find-ids-parity.
30
+
31
+ -- Маска 0.21 → тело регулярного выражения (maskBody, acl.ts:66): {a,b} — альтернативы (рекурсивно),
32
+ -- * — хвост из одного и более сегментов, спецсимволы экранируются.
33
+ create or replace function @SCHEMA@.acl_mask_re(p_mask text) returns text
34
+ language plpgsql immutable parallel safe
35
+ as $$
36
+ declare
37
+ r text := '';
38
+ n int := char_length(p_mask);
39
+ i int := 1;
40
+ j int;
41
+ depth int;
42
+ inner_s text;
43
+ parts text[];
44
+ d int;
45
+ st int;
46
+ k int;
47
+ ch text;
48
+ begin
49
+ while i <= n loop
50
+ ch := substr(p_mask, i, 1);
51
+ if ch = '{' then
52
+ depth := 1;
53
+ j := i + 1;
54
+ while j <= n and depth > 0 loop
55
+ if substr(p_mask, j, 1) = '{' then depth := depth + 1;
56
+ elsif substr(p_mask, j, 1) = '}' then depth := depth - 1;
57
+ end if;
58
+ j := j + 1;
59
+ end loop;
60
+ inner_s := substr(p_mask, i + 1, greatest(j - i - 2, 0));
61
+ parts := '{}';
62
+ d := 0;
63
+ st := 1;
64
+ for k in 1 .. char_length(inner_s) + 1 loop
65
+ if k = char_length(inner_s) + 1 or (substr(inner_s, k, 1) = ',' and d = 0) then
66
+ parts := parts || substr(inner_s, st, k - st);
67
+ st := k + 1;
68
+ elsif substr(inner_s, k, 1) = '{' then d := d + 1;
69
+ elsif substr(inner_s, k, 1) = '}' then d := d - 1;
70
+ end if;
71
+ end loop;
72
+ r := r || '(?:' || array_to_string(array(select @SCHEMA@.acl_mask_re(x) from unnest(parts) with ordinality u(x, o) order by o), '|') || ')';
73
+ i := j;
74
+ continue;
75
+ elsif ch = '*' then
76
+ r := r || '[^.]+(?:\.[^.]+)*';
77
+ elsif position(ch in '.\+?()[]^$|') > 0 then
78
+ r := r || '\' || ch;
79
+ else
80
+ r := r || ch;
81
+ end if;
82
+ i := i + 1;
83
+ end loop;
84
+ return r;
85
+ end $$;
86
+
87
+ create or replace function @SCHEMA@.acl_mask_ok(p_mask text, p_value text) returns boolean
88
+ language sql immutable parallel safe
89
+ return p_value ~ ('^' || @SCHEMA@.acl_mask_re(p_mask) || '$');
90
+
91
+ -- Шаблон множества "{A,B}" | "!{A,B}" против значений (matchCategories, acl.ts:51). Маска — только строка
92
+ -- (описание класса Resource); не строка — null, «неизвестно» (в 0.21 совпадала со всеми): строка,
93
+ -- записанная в обход проверки, разрешений не даёт, а запрет по ней действует на всех, как прежде (acl_rules).
94
+ create or replace function @SCHEMA@.acl_set_ok(p_spec jsonb, p_have text[]) returns boolean
95
+ language plpgsql immutable parallel safe
96
+ as $$
97
+ declare
98
+ s text;
99
+ neg boolean;
100
+ list text[];
101
+ begin
102
+ if jsonb_typeof(p_spec) is distinct from 'string' then
103
+ return null;
104
+ end if;
105
+ s := p_spec #>> '{}';
106
+ neg := left(s, 1) = '!';
107
+ s := regexp_replace(regexp_replace(regexp_replace(s, '^!', ''), '^\{', ''), '\}$', '');
108
+ list := array(select btrim(x) from unnest(string_to_array(s, ',')) x where btrim(x) <> '');
109
+ return (list && coalesce(p_have, '{}')) <> neg;
110
+ end $$;
111
+
112
+ -- Группа (Resource ACCOUNT) против субъекта. p_same — правило того же арендатора, что и рабочий:
113
+ -- только тогда учитываются роли членства. null — решает маска не строкой (acl_set_ok).
114
+ create or replace function @SCHEMA@.acl_group_ok(p_pat jsonb, p_cats text[], p_roles text[], p_same boolean) returns boolean
115
+ language sql immutable parallel safe
116
+ return p_pat is null or jsonb_typeof(p_pat) <> 'object'
117
+ or ((not (p_pat ? 'categories') or @SCHEMA@.acl_set_ok(p_pat->'categories', p_cats))
118
+ and (not (p_pat ? 'roles') or (p_same and @SCHEMA@.acl_set_ok(p_pat->'roles', p_roles))));
119
+
120
+ -- Объект строк (READ | WRITE | DELETE) против имён класса: класс, предки и их алиасы (matchesRow). Нет
121
+ -- маски — все классы; маска не строкой — null, как у acl_set_ok (acl_plan_for: разрешение по ней не
122
+ -- действует, запрет — на все классы).
123
+ create or replace function @SCHEMA@.acl_class_ok(p_pat jsonb, p_names text[]) returns boolean
124
+ language sql immutable parallel safe
125
+ return case when p_pat is null or jsonb_typeof(p_pat) <> 'object' or not (p_pat ? 'class') then true
126
+ when jsonb_typeof(p_pat->'class') <> 'string' then null
127
+ else exists (select 1 from unnest(p_names) n where @SCHEMA@.acl_mask_ok(p_pat->>'class', n)) end;
128
+
129
+ -- Объект API против эндпоинта (matchesApi): нет pattern — все; pattern без строки endpoint — ни один.
130
+ create or replace function @SCHEMA@.acl_endpoint_ok(p_pat jsonb, p_endpoint text) returns boolean
131
+ language sql immutable parallel safe
132
+ return p_pat is null or jsonb_typeof(p_pat) <> 'object'
133
+ or (jsonb_typeof(p_pat->'endpoint') = 'string' and @SCHEMA@.acl_mask_ok(p_pat->>'endpoint', p_endpoint));
134
+
135
+ -- Подстановка "$actor" ("$account") и "$tenant" по всем значениям шаблона.
136
+ create or replace function @SCHEMA@.acl_subst(v jsonb, p_actor uuid, p_tenant uuid) returns jsonb
137
+ language plpgsql immutable parallel safe
138
+ as $$
139
+ begin
140
+ case jsonb_typeof(v)
141
+ when 'string' then
142
+ if v #>> '{}' in ('$actor', '$account') then return to_jsonb(p_actor::text);
143
+ elsif v #>> '{}' = '$tenant' then return to_jsonb(p_tenant::text);
144
+ end if;
145
+ return v;
146
+ when 'array' then
147
+ return coalesce((select jsonb_agg(@SCHEMA@.acl_subst(x, p_actor, p_tenant) order by o)
148
+ from jsonb_array_elements(v) with ordinality t(x, o)), '[]'::jsonb);
149
+ when 'object' then
150
+ return coalesce((select jsonb_object_agg(t.k, @SCHEMA@.acl_subst(t.x, p_actor, p_tenant))
151
+ from jsonb_each(v) t(k, x)), '{}'::jsonb);
152
+ else
153
+ return v;
154
+ end case;
155
+ end $$;
156
+
157
+ -- Условие на строку победившего разрешения: шаблон без class с подстановкой; {} — без условия.
158
+ create or replace function @SCHEMA@.acl_cond(p_pat jsonb, p_actor uuid, p_tenant uuid) returns jsonb
159
+ language sql immutable parallel safe
160
+ return case when p_pat is null or jsonb_typeof(p_pat) <> 'object' then '{}'::jsonb
161
+ else @SCHEMA@.acl_subst(p_pat - 'class', p_actor, p_tenant) end;
162
+
163
+ -- Строка проходит план для политик: класс в списке без условия (p_all) — сразу да, иначе вхождение
164
+ -- колонок строки в условие класса (p_plan); класса нет в плане — запрет. Встраиваемая: каждый
165
+ -- параметр — один раз (аргументы-подзапросы иначе не дали бы встроить), тело стандартным SQL;
166
+ -- объект строки собирается только для классов с условием.
167
+ create or replace function @SCHEMA@.acl_ok(p_all text[], p_plan jsonb, p_class text, p_id uuid, p_owner uuid, p_tenant uuid,
168
+ p_tags text[], p_data jsonb, p_links jsonb) returns boolean
169
+ language sql stable parallel safe
170
+ return p_class = any (p_all)
171
+ or jsonb_build_object('id', p_id, 'owner', p_owner, 'tenant', p_tenant, 'tags', p_tags, 'data', p_data, 'links', p_links)
172
+ @> coalesce(p_plan -> p_class, '[]'::jsonb);
173
+
174
+ -- То же по одной карте плана — в функциях владельца, где план лежит в переменной (параметр дешёвый,
175
+ -- и функция встраивается, хотя план упомянут дважды).
176
+ create or replace function @SCHEMA@.acl_row_ok(p_plan jsonb, p_class text, p_id uuid, p_owner uuid, p_tenant uuid,
177
+ p_tags text[], p_data jsonb, p_links jsonb) returns boolean
178
+ language sql stable parallel safe
179
+ return coalesce(p_plan -> p_class = '{}'::jsonb, false)
180
+ or jsonb_build_object('id', p_id, 'owner', p_owner, 'tenant', p_tenant, 'tags', p_tags, 'data', p_data, 'links', p_links)
181
+ @> coalesce(p_plan -> p_class, '[]'::jsonb);
182
+
183
+ -- Правила, применимые к субъекту в рабочем арендаторе (System и сам арендатор), с объектами категории
184
+ -- p_category; p_system_only — только правила System (права auth.* и letopis.*: их не выдаёт арендатор).
185
+ create or replace function @SCHEMA@.acl_rules(p_tenant uuid, p_cats text[], p_roles text[], p_category text, p_system_only boolean)
186
+ returns table (id uuid, weight numeric, deny boolean, alias text, pattern jsonb, grp text, code jsonb, message text)
187
+ language plpgsql stable
188
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
189
+ as $$
190
+ begin
191
+ -- PL/pgSQL: план запроса кэшируется на сеанс (SQL-функция с set планировала бы его на каждый вызов)
192
+ return query
193
+ -- вес правила арендатора — не больше 999: правила System с весом от 1000 — административный слой
194
+ -- (бан), который арендатор своими правилами не перевесит (ревью этапа 5)
195
+ select x.id, case when x.tenant = '@SYSTEM@'::uuid then coalesce((x.data->>'weight')::numeric, 0)
196
+ else least(coalesce((x.data->>'weight')::numeric, 0), 999) end,
197
+ coalesce(x.data->>'permission', 'deny') <> 'allow',
198
+ o.data->>'alias', o.data->'pattern', g.data->>'alias', x.data->'code', x.data->>'message'
199
+ from @SCHEMA@.entity x
200
+ join @SCHEMA@.entity g on g.id = (x.links->>'Group')::uuid and g.class = 'Resource'
201
+ join @SCHEMA@.entity o on o.id = (x.links->>'Object')::uuid and o.class = 'Resource'
202
+ where x.class = 'rule'
203
+ and (x.tenant = '@SYSTEM@'::uuid or (not p_system_only and x.tenant = p_tenant))
204
+ and coalesce((x.data->>'enabled')::boolean, true)
205
+ and g.data->>'category' = 'ACCOUNT' and o.data->>'category' = p_category
206
+ -- маска группы не строкой (null): разрешение не действует, запрет — на всех
207
+ and coalesce(@SCHEMA@.acl_group_ok(g.data->'pattern', p_cats, p_roles, x.tenant = p_tenant),
208
+ coalesce(x.data->>'permission', 'deny') <> 'allow');
209
+ end $$;
210
+
211
+ -- План прав субъекта на операцию над строками: { <класс>: <условие> } по всем классам арендатора и
212
+ -- System; запрещённых классов в плане нет. Маска класса разбирается один раз на правило: простое имя
213
+ -- сравнивается строкой, маска с {…} или * — регулярным выражением (как acl_class_ok). Маска класса не
214
+ -- строкой: разрешение отбрасывается, запрет совпадает со всеми классами.
215
+ create or replace function @SCHEMA@.acl_plan_for(p_actor uuid, p_tenant uuid, p_cats text[], p_roles text[], p_op text)
216
+ returns jsonb
217
+ language plpgsql stable
218
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
219
+ as $$
220
+ begin
221
+ return (
222
+ with r as (
223
+ select x.*, m.mask, case when m.mask ~ '[{*]' then '^' || @SCHEMA@.acl_mask_re(m.mask) || '$' end as re
224
+ from @SCHEMA@.acl_rules(p_tenant, p_cats, p_roles, p_op, false) x
225
+ cross join lateral (select case when jsonb_typeof(x.pattern) = 'object' and jsonb_typeof(x.pattern->'class') = 'string'
226
+ then x.pattern->>'class' end as mask,
227
+ coalesce(jsonb_typeof(x.pattern) = 'object' and x.pattern ? 'class'
228
+ and jsonb_typeof(x.pattern->'class') <> 'string', false) as bad) m
229
+ where x.deny or not m.bad
230
+ ), cl as (
231
+ select c.data->>'name' as name, c.data->>'alias' as alias, c.data->'resolved'->'ancestors' as anc
232
+ from @SCHEMA@.entity c
233
+ where c.class = 'Class' and c.tenant in (p_tenant, '@SYSTEM@'::uuid)
234
+ ), k as (
235
+ select cl.name,
236
+ array(select jsonb_array_elements_text(cl.anc))
237
+ || array(select a.alias from cl a where a.alias is not null and cl.anc ? a.name) as names
238
+ from cl
239
+ )
240
+ select coalesce(jsonb_object_agg(k.name, @SCHEMA@.acl_cond(w.pattern, p_actor, p_tenant)), '{}'::jsonb)
241
+ from k
242
+ cross join lateral (
243
+ select r.deny, r.pattern from r
244
+ where r.mask is null
245
+ or (r.re is null and r.mask = any (k.names))
246
+ or (r.re is not null and exists (select 1 from unnest(k.names) n where n ~ r.re))
247
+ order by r.weight desc, r.deny desc, r.alias, r.id
248
+ limit 1) w
249
+ where not w.deny);
250
+ end $$;
251
+
252
+ -- Счётчик изменений прав: строки rule, Resource, member, Account и Class меняют планы прав. Его
253
+ -- повышают триггеры после оператора, функции владельца и загрузчик; хранится в строке secret с
254
+ -- постоянным id (там же ключ подписи плана). Блокировка строки счётчика упорядочивает изменения прав.
255
+ create or replace function @SCHEMA@.acl_bump() returns void
256
+ language sql volatile
257
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
258
+ as $$
259
+ -- остальные ключи meta (курсор прореживания, метка умолчания хранения, этап 6) сохраняются
260
+ update @SCHEMA@.secret s set meta = s.meta || jsonb_build_object('v', coalesce((s.meta->>'v')::bigint, 0) + 1)
261
+ where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY'
262
+ $$;
263
+
264
+ -- План прав из подписанного пропуска letopis.acl (acl_token) или заново. Пропуск — «подпись.json»:
265
+ -- HMAC-SHA256 ключом из secret, в json — счётчик изменений прав v, актор a, арендатор t и планы трёх
266
+ -- операций. Подходит, только если подпись верна, актор и арендатор те же, а счётчик не изменился
267
+ -- (счётчик и правила читаются одним снимком оператора); иначе план считается заново — пропуск лишь
268
+ -- ускоряет, на правильность не влияет. Подделать пропуск без ключа нельзя.
269
+ create or replace function @SCHEMA@.acl_plan_cached(p_actor uuid, p_tenant uuid, p_cats text[], p_roles text[], p_op text)
270
+ returns jsonb
271
+ language plpgsql stable
272
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
273
+ as $$
274
+ declare
275
+ raw text := nullif(current_setting('letopis.acl', true), '');
276
+ body text;
277
+ k record;
278
+ j jsonb;
279
+ begin
280
+ if raw is not null and position('.' in raw) = 65 then
281
+ body := substr(raw, 66);
282
+ select s.material, s.meta into k from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY';
283
+ -- сравниваются хэши подписей, а не сами подписи: время сравнения ничего не подсказывает
284
+ if k.material is not null
285
+ and sha256(convert_to(left(raw, 64), 'UTF8'))
286
+ = sha256(convert_to(encode(@PGCRYPTO@.hmac(convert_to(body, 'UTF8'), k.material, 'sha256'), 'hex'), 'UTF8')) then
287
+ j := body::jsonb;
288
+ if j->>'v' = coalesce(k.meta->>'v', '0') and j->>'a' = p_actor::text and j->>'t' = p_tenant::text and j ? p_op then
289
+ return j->p_op;
290
+ end if;
291
+ end if;
292
+ end if;
293
+ return @SCHEMA@.acl_plan_for(p_actor, p_tenant, p_cats, p_roles, p_op);
294
+ end $$;
295
+
296
+ -- План прав актора сессии (ctx) на операцию: READ, WRITE или DELETE. Нет сессии — пустой план.
297
+ create or replace function @SCHEMA@.acl_plan(p_op text) returns jsonb
298
+ language plpgsql stable security definer
299
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
300
+ as $$
301
+ declare
302
+ c record;
303
+ begin
304
+ if p_op not in ('READ', 'WRITE', 'DELETE') then
305
+ perform @SCHEMA@.lt_fail('invalid_data', format('операция %s: нужна READ, WRITE или DELETE', p_op));
306
+ end if;
307
+ select * into c from @SCHEMA@.ctx();
308
+ if c.account is null then
309
+ return '{}'::jsonb;
310
+ end if;
311
+ return @SCHEMA@.acl_plan_cached(c.account, c.tenant, c.categories, c.roles, p_op);
312
+ end $$;
313
+
314
+ -- Классы, разрешённые актору сессии без условия, — для политик (acl_ok).
315
+ create or replace function @SCHEMA@.acl_all(p_op text) returns text[]
316
+ language plpgsql stable security definer
317
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
318
+ as $$
319
+ begin
320
+ return array(select k.key from jsonb_each(@SCHEMA@.acl_plan(p_op)) k where k.value = '{}'::jsonb);
321
+ end $$;
322
+
323
+ -- Подписанный пропуск прав актора сессии для letopis.acl: библиотека берёт его один раз на актора и
324
+ -- арендатора и передаёт в каждой транзакции, пока не изменятся права (сигнал) или не истечёт срок.
325
+ -- Пропуск открывает клиенту его собственный план (классы и условия), но не правила целиком.
326
+ -- Нет сессии — null.
327
+ create or replace function @SCHEMA@.acl_token() returns text
328
+ language plpgsql stable security definer
329
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
330
+ as $$
331
+ declare
332
+ c record;
333
+ k record;
334
+ body text;
335
+ begin
336
+ select * into c from @SCHEMA@.ctx();
337
+ if c.account is null then
338
+ return null;
339
+ end if;
340
+ select s.material, s.meta into k from @SCHEMA@.secret s where s.id = '@SYSTEM@'::uuid and s.kind = 'KEY';
341
+ body := jsonb_build_object('v', coalesce(k.meta->>'v', '0'), 'a', c.account, 't', c.tenant,
342
+ 'READ', @SCHEMA@.acl_plan_for(c.account, c.tenant, c.categories, c.roles, 'READ'),
343
+ 'WRITE', @SCHEMA@.acl_plan_for(c.account, c.tenant, c.categories, c.roles, 'WRITE'),
344
+ 'DELETE', @SCHEMA@.acl_plan_for(c.account, c.tenant, c.categories, c.roles, 'DELETE'))::text;
345
+ return encode(@PGCRYPTO@.hmac(convert_to(body, 'UTF8'), k.material, 'sha256'), 'hex') || '.' || body;
346
+ end $$;
347
+
348
+ -- Актор сессии и его план прав на операцию — для функций владельца, которые отбирают строки без RLS
349
+ -- (find_*, превью удаления, проверка целей): { tenant, account, plan }; нет сессии — null.
350
+ create or replace function @SCHEMA@.acl_actor(p_op text) returns jsonb
351
+ language plpgsql stable
352
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
353
+ as $$
354
+ declare
355
+ c record;
356
+ begin
357
+ select * into c from @SCHEMA@.ctx();
358
+ if c.account is null then
359
+ return null;
360
+ end if;
361
+ return jsonb_build_object('tenant', c.tenant, 'account', c.account,
362
+ 'plan', @SCHEMA@.acl_plan_cached(c.account, c.tenant, c.categories, c.roles, p_op));
363
+ end $$;
364
+
365
+ -- Субъект прав по аккаунту и арендатору без сессии — для загрузчика с опцией as (§2.6, п. 10;
366
+ -- функция владельца, приложению не выдана): аккаунт включён и лежит в System, арендатор — сам аккаунт
367
+ -- или по членству (как в ctx). Отдаёт категории, роли и планы READ и WRITE.
368
+ create or replace function @SCHEMA@.acl_subject(p_account uuid, p_tenant uuid) returns jsonb
369
+ language plpgsql stable
370
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
371
+ as $$
372
+ declare
373
+ a jsonb;
374
+ m jsonb;
375
+ cats text[];
376
+ roles text[];
377
+ begin
378
+ select e.data into a from @SCHEMA@.entity e
379
+ where e.id = p_account and e.class = 'Account' and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true);
380
+ if a is null then
381
+ perform @SCHEMA@.lt_fail('target_not_found', 'аккаунт не найден или отключён');
382
+ end if;
383
+ if p_tenant <> p_account then
384
+ select x.data into m from @SCHEMA@.entity x
385
+ 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;
386
+ if m is null or not exists (select 1 from @SCHEMA@.entity e where e.id = p_tenant and e.class = 'Account'
387
+ and e.tenant = '@SYSTEM@'::uuid and coalesce((e.data->>'enabled')::boolean, true)) then
388
+ perform @SCHEMA@.lt_fail('tenant_denied', 'нет членства в арендаторе');
389
+ end if;
390
+ end if;
391
+ cats := array(select jsonb_array_elements_text(coalesce(a->'categories', '[]'::jsonb)));
392
+ roles := case when p_tenant = p_account then array['owner'] else array(select jsonb_array_elements_text(coalesce(m->'roles', '[]'::jsonb))) end;
393
+ return jsonb_build_object('categories', to_jsonb(cats), 'roles', to_jsonb(roles),
394
+ 'read', @SCHEMA@.acl_plan_for(p_account, p_tenant, cats, roles, 'READ'),
395
+ 'write', @SCHEMA@.acl_plan_for(p_account, p_tenant, cats, roles, 'WRITE'));
396
+ end $$;
397
+
398
+ -- Решение по эндпоинту API: { allow, rule, code, message } (check 0.21).
399
+ create or replace function @SCHEMA@.acl_api_for(p_tenant uuid, p_cats text[], p_roles text[], p_endpoint text, p_system_only boolean)
400
+ returns jsonb
401
+ language plpgsql stable
402
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
403
+ as $$
404
+ begin
405
+ return coalesce((
406
+ select case when not w.deny then jsonb_build_object('allow', true, 'rule', w.rule)
407
+ else jsonb_strip_nulls(jsonb_build_object('allow', false, 'rule', w.rule, 'code', w.code, 'message', w.message)) end
408
+ from (
409
+ select r.deny, r.code, r.message,
410
+ jsonb_build_object('id', r.id, 'group', r.grp, 'object', r.alias, 'weight', r.weight,
411
+ 'permission', case when r.deny then 'deny' else 'allow' end) as rule
412
+ from @SCHEMA@.acl_rules(p_tenant, p_cats, p_roles, 'API', p_system_only) r
413
+ where @SCHEMA@.acl_endpoint_ok(r.pattern, p_endpoint)
414
+ order by r.weight desc, r.deny desc, r.alias, r.id
415
+ limit 1) w),
416
+ jsonb_build_object('allow', false, 'message', 'нет подходящего правила (запрет по умолчанию)'));
417
+ end $$;
418
+
419
+ -- db.acl.check(endpoint): решение для актора сессии по правилам System и его арендатора.
420
+ create or replace function @SCHEMA@.acl_check(p_endpoint text) returns jsonb
421
+ language plpgsql stable security definer
422
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
423
+ as $$
424
+ declare
425
+ c record;
426
+ begin
427
+ select * into c from @SCHEMA@.ctx();
428
+ if c.account is null then
429
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
430
+ end if;
431
+ return @SCHEMA@.acl_api_for(c.tenant, c.categories, c.roles, p_endpoint, false);
432
+ end $$;
433
+
434
+ -- Внутренняя проверка прав auth.* и letopis.*: только правила System. Отказ — acl_denied.
435
+ create or replace function @SCHEMA@.acl_require(p_endpoint text) returns void
436
+ language plpgsql stable
437
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
438
+ as $$
439
+ declare
440
+ c record;
441
+ begin
442
+ select * into c from @SCHEMA@.ctx();
443
+ if c.account is null then
444
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
445
+ end if;
446
+ if not coalesce((@SCHEMA@.acl_api_for(c.tenant, c.categories, c.roles, p_endpoint, true)->>'allow')::boolean, false) then
447
+ perform @SCHEMA@.lt_fail('acl_denied', format('нет права %s', p_endpoint));
448
+ end if;
449
+ end $$;
450
+
451
+ -- db.acl.checkData(class, op): { allow, filter } для актора сессии.
452
+ create or replace function @SCHEMA@.acl_check_data(p_class text, p_op text) returns jsonb
453
+ language plpgsql stable security definer
454
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
455
+ as $$
456
+ declare
457
+ p jsonb := @SCHEMA@.acl_plan(p_op);
458
+ begin
459
+ if not (p ? p_class) then
460
+ return jsonb_build_object('allow', false);
461
+ end if;
462
+ return case when p->p_class = '{}'::jsonb then jsonb_build_object('allow', true)
463
+ else jsonb_build_object('allow', true, 'filter', p->p_class) end;
464
+ end $$;
465
+
466
+ -- Видимость строки для чтения — то же условие, что using политик entity_select и log_select.
467
+ -- Отдельная функция, а не выражение политики: аргументы-подзапросы, использованные в теле больше
468
+ -- одного раза, не дают PostgreSQL встроить функцию, и политика потеряла бы индексы.
469
+ create or replace function @SCHEMA@.row_visible(p_tenant uuid, p_class text, p_id uuid, p_owner uuid, p_tags text[],
470
+ p_data jsonb, p_links jsonb, c_tenant uuid, c_account uuid, c_plan jsonb)
471
+ returns boolean
472
+ language sql stable parallel safe
473
+ return (p_tenant = c_tenant and (p_class = 'Class' or @SCHEMA@.acl_row_ok(c_plan, p_class, p_id, p_owner, p_tenant, p_tags, p_data, p_links)))
474
+ or (p_class = 'Class' and p_tenant = '@SYSTEM@'::uuid and c_account is not null)
475
+ or (p_class = 'Account' and p_id = c_account)
476
+ or (p_class = 'member' and p_links->>'User' = c_account::text);
477
+
478
+ -- до этапа 5 видимость не зависела от прав
479
+ drop function if exists @SCHEMA@.row_visible(uuid, text, uuid, uuid, uuid);
480
+
481
+ drop policy if exists entity_select on @SCHEMA@.entity;
482
+ create policy entity_select on @SCHEMA@.entity for select to @APP@
483
+ using (
484
+ (tenant = (select @SCHEMA@.ctx_tenant())
485
+ and (class = 'Class' or @SCHEMA@.acl_ok((select @SCHEMA@.acl_all('READ')), (select @SCHEMA@.acl_plan('READ')), class, id, owner, tenant, tags, data, links)))
486
+ or (class = 'Class' and tenant = '@SYSTEM@'::uuid and (select @SCHEMA@.ctx_account()) is not null)
487
+ or (class = 'Account' and id = (select @SCHEMA@.ctx_account()))
488
+ or (class = 'member' and links->>'User' = (select @SCHEMA@.ctx_account())::text)
489
+ );
490
+
491
+ drop policy if exists entity_insert on @SCHEMA@.entity;
492
+ create policy entity_insert on @SCHEMA@.entity for insert to @APP@
493
+ with check (tenant = (select @SCHEMA@.ctx_tenant())
494
+ and @SCHEMA@.acl_ok((select @SCHEMA@.acl_all('WRITE')), (select @SCHEMA@.acl_plan('WRITE')), class, id, owner, tenant, tags, data, links));
495
+
496
+ -- правка: условие ACL на прежние и на новые значения строки
497
+ drop policy if exists entity_update on @SCHEMA@.entity;
498
+ create policy entity_update on @SCHEMA@.entity for update to @APP@
499
+ using (tenant = (select @SCHEMA@.ctx_tenant())
500
+ and @SCHEMA@.acl_ok((select @SCHEMA@.acl_all('WRITE')), (select @SCHEMA@.acl_plan('WRITE')), class, id, owner, tenant, tags, data, links))
501
+ with check (tenant = (select @SCHEMA@.ctx_tenant())
502
+ and @SCHEMA@.acl_ok((select @SCHEMA@.acl_all('WRITE')), (select @SCHEMA@.acl_plan('WRITE')), class, id, owner, tenant, tags, data, links));
503
+
504
+ drop policy if exists entity_delete on @SCHEMA@.entity;
505
+ create policy entity_delete on @SCHEMA@.entity for delete to @APP@
506
+ using (tenant = (select @SCHEMA@.ctx_tenant())
507
+ and @SCHEMA@.acl_ok((select @SCHEMA@.acl_all('DELETE')), (select @SCHEMA@.acl_plan('DELETE')), class, id, owner, tenant, tags, data, links));
508
+
509
+ drop policy if exists log_select on @SCHEMA@.log;
510
+ create policy log_select on @SCHEMA@.log for select to @APP@
511
+ using (
512
+ (tenant = (select @SCHEMA@.ctx_tenant())
513
+ and (class = 'Class' or @SCHEMA@.acl_ok((select @SCHEMA@.acl_all('READ')), (select @SCHEMA@.acl_plan('READ')), class, id, owner, tenant, tags, data, links)))
514
+ or (class = 'Class' and tenant = '@SYSTEM@'::uuid and (select @SCHEMA@.ctx_account()) is not null)
515
+ or (class = 'Account' and id = (select @SCHEMA@.ctx_account()))
516
+ or (class = 'member' and links->>'User' = (select @SCHEMA@.ctx_account())::text)
517
+ );
518
+
519
+ -- ранняя редакция этапа 5: проверка строки по одной карте плана в политиках
520
+ drop function if exists @SCHEMA@.acl_ok(jsonb, text, uuid, uuid, uuid, text[], jsonb, jsonb);
521
+
522
+ do $$
523
+ declare f text;
524
+ begin
525
+ foreach f in array array[
526
+ 'acl_mask_re(text)', 'acl_mask_ok(text, text)', 'acl_set_ok(jsonb, text[])', 'acl_group_ok(jsonb, text[], text[], boolean)',
527
+ 'acl_class_ok(jsonb, text[])', 'acl_endpoint_ok(jsonb, text)', 'acl_subst(jsonb, uuid, uuid)', 'acl_cond(jsonb, uuid, uuid)',
528
+ 'acl_ok(text[], jsonb, text, uuid, uuid, uuid, text[], jsonb, jsonb)', 'acl_row_ok(jsonb, text, uuid, uuid, uuid, text[], jsonb, jsonb)', 'acl_rules(uuid, text[], text[], text, boolean)',
529
+ 'acl_plan_for(uuid, uuid, text[], text[], text)', 'acl_bump()', 'acl_plan_cached(uuid, uuid, text[], text[], text)',
530
+ 'acl_plan(text)', 'acl_all(text)', 'acl_token()', 'acl_actor(text)', 'acl_subject(uuid, uuid)', 'acl_api_for(uuid, text[], text[], text, boolean)',
531
+ 'acl_check(text)', 'acl_require(text)', 'acl_check_data(text, text)',
532
+ 'row_visible(uuid, text, uuid, uuid, text[], jsonb, jsonb, uuid, uuid, jsonb)'] loop
533
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
534
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
535
+ end loop;
536
+ end $$;
537
+ -- политики вызываются с правами приложения: план прав (функция владельца) и встраиваемая проверка
538
+ grant execute on function @SCHEMA@.acl_plan(text), @SCHEMA@.acl_all(text), @SCHEMA@.acl_ok(text[], jsonb, text, uuid, uuid, uuid, text[], jsonb, jsonb),
539
+ @SCHEMA@.acl_check(text), @SCHEMA@.acl_check_data(text, text), @SCHEMA@.acl_token() to @APP@;