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.
- package/AGENT-CHEATSHEET.en.md +368 -0
- package/AGENT-CHEATSHEET.md +354 -0
- package/CHANGELOG.md +348 -0
- package/MIGRATION.md +190 -0
- package/README.en.md +1937 -0
- package/README.md +1493 -3466
- 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 +369 -551
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +164 -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 -250
- 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 +1369 -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 -351
- 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 -600
- package/sql/seed.auth.sql +0 -73
package/sql/80-auth.sql
ADDED
|
@@ -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@;
|