Динамические учетные данные PostgreSQL с помощью HashiCorp Vault (на примере PHP Symfony и Go)

Я играл с HashiCorp Vault, пытаясь интегрировать динамические секреты (одна из многих возможностей Vault) в веб-приложение.

По сути, веб-приложение подключается к базе данных (PostgreSQL), используя динамически генерируемые учетные данные (имя пользователя & пароль), которые вы можете менять, когда захотите, и все это будет прозрачно для вашего приложения.

Vault обрабатывает генерацию учетных данных (и, таким образом, создание соответствующего имени пользователя и пароля в PostgreSQL) и истечение срока действия (и, таким образом, удаление имени пользователя из БД).

Я ни в коем случае не являюсь экспертом по Vault, это был мой первый опыт работы с ним. Я решил поделиться этой статьей с примером, чтобы..:

  • Лучше писать технические статьи
  • Научиться лучше формулировать & доносить свои мысли.
  • Показать реальный пример использования Vault в вашем приложении, поскольку их не так много.

В любом случае, давайте приступим.

Начальная настройка Vault

Сначала нам нужно настроить некоторые вещи в Vault. Установить Vault очень просто, поэтому сначала сделайте это.

После установки Vault на вашу систему, вы можете запустить dev-сервер с помощью команды

vault server -dev
Войти в полноэкранный режим Выйти из полноэкранного режима

На выходе вы получите три важные информации:

  • Конечная точка API (которая совпадает с URL пользовательского интерфейса). Если вы запустили dev-сервер без каких-либо аргументов, то это, вероятно, http://127.0.0.1:8200.
  • Токен unseal. Он используется для разблокировки Vault из его запечатанного состояния. Всякий раз, когда Vault перезагружается и/или инициализируется, он начинает работу в запечатанном состоянии, поэтому вам нужно будет сначала снять с него печать. Нам не нужно беспокоиться об этом, поскольку при использовании dev-сервера Vault уже инициализирован и разгерметизирован.
  • Корневой токен. Токен, который мы будем использовать для аутентификации наших запросов к API. Это хорошая идея только при запуске dev-сервера и опробовании некоторых вещей, но в реальном мире корневой токен нужен только в экстренных случаях и для начальной настройки пользователей/политик… и т.д.

Теперь, когда у нас запущен сервер Vault, оставьте этот терминал открытым и откройте новый (или новую панель Tmux или что-то еще).

Давайте настроим наш доступ к Vault.

# COnfigure our access
export VAULT_ADDR="http://127.0.0.1:8200"
export VAULT_TOKEN="THE ROOT TOKEN YOU GOT FROM RUNNING SERVER"

# verify the connection 
vault status
Войти в полноэкранный режим Выйдите из полноэкранного режима

Вы должны увидеть надпись «Initialized: true» & «Sealed: false».

Начальная настройка PostgreSQL

Давайте выполним начальную настройку нашей базы данных PostgreSQL. Я использую виртуальную машину (созданную с помощью Vagrant) Ubuntu 22.04 и установил на нее PostgreSQL 14. Ознакомьтесь с этой статьей на DigitalOcean о том, как его установить.

После его установки давайте выполним некоторые настройки:

# Inside the VM
sudo -i -u postgres

# Open PostgreSQL cli
psql

# and create a new "vault" user. 
CREATE ROLE "vault" WITH SUPERUSER LOGIN ENCRYPTED PASSWORD 'vault-password';
Войдите в полноэкранный режим Выйти из полноэкранного режима

После этого давайте убедимся, что PostgreSQL принимает удаленные соединения из-за пределов виртуальной машины.

  1. Откройте файл /etc/postgresql/14/main/postgresql.conf и обновите listen_addresses до 0.0.0.0.
  2. Откройте /etc/postgresql/14/main/pg_hba.conf и добавьте эту строку host all all 0.0.0.0/0 md5.

Это в основном указывает серверу PostgreSQL прослушивать удаленные соединения, а не только локальные (1), а конфигурация HBA позволяет всем пользователям подключаться из любого места, используя свои пароли (2).

Опять же, это не лучшая идея для производственной системы, так как это слишком сильно раскрывает ваш экземпляр PostgreSQL. Мы здесь только играем, так что все в порядке.

Настройка Vault для использования нашей базы данных PostgreSQL

Теперь, когда у нас инициализированы Vault и Postgres, давайте настроим Vault для подключения и управления учетными данными Postgres.

Подключение Vault к Postgres

Vault может управлять секретами с помощью своих Secrets Engines, которые включают AWS, GCP, Key Value, LDAP, SSH, базы данных… и так далее. Полный список можно найти в документации.

Secrets Engines — это компоненты Vault, которые хранят, генерируют и шифруют секреты. Нас интересует движок database.

Движок database поддерживает широкий спектр баз данных, включая, но не ограничиваясь PostgreSQL, MySQL, Redshift и Elasticsearch.

Давайте включим движок и настроим его на использование нашей базы данных Postgrs.

# Enable the database secrets engine
vault secrets enable database # 1

# Configure the engine to connect to our Postgres database using the user we created earlier.
vault write database/config/application_db  # 2
    plugin_name=postgresql-database-plugin  # 3
    allowed_roles="dbuser"                  # 4
    connection_url="postgresql://{{username}}:{{password}}@192.168.56.101:5432/postgres"  # 5
    username="vault"  # 6
    password="vault-password"
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте объясним, что мы здесь сделали:

  1. Мы включили движок secrets в Vault по пути database.
  2. Мы записываем новые данные конфига в наш движок базы данных, мы назвали этот конфиг application_db, но вы можете назвать его как угодно.
  3. Имя плагина для использования. Здесь мы используем PostgreSQL.
  4. Разрешенные роли, которые должны быть связаны с этой конфигурацией. Роль здесь относится к роли Vault, а не к роли Postgres. Мы создадим роль Vault после этого.
  5. Настройте URL подключения к Postgres. 192.168.56.101 — это IP-адрес моей виртуальной машины, поэтому измените его на свой (или 127.0.0.1, если вы работаете локально). Мы используем базу данных по умолчанию postgres для подключения, поскольку у нашего пользователя vault нет ни базы данных, ни необходимости в ней.
  6. Введите имя пользователя и пароль для пользователя vault, созданного нами ранее.

Теперь создадим роль Vault, которая будет управлять созданием учетных данных в Vault & Postgres.

При создании роли мы предоставляем:

  • Утверждения создания: Vault будет использовать это, чтобы знать, как создать пользователя в Postgres, когда мы будем запрашивать новые учетные данные.
  • Заявления об отзыве: Vault будет выполнять эти команды в Postgres всякий раз, когда срок действия учетных данных истечет (TTL достигнут).
  • TTL (Time-To-Live) учетных данных. Когда срок действия учетных данных истечет, Vault выполнит команды отзыва и удалит учетные данные из своего хранилища.

Вот как это сделать с помощью Postgres:

vault write database/roles/dbuser 
    db_name="app" 
    max_ttl="10m" 
    creation_statements="CREATE USER "{{name}}" WITH SUPERUSER ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "{{name}}";" 
    revocation_statements="REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "{{name}}"; DROP OWNED BY "{{name}}"; DROP ROLE "{{name}}";"
Войдите в полноэкранный режим Выйти из полноэкранного режима

Для наглядности вот как выглядят заявления о создании и отзыве:

-- Creation
CREATE USER "{{name}}" WITH SUPERUSER ENCRYPTED PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "{{name}}";

-- Revokation
REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "{{name}}";
DROP OWNED BY "{{name}}";
DROP ROLE "{{name}}";
Войти в полноэкранный режим Выход из полноэкранного режима

По сути, мы создали роль Vault, которая:

  • Истекает через 10 минут после создания.
  • Создает пользователя Postgres с привилегиями суперпользователя, который имеет доступ ко всем таблицам в схеме public.
  • Правильно удаляет пользователя Postgres по истечении срока действия.

Конечно, привилегии SUPERUSER не нужны и даже опасно давать их пользователю, которому нужны только привилегии SELECT, UPDATE, INSERT & DELETE. В производстве вам придется настроить операторы создания на более безопасные: Удалите SUPERUSER & дайте наименее необходимые разрешения только необходимым таблицам.

Я добавил привилегию SUPERUSER и установил короткий TTL только для примера.

Теперь, когда у нас настроена роль Vault, давайте попробуем создать некоторые учетные данные, просто считав их из database/creds/dbuser, как показано ниже:

vault read database/creds/dbuser
Войдите в полноэкранный режим Выйти из полноэкранного режима

Это выведет

  • Идентификатор аренды, который вы можете использовать для возобновления аренды учетных данных и предотвращения истечения срока их действия.
  • Продолжительность аренды, в нашем случае это 10 минут.
  • Сгенерированное имя пользователя и пароль!

Давайте проверим это с помощью psql

psql -h192.168.56.101 -u v-root-truly-Jixx1aASFjSmjjYF2Vin-1660473935 -d postgres -p
Войдите в полноэкранный режим Выйти из полноэкранного режима

Введите сгенерированный пароль и вы должны войти!

Примеры приложений

Стратегия, которую мы используем здесь, заключается в том, что наши приложения должны подключаться к Vault, считывать/генерировать новые учетные данные и использовать их до истечения срока действия текущих (т.е. каждые 10 минут).

Давайте сделаем два примера приложений, чтобы показать, как это делается.
Одно с использованием Go, а другое с использованием PHP (w/ Symfony framework).

Но сначала давайте создадим таблицу и заполним ее фиктивными данными. С помощью psql:

CREATE DATABASE app;
c app
CREATE TABLE users (id serial, name VARCHAR);
INSERT INTO users (name) VALUES ('Jack Reacher');
Войдите в полноэкранный режим Выйти из полноэкранного режима

Пример на Go!

Приложение Go будет открывать конечную точку HTTP, которая будет просто
получать всех пользователей из нашей базы данных Postgres, используя сгенерированные Vault учетные данные.

Создайте новый каталог и выполните следующие действия.

go mod init go-vault-example
go get -u "github.com/mittwald/vaultgo"
go get -u "github.com/gorilla/mux"
go get -u "github.com/lib/pq"
Войти в полноэкранный режим Выйдите из полноэкранного режима

Это позволит установить следующие зависимости нашего приложения

  • Mux для нашей конечной точки HTTP
  • pq для включения поддержки Postgres при использовании пакета database/sql для запроса нашей базы данных Postgres.
  • Vault Go — библиотека, которую мы будем использовать для чтения учетных данных с нашего сервера Vault.

Вот наше полное приложение на Go:

package main

import (
    "database/sql"
    "net/http"
    "strings"
    "fmt"

    vault "github.com/mittwald/vaultgo"
    "github.com/gorilla/mux"
    _ "github.com/lib/pq"
)

type VaultCreds struct {
    Data struct {
        User        string `json:"username"`
        Password    string  `json:"password"`
    } `json:"data"`
}

type DbConnection struct {
    Dbname string
    Host string
    Port int
    User string 
    Password string
}

// Read (generate) credentials from our Vault server.
// Don't forget to update your Vault address and token.
func getDBConnectionConfig() DbConnection {
    c, err := vault.NewClient("VAULT SERVER ADDRESS", vault.WithCaPath(""), vault.WithAuthToken("VAULT AUTH TOKEN"))
    if err != nil {
        panic(err)
    }

    key := []string{"v1", "database", "creds", "dbuser"}
    options := &vault.RequestOptions{}
    response := &VaultCreds{}

    err = c.Read(key, response, options)
    if err != nil {
        panic(err)
    }

    return DbConnection{
        Dbname: "app",
        Host: "192.168.56.101",
        Port: 5432,
        User: response.Data.User,
        Password: response.Data.Password,
    }
}

// This function opens up a new Postgres connection to our server and returns it.
func openConnection() *sql.DB {
    config := getDBConnectionConfig()
    connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", config.Host, config.Port, config.User, config.Password, config.Dbname)

    db, err := sql.Open("postgres", connStr)
    if err != nil {
        panic(err)
    }

    err = db.Ping()
    if err != nil {
        panic(err)
    }

    fmt.Printf("Connected to PostgreSQL db using user <%s> and password <%s>n", config.User, config.Password);
    return db
}

// Factory to create the function that handles the index request "/".
// It queries the database and return a join of all names in the users table. Pretty simple.
func newIndexHandler(db *sql.DB) func(http.ResponseWriter, *http.Request) {
    return func(w http.ResponseWriter, r *http.Request) {
        rows, err := db.Query(`SELECT name FROM users`)
        if err != nil {
            panic(err)
        }
        defer rows.Close()


        names := []string{}
        for rows.Next() {
            var name string
            err = rows.Scan(&name)
            if err != nil {
                panic(err)
            }

            names = append(names, name)
        }

        w.WriteHeader(http.StatusOK)
        fmt.Fprint(w, strings.Join(names, ", "))
    }
}

func main() {
    // Setup DB connection
    db := openConnection()
    defer db.Close();

    // Setup router 
    r := mux.NewRouter()
    r.HandleFunc("/", newIndexHandler(db))
    http.Handle("/", r)

    // Start listening
    fmt.Println("Listening on 127.0.0.1:8002")
    http.ListenAndServe("127.0.0.1:8002", r)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Это довольно простая программа на Go, которая получает данные из нашей таблицы пользователей в Postgres.
Единственная интересная часть заключается в том, что она считывает учетные данные с нашего сервера Vault и использует их для подключения.
Наше приложение совершенно не замечает, как эти учетные данные генерируются и когда. Оно просто считывает их и использует.

go build
./go-vault-example

#
# Output is something like
# Connected to PostgreSQL db using user <v-root-truly-OQZIsxFo71ytoXb3aIKo-1660475644> and password <4u-A74pocYWqzUaSqW5L>                                                                     │
# Listening on 127.0.0.1:8002
#
Вход в полноэкранный режим Выход из полноэкранного режима

Зайдите на сайт http://127.0.0.1:8002 в браузере, и вы увидите вывод некоторых данных.

Теперь учетные данные считываются (и, следовательно, генерируются) только тогда, когда мы запускаем нашу программу. Если бы мы захотели сгенерировать новую пару учетных данных, нам пришлось бы остановить программу и запустить ее снова, а это не очень хорошая идея.
Поэтому давайте воспользуемся для этого системными сигналами, добавив этот фрагмент непосредственно перед запуском HTTP-сервера.

// Setup reload signal using SIGHUP
// Sending SIGHUB signal to our process will make it close the current DB connection
// and open a new one with newly generated credentials.
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGHUP)
go func() {
    <-signals

    fmt.Println("Reloading: Terminating current connection and creating a new one.")
    db.Close()
    db = openConnection()
}()

// Start Listening
// ... etc.
Войти в полноэкранный режим Выйти из полноэкранного режима

Не забудьте добавить импорты os, syscall и os/signals.

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

Соберите и запустите приложение снова, и давайте попробуем его в действии.

PID=$(pgrep go-vault-example) # Get the PID of our process
kill -HUP $PID # Send a SIGHUP signal
Вход в полноэкранный режим Выход из полноэкранного режима

Наша программа выведет что-то вроде

Reloading: Terminating current connection and creating a new one.
Connected to PostgreSQL db using user <v-root-truly-63msxAJoWUb1QJ50hxfL-1660476797> and password <oaY-a1bl4IShVzcKEoNp>
Вход в полноэкранный режим Выход из полноэкранного режима

Поскольку наши учетные данные истекают через 10 минут, нам просто нужно послать сигнал SIGHUP в нашу программу до того, как это произойдет.
Для этого мы можем настроить задание cron, которое будет выполняться каждые 8 минут или что-то в этом роде.

В заключение:

  1. Мы запускаем нашу программу, и она генерирует новую пару учетных данных.
  2. Мы перезагружаем нашу программу каждые 8 минут и генерируем новые учетные данные.
  3. Срок действия старых учетных данных всегда истекает, и они удаляются из нашей базы данных (таким образом, в течение 2 минут старый пользователь никем не используется).

Вот и все, это базовый пример того, как вы можете использовать динамические учетные данные Vault в вашем Go приложении.

Пример PHP (w/ Symfony Framework)

Давайте создадим новое минимальное приложение Symfony 6.1, с Doctrine ORM, Vault PHP Client & и парой его зависимостей.

composer create-project symfony/skeleton:"6.1.*" php-vault-example
cd php-vault-example
composer require orm
composer require csharpru/vault-php
composer require alextartan/guzzle-psr18-adapter
composer require laminas/laminas-diactoros
Вход в полноэкранный режим Выход из полноэкранного режима

Symfony по умолчанию использует YAML файлы для конфигурации, но это не совсем подходит для данного случая. Нам нужно использовать конфигурационные файлы PHP, потому что это дает нам возможность запускать некоторый пользовательский код для получения учетных данных базы данных из Vault.
Во-первых, давайте удалим конфиг подключения из config/packages/doctrine.yml.

doctrine:
    # Remove the "dbal" line and its children.
    dbal:
Войдите в полноэкранный режим Выйти из полноэкранного режима

Затем создадим новый файл config/packages/db_connection.php следующим образом

<?php

use AppInitVaultConfig;
use SymfonyConfigDoctrineConfig;

$config = new VaultConfig();
$credentials = $config->getDbConfig();

return static function (DoctrineConfig $doctrine) use ($credentials) {
    $dbal = $doctrine->dbal()
        ->connection('default')
        ->driver('pdo_pgsql')
        ->dbname('app')
        ->serverVersion('14')
        ->user($credentials['username'])
        ->password($credentials['password'])
        ->host('192.168.56.101')
        ->port('5432');
};
Войти в полноэкранный режим Выйти из полноэкранного режима

У нас все еще нет класса VaultConfig, поэтому давайте создадим его в src/Init/VaultConfig.php.

<?php

namespace AppInit;

use GuzzleHttpPsr7Uri;
use LaminasDiactorosRequestFactory;
use LaminasDiactorosStreamFactory;
use VaultClient;
use VaultAuthenticationStrategiesTokenAuthenticationStrategy;

class VaultConfig {

    public const VAULT_ADDR = 'http://127.0.0.1:8200';

    public const VAULT_TOKEN = 'hvs.mjlW8faLW7GHQdbxAc3t8aEz';

    public const CREDENTIALS_PATH = '/database/creds/dbuser';

    private Client $client;

    public function __construct() {
        $client = new Client(
            new Uri(self::VAULT_ADDR),
            new AlexTartanGuzzlePsr18AdapterClient(),
            new RequestFactory(),
            new StreamFactory()
        );

        try {
            $authenticated = $client->setAuthenticationStrategy(new TokenAuthenticationStrategy(self::VAULT_TOKEN))
                ->authenticate();
        } catch (Exception $e) {
            die($e->getMessage());
        }

        if (!$authenticated) {
            die("Could not authenticate to Vault server at: " . self::VAULT_ADDR);
        }

        $this->client = $client;
    }


    public function getDbConfig(): array {
        try {
            $response = $this->client->read(self::CREDENTIALS_PATH);
        } catch (Exception $e) {
            die($e->getMessage());
        }

        return $response->getData();
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Не забудьте обновить VAULT_ADDR & VAULT_TOKEN до ваших значений.
Теперь давайте создадим простой контроллер, который будет считывать данные из нашей таблицы users.

Создайте новый файл src/Controller/IndexController.php.

<?php

namespace AppController;

use DoctrineDBALConnection;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationJsonResponse;
use SymfonyComponentRoutingAnnotationRoute;

class IndexController extends AbstractController {
    #[Route('/', name: 'app_index')]
    public function index(Connection $connection): JsonResponse  {
        $users = $connection->executeQuery("SELECT * FROM users")
            ->fetchAllAssociative();

        return $this->json([
            'data' => $users,
            'connection' => [
                'user' => $connection->getParams()['user'],
                'password' => $connection->getParams()['password'],
            ]
        ]);
    }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Давайте запустим локальный PHP-сервер и посмотрим на наш ответ

php -S localhost:8000 -t public
Вход в полноэкранный режим Выйти из полноэкранного режима

Посетив сайт http://localhost:8000, вы увидите JSON-ответ, содержащий наших пользователей, а также используемые в данный момент имя пользователя/пароль PostgreSQL.

Теперь, Symfony фактически кэширует данные конфигурации после первого выполнения, поэтому наши учетные данные генерируются только один раз и затем кэшируются, что очень хорошо, потому что мы определенно не хотим, чтобы наши учетные данные базы данных генерировались при каждом запросе.
Так что если мы захотим сгенерировать новую пару, все, что нам нужно сделать, это очистить кэш symfony!

./bin/console cache:clear
Вход в полноэкранный режим Выход из полноэкранного режима

Если вы снова посетите веб-страницу, вы увидите, что используется новая пара учетных данных. Как и в случае с приложением Go, нам просто нужно очистить кэш до истечения срока действия учетных данных, и все будет в порядке.
В заключение:

  1. Мы запускаем наше PHP-приложение, и оно впервые считывает конфигурацию и генерирует новые учетные данные.
  2. Мы очищаем кэш Symfony каждые 8 минут или около того, чтобы конфигурация была перечитана и новые учетные данные были сгенерированы и использованы.
  3. Старые учетные данные всегда истекают и удаляются из нашей базы данных (таким образом, в течение 2 минут старый пользователь никем не используется).

Заключительные замечания

  • Я не тестировал этот подход в производственной среде, и я уверен, что это вызовет некоторые приятные проблемы, например, что произойдет, если ваше приложение получает много одновременных запросов, а вы чередуете ключи? Вы можете получить некоторое время простоя при каждой ротации ключей, так что за этим стоит следить.
  • TTL, которые мы использовали, очевидно, нереалистичны и предназначены только для тестирования. В реальной жизни вы захотите использовать более длинный TTL в зависимости от условий использования.
  • Как я уже сказал в начале статьи, я ни в коем случае не являюсь экспертом по Vault или Go (в основном я работаю с PHP). Я хотел написать эту статью только для того, чтобы поделиться примерами использования Vault внутри приложения.
  • Если вы заметите что-то не так или что-то можно было сделать лучше, я буду рад вашим отзывам.

Давайте подытожим, что мы сделали

Давайте подытожим, что мы сделали сегодня:

  1. Мы установили локальный сервер Vault для тестирования.
  2. Мы настроили Vault так, чтобы он мог общаться с PostgreSQL и динамически генерировать учетные данные, используя механизм секретов database.
  3. Мы протестировали интеграцию, создав новые учетные данные и увидев, что они также созданы в PostgreSQL. По истечении срока действия учетных данных мы заметили, что пользователь исчез из нашей базы данных Postgres.
  4. Мы создали базовое приложение Go, которое считывает динамические учетные данные из Vault и использует их для запроса к базе данных.
  5. Затем мы добавили механизм в наше приложение Go, чтобы иметь возможность поворачивать ключи с помощью системных сигналов (SIGHUP).
  6. Затем мы создали простое PHP-приложение, используя фреймворк Symfony, и сделали то же самое, что и в Go.

Спасибо за чтение!

Оцените статью
devanswers.ru
Добавить комментарий