MBL
Go / Проекты / SessionProxy / SessionProxy: идея и схема данных
Go сложный

SessionProxy: идея и схема данных

projectspostgressecurity

Архитектурная схема (archify) →

Self-hosted reverse proxy на Go (github.com/Mentigen/SessionProxy): владелец аккаунта на любом сайте временно делится доступом с гостем, не раскрывая логин, пароль и настоящие куки. Гость работает с сайтом «как будто залогинен владельцем», но физически не видит его кук и токенов, ограничен по времени, числу запросов и трафику, не может зайти на чувствительные пути вроде /settings или /billing.

Это не CRUD-упражнение — тут реальная модель угроз (утечка кук, злоупотребление доступом) и конкретный механизм защиты от каждой конкретной угрозы, а не абстрактная «безопасность вообще». Именно поэтому это сильный ответ на «расскажи о своём сложном проекте» — есть что аргументировать под встречными вопросами, а не только описать функциональность.

Схема разложена по группам сущностей, не одной плоской таблицей

Пользователи и устройства — users, devices, api_keys. Ключ API может быть не привязан ни к какому устройству (например, выпущен из веб-интерфейса, а не с конкретного ноутбука) — это осознанно опциональная связь, а не обязательная.

Целевые сайты и сессии — target_sites (описание сайта, к которому даётся доступ), original_sessions (импортированная сессия владельца конкретно для этого сайта). Куки и токены НЕ хранятся полем внутри original_sessions — вынесены в отдельные session_cookies/session_tokens, зашифрованными.

Разделение кук/токенов на отдельные таблицы даёт две вещи сразу: можно шифровать/ротировать их независимо от метаданных сессии, и можно добавить новый тип авторизационных данных (например, второй токен для другого поддомена) без изменения схемы original_sessions.

Ссылки и политики доступа — shared_links с уникальным токеном в URL. Важная деталь: у shared_links НЕТ собственного поля user_id. Владелец ссылки выводится ТОЛЬКО через цепочку shared_links.original_session_id → original_sessions.user_id.

Если бы user_id дублировался прямо в shared_links, это была бы транзитивная зависимость — нарушение 3НФ, и хуже того, реальный риск рассинхронизации: два поля, которые ДОЛЖНЫ совпадать, могут разойтись при неаккуратном обновлении одного без другого.

Дальше — access_policies (шаблоны лимитов: max_requests, max_bytes_transferred, max_ttl_seconds, max_violation_count), link_policies как M:N-связка между ссылками и политиками. Блокировка путей устроена так же аккуратно: blacklisted_endpoints хранит сам путь/шаблон, а HTTP-методы, которые нужно блокировать для этого пути, вынесены в ОТДЕЛЬНУЮ таблицу endpoint_blocked_methods — если бы список методов лежал одной строкой в blacklisted_endpoints (например "GET,POST"), это было бы нарушение 1НФ (не атомарное значение в ячейке).

Гости и счётчики использования — guests (IP/user agent/fingerprint), guest_sessions (привязаны к конкретной shared_link), usage_counters — отношение 1:1 к shared_links, не денормализовано прямо в неё, чтобы обновление счётчика не конкурировало за ту же строку, что и метаданные ссылки.

Логи и безопасность — proxy_access_logs (лог каждого запроса через прокси), security_events (нарушения), revocation_reasons (справочник причин отключения ссылки: ttl_expired/request_limit/traffic_limit/violation_limit/manual), link_terminations (факт отключения с привязкой к причине).

Два осознанных отступления от нормализации

Первое. proxy_access_logs и security_events хранят shared_link_id НАПРЯМУЮ, хотя его теоретически можно вывести через JOIN от guest_session_id. Причина: guest_session_id в этих таблицах nullable — запись лога обязана появиться даже при сбое ДО того, как гостевая сессия вообще была установлена (например, при попытке атаки на несуществующий токен). Заворачивать получение shared_link_id в JOIN на каждый лог-запрос дороже по производительности, чем один денормализованный внешний ключ, который в 99% случаев и так совпадает с тем, что дал бы JOIN.

Второе. security_events.details — колонка jsonb для произвольных метаданных конкретного нарушения (например, заголовки запроса или параметры URL, которые привели к срабатыванию правила). Заранее нормализовать структуру этих метаданных нельзя — набор полей зависит от типа нарушения, который сам не фиксирован жёстким списком.

Лимиты нельзя проверить одним SQL-запросом — и это не баг схемы

Сравнение usage_counters с порогами из access_policies и перевод shared_links.status в terminated физически нельзя сделать декларативно средствами одного Postgres-оператора, потому что нужные данные лежат в разных таблицах, а решение зависит от бизнес-правила (какая политика «строже»), а не просто от сравнения двух чисел.

Это открыто признанное ограничение схемы, а не недосмотр — логика применения лимитов сознательно вынесена в код приложения (см. следующую статью про data plane и лимиты), потому что там она читаема и тестируема юнит-тестами, а размазанная по триггерам БД — нет.

Самопроверка 0 / 3
Могу объяснить одним предложением, какую угрозу решает SessionProxy
Понимаю, почему у shared_links нет собственного user_id — владелец выводится только через original_session_id
Могу назвать оба осознанных отступления от 3НФ и объяснить, зачем каждое
Как усвоено?