Files
BitrixDealsBot/db/migrations/001_initial.sql
T

494 lines
16 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Подключение расширения pgcrypto для генерации UUID.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE SCHEMA IF NOT EXISTS binding;
CREATE SCHEMA IF NOT EXISTS oauth;
CREATE TABLE IF NOT EXISTS binding.portals (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
-- member_id - уникальный идентификатор портала из Битрикса.
member_id text NOT NULL UNIQUE,
-- domain - домен портала, например: example.bitrix24.ru.
domain text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS binding.tokens (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
bitrix_user_id bigint NOT NULL,
-- Хеш токена, генерируется сервером.
token_hash bytea NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS binding_tokens_owner_idx
ON binding.tokens (portal_id, bitrix_user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS binding.user_bindings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
bitrix_user_id bigint NOT NULL,
telegram_user_id bigint NOT NULL,
telegram_chat_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (portal_id, bitrix_user_id),
UNIQUE (portal_id, telegram_user_id)
);
CREATE TABLE IF NOT EXISTS oauth.user_credentials (
portal_id bigint NOT NULL REFERENCES binding.portals(id) ON DELETE CASCADE,
bitrix_user_id bigint NOT NULL,
access_token bytea NOT NULL,
refresh_token bytea NOT NULL,
expires_at timestamptz NOT NULL,
-- Двойной механизм защиты от гонок данных при обновлении токенов.
version bigint NOT NULL DEFAULT 1,
refresh_locked_until timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (portal_id, bitrix_user_id)
);
CREATE OR REPLACE FUNCTION binding.issue_v1(
p_member_id text,
p_domain text,
p_bitrix_user_id bigint,
p_token_hash bytea,
p_token_expires_at timestamptz,
p_access_token bytea,
p_refresh_token bytea,
p_oauth_expires_at timestamptz
)
RETURNS TABLE(token_id uuid, expires_at timestamptz)
LANGUAGE plpgsql
-- Задаем SECURITY DEFINER, чтобы функция выполнялась с правами владельца
-- схемы binding.
SECURITY DEFINER
-- Устанавливаем search_path в pg_catalog, чтобы нельзя было подменить
-- функции в схеме binding или oauth.
SET search_path = pg_catalog
AS $function$
#variable_conflict error
DECLARE
v_portal_id bigint;
v_token_id uuid;
BEGIN
-- Добавляем данные о портале, если его ещё нет,
-- или обновляем домен, если портал уже существует.
INSERT INTO binding.portals(member_id, domain)
VALUES (p_member_id, lower(p_domain))
ON CONFLICT (member_id) DO UPDATE
SET domain = EXCLUDED.domain,
updated_at = now()
RETURNING id INTO v_portal_id;
-- Сохраняем OAuth-данные пользователя, если их ещё нет,
-- или обновляем их, если они уже существуют.
INSERT INTO oauth.user_credentials(
portal_id,
bitrix_user_id,
access_token,
refresh_token,
expires_at
)
VALUES (
v_portal_id,
p_bitrix_user_id,
p_access_token,
p_refresh_token,
p_oauth_expires_at
)
ON CONFLICT (portal_id, bitrix_user_id) DO UPDATE
SET access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
expires_at = EXCLUDED.expires_at,
version = oauth.user_credentials.version + 1,
refresh_locked_until = NULL,
updated_at = now();
-- Отзываем все предыдущие неиспользованные токены пользователя.
UPDATE binding.tokens
SET revoked_at = now()
WHERE portal_id = v_portal_id
AND bitrix_user_id = p_bitrix_user_id
AND consumed_at IS NULL
AND revoked_at IS NULL;
-- Выпускаем новый токен привязки.
INSERT INTO binding.tokens(
portal_id,
bitrix_user_id,
token_hash,
expires_at
)
VALUES (
v_portal_id,
p_bitrix_user_id,
p_token_hash,
p_token_expires_at
)
RETURNING id INTO v_token_id;
-- Возвращаем идентификатор токена и срок его действия.
RETURN QUERY SELECT v_token_id, p_token_expires_at;
END
$function$;
COMMENT ON FUNCTION binding.issue_v1(
text,
text,
bigint,
bytea,
timestamptz,
bytea,
bytea,
timestamptz
)
IS $doc$
Сохраняет OAuth-данные пользователя и выпускает токен привязки.
Гарантии:
- предыдущие неиспользованные токены отзываются;
- при обновлении OAuth-данных увеличивается их версия.
Возвращает:
- token_id - идентификатор токена;
- expires_at - срок действия токена.
$doc$;
CREATE OR REPLACE FUNCTION binding.consume_v1(
p_token_hash bytea,
p_telegram_user_id bigint,
p_telegram_chat_id bigint
)
RETURNS TABLE(
member_id text,
domain text,
bitrix_user_id bigint,
telegram_user_id bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
#variable_conflict error
DECLARE
v_portal_id bigint;
v_bitrix_user_id bigint;
BEGIN
-- UPDATE не позволит двум запросам погасить один токен.
UPDATE binding.tokens AS token
SET consumed_at = now()
WHERE token.token_hash = p_token_hash
AND token.consumed_at IS NULL
AND token.revoked_at IS NULL
AND token.expires_at > now()
RETURNING token.portal_id, token.bitrix_user_id
INTO v_portal_id, v_bitrix_user_id;
-- Если UPDATE не вернул ни одной строки, значит токен недействителен.
IF NOT FOUND THEN
RETURN;
END IF;
-- Удаляем все привязки к Telegram для данного портала и пользователя Битрикса,
-- кроме той, которая соответствует текущему пользователю Битрикса.
DELETE FROM binding.user_bindings AS user_binding
WHERE user_binding.portal_id = v_portal_id
AND user_binding.telegram_user_id = p_telegram_user_id
AND user_binding.bitrix_user_id <> v_bitrix_user_id;
-- Добавляем или обновляем привязку к Telegram для данного портала
-- и пользователя Битрикса.
INSERT INTO binding.user_bindings(
portal_id,
bitrix_user_id,
telegram_user_id,
telegram_chat_id
)
VALUES (
v_portal_id,
v_bitrix_user_id,
p_telegram_user_id,
p_telegram_chat_id
)
ON CONFLICT ON CONSTRAINT user_bindings_pkey DO UPDATE
SET telegram_user_id = EXCLUDED.telegram_user_id,
telegram_chat_id = EXCLUDED.telegram_chat_id,
updated_at = now();
RETURN QUERY
SELECT
portal.member_id,
portal.domain,
v_bitrix_user_id,
p_telegram_user_id
FROM binding.portals AS portal
WHERE portal.id = v_portal_id;
END
$function$;
COMMENT ON FUNCTION binding.consume_v1(
bytea,
bigint,
bigint
)
IS $doc$
Погашает токен привязки и сохраняет привязку к Telegram.
Гарантии:
- токен погашается только один раз;
- если токен недействителен, функция возвращает пустой результат;
- если токен действителен, функция возвращает данные портала и пользователя
Битрикса, а также сохраняет привязку к Telegram;
- если пользователь Битрикса уже был привязан к другому пользователю Telegram,
старая привязка удаляется.
Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- telegram_user_id - идентификатор пользователя Telegram.
$doc$;
CREATE OR REPLACE FUNCTION binding.find_by_telegram_v1(
p_telegram_user_id bigint,
p_member_id text DEFAULT NULL
)
RETURNS TABLE(
member_id text,
domain text,
bitrix_user_id bigint,
telegram_user_id bigint
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
SELECT
portal.member_id,
portal.domain,
user_binding.bitrix_user_id,
user_binding.telegram_user_id
FROM binding.user_bindings AS user_binding
JOIN binding.portals AS portal ON portal.id = user_binding.portal_id
WHERE user_binding.telegram_user_id = p_telegram_user_id
AND (p_member_id IS NULL OR portal.member_id = p_member_id)
ORDER BY user_binding.updated_at DESC
LIMIT 1
$function$;
COMMENT ON FUNCTION binding.find_by_telegram_v1(
bigint,
text
)
IS $doc$
Находит привязку к Telegram по идентификатору пользователя Telegram.
Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- telegram_user_id - идентификатор пользователя Telegram.
Если p_member_id не NULL, то поиск ограничивается указанным порталом.
$doc$;
CREATE OR REPLACE FUNCTION oauth.get_credentials_v1(
p_member_id text,
p_bitrix_user_id bigint
)
RETURNS TABLE(
member_id text,
domain text,
bitrix_user_id bigint,
access_token bytea,
refresh_token bytea,
expires_at timestamptz,
version bigint
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
SELECT
portal.member_id,
portal.domain,
credentials.bitrix_user_id,
credentials.access_token,
credentials.refresh_token,
credentials.expires_at,
credentials.version
FROM oauth.user_credentials AS credentials
JOIN binding.portals AS portal ON portal.id = credentials.portal_id
WHERE portal.member_id = p_member_id
AND credentials.bitrix_user_id = p_bitrix_user_id
$function$;
COMMENT ON FUNCTION oauth.get_credentials_v1(
text,
bigint
)
IS $doc$
Находит OAuth-данные пользователя по идентификатору портала и идентификатору
пользователя Битрикса.
Возвращает:
- member_id - идентификатор портала;
- domain - домен портала;
- bitrix_user_id - идентификатор пользователя Битрикса;
- access_token - токен доступа;
- refresh_token - токен обновления;
- expires_at - срок действия токена доступа;
- version - версия данных.
$doc$;
CREATE OR REPLACE FUNCTION oauth.claim_refresh_v1(
p_member_id text,
p_bitrix_user_id bigint,
p_version bigint
)
RETURNS boolean
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
-- Создаем временную таблицу claimed,
-- которая будет содержать результат обновления (CTE).
WITH claimed AS (
UPDATE oauth.user_credentials AS credentials
SET refresh_locked_until = now() + interval '30 seconds'
FROM binding.portals AS portal
WHERE credentials.portal_id = portal.id
AND portal.member_id = p_member_id
AND credentials.bitrix_user_id = p_bitrix_user_id
AND credentials.version = p_version
AND (
credentials.refresh_locked_until IS NULL
OR credentials.refresh_locked_until < now()
)
RETURNING 1
)
SELECT EXISTS(SELECT 1 FROM claimed)
$function$;
COMMENT ON FUNCTION oauth.claim_refresh_v1(
text,
bigint,
bigint
)
IS $doc$
Пытается захватить токен обновления для пользователя.
Возвращает:
- true, если захват успешен;
- false, если захват не удался.
$doc$;
CREATE OR REPLACE FUNCTION oauth.finish_refresh_v1(
p_member_id text,
p_bitrix_user_id bigint,
p_version bigint,
p_access_token bytea,
p_refresh_token bytea,
p_expires_at timestamptz
)
RETURNS boolean
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
WITH updated AS (
UPDATE oauth.user_credentials AS credentials
SET access_token = p_access_token,
refresh_token = p_refresh_token,
expires_at = p_expires_at,
version = credentials.version + 1,
refresh_locked_until = NULL,
updated_at = now()
FROM binding.portals AS portal
WHERE credentials.portal_id = portal.id
AND portal.member_id = p_member_id
AND credentials.bitrix_user_id = p_bitrix_user_id
AND credentials.version = p_version
RETURNING 1
)
SELECT EXISTS(SELECT 1 FROM updated)
$function$;
COMMENT ON FUNCTION oauth.finish_refresh_v1(
text,
bigint,
bigint,
bytea,
bytea,
timestamptz
) IS $doc$
Завершает процесс обновления токена для пользователя.
Возвращает:
- true, если обновление успешно завершено;
- false, если обновление не удалось.
$doc$;
CREATE OR REPLACE FUNCTION oauth.release_refresh_v1(
p_member_id text,
p_bitrix_user_id bigint,
p_version bigint
)
RETURNS void
LANGUAGE sql
VOLATILE
SECURITY DEFINER
SET search_path = pg_catalog
AS $function$
UPDATE oauth.user_credentials AS credentials
SET refresh_locked_until = NULL
FROM binding.portals AS portal
WHERE credentials.portal_id = portal.id
AND portal.member_id = p_member_id
AND credentials.bitrix_user_id = p_bitrix_user_id
AND credentials.version = p_version
$function$;
COMMENT ON FUNCTION oauth.release_refresh_v1(
text,
bigint,
bigint
) IS $doc$
Освобождает токен обновления для пользователя.
Возвращает:
- void.
$doc$;
-- Отзываем все права у PUBLIC.
REVOKE ALL ON ALL TABLES IN SCHEMA binding, oauth FROM PUBLIC;
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA binding, oauth FROM PUBLIC;
-- Даем права на использование схемы и выполнение функций ролям site_role
-- и bot_role.
GRANT USAGE ON SCHEMA binding TO site_role, bot_role;
GRANT USAGE ON SCHEMA oauth TO bot_role;
-- Даем права на выполнение функций ролям site_role и bot_role.
GRANT EXECUTE ON FUNCTION binding.issue_v1(
text, text, bigint, bytea, timestamptz, bytea, bytea, timestamptz
) TO site_role;
GRANT EXECUTE ON FUNCTION binding.consume_v1(bytea, bigint, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION binding.find_by_telegram_v1(bigint, text) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.get_credentials_v1(text, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.claim_refresh_v1(text, bigint, bigint) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.finish_refresh_v1(
text, bigint, bigint, bytea, bytea, timestamptz
) TO bot_role;
GRANT EXECUTE ON FUNCTION oauth.release_refresh_v1(text, bigint, bigint)
TO bot_role;