Достался в наследство продукт на Битриксе и вокруг него – зоопарк доступов, каждый кривой по-своему. Сам Битрикс с его авторизацией и админкой торчит в интернет. GitLab, арендованный где-то на стороне, светит страницей входа наружу. На серверы заходят по паролю – без ключей, без сертов, а сами доступы раздаются руками, "скинь пароль в личку". Картина ясна: со входом надо наводить порядок. Один раз и на всех.
Чинить этот зоопарк на месте смысла не было. Мы переезжали – весь продукт целиком уезжал в облако, в чистый контур, закрытый от мира с самого начала. А развернуть в нём предстояло немало: мониторинг, многочисленные контуры для сборки и тестирования, бэкофис и прочее по мелочи – и у каждого свой веб-интерфейс, который иначе торчал бы наружу своим логином и портом. Городить перед каждым отдельный костыль – путь в никуда: нужен один общий вход на весь этот растущий зоопарк. Старьё на дешёвом хостинге я не трогал сверх необходимого: подлатал только то, без чего оно не доживёт до переезда, – месяц-полтора на подпорках, а дальше баста. Поэтому и вход строился не заплаткой на старом, а начисто: deny-by-default с нулевого дня.
Порядок этот – на двух фронтах. Первый – команда: внутренние тулы и серверы, доступ для своих. Второй – пользователи: регистрация и вход на самом сайте, те самые двадцать с лишним тысяч живых аккаунтов на битриксовой авторизации. Фронты разные, решаются по-разному. Эта статья – про первый; про увод пользователей с Битрикса будет отдельно.
Оговорюсь сразу: серверный SSH – своя история (ключи и сертификаты вместо паролей, бастион), и её я закрывал параллельно. Ниже – про браузерные тулы, к которым ходят люди.
Первый инстинкт: бастион и VPN
Контур закрыт, тулы внутри. Вопрос – как впускать своих. Первое, что приходит в голову – бастион и VPN. И это честно работает: наружу торчит только вход, зашёл по VPN – ходи. WireGuard сегодня ставится за вечер.
Ломается это об людей. Сотрудников энцать, и половина – не инженеры. Маркетолог не будет ставить VPN-клиент, крутить профиль и запускать этот блудняк каждый раз, когда надо глянуть карточку в админке. С телефона – тем более. А ещё VPN даёт сетевое доверие: ты внутри – значит видишь всё, что в сети. Мне же надо наоборот – чтобы человек видел ровно те тулы, что положены его роли, и ни портом больше.
Значит нужен простой вход: открыл браузер, залогинился как в гугл – и ты внутри. Без клиента, без профилей, без звонков в поддержку.
Два вопроса, которые все схлопывают в слово "SSO"
Тут и прячется развилка. На самом деле вопроса два, и решаются они разными штуками:
- Кто ты? – это про identity, единый логин. Тут нужен IdP.
- Кто стоит у двери? – это про то, чтобы аноним вообще не долетел до тулы. Тут нужен access-proxy.
IdP отвечает на первый вопрос и ни разу – на второй. На этом спотыкаются чаще всего, поэтому разберу отдельно.
Вопрос 1 – простой SSO: почему не Keycloak, почему Zitadel
Искал self-hosted IdP. Облачные Auth0/Okta отпадают сразу: данные и решения о входе уезжают за границу, а у меня 152-ФЗ и внутренний контур. Плюс плата за место.
Из self-hosted первым под руку лезет Keycloak – дедушка жанра, умеет всё. Откинул: тяжёлый JVM-комбайн. Память, долгий старт, тюнинг ради тюнинга, а UX как из нулевых.
Взял Zitadel. Он на Go – лёгкий бинарь, быстрый старт, скромный по памяти. OIDC из коробки, организации, Actions (кастомная логика на входе), нормальная консоль. Поднялся, стал единым стором личностей: один аккаунт на все тулы, 2FA под рукой (TOTP, ключи, passkeys), аудит входов в одном месте.
Вопрос 2 – а как закрыться от мира: почему Pomerium
Zitadel теперь знает, кто я. Но он – паспортный стол, а не вахтёр. Он не стоит перед Grafana и не разворачивает анонима на входе: Grafana как торчала в интернет, так и торчит, просто теперь умеет пускать по OIDC. Между "тула умеет OIDC" и "к туле не подойти без входа" – пропасть.
Значит нужен слой перед тулами – identity-aware proxy. Что смотрел:
- Cloudflare Access / Google IAP – удобно, но data-path и решения о доступе снова уезжают за рубеж. 152-ФЗ, мимо.
- Authentik – хочет быть ещё и IdP. У меня уже есть Zitadel, второй identity-стор – это раскол и лишний вес. Мимо.
Критерий, который всё решил, – конфиг как код. Хочу, чтобы политики доступа лежали в гите, ревьюились в MR и раскатывались пайплайном, а не тыкались мышкой в чужой админке. Под это лёг Pomerium в редакции Core – open source, self-hosted, без лимитов (не путать с Pomerium Zero, где control plane висит у них в облаке – опять иностранный контур).
На чём он сам? Pomerium – Go для control plane, а собственно трафик тащит встроенный Envoy (тот самый, из k8s-мира). То есть policy, OIDC и сессии – лёгкий Go-бинарь, а проксирование – проверенный боем Envoy под капотом. Zitadel тоже Go. Итого весь мой вход – два Go-сервиса вместо JVM-фермы.
Как они склеиваются
Схема простая:
браузер → grafana.company.ru → Pomerium → (нет сессии) Zitadel (логин) → назад → апстрим
Первый раз тебя редиректит в Zitadel, логин (и второй фактор, если включил), дальше сессия живёт и по остальным тулам вход молчаливый – та же сессия, пароль второй раз не спрашивают.
Работает это в два слоя, и это осознанно:
- Pomerium – грубый сетевой гейт. Deny-by-default, пускает по claim
groupsиз токена. Не в нужной группе – 403, до тулы даже не долетел. - Нативный OIDC самой тулы – тонкий RBAC внутри. Pomerium сказал "этот человек – ops", Grafana на основании той же сессии выдала ему роль Editor. Без второго пароля.
Прокси решает "пускать ли к двери вообще", тула – "что тебе можно внутри".
Вопрос из зала
– А почему не nginx? У меня уже стоит.
Можно: nginx + auth_request + oauth2-proxy реально закрывает пару аппов. Но:
- oauth2-proxy отвечает по сути "залогинен / нет" – это грубо. Авторизация по роли на конкретный роут ("в Grafana пускаем ops и admins, в биллинг – только billing") собирается вручную через
map, lua и заголовки. У Pomerium это политика на роут из коробки. - Политика живёт россыпью по nginx-конфигам императивно, а не одним декларативным файлом под GitOps. На зоопарке из десятка тулов это расходится.
- Pomerium отдаёт апстриму подписанный identity-JWT (проверяется по JWKS) – апп доверяет не "какому-то заголовку", а криптоподписи шлюза.
- Auto-TLS на роут, сессии, рефреш и логаут по всему зоопарку – централизованно.
Честно: на 1–2 аппа nginx + oauth2-proxy легче и нормально. Pomerium отбивает себя, когда тулов зоопарк и нужна authz-как-код плюс единый identity-контракт к апстримам. Нет зоопарка – не тащи Pomerium.
– Так у Grafana и GitLab уже есть свой SSO. Зачем прокси вообще?
Нативный OIDC даёт RBAC внутри аппа, но не разворачивает анонима у двери – тула по-прежнему торчит в интернет: страница логина, необновлённая CVE, дебаг-эндпоинты доступны всем. Прокси – единый deny-by-default перед всем, включая тулы без нормального SSO (или с платным – "SSO tax"). Поэтому берём оба слоя: прокси (грубый гейт) плюс нативный OIDC (тонкий RBAC).
– Ну поставьте WireGuard, он же теперь простой.
Тот же клиент на каждом устройстве плюс телефоны плюс не-техи. И главное – VPN даёт сетевое доверие: ты в сети, значит видишь всё. Zero-trust – наоборот: нахождение в сети не даёт ничего, каждый запрос проверяется по личности и роли. Плюс ноль per-app аудита.
– Basic Auth не проще?
Общий htpasswd – ни 2FA, ни единого входа, ни отзыва по одному человеку, пароли в конфигах. Это ровно та "раздача доступов руками", от которой бежим.
– А это не single point of failure?
Да, шлюз становится критпутём – поэтому он в отдельном ops-сегменте (не делит отказ со стейджем), с задокументированным break-glass, а машинный трафик (CI, registry, ingest) идёт мимо него. Об этом – следующий пункт.
Человек против машины – то, на чём спотыкаются
Тут легко начудить и потом потратить время на разборы: завернуть за интерактивный SSO вообще всё. Прокси редиректит на страницу логина, а git, CI, реестр образов и приём телеметрии браузер не открывают. Они приходят с токеном, получают 302 на логин-страницу и давятся HTML-ом.
Поэтому машинный трафик – мимо шлюза:
- GitLab повесил на нативный OIDC (кнопка "войти через Zitadel" для людей), а git/CI/API/registry ходят как ходили, по своим токенам, не через Pomerium.
- Приём ошибок в трекер (ingest по DSN) пустил отдельным маршрутом в обход SSO – сам DSN и есть аутентификация. Это классический паттерн Sentry с отдельным ingest-хостом.
Правило: за редирект-прокси – только браузерный трафик человека. Всё, что ходит по токену, – рядом, не через него.
Конфиг как код
Политики – в гите. Роут выглядит примерно так:
- from: https://grafana.company.ru
to: http://grafana-internal:3000
policy:
- allow:
or:
- claim/groups: ops
- claim/groups: admins
Роли, юзеры и гранты в Zitadel – тоже в Terraform. Саморегистрацию в IdP выключил: новый человек не заводит себе аккаунт сам. Онбординг = дай email и ФИО, я добавляю ресурс в код, MR, apply – и у человека вход во все положенные тулы. Убрать доступ – убрать строку. Кто, когда и куда ходил – в access-логах шлюза.
Это и есть то, ради чего всё затевалось: доступы перестали быть паролями в личке и стали кодом с историей.
Счёт от продакшена
Гладко было на бумаге. Что откусило вечера:
- Хардкод root-url в compose. У Grafana
ROOT_URLбыл прописан прямо вdocker-composeвenvironment, а он перебиваетenv_file. Итог –redirect_uriуезжал в localhost, Zitadel такой вход отбивал. Урок: в composeenvironmentсильнееenv_file, не держи там то, что задаёшь снаружи. - redirect_uri за прокси приходит как http:// и с внутренним хостом. Классика reverse-proxy: апп за Pomerium не знает, что снаружи https и другой домен. Лечится передачей
X-Forwarded-Proto(апп должен ему верить) и сохранением исходногоHost. Пока не настроишь – IdP отбивает "invalid redirect". - CSRF за прокси. Тот же корень – апп видит origin не тот, что у пользователя. Добавляешь внешний домен в доверенные origin – отпускает.
- "No data" во всех панелях Grafana. Отдельная засада: вход прошёл, дашборды открылись, а данных нет. Прокси резал POST-запросы к данным по CSRF. Поймал только когда воспроизвёл ровно как юзер – через cookie-сессию и шлюз, а не своим admin-шорткатом напрямую. Мораль: user-facing баг воспроизводи путём юзера, иначе видишь не ту картину.
- Сертификат не выписался после смены DNS. Если A-запись тулы переключаешь уже ПОСЛЕ того, как накатил маршрут, Pomerium залипает на старой неудачной попытке ACME и сам не перевыпускает – отдаёт чужой дефолтный серт, клиент видит TLS-mismatch. Лечится рестартом контейнера шлюза, перевыпуск за секунды.
- Автозаведённый юзер в блоке – это фича. Первый вход нового человека создал аккаунт в статусе "ждёт подтверждения" и не пустил. Секунду думал, что сломал – оказалось наоборот: fail-safe сработал, чужой по OIDC сам себе доступ не выпишет. Подтверждаешь и линкуешь вручную, осознанно.
Что в итоге
На выходе: один вход на весь зоопарк (2FA доступен – TOTP, ключи, passkeys), deny-by-default перед каждой тулой, онбординг и оффбординг через MR, аудит в одном месте. Маркетолог заходит из браузера и не знает слов "VPN-профиль". Инженер логинится один раз на все тулы.
И да – это закрыло только внутренний контур, для команды. Пользовательская регистрация всё ещё висела пуповиной на Битриксе. Как я уводил вход двадцати с лишним тысяч живых пользователей с битриксовой авторизации на GoTrue – в следующий раз.