letopis 0.21.0 → 1.0.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 +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +337 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1938 -0
- package/README.md +1493 -3469
- package/dist/acl.d.ts +26 -50
- package/dist/acl.js +22 -267
- package/dist/admin.d.ts +138 -0
- package/dist/admin.js +170 -0
- package/dist/auth.d.ts +120 -73
- package/dist/auth.js +121 -306
- package/dist/cache.d.ts +73 -0
- package/dist/cache.js +148 -0
- package/dist/chain.d.ts +124 -191
- package/dist/chain.js +362 -563
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +178 -0
- package/dist/demo/booking.d.ts +289 -0
- package/dist/demo/booking.js +159 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +70 -0
- package/dist/import.d.ts +179 -0
- package/dist/import.js +792 -0
- package/dist/index.d.ts +172 -26
- package/dist/index.js +304 -178
- package/dist/jsonschema.d.ts +22 -0
- package/dist/jsonschema.js +167 -0
- package/dist/load.d.ts +76 -0
- package/dist/load.js +884 -0
- package/dist/model.d.ts +166 -0
- package/dist/model.js +224 -0
- package/dist/ops.d.ts +7 -6
- package/dist/ops.js +7 -51
- package/dist/pglite.d.ts +22 -0
- package/dist/pglite.js +45 -0
- package/dist/registry.d.ts +57 -0
- package/dist/registry.js +82 -0
- package/dist/sql.d.ts +59 -142
- package/dist/sql.js +568 -654
- package/dist/sync.d.ts +31 -0
- package/dist/sync.js +108 -0
- package/dist/tx.d.ts +129 -8
- package/dist/tx.js +300 -73
- package/dist/typed.d.ts +97 -0
- package/dist/typed.js +1 -0
- package/dist/types.d.ts +71 -252
- package/dist/types.js +27 -108
- package/dist/up.d.ts +140 -47
- package/dist/up.js +339 -267
- package/dist/uuid.d.ts +21 -6
- package/dist/uuid.js +48 -64
- package/dist/validate.d.ts +24 -0
- package/dist/validate.js +251 -0
- package/dist/watch.d.ts +62 -0
- package/dist/watch.js +168 -0
- package/dist/write.d.ts +117 -74
- package/dist/write.js +658 -720
- package/llms.txt +26 -0
- package/package.json +49 -19
- package/sql/10-core.sql +136 -0
- package/sql/15-errors.sql +60 -0
- package/sql/20-context.sql +153 -0
- package/sql/30-validate.sql +423 -0
- package/sql/40-class.sql +259 -0
- package/sql/50-acl.sql +539 -0
- package/sql/60-write.sql +1372 -0
- package/sql/70-read.sql +245 -0
- package/sql/80-auth.sql +827 -0
- package/sql/90-time.sql +957 -0
- package/sql/95-seed.system.sql +178 -0
- package/sql/99-revision.sql +3 -0
- package/sql/README.md +56 -0
- package/sql/seed.booking.sql +39 -112
- package/dist/schema.d.ts +0 -15
- package/dist/schema.js +0 -352
- package/dist/sessions.d.ts +0 -32
- package/dist/sessions.js +0 -114
- package/dist/tables.d.ts +0 -105
- package/dist/tables.js +0 -248
- package/docker/Dockerfile +0 -40
- package/docker/start.sh +0 -18
- package/scripts/check-docs.mjs +0 -375
- package/scripts/gen-api-contract.mjs +0 -226
- package/scripts/gen-types.mjs +0 -350
- package/scripts/release-notes.mjs +0 -76
- package/scripts/schema-sync.mjs +0 -185
- package/sql/ddl.sql +0 -685
- 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@;
|