letopis 0.20.3 → 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 +348 -0
  4. package/MIGRATION.md +190 -0
  5. package/README.en.md +1937 -0
  6. package/README.md +1493 -3466
  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 +369 -551
  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 -250
  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 -351
  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 -600
  89. package/sql/seed.auth.sql +0 -73
@@ -0,0 +1,827 @@
1
+ -- letopis 1.0 — креды, вход, сессии (план, §2.9, этап 5, п. 1–3).
2
+ -- Всё лежит в secret; приложение не видит ни одной строки и работает только через эти функции.
3
+ -- Виды записей: PASSWORD (и другие «логин/пароль» — EMAIL и т. п.: хэш scrypt считает и проверяет
4
+ -- сервис в Node), APIKEY (lts_…, в базе sha256), KEYSECRET (пара ключ + секрет, в базе sha256
5
+ -- секрета), OTP (одноразовый код, sha256, срок, счётчик попыток), TOTP (RFC 6238, HMAC-SHA1 из
6
+ -- pgcrypto), внешние identity (TELEGRAM, GOOGLE, …: связка, токен проверяет сервис), SESSION.
7
+ -- Пара «вид, идентификатор» уникальна среди неудалённых записей без учёта регистра (индекс
8
+ -- secret_kind_ident). Права: auth.* — по правилам System (сервис, системный администратор);
9
+ -- свои креды аккаунт привязывает сам только после недавнего входа; свои ключи (APIKEY, KEYSECRET)
10
+ -- сервис выпускает и отзывает сам по сессии своего ключа. Сессия по ключу помнит его id (meta.cred):
11
+ -- отзыв ключа гасит только его сессии.
12
+
13
+ -- Недавний вход: способы входа и время — в meta сессии.
14
+ create or replace function @SCHEMA@.auth_recent() returns interval
15
+ language sql immutable parallel safe
16
+ return interval '10 minutes';
17
+
18
+ create or replace function @SCHEMA@.auth_kind_ok(p_kind text) returns boolean
19
+ language sql immutable parallel safe
20
+ return p_kind ~ '^[A-Z][A-Z0-9_]{1,31}$' and p_kind not in ('SESSION', 'APIKEY', 'KEYSECRET', 'OTP', 'TOTP', 'KEY');
21
+
22
+ -- Действующая сессия обязательна (иначе no_session).
23
+ create or replace function @SCHEMA@.auth_session_required(p_account uuid) returns void
24
+ language plpgsql immutable
25
+ as $$
26
+ begin
27
+ if p_account is null then
28
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
29
+ end if;
30
+ end $$;
31
+
32
+ -- Может ли актор менять креды аккаунта: сам аккаунт после недавнего входа (не через имперсонацию)
33
+ -- или обладатель права auth.credential (правила System). Отказ одинаков в любом случае.
34
+ create or replace function @SCHEMA@.auth_can_manage(p_account uuid) returns void
35
+ language plpgsql stable
36
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
37
+ as $$
38
+ declare
39
+ c record;
40
+ login_at timestamptz;
41
+ methods jsonb;
42
+ begin
43
+ select * into c from @SCHEMA@.ctx();
44
+ if c.account is null then
45
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
46
+ end if;
47
+ if c.account = p_account and c.service is null then
48
+ select (s.meta->>'login_at')::timestamptz, coalesce(s.meta->'methods', '[]'::jsonb) into login_at, methods
49
+ from @SCHEMA@.secret s where s.id = c.session;
50
+ -- недавний вход — интерактивный (ключ, пара ключ + секрет и сессия от сервиса не в счёт); у аккаунта
51
+ -- с включённым TOTP — ещё и пройденный TOTP (ревью этапа 5)
52
+ if login_at > clock_timestamp() - @SCHEMA@.auth_recent()
53
+ and exists (select 1 from jsonb_array_elements_text(methods) m where m not in ('APIKEY', 'KEYSECRET', 'SERVICE'))
54
+ and (methods ? 'TOTP' or not exists (select 1 from @SCHEMA@.secret t where t.account = p_account and t.kind = 'TOTP'
55
+ and t.deleted_at is null and coalesce((t.meta->>'confirmed')::boolean, false))) then
56
+ return;
57
+ end if;
58
+ end if;
59
+ if not coalesce((@SCHEMA@.acl_api_for(c.tenant, c.categories, c.roles, 'auth.credential', true)->>'allow')::boolean, false) then
60
+ perform @SCHEMA@.lt_fail('acl_denied', 'привязка отклонена: нужен недавний вход или право auth.credential');
61
+ end if;
62
+ -- креды System, сервисов и системных администраторов выдаёт только системный администратор: иначе
63
+ -- сервис привязал бы себе вход под ними (ревью этапа 5)
64
+ if not @SCHEMA@.auth_target_ok(p_account) and not @SCHEMA@.auth_is_sysadmin() then
65
+ perform @SCHEMA@.lt_fail('acl_denied', 'привязка отклонена: нужен недавний вход или право auth.credential');
66
+ end if;
67
+ end $$;
68
+
69
+ -- Креды и сессии System, сервисов и системных администраторов затрагивает только сам аккаунт или
70
+ -- системный администратор (ревью этапа 5).
71
+ create or replace function @SCHEMA@.auth_guard(p_account uuid) returns void
72
+ language plpgsql stable
73
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
74
+ as $$
75
+ declare
76
+ c record;
77
+ begin
78
+ select * into c from @SCHEMA@.ctx();
79
+ if p_account is distinct from c.account and not @SCHEMA@.auth_target_ok(p_account) and not @SCHEMA@.auth_is_sysadmin() then
80
+ perform @SCHEMA@.lt_fail('acl_denied', 'креды и сессии этого аккаунта меняет только он сам или системный администратор');
81
+ end if;
82
+ end $$;
83
+
84
+ -- Актор — системный администратор: право letopis.credential по правилам System (роль admin в System).
85
+ create or replace function @SCHEMA@.auth_is_sysadmin() returns boolean
86
+ language plpgsql stable
87
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
88
+ as $$
89
+ declare
90
+ c record;
91
+ begin
92
+ select * into c from @SCHEMA@.ctx();
93
+ return c.account is not null
94
+ and coalesce((@SCHEMA@.acl_api_for(c.tenant, c.categories, c.roles, 'letopis.credential', true)->>'allow')::boolean, false);
95
+ end $$;
96
+
97
+ -- Подтверждать кред может только обладатель права auth.credential: свой кред аккаунт сам не
98
+ -- подтверждает (иначе обошёл бы requireConfirmed — проверку почты или телефона сервисом).
99
+ create or replace function @SCHEMA@.auth_may_confirm() returns boolean
100
+ language plpgsql stable
101
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
102
+ as $$
103
+ declare
104
+ c record;
105
+ begin
106
+ select * into c from @SCHEMA@.ctx();
107
+ return c.account is not null
108
+ and coalesce((@SCHEMA@.acl_api_for(c.tenant, c.categories, c.roles, 'auth.credential', true)->>'allow')::boolean, false);
109
+ end $$;
110
+
111
+ -- Аккаунт, для которого можно выпустить сессию: существует, включён, не System, не сервис и не
112
+ -- системный администратор (иначе сервис получил бы их права).
113
+ create or replace function @SCHEMA@.auth_target_ok(p_account uuid) returns boolean
114
+ language sql stable
115
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
116
+ return exists (select 1 from @SCHEMA@.entity a
117
+ where a.id = p_account and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid
118
+ and coalesce((a.data->>'enabled')::boolean, true) and a.id <> '@SYSTEM@'::uuid and not coalesce(a.data->'categories' ?| array['System', 'Service'], false))
119
+ and not exists (select 1 from @SCHEMA@.entity x
120
+ where x.id = @SCHEMA@.v5_id('@SYSTEM@'::uuid, 'member', array[p_account::text, '@SYSTEM@']) and x.class = 'member');
121
+
122
+ -- Аккаунт включён (для входа по кредам: сервисы и администраторы тоже входят).
123
+ create or replace function @SCHEMA@.auth_enabled(p_account uuid) returns boolean
124
+ language sql stable
125
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
126
+ return exists (select 1 from @SCHEMA@.entity a
127
+ where a.id = p_account and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid
128
+ and coalesce((a.data->>'enabled')::boolean, true));
129
+
130
+ -- Аккаунт — сервис (категория Service в System).
131
+ create or replace function @SCHEMA@.auth_is_service(p_account uuid) returns boolean
132
+ language sql stable
133
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
134
+ return exists (select 1 from @SCHEMA@.entity a
135
+ where a.id = p_account and a.class = 'Account' and a.tenant = '@SYSTEM@'::uuid
136
+ and coalesce(a.data->'categories' ? 'Service', false));
137
+
138
+ -- Свои ключи (APIKEY, KEYSECRET) сервис выпускает и отзывает сам: актор — сам сервис, не через
139
+ -- имперсонацию, по сессии, открытой его ключом (интерактивного входа у сервиса нет, а auth_can_manage
140
+ -- креды сервисов отдаёт только системному администратору). Прочие свои креды и ключи других
141
+ -- сервисов, System и системных администраторов — по-прежнему auth_can_manage.
142
+ create or replace function @SCHEMA@.auth_service_self(p_account uuid) returns boolean
143
+ language plpgsql stable
144
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
145
+ as $$
146
+ declare
147
+ c record;
148
+ begin
149
+ select * into c from @SCHEMA@.ctx();
150
+ return coalesce(c.account = p_account and c.service is null and @SCHEMA@.auth_is_service(p_account)
151
+ and exists (select 1 from @SCHEMA@.secret s
152
+ where s.id = c.session and coalesce(s.meta->'methods', '[]'::jsonb) ?| array['APIKEY', 'KEYSECRET']), false);
153
+ end $$;
154
+
155
+ -- Погасить сессии аккаунта, кроме p_except; каждая погашенная — сигнал кэшу с началом хэша токена.
156
+ create or replace function @SCHEMA@.auth_kill(p_account uuid, p_except uuid) returns int
157
+ language plpgsql volatile
158
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
159
+ as $$
160
+ declare
161
+ sig text[];
162
+ begin
163
+ with k as (
164
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp()
165
+ where s.account = p_account and s.kind = 'SESSION' and s.deleted_at is null and s.id is distinct from p_except
166
+ returning s.material)
167
+ select array_agg('session:' || left(encode(k.material, 'hex'), 16)) into sig from k;
168
+ perform @SCHEMA@.signal(sig);
169
+ return coalesce(cardinality(sig), 0);
170
+ end $$;
171
+
172
+ -- Погасить сессии, открытые ключом p_cred (APIKEY, KEYSECRET: meta.cred), кроме p_except. Сессия по
173
+ -- ключу того же вида без отметки креда (открыта до появления отметки) тоже гаснет: чей это ключ, не
174
+ -- узнать, а пережить отзыв своего ключа она не должна.
175
+ create or replace function @SCHEMA@.auth_kill_cred(p_account uuid, p_cred uuid, p_kind text, p_except uuid) returns int
176
+ language plpgsql volatile
177
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
178
+ as $$
179
+ declare
180
+ sig text[];
181
+ begin
182
+ with k as (
183
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp()
184
+ where s.account = p_account and s.kind = 'SESSION' and s.deleted_at is null and s.id is distinct from p_except
185
+ and (s.meta->>'cred' = p_cred::text or (not s.meta ? 'cred' and coalesce(s.meta->'methods', '[]'::jsonb) ? p_kind))
186
+ returning s.material)
187
+ select array_agg('session:' || left(encode(k.material, 'hex'), 16)) into sig from k;
188
+ perform @SCHEMA@.signal(sig);
189
+ return coalesce(cardinality(sig), 0);
190
+ end $$;
191
+
192
+ -- Сессия сервисом для пользователя (db.auth.sessionFor, вход по паролю и identity): право
193
+ -- auth.session, способы входа — в meta.
194
+ create or replace function @SCHEMA@.issue_session(p_account uuid, p_ttl interval default interval '7 days',
195
+ p_methods text[] default array['SERVICE']) returns text
196
+ language plpgsql volatile security definer
197
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
198
+ as $$
199
+ declare
200
+ c record;
201
+ begin
202
+ select * into c from @SCHEMA@.ctx();
203
+ perform @SCHEMA@.auth_session_required(c.account);
204
+ perform @SCHEMA@.acl_require('auth.session');
205
+ if not @SCHEMA@.auth_target_ok(p_account) then
206
+ perform @SCHEMA@.lt_fail('acl_denied', 'сессии для этого аккаунта не выпускаются');
207
+ end if;
208
+ return @SCHEMA@.new_session(p_account, p_ttl, jsonb_build_object('methods', to_jsonb(p_methods), 'service', c.account));
209
+ end $$;
210
+
211
+ -- Хэш пароля для проверки сервисом (scrypt — в Node): только с правом auth.password.
212
+ create or replace function @SCHEMA@.auth_password_get(p_ident text, p_kind text default 'PASSWORD')
213
+ returns table (cred uuid, account uuid, hash text, confirmed boolean)
214
+ language plpgsql stable security definer
215
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
216
+ as $$
217
+ #variable_conflict use_column
218
+ begin
219
+ perform @SCHEMA@.acl_require('auth.password');
220
+ if not @SCHEMA@.auth_kind_ok(p_kind) then
221
+ return;
222
+ end if;
223
+ return query
224
+ select s.id, s.account, convert_from(s.material, 'UTF8'), coalesce((s.meta->>'confirmed')::boolean, false)
225
+ from @SCHEMA@.secret s
226
+ where s.kind = p_kind and s.deleted_at is null
227
+ and casefold(s.ident) collate pg_unicode_fast = casefold(p_ident) collate pg_unicode_fast
228
+ and coalesce((s.meta->>'enabled')::boolean, true)
229
+ and @SCHEMA@.auth_enabled(s.account);
230
+ end $$;
231
+
232
+ -- Пароль (и любой «логин/пароль»): хэш приходит готовым (scrypt в Node). Новый идентификатор —
233
+ -- новая запись; свой — замена хэша. Смена пароля гасит остальные сессии аккаунта (текущую — если
234
+ -- p_keep_current = false). Занятый чужим аккаунтом идентификатор — тот же отказ, что без права.
235
+ create or replace function @SCHEMA@.auth_set_password(p_account uuid, p_ident text, p_hash text,
236
+ p_kind text default 'PASSWORD', p_confirmed boolean default null, p_keep_current boolean default true) returns uuid
237
+ language plpgsql volatile security definer
238
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
239
+ as $$
240
+ declare
241
+ c record;
242
+ cur record;
243
+ cid uuid;
244
+ begin
245
+ perform @SCHEMA@.auth_can_manage(p_account);
246
+ select * into c from @SCHEMA@.ctx();
247
+ if not @SCHEMA@.auth_may_confirm() then
248
+ p_confirmed := null;
249
+ end if;
250
+ if not @SCHEMA@.auth_kind_ok(p_kind) or coalesce(p_ident, '') = '' or p_hash !~ '^scrypt\$' then
251
+ perform @SCHEMA@.lt_fail('invalid_data', 'пароль: вид, идентификатор и хэш scrypt$… обязательны');
252
+ end if;
253
+ select s.id, s.account into cur from @SCHEMA@.secret s
254
+ where s.kind = p_kind and s.deleted_at is null
255
+ and casefold(s.ident) collate pg_unicode_fast = casefold(p_ident) collate pg_unicode_fast
256
+ for update;
257
+ if cur.id is not null and cur.account <> p_account then
258
+ perform @SCHEMA@.lt_fail('acl_denied', 'привязка отклонена: нужен недавний вход или право auth.credential');
259
+ end if;
260
+ if cur.id is not null then
261
+ update @SCHEMA@.secret s
262
+ set material = convert_to(p_hash, 'UTF8'),
263
+ meta = case when p_confirmed is null then s.meta else s.meta || jsonb_build_object('confirmed', p_confirmed) end
264
+ where s.id = cur.id;
265
+ cid := cur.id;
266
+ perform @SCHEMA@.auth_kill(p_account, case when p_keep_current then c.session end);
267
+ else
268
+ begin
269
+ insert into @SCHEMA@.secret (account, kind, ident, material, meta)
270
+ values (p_account, p_kind, p_ident, convert_to(p_hash, 'UTF8'), jsonb_build_object('confirmed', coalesce(p_confirmed, false)))
271
+ returning id into cid;
272
+ exception when unique_violation then
273
+ perform @SCHEMA@.lt_fail('acl_denied', 'привязка отклонена: нужен недавний вход или право auth.credential');
274
+ end;
275
+ end if;
276
+ return cid;
277
+ end $$;
278
+
279
+ -- Ключ API: lts_ и 48 шестнадцатеричных знаков; отдаётся один раз, в базе — sha256, в ident — префикс.
280
+ -- У аккаунта может быть несколько ключей: ротация — новый ключ и отзыв старого без простоя (отзыв
281
+ -- гасит только сессии старого ключа). Себе сервис выпускает ключ сам по сессии своего ключа.
282
+ create or replace function @SCHEMA@.auth_issue_apikey(p_account uuid, p_name text default null, p_ttl interval default null)
283
+ returns table (key text, id uuid)
284
+ language plpgsql volatile security definer
285
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
286
+ as $$
287
+ #variable_conflict use_variable
288
+ declare
289
+ k text;
290
+ cid uuid;
291
+ i int;
292
+ begin
293
+ if not @SCHEMA@.auth_service_self(p_account) then
294
+ perform @SCHEMA@.auth_can_manage(p_account);
295
+ end if;
296
+ for i in 1 .. 5 loop
297
+ k := 'lts_' || encode(@PGCRYPTO@.gen_random_bytes(24), 'hex');
298
+ begin
299
+ insert into @SCHEMA@.secret as x (account, kind, ident, material, meta, expires_at)
300
+ values (p_account, 'APIKEY', left(k, 12), sha256(convert_to(k, 'UTF8')),
301
+ jsonb_strip_nulls(jsonb_build_object('name', p_name, 'confirmed', true)), clock_timestamp() + p_ttl)
302
+ returning x.id into cid;
303
+ key := k;
304
+ id := cid;
305
+ return next;
306
+ return;
307
+ exception when unique_violation then
308
+ null; -- совпал префикс: новый ключ
309
+ end;
310
+ end loop;
311
+ perform @SCHEMA@.lt_raise('40001', 'conflict', 'не удалось выпустить ключ с уникальным префиксом');
312
+ end $$;
313
+
314
+ -- Вход по ключу API для db.auth.verifyApiKey: { account, token } или null (login_apikey — для
315
+ -- connect(), бросает no_session). Сессия помнит ключ (meta.cred): его отзыв гасит её.
316
+ create or replace function @SCHEMA@.auth_login_apikey(p_key text) returns jsonb
317
+ language plpgsql volatile security definer
318
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
319
+ as $$
320
+ declare
321
+ k record;
322
+ begin
323
+ select s.id, s.account into k from @SCHEMA@.secret s
324
+ where s.kind = 'APIKEY' and s.deleted_at is null
325
+ and casefold(s.ident) collate pg_unicode_fast = casefold(left(coalesce(p_key, ''), 12)) collate pg_unicode_fast
326
+ and s.material = sha256(convert_to(coalesce(p_key, ''), 'UTF8'))
327
+ and (s.expires_at is null or s.expires_at > clock_timestamp())
328
+ and coalesce((s.meta->>'enabled')::boolean, true)
329
+ and @SCHEMA@.auth_enabled(s.account);
330
+ if k.account is null then
331
+ return null;
332
+ end if;
333
+ return jsonb_build_object('account', k.account,
334
+ 'token', @SCHEMA@.new_session(k.account, interval '7 days', jsonb_build_object('methods', jsonb_build_array('APIKEY'), 'cred', k.id)));
335
+ end $$;
336
+
337
+ -- Пара ключ + секрет: ключ — открытый идентификатор, секрет отдаётся один раз, в базе — sha256.
338
+ -- Себе сервис выпускает пару сам по сессии своего ключа.
339
+ create or replace function @SCHEMA@.auth_issue_keysecret(p_account uuid, p_name text default null)
340
+ returns table (key text, secret text, id uuid)
341
+ language plpgsql volatile security definer
342
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
343
+ as $$
344
+ #variable_conflict use_variable
345
+ declare
346
+ k text := encode(@PGCRYPTO@.gen_random_bytes(8), 'hex');
347
+ s text := encode(@PGCRYPTO@.gen_random_bytes(24), 'hex');
348
+ begin
349
+ if not @SCHEMA@.auth_service_self(p_account) then
350
+ perform @SCHEMA@.auth_can_manage(p_account);
351
+ end if;
352
+ insert into @SCHEMA@.secret as x (account, kind, ident, material, meta)
353
+ values (p_account, 'KEYSECRET', k, sha256(convert_to(s, 'UTF8')), jsonb_strip_nulls(jsonb_build_object('name', p_name, 'confirmed', true)))
354
+ returning x.id into id;
355
+ key := k;
356
+ secret := s;
357
+ return next;
358
+ end $$;
359
+
360
+ -- Вход по паре ключ + секрет: { account, token } или null. Сессия помнит пару (meta.cred).
361
+ create or replace function @SCHEMA@.login_keysecret(p_key text, p_secret text) returns jsonb
362
+ language plpgsql volatile security definer
363
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
364
+ as $$
365
+ declare
366
+ k record;
367
+ begin
368
+ select s.id, s.account into k from @SCHEMA@.secret s
369
+ where s.kind = 'KEYSECRET' and s.deleted_at is null
370
+ and casefold(s.ident) collate pg_unicode_fast = casefold(p_key) collate pg_unicode_fast
371
+ and s.material = sha256(convert_to(coalesce(p_secret, ''), 'UTF8'))
372
+ and coalesce((s.meta->>'enabled')::boolean, true)
373
+ and @SCHEMA@.auth_enabled(s.account);
374
+ if k.account is null then
375
+ return null;
376
+ end if;
377
+ return jsonb_build_object('account', k.account,
378
+ 'token', @SCHEMA@.new_session(k.account, interval '7 days', jsonb_build_object('methods', jsonb_build_array('KEYSECRET'), 'cred', k.id)));
379
+ end $$;
380
+
381
+ -- Одноразовый код для доставки сервисом (почта, SMS, сброс пароля): только с правом auth.otp.
382
+ -- Повторный выпуск на тот же идентификатор заменяет код: действует только последний.
383
+ create or replace function @SCHEMA@.auth_issue_otp(p_account uuid, p_ident text, p_kind text default 'OTP',
384
+ p_ttl interval default interval '10 minutes', p_digits int default 6, p_max_attempts int default 5) returns text
385
+ language plpgsql volatile security definer
386
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
387
+ as $$
388
+ declare
389
+ code text;
390
+ begin
391
+ perform @SCHEMA@.acl_require('auth.otp');
392
+ if p_kind !~ '^OTP(_[A-Z0-9]{1,27})?$' or coalesce(p_ident, '') = '' or p_digits not between 4 and 10 then
393
+ perform @SCHEMA@.lt_fail('invalid_data', 'код: вид OTP или OTP_<имя>, идентификатор, 4–10 знаков');
394
+ end if;
395
+ if not @SCHEMA@.auth_enabled(p_account) then
396
+ perform @SCHEMA@.lt_fail('target_not_found', 'аккаунт не найден');
397
+ end if;
398
+ if not @SCHEMA@.auth_target_ok(p_account) and not @SCHEMA@.auth_is_sysadmin() then
399
+ perform @SCHEMA@.lt_fail('acl_denied', 'коды входа для System, сервисов и системных администраторов выпускает только системный администратор');
400
+ end if;
401
+ code := lpad((('x' || encode(@PGCRYPTO@.gen_random_bytes(7), 'hex'))::bit(56)::bigint % (10::bigint ^ p_digits)::bigint)::text, p_digits, '0');
402
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp()
403
+ where s.kind = p_kind and s.deleted_at is null
404
+ and casefold(s.ident) collate pg_unicode_fast = casefold(p_ident) collate pg_unicode_fast;
405
+ insert into @SCHEMA@.secret (account, kind, ident, material, meta, expires_at)
406
+ values (p_account, p_kind, p_ident, sha256(convert_to(code, 'UTF8')),
407
+ jsonb_build_object('attempts', 0, 'max_attempts', least(greatest(coalesce(p_max_attempts, 5), 1), 10), 'confirmed', true),
408
+ clock_timestamp() + p_ttl);
409
+ return code;
410
+ end $$;
411
+
412
+ -- Вход по одноразовому коду: проверка атомарна (строка кода блокируется), учитывает срок и счётчик
413
+ -- попыток (лимит задал выпуск кода, а не вход); успех сжигает код, перебор — тоже. { account, token } или null.
414
+ create or replace function @SCHEMA@.login_otp(p_ident text, p_code text, p_kind text default 'OTP')
415
+ returns jsonb
416
+ language plpgsql volatile security definer
417
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
418
+ as $$
419
+ declare
420
+ s record;
421
+ n int;
422
+ begin
423
+ -- счётчик попыток транзакционный: вызывающий мог бы откатывать неудачи к точке сохранения, поэтому
424
+ -- код проверяет только сервис с правом auth.otp, и частоту попыток ограничивает он (ревью этапа 5)
425
+ perform @SCHEMA@.acl_require('auth.otp');
426
+ select x.id, x.account, x.material, x.expires_at, coalesce((x.meta->>'attempts')::int, 0) as attempts,
427
+ coalesce((x.meta->>'max_attempts')::int, 5) as max_attempts into s
428
+ from @SCHEMA@.secret x
429
+ where x.kind = p_kind and x.kind ~ '^OTP' and x.deleted_at is null
430
+ and casefold(x.ident) collate pg_unicode_fast = casefold(p_ident) collate pg_unicode_fast
431
+ for update;
432
+ if s.id is null then
433
+ return null;
434
+ end if;
435
+ if s.expires_at <= clock_timestamp() then
436
+ update @SCHEMA@.secret set deleted_at = clock_timestamp() where id = s.id;
437
+ return null;
438
+ end if;
439
+ if s.material <> sha256(convert_to(coalesce(p_code, ''), 'UTF8')) then
440
+ n := s.attempts + 1;
441
+ if n >= s.max_attempts then
442
+ update @SCHEMA@.secret set deleted_at = clock_timestamp() where id = s.id;
443
+ else
444
+ update @SCHEMA@.secret set meta = meta || jsonb_build_object('attempts', n) where id = s.id;
445
+ end if;
446
+ return null;
447
+ end if;
448
+ update @SCHEMA@.secret set deleted_at = clock_timestamp() where id = s.id;
449
+ if not @SCHEMA@.auth_enabled(s.account) then
450
+ return null;
451
+ end if;
452
+ return jsonb_build_object('account', s.account,
453
+ 'token', @SCHEMA@.new_session(s.account, interval '7 days', jsonb_build_object('methods', jsonb_build_array(p_kind))));
454
+ end $$;
455
+
456
+ -- TOTP (RFC 6238): код шага — HOTP(секрет, шаг) на HMAC-SHA1, 6 знаков, шаг 30 секунд.
457
+ create or replace function @SCHEMA@.totp_code(p_secret bytea, p_step bigint) returns text
458
+ language plpgsql immutable
459
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
460
+ as $$
461
+ declare
462
+ h bytea := @PGCRYPTO@.hmac(int8send(p_step), p_secret, 'sha1');
463
+ o int := get_byte(h, 19) & 15;
464
+ begin
465
+ return lpad(((((get_byte(h, o) & 127)::bigint << 24) | (get_byte(h, o + 1)::bigint << 16)
466
+ | (get_byte(h, o + 2)::bigint << 8) | get_byte(h, o + 3)::bigint) % 1000000)::text, 6, '0');
467
+ end $$;
468
+
469
+ -- Подключение TOTP: новый секрет (20 случайных байт) отдаётся один раз; фактор активирует первая
470
+ -- успешная проверка. Повторное подключение заменяет секрет и сбрасывает подтверждение.
471
+ create or replace function @SCHEMA@.auth_enroll_totp(p_account uuid) returns bytea
472
+ language plpgsql volatile security definer
473
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
474
+ as $$
475
+ declare
476
+ sec bytea := @PGCRYPTO@.gen_random_bytes(20);
477
+ begin
478
+ perform @SCHEMA@.auth_can_manage(p_account);
479
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp()
480
+ where s.account = p_account and s.kind = 'TOTP' and s.deleted_at is null;
481
+ insert into @SCHEMA@.secret (account, kind, ident, material, meta)
482
+ values (p_account, 'TOTP', p_account::text, sec, jsonb_build_object('confirmed', false, 'last_step', 0));
483
+ return sec;
484
+ end $$;
485
+
486
+ -- Проверка кода TOTP в окне ±p_window шагов: повтор использованного шага отклоняется; первая удача
487
+ -- включает фактор. Проверяет сам аккаунт (тогда способ TOTP добавляется к текущей сессии) или
488
+ -- обладатель права auth.totp.
489
+ create or replace function @SCHEMA@.auth_verify_totp(p_account uuid, p_code text, p_window int default 1) returns boolean
490
+ language plpgsql volatile security definer
491
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
492
+ as $$
493
+ declare
494
+ c record;
495
+ s record;
496
+ now_step bigint := floor(extract(epoch from clock_timestamp()) / 30)::bigint;
497
+ st bigint;
498
+ w int;
499
+ begin
500
+ select * into c from @SCHEMA@.ctx();
501
+ perform @SCHEMA@.auth_session_required(c.account);
502
+ -- счётчик неудач транзакционный (откат к точке сохранения его снял бы): проверяет сервис с правом
503
+ -- auth.totp и ограничивает частоту сам (ревью этапа 5)
504
+ perform @SCHEMA@.acl_require('auth.totp');
505
+ perform @SCHEMA@.auth_guard(p_account);
506
+ select x.id, x.material, coalesce((x.meta->>'last_step')::bigint, 0) as last_step,
507
+ coalesce((x.meta->>'fails')::int, 0) as fails, (x.meta->>'locked_until')::timestamptz as locked_until into s
508
+ from @SCHEMA@.secret x
509
+ where x.account = p_account and x.kind = 'TOTP' and x.deleted_at is null
510
+ for update;
511
+ if s.id is null or s.locked_until > clock_timestamp() then
512
+ return false;
513
+ end if;
514
+ -- окно — не больше шага в каждую сторону: шире окно — проще перебор (ревью этапа 5)
515
+ w := least(greatest(coalesce(p_window, 1), 0), 1);
516
+ for st in now_step - w .. now_step + w loop
517
+ continue when st <= s.last_step;
518
+ if @SCHEMA@.totp_code(s.material, st) = coalesce(p_code, '') then
519
+ update @SCHEMA@.secret set meta = (meta - 'fails' - 'locked_until') || jsonb_build_object('last_step', st, 'confirmed', true) where id = s.id;
520
+ if c.account = p_account and c.service is null then
521
+ update @SCHEMA@.secret x
522
+ set meta = x.meta || jsonb_build_object('methods', coalesce(x.meta->'methods', '[]'::jsonb) || '["TOTP"]'::jsonb,
523
+ 'login_at', clock_timestamp())
524
+ where x.id = c.session;
525
+ end if;
526
+ return true;
527
+ end if;
528
+ end loop;
529
+ -- неудача: после пяти подряд проверка закрыта на 5 минут
530
+ update @SCHEMA@.secret set meta = meta || jsonb_build_object('fails', s.fails + 1)
531
+ || case when s.fails + 1 >= 5 then jsonb_build_object('fails', 0, 'locked_until', clock_timestamp() + interval '5 minutes') else '{}'::jsonb end
532
+ where id = s.id;
533
+ return false;
534
+ end $$;
535
+
536
+ create or replace function @SCHEMA@.auth_totp_enabled(p_account uuid) returns boolean
537
+ language plpgsql stable security definer
538
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
539
+ as $$
540
+ declare
541
+ c record;
542
+ begin
543
+ select * into c from @SCHEMA@.ctx();
544
+ perform @SCHEMA@.auth_session_required(c.account);
545
+ if c.account <> p_account then
546
+ perform @SCHEMA@.acl_require('auth.totp');
547
+ perform @SCHEMA@.auth_guard(p_account);
548
+ end if;
549
+ return exists (select 1 from @SCHEMA@.secret s where s.account = p_account and s.kind = 'TOTP' and s.deleted_at is null
550
+ and coalesce((s.meta->>'confirmed')::boolean, false));
551
+ end $$;
552
+
553
+ -- Внешняя identity (TELEGRAM, GOOGLE, SSO-провайдер): токен проверяет сервис, здесь — связка.
554
+ create or replace function @SCHEMA@.auth_link(p_account uuid, p_kind text, p_ident text, p_meta jsonb default '{}',
555
+ p_confirmed boolean default false) returns uuid
556
+ language plpgsql volatile security definer
557
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
558
+ as $$
559
+ declare
560
+ cid uuid;
561
+ begin
562
+ perform @SCHEMA@.auth_can_manage(p_account);
563
+ if not @SCHEMA@.auth_may_confirm() then
564
+ p_confirmed := false;
565
+ end if;
566
+ if not @SCHEMA@.auth_kind_ok(p_kind) or p_kind = 'PASSWORD' or coalesce(p_ident, '') = '' then
567
+ perform @SCHEMA@.lt_fail('invalid_data', 'identity: вид (TELEGRAM, GOOGLE, …) и идентификатор обязательны');
568
+ end if;
569
+ begin
570
+ insert into @SCHEMA@.secret (account, kind, ident, meta)
571
+ values (p_account, p_kind, p_ident, coalesce(p_meta, '{}'::jsonb) - 'confirmed' - 'enabled'
572
+ || jsonb_build_object('confirmed', coalesce(p_confirmed, false)))
573
+ returning id into cid;
574
+ exception when unique_violation then
575
+ perform @SCHEMA@.lt_fail('acl_denied', 'привязка отклонена: нужен недавний вход или право auth.credential');
576
+ end;
577
+ return cid;
578
+ end $$;
579
+
580
+ -- Поиск аккаунта по identity: только с правом auth.identity; неподтверждённая связка — только при
581
+ -- p_require_confirmed = false.
582
+ create or replace function @SCHEMA@.auth_lookup(p_kind text, p_ident text, p_require_confirmed boolean default true) returns uuid
583
+ language plpgsql stable security definer
584
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
585
+ as $$
586
+ declare
587
+ acc uuid;
588
+ begin
589
+ perform @SCHEMA@.acl_require('auth.identity');
590
+ select s.account into acc from @SCHEMA@.secret s
591
+ where s.kind = p_kind and s.deleted_at is null and @SCHEMA@.auth_kind_ok(s.kind)
592
+ and casefold(s.ident) collate pg_unicode_fast = casefold(p_ident) collate pg_unicode_fast
593
+ and coalesce((s.meta->>'enabled')::boolean, true)
594
+ and (not coalesce(p_require_confirmed, true) or coalesce((s.meta->>'confirmed')::boolean, false))
595
+ and @SCHEMA@.auth_enabled(s.account);
596
+ return acc;
597
+ end $$;
598
+
599
+ -- Креды аккаунта без секретного материала: сам аккаунт или право auth.credential.
600
+ create or replace function @SCHEMA@.auth_credentials(p_account uuid)
601
+ returns table (id uuid, kind text, ident text, confirmed boolean, enabled boolean, meta jsonb, created_at timestamptz, expires_at timestamptz)
602
+ language plpgsql stable security definer
603
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
604
+ as $$
605
+ #variable_conflict use_column
606
+ declare
607
+ c record;
608
+ begin
609
+ select * into c from @SCHEMA@.ctx();
610
+ perform @SCHEMA@.auth_session_required(c.account);
611
+ if c.account <> p_account then
612
+ perform @SCHEMA@.acl_require('auth.credential');
613
+ perform @SCHEMA@.auth_guard(p_account);
614
+ end if;
615
+ return query
616
+ select s.id, s.kind, case when s.kind = 'TOTP' then null else s.ident end,
617
+ coalesce((s.meta->>'confirmed')::boolean, false), coalesce((s.meta->>'enabled')::boolean, true),
618
+ s.meta - 'confirmed' - 'enabled' - 'attempts' - 'last_step', s.created_at, s.expires_at
619
+ from @SCHEMA@.secret s
620
+ where s.account = p_account and s.kind not in ('SESSION', 'KEY') and s.deleted_at is null
621
+ and (s.expires_at is null or s.expires_at > clock_timestamp())
622
+ order by s.created_at, s.id;
623
+ end $$;
624
+
625
+ -- Удаление креда. Ключ API и пара ключ + секрет гасят только свои сессии (meta.cred) — ротация без
626
+ -- простоя; прочие креды — остальные сессии аккаунта. Текущую сессию — если p_keep_current = false.
627
+ -- Свои ключи сервис отзывает сам по сессии своего ключа; последний действующий ключ сервиса не
628
+ -- отзывается никем: сервис остался бы без входа.
629
+ create or replace function @SCHEMA@.auth_revoke_credential(p_id uuid, p_keep_current boolean default true) returns boolean
630
+ language plpgsql volatile security definer
631
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
632
+ as $$
633
+ declare
634
+ c record;
635
+ cr record;
636
+ is_key boolean;
637
+ begin
638
+ select * into c from @SCHEMA@.ctx();
639
+ perform @SCHEMA@.auth_session_required(c.account);
640
+ select s.account, s.kind,
641
+ (s.expires_at is null or s.expires_at > clock_timestamp()) and coalesce((s.meta->>'enabled')::boolean, true) as live
642
+ into cr from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY') and s.deleted_at is null;
643
+ if cr.account is null then
644
+ return false;
645
+ end if;
646
+ is_key := cr.kind in ('APIKEY', 'KEYSECRET');
647
+ if not (is_key and @SCHEMA@.auth_service_self(cr.account)) then
648
+ perform @SCHEMA@.auth_can_manage(cr.account);
649
+ end if;
650
+ if is_key and cr.live and @SCHEMA@.auth_is_service(cr.account) then
651
+ -- ключи сервиса — под блокировкой: два параллельных отзыва не снимут оба последних ключа
652
+ perform 1 from @SCHEMA@.secret s
653
+ where s.account = cr.account and s.kind in ('APIKEY', 'KEYSECRET') and s.deleted_at is null
654
+ for update;
655
+ if not exists (select 1 from @SCHEMA@.secret s
656
+ where s.account = cr.account and s.kind in ('APIKEY', 'KEYSECRET') and s.deleted_at is null and s.id <> p_id
657
+ and (s.expires_at is null or s.expires_at > clock_timestamp()) and coalesce((s.meta->>'enabled')::boolean, true)) then
658
+ perform @SCHEMA@.lt_fail('acl_denied', 'последний действующий ключ сервиса не отзывается: сначала выпустите новый');
659
+ end if;
660
+ end if;
661
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp() where s.id = p_id and s.deleted_at is null;
662
+ if not found then
663
+ return false;
664
+ end if;
665
+ if is_key then
666
+ perform @SCHEMA@.auth_kill_cred(cr.account, p_id, cr.kind, case when p_keep_current then c.session end);
667
+ else
668
+ perform @SCHEMA@.auth_kill(cr.account, case when p_keep_current then c.session end);
669
+ end if;
670
+ return true;
671
+ end $$;
672
+
673
+ -- Признаки confirmed и enabled креда: только с правом auth.credential.
674
+ create or replace function @SCHEMA@.auth_set_flags(p_id uuid, p_confirmed boolean default null, p_enabled boolean default null) returns boolean
675
+ language plpgsql volatile security definer
676
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
677
+ as $$
678
+ declare
679
+ acc uuid;
680
+ begin
681
+ perform @SCHEMA@.acl_require('auth.credential');
682
+ select s.account into acc from @SCHEMA@.secret s where s.id = p_id and s.kind not in ('SESSION', 'KEY') and s.deleted_at is null;
683
+ if acc is null then
684
+ return false;
685
+ end if;
686
+ perform @SCHEMA@.auth_guard(acc);
687
+ update @SCHEMA@.secret s
688
+ set meta = s.meta || jsonb_strip_nulls(jsonb_build_object('confirmed', p_confirmed, 'enabled', p_enabled))
689
+ where s.id = p_id;
690
+ return found;
691
+ end $$;
692
+
693
+ -- Продление текущей сессии (библиотека — не чаще раза в минуту, §2.9). Новый срок — от сейчас.
694
+ create or replace function @SCHEMA@.auth_refresh(p_ttl interval default interval '7 days') returns timestamptz
695
+ language plpgsql volatile security definer
696
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
697
+ as $$
698
+ declare
699
+ c record;
700
+ e timestamptz;
701
+ begin
702
+ select * into c from @SCHEMA@.ctx();
703
+ perform @SCHEMA@.auth_session_required(c.account);
704
+ -- не дольше срока, выданного при входе (meta.ttl): продление не делает сессию вечной (ревью этапа 5)
705
+ update @SCHEMA@.secret s
706
+ set expires_at = greatest(s.expires_at, clock_timestamp()
707
+ + least(coalesce(p_ttl, interval '7 days'), coalesce((s.meta->>'ttl')::interval, interval '7 days')))
708
+ where s.id = c.session returning s.expires_at into e;
709
+ return e;
710
+ end $$;
711
+
712
+ -- Выход: гасит текущую сессию.
713
+ create or replace function @SCHEMA@.auth_logout() returns boolean
714
+ language plpgsql volatile security definer
715
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
716
+ as $$
717
+ declare
718
+ c record;
719
+ m bytea;
720
+ begin
721
+ select * into c from @SCHEMA@.ctx();
722
+ perform @SCHEMA@.auth_session_required(c.account);
723
+ update @SCHEMA@.secret s set deleted_at = clock_timestamp() where s.id = c.session and s.deleted_at is null returning s.material into m;
724
+ perform @SCHEMA@.signal(array['session:' || left(encode(m, 'hex'), 16)]);
725
+ return m is not null;
726
+ end $$;
727
+
728
+ -- Выход везде: все сессии аккаунта (своего — сам; чужого — с правом auth.session).
729
+ create or replace function @SCHEMA@.auth_logout_all(p_account uuid default null) returns int
730
+ language plpgsql volatile security definer
731
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
732
+ as $$
733
+ declare
734
+ c record;
735
+ acc uuid;
736
+ begin
737
+ select * into c from @SCHEMA@.ctx();
738
+ perform @SCHEMA@.auth_session_required(c.account);
739
+ acc := coalesce(p_account, c.account);
740
+ if acc <> c.account or c.service is not null then
741
+ perform @SCHEMA@.acl_require('auth.session');
742
+ perform @SCHEMA@.auth_guard(acc);
743
+ end if;
744
+ return @SCHEMA@.auth_kill(acc, null);
745
+ end $$;
746
+
747
+ -- Переключение арендатора сессии (auth.switch): свой или по членству. Сессия ищется по токену
748
+ -- напрямую: прежний арендатор мог стать недоступным (выход из членства), и ctx() отказал бы.
749
+ create or replace function @SCHEMA@.auth_switch(p_tenant uuid) returns uuid
750
+ language plpgsql volatile security definer
751
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
752
+ as $$
753
+ declare
754
+ tok text := nullif(current_setting('letopis.token', true), '');
755
+ s record;
756
+ begin
757
+ if nullif(current_setting('letopis.account', true), '') is not null then
758
+ perform @SCHEMA@.lt_fail('invalid_query', 'auth.switch — для собственной сессии, не при имперсонации');
759
+ end if;
760
+ select x.id, x.account into s from @SCHEMA@.secret x
761
+ where x.kind = 'SESSION' and x.deleted_at is null and x.material = sha256(convert_to(coalesce(tok, ''), 'UTF8'))
762
+ and (x.expires_at is null or x.expires_at > clock_timestamp())
763
+ for update;
764
+ if s.id is null or not @SCHEMA@.auth_enabled(s.account) then
765
+ perform @SCHEMA@.lt_fail('no_session', 'нет действующей сессии');
766
+ end if;
767
+ if p_tenant <> s.account and not (
768
+ exists (select 1 from @SCHEMA@.entity x
769
+ where x.id = @SCHEMA@.v5_id(p_tenant, 'member', array[s.account::text, p_tenant::text]) and x.class = 'member' and x.tenant = p_tenant)
770
+ and @SCHEMA@.auth_enabled(p_tenant)) then
771
+ perform @SCHEMA@.lt_fail('tenant_denied', 'нет членства в арендаторе');
772
+ end if;
773
+ update @SCHEMA@.secret x
774
+ set meta = case when p_tenant = s.account then x.meta - 'tenant' else x.meta || jsonb_build_object('tenant', p_tenant) end
775
+ where x.id = s.id;
776
+ return p_tenant;
777
+ end $$;
778
+
779
+ -- Арендаторы, доступные аккаунту сессии: собственный и по членству (с ролями).
780
+ create or replace function @SCHEMA@.auth_tenants() returns table (tenant uuid, roles jsonb)
781
+ language plpgsql stable security definer
782
+ set search_path = pg_catalog, @SCHEMA@, pg_temp
783
+ as $$
784
+ #variable_conflict use_column
785
+ declare
786
+ c record;
787
+ begin
788
+ select * into c from @SCHEMA@.ctx();
789
+ perform @SCHEMA@.auth_session_required(c.account);
790
+ return query
791
+ select c.account, '["owner"]'::jsonb
792
+ union all
793
+ select m.tenant, coalesce(m.data->'roles', '[]'::jsonb)
794
+ from @SCHEMA@.entity m
795
+ where m.class = 'member' and m.links->>'User' = c.account::text and @SCHEMA@.auth_enabled(m.tenant);
796
+ end $$;
797
+
798
+ do $$
799
+ declare f text;
800
+ begin
801
+ foreach f in array array[
802
+ 'auth_recent()', 'auth_kind_ok(text)', 'auth_session_required(uuid)', 'auth_can_manage(uuid)', 'auth_target_ok(uuid)', 'auth_enabled(uuid)',
803
+ 'auth_is_service(uuid)', 'auth_service_self(uuid)', 'auth_kill(uuid, uuid)', 'auth_kill_cred(uuid, uuid, text, uuid)', 'auth_guard(uuid)',
804
+ 'auth_is_sysadmin()', 'auth_may_confirm()', 'issue_session(uuid, interval, text[])', 'auth_password_get(text, text)',
805
+ 'auth_set_password(uuid, text, text, text, boolean, boolean)', 'auth_issue_apikey(uuid, text, interval)',
806
+ 'auth_issue_keysecret(uuid, text)', 'auth_login_apikey(text)', 'login_keysecret(text, text)', 'auth_issue_otp(uuid, text, text, interval, int, int)',
807
+ 'login_otp(text, text, text)', 'totp_code(bytea, bigint)', 'auth_enroll_totp(uuid)', 'auth_verify_totp(uuid, text, int)',
808
+ 'auth_totp_enabled(uuid)', 'auth_link(uuid, text, text, jsonb, boolean)', 'auth_lookup(text, text, boolean)',
809
+ '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
811
+ execute format('alter function @SCHEMA@.%s owner to @OWNER@', f);
812
+ execute format('revoke all on function @SCHEMA@.%s from public', f);
813
+ end loop;
814
+ end $$;
815
+ -- до этапа 5 выпуск сессии сервисом принимал два аргумента; ранние сигнатуры OTP этапа 5
816
+ drop function if exists @SCHEMA@.issue_session(uuid, interval);
817
+ drop function if exists @SCHEMA@.auth_issue_otp(uuid, text, text, interval, int);
818
+ drop function if exists @SCHEMA@.login_otp(text, text, text, int);
819
+ grant execute on function
820
+ @SCHEMA@.issue_session(uuid, interval, text[]), @SCHEMA@.auth_password_get(text, text),
821
+ @SCHEMA@.auth_set_password(uuid, text, text, text, boolean, boolean), @SCHEMA@.auth_issue_apikey(uuid, text, interval),
822
+ @SCHEMA@.auth_issue_keysecret(uuid, text), @SCHEMA@.auth_login_apikey(text), @SCHEMA@.login_keysecret(text, text), @SCHEMA@.auth_issue_otp(uuid, text, text, interval, int, int),
823
+ @SCHEMA@.login_otp(text, text, text), @SCHEMA@.auth_enroll_totp(uuid), @SCHEMA@.auth_verify_totp(uuid, text, int),
824
+ @SCHEMA@.auth_totp_enabled(uuid), @SCHEMA@.auth_link(uuid, text, text, jsonb, boolean), @SCHEMA@.auth_lookup(text, text, boolean),
825
+ @SCHEMA@.auth_credentials(uuid), @SCHEMA@.auth_revoke_credential(uuid, boolean), @SCHEMA@.auth_set_flags(uuid, boolean, boolean),
826
+ @SCHEMA@.auth_refresh(interval), @SCHEMA@.auth_logout(), @SCHEMA@.auth_logout_all(uuid), @SCHEMA@.auth_switch(uuid),
827
+ @SCHEMA@.auth_tenants() to @APP@;