Достался в наследство продукт на Битриксе и вокруг него – зоопарк доступов, каждый кривой по-своему. Сам Битрикс с его авторизацией и админкой торчит в интернет. 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 такой вход отбивал. Урок: в compose environment сильнее 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 – в следующий раз.