Избавляемся от хранения паролей в открытом виде: получаем динамически временные пароли с коротким TTL по токену. Делаем простую Vault-обёртку для Django-драйвера PostgreSQL.

Зачем всё это надо, в чем смысл?

Смысл в том, чтобы пользователь, которому требуется доступ к базе данных, создавался на короткий срок и имел лишь необходимые для выполнения своих задач права. Факт получения доступа нашим приложением должен записываться в журнал. После истечения TTL временной учётки пользователь должен удаляться, а наше приложение должно получать новую пару логин+пароль. При этом сам по себе пользователь, от которого выполняется раздача прав, должен обладать минимальными правами и не лезть своим носом в пользовательские данные.

В чём проблема?

Звучит просто. Но первая проблема в том, что нельзя просто так взять и удалить пользователя в PostgreSQL. DROP ROLE username не даст удалить его, потому что он владеет, например, таблицами, созданными при миграциях: role "%s" cannot be dropped because some objects depend on it. Можно было бы перед удалением сделать REASSIGN OWNED BY username to real_owner. Только вот reassign происходит в текущей базе данных. 

Because REASSIGN OWNED does not affect objects within other databases, it is usually necessary to execute this command in each database that contains objects owned by a role that is to be removed.

А текущая база данных для нашего административного пользователя Vault - не та же, с которой работает наш временный пользователь, то есть REASSIGN OWNED ничего не даст. Можно делать точки монтирования для каждой базы данных и создавать пользователя vault_<dbname>_admin, наделять его какими-то правами и делать reassign, но это неудобно.

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

  1. Создаём пользователя vault с ограниченными правами: LOGIN NOCREATEDB NOINHERIT CREATEROLE NOSUPERUSER.
  2. Создаём NOLOGIN учётку владельца базы данных. Владельцу БД незачем логиниться.
  3. Роль db-full-access в Vault будет создавать временного пользователя, который наследует некоторые права владельца базы данных.
  4. Временный пользователь каждый раз должен выполнять SET ROLE владельца базы данных.

У этого сценария есть один жирный минус (а может быть и плюс): поскольку сразу после подключения установлена роль владельца базы, то временный пользователь может быть удален в любой момент и при этом он всё равно будет иметь возможность пользоваться установленным подключением, - на него все наши TTL не действуют. То есть его можно удалять сразу, через connection_timeout секунд.

Создадим базу данных

DROP DATABASE IF EXISTS force_fm;
CREATE DATABASE force_fm ENCODING 'utf8';
CREATE ROLE force_fm WITH NOLOGIN;
ALTER DATABASE force_fm OWNER TO "force_fm";

Создадим техническую учётку Vault

CREATE ROLE vault WITH ENCRYPTED PASSWORD 'lol' LOGIN NOCREATEDB NOINHERIT CREATEROLE NOSUPERUSER;

Смонтируем PostgreSQL secret backend

Хинт

Если во время экспериментов бекенд секретов убит, например, не может быть отмонтирован ввиду поломанного SQL, или отсутствия каких-либо прав, то для грубого размонтирования есть /sys/revoke-force/<mount_point>.


Писать SQL в баше с постоянным `\` лично мне неудобно, поэтому я опять буду пользоваться hvac.

CREATE ROLE "{{name}}" WITH LOGIN ENCRYPTED PASSWORD '{{password}}'
	VALID UNTIL '{{expiration}}'
	IN ROLE "force_fm" INHERIT NOCREATEROLE NOCREATEDB NOSUPERUSER NOREPLICATION NOBYPASSRLS;
PG_MOUNT = 'force.fm/postgresql'
client.disable_secret_backend(PG_MOUNT)
client.enable_secret_backend('postgresql', 'Force FM PostgreSQL', mount_point=PG_MOUNT)
client.write(
    join(PG_MOUNT, 'config/connection'),
    lease='10s', lease_max='10s',
    connection_url='postgresql://'
                   'vault:azaza'
                   '@trash.force.fm:5432/postgres'
)
client.write(
    join(PG_MOUNT, 'roles', 'db-full-access'),
    sql="""
    CREATE ROLE "{{name}}"
        WITH LOGIN ENCRYPTED PASSWORD '{{password}}'
        VALID UNTIL '{{expiration}}'
        IN ROLE "force_fm" INHERIT NOCREATEROLE NOCREATEDB NOSUPERUSER NOREPLICATION NOBYPASSRLS;
    """,
    revocation_sql="""
    DROP ROLE "{{name}}";
    """
)

Теперь можно получать credentials так:

creds = client.read(join(PG_MOUNT, 'creds/db-full-access')))
{'auth': None,
 'data': {'password': 'e1252a79-18f5-f44b-8dae-7d4feabf4c3f',
          'username': 'root-d1741407-e161-e233-6f7c-83cb5f72deeb'},
 'lease_duration': 2764800,
 'lease_id': 'force.fm/postgresql/creds/db-full-access/78842b47-35be-8534-6706-164e058a3629',
 'renewable': True,
 'request_id': '71c4b874-69e5-8380-2a0a-ce92d3528b97',
 'warnings': None,
 'wrap_info': None}

Слегка переделываем django.db.backends.postgresql.base

Поскольку из-под логин-учётки выполняется SET ROLE, то беспокоиться о TTL вроде бы как и не надо. Достаточно выставить вменяемый CONN_MAX_AGE, дабы не мучить Vault с базой понапрасну. Сама учётка в примере выше будет жить 10 секунд, после чего удалится, а сброс соединения так или иначе вынудит Vault сделать новую учётку. Задача сводится к тому, чтобы просто в get_connection_params() получать creds/role и выставлять сразу после подключения роль владельца. Имя владельца базы данных берётся из ['USER'], потому что он уже не нужен для самого подключения. Листинг приведён весь, копия на github.

Хинт

Если кажется, что CONN_MAX_AGE не работает, то это потому что под dev-сервером на каждый запрос создаётся новый поток, в котором создаётся новое подключение.

Хинт

Имеет смысл для DEBUG=True использовать свои настройки базы.

Настройки DATABASES выглядят примерно так:

DATABASES = {
    'default': {
        'NAME': 'force_fm',
        'ENGINE': 'pgvault',
        'HOST': 'trash.force.fm',
        'USER': 'force_fm',  # SET ROLE USER
        'PORT': '',
        'CONN_MAX_AGE': 6000,
        'VAULT': {
            'URL': 'https://trash.force.fm:18400',
            'TOKEN': 'azaza',
            'MOUNT': 'force.fm/postgresql',
            'ROLE': 'db-full-access',
            'CERTS': (
                os.path.join(CERTS_DIR, 'client1__bundle.crt'),
                os.path.join(CERTS_DIR, 'client1.key'),
            ),
            'VERIFY': os.path.join(CERTS_DIR, 'force.fm__root_ca.crt'),
        }
    }
}
import hvac
from os.path import join

from django.db.backends.signals import connection_created
from django.core.exceptions import ImproperlyConfigured
from django.db.backends.postgresql.base import DatabaseWrapper as DjangoPostgresqlDatabaseWrapper
from django.dispatch import receiver


REQUIRED_VAULT_OPTIONS = ['URL', 'TOKEN', 'MOUNT', 'ROLE']


class DatabaseWrapper(DjangoPostgresqlDatabaseWrapper):
    def _get_vault_client_connection_options(self) -> dict:
        _vault = self.settings_dict['VAULT']
        opts = {
            'url': _vault['URL'],
            'token': _vault['TOKEN']
        }
        if _vault.get('CERT'):
            opts['cert'] = _vault['CERT']
        if _vault.get('VERIFY'):
            opts['verify'] = _vault['VERIFY']
        return opts

    def _get_vault_creds(self):
        client = hvac.Client(**self._get_vault_client_connection_options())
        creds_dict = client.read(
            join(self.settings_dict['VAULT']['MOUNT'], 'creds/%s' % self.settings_dict['VAULT']['ROLE'])
        )['data']
        client.close()
        return {
            'user': creds_dict['username'],
            'password': creds_dict['password']
        }

    def get_connection_params(self):
        settings_dict = self.settings_dict
        if not settings_dict.get('USER'):
            raise ImproperlyConfigured("DATABASE['USER'] is empty.!")

        if not settings_dict['NAME']:
            raise ImproperlyConfigured("DATABASE['NAME'] is empty. Nope!")
        if 'VAULT' not in settings_dict:
            raise ImproperlyConfigured("DATABASE['VAULT'] is empty. Nope!")

        _vault = settings_dict['VAULT']
        if not isinstance(_vault, dict):
            raise ImproperlyConfigured("DATABASE['VAULT'] is not a dict. Nope!")

        for k in REQUIRED_VAULT_OPTIONS:
            if not _vault.get(k, None):
                raise ImproperlyConfigured("DATABASE['VAULT'] has no required key: '%s'. Nope!" % k)

        conn_params = {'database': settings_dict['NAME']}
        conn_params.update(self._get_vault_creds())
        conn_params.update(settings_dict['OPTIONS'])
        conn_params.pop('isolation_level', None)
        if settings_dict['HOST']:
            conn_params['host'] = settings_dict['HOST']
        if settings_dict['PORT']:
            conn_params['port'] = settings_dict['PORT']
        return conn_params


@receiver(connection_created, sender=DatabaseWrapper, dispatch_uid='postgresql_connection_created')
def set_role(sender, connection, **kwargs):
    role = connection.settings_dict['USER']
    connection.cursor().execute('SET ROLE %s', (role,))
night-crawler
Просмотров: 132
blog comments powered by Disqus