Триггеры WebAssembly в libSQL

Пользовательские функции WebAssembly в libSQL можно использовать для создания мощных триггеров, упрощающих автоматизацию рабочих процессов.

Первый официальный выпуск libSQL, нашей открытой версии SQLite, принес возможность динамически создавать определяемые пользователем функции на базе WebAssembly. Это интересная функция сама по себе, но в сочетании с триггерами базы данных они становятся мощным строительным блоком для автоматизации ваших рабочих процессов.

Триггеры базы данных

Триггеры базы данных — это древняя функция в мире баз данных, реализованная в большинстве продуктов, и libSQL не является исключением. Триггер — это просто хранимая процедура, которая запускается автоматически, когда в базе данных происходит определенное событие. В libSQL (и SQLite) следующие действия могут активировать триггер:

  • ВСТАВЛЯТЬ
  • ОБНОВЛЯТЬ
  • УДАЛИТЬ

Более того, триггер можно настроить на выполнение до, после или вместо исходного действия с некоторыми ограничениями.

Синтаксис оператора CREATE TRIGGER очень выразителен:

CREATE [TEMP|TEMPORARY] TRIGGER [IF NOT EXISTS] trigger
    [BEFORE|AFTER|INSTEAD OF]
    [INSERT|UPDATE OF [column_name*]|DELETE]
  ON table_name
    [FOR EACH ROW] [WHEN condition]
BEGIN
  [statement*];
END;

Но не волнуйтесь, мы рассмотрим несколько простых примеров.

Пользовательские функции WebAssembly

libSQL 0.1.0 позволяет пользователям создавать пользовательские функции не только программно, но и с помощью оператора CREATE FUNCTION, хорошо известного в мире SQL.

WebAssembly был выбран из-за его надежности, портативности и безопасности, а также производительности. Как объяснялось в нашей предыдущей статье, пользовательские функции WebAssembly можно легко реализовать на Rust с помощью библиотеки libsql_bindgen. Чтобы сделать его еще более эргономичным, мы размещаем интерактивное приложение libSQL bindgen — вы также можете развернуть собственное зеркало из нашего репозитория.

Вариант использования: управление пользователями

В этом примере мы реализуем поток базы данных для создания новых записей пользователей, убедившись, что их пароли не хранятся в открытом виде. Мы будем полагаться на триггеры базы данных, основанные на определяемых пользователем функциях WebAssembly, для автоматизации работы, перенося сложность на уровень базы данных.

Позже мы также представим проверку электронной почты, основанную на отправке пользователям одноразовых токенов.

Вот схема потока, реализованного в этом посте:

Таблица пользователей определяется по следующей схеме:

CREATE TABLE users(
  user TEXT PRIMARY KEY,
  email TEXT,
  pass TEXT
);

Шифрование паролей пользователей

Первая часть рабочего процесса заключается в сохранении паролей в зашифрованном виде, а не в виде открытого текста. Создадим таблицу с секретными значениями, используемыми для шифрования паролей вновь созданных пользователей:

CREATE TABLE secrets(secret text);
INSERT INTO secrets VALUES ('s3cretk3y');

Далее нам нужна функция шифрования. Вот где пользовательские функции WebAssembly становятся полезными. С помощью libSQL bindgen следующий фрагмент Rust может быть скомпилирован в Wasm и зарегистрирован как определяемая пользователем функция с именем encrypt:

pub fn encrypt(data: String, key: String) -> String {
  use magic_crypt::MagicCryptTrait;
  let mc = magic_crypt::new_magic_crypt!(key, 256);
  mc.encrypt_str_to_base64(data)
}

Сохраните сгенерированный SQL в локальный файл и загрузите его в libsql оболочке, вызвав .read <path-to-your-file> и вуаля, функция зарегистрирована! Вы также можете попробовать скопировать и вставить SQL прямо в оболочку libsql.

С доступными секретами и функцией encrypt() наш первый триггер можно создать следующим образом:

CREATE TRIGGER encrypt_pass AFTER INSERT ON users
  BEGIN
    UPDATE users
      SET pass = encrypt(
        new.pass,
        (SELECT secret FROM secrets LIMIT 1)
      )
  WHERE user = new.user;
END;

Сразу же после того, как новая строка вставлена ​​в пользователей, предоставленное значение для pass заменяется зашифрованным значением с использованием результата функции. Функция шифрования принимает два параметра: пароль и значение сегодняшнего секретного ключа, выбранного из ранее созданной таблицы secrets.

Проверка триггера

Чтобы проверить, работает ли шифрование, была бы весьма кстати соответствующая функция для выполнения описания. Вот исходный код, который вы снова отправляете в bindgen для генерации кода для UDF:

pub fn decrypt(data: String, key: String) -> String {
  use magic_crypt::MagicCryptTrait;
  let mc = magic_crypt::new_magic_crypt!(key, 256);
  mc.decrypt_base64_to_string(data)
    .unwrap_or_else(|_| "[ACCESS DENIED]".to_string())
}

Давайте проверим, работает ли триггер, добавив несколько записей в таблицу и проверив ее содержимое:

INSERT INTO users VALUES (
  'peter',
  '[email protected]',
  'roe-deer'
); 

INSERT INTO users VALUES (
  'iku',
  '[email protected]',
  'turso'
);

SELECT user, pass, decrypt(
    pass,
    (SELECT secret FROM secrets LIMIT 1)
  ) AS decrypted
  FROM users;

user   pass                      decrypted
-----  ------------------------  ---------
peter  1mXVhOvX0YWGHcfEYCvqgg==  roe-deer 
iku    GeJmXZujKUJbRai+S/4cBA==  turso 

SELECT user, pass, decrypt(
    pass,
    'incorrect-pass'
  ) AS decrypted
  FROM users;

user   pass                      decrypted      
-----  ------------------------  ---------------
peter  1mXVhOvX0YWGHcfEYCvqgg==  [ACCESS DENIED]
iku    GeJmXZujKUJbRai+S/4cBA==  [ACCESS DENIED]

Идеально — пароли хранятся в зашифрованном виде, но их все равно можно расшифровать с помощью соответствующего ключа.

Подтверждение адреса электронной почты

Но мы еще далеко не закончили изучение возможностей триггеров Wasm! На следующем этапе мы усилим процесс управления пользователями, создав уникальный одноразовый токен для каждого пользователя и поместив его в отдельную таблицу. Позже записи из этой таблицы могут быть обработаны внешней программой, которая отправляет сообщение каждому вновь созданному пользователю с просьбой подтвердить свой адрес электронной почты, отправив токен, сгенерированный специально для него.

Таблица, подготовленная для хранения одноразовых токенов, имеет следующую схему:

CREATE TABLE tokens(
  user text PRIMARY KEY,
  date text,
  token text
);

Вот реализация функции single_use_token на Rust, которая принимает имя пользователя и текущее время в качестве аргументов и возвращает сгенерированный токен:

pub fn single_use_token(user: String, seed: u64) -> String {
  use rand::{Rng, SeedableRng};
  use std::hash::{Hash, Hasher};
  let mut hasher = std::collections::hash_map::DefaultHasher::new();
  let hash = user.hash(&mut hasher);
  let token = rand::rngs::StdRng::seed_from_u64(seed + hasher.finish())
              .gen::<u64>();
  format!("{:x}", token)
}

И теперь триггер шифрования паролей, который мы создали в предыдущем абзаце, можно усилить другим действием — обратите внимание, что несколько операций могут выполняться одним триггером, поэтому мы можем просто удалить предыдущий и заменить его новым:

DROP TRIGGER IF EXISTS encrypt_pass;
CREATE TRIGGER encrypt_pass_and_generate_token
    AFTER INSERT ON users
    BEGIN
        UPDATE users
            SET pass = encrypt(
                new.pass,
                (SELECT secret FROM secrets LIMIT 1)
            )
            WHERE user = new.user;
        INSERT INTO tokens VALUES (
            new.user,
            date(),
            single_use_token(new.user, unixepoch())
        );
    END;

Проверка на практике

Наконец, давайте проверим, что одноразовый токен действительно был сгенерирован и вставлен в таблицу tokens.

INSERT INTO users VALUES (
  'wojtek',
  'wojtek@ii_corps.pl',
  'the bear'
);

SELECT * FROM tokens WHERE user = 'wojtek';
user    date        token   
------  ----------  ----------------
wojtek  2022-12-16  48e2873b1c5d861b

Краткое содержание

Интеграция с WebAssembly привнесла переносимые и безопасные пользовательские функции в libSQL в его первом официальном выпуске. Они полезны не только для запросов, но и в качестве строительных блоков для триггеров базы данных. С помощью обеих функций на уровень базы данных можно передать больше бизнес-логики, а вычисления выполнять ближе к данным. Это повышает производительность, безопасность и уровень контроля над вашими данными.

Узнайте больше о пользовательских функциях WebAssembly в нашем предыдущем сообщении в блоге. Если вы хотите начать работу с libSQL, вы можете перейти в наш репозиторий GitHub.