Skip to content

Commit adeec14

Browse files
authored
feat: per-sender tokens for GitLab webhook (team isolation) (#20)
1 parent 9885a90 commit adeec14

17 files changed

Lines changed: 1433 additions & 68 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ CLI и HTTP-сервер для отправки сообщений в корп
1515
- **Отправка сообщений** из CLI, скриптов, пайплайнов CI/CD
1616
- **API-запросы** — произвольные вызовы eXpress BotX API из командной строки с автоаутентификацией
1717
- **HTTP-сервер** с API для отправки и приёма вебхуков
18-
- **Alertmanager, Grafana и GitLab** — готовые эндпоинты для мониторинга; универсальный приёмник любых событий GitLab с фильтрами и шаблонами ([examples/gitlab/](examples/gitlab/))
18+
- **Alertmanager, Grafana и GitLab** — готовые эндпоинты для мониторинга; универсальный приёмник любых событий GitLab с фильтрами, шаблонами и изоляцией команд по своим токенам ([examples/gitlab/](examples/gitlab/))
1919
- **Асинхронная очередь** — RabbitMQ или Kafka для надёжной доставки
2020
- **Секреты** — поддержка переменных окружения и HashiCorp Vault
2121
- **Kubernetes-ready** — Docker, Helm chart, бинарник
@@ -167,7 +167,7 @@ chats:
167167
168168
## Интеграции
169169
170-
В режиме веб-сервера есть методы для интеграции с alertmanager, grafana и gitlab (универсальный приёмник любых событий GitLab с фильтрами `only`/`exclude`, шаблонами по типам и `error_events`).
170+
В режиме веб-сервера есть методы для интеграции с alertmanager, grafana и gitlab (универсальный приёмник любых событий GitLab с фильтрами `only`/`exclude`, шаблонами по типам и `error_events`). Несколько команд могут делить один GitLab-эндпоинт с изоляцией по своим `X-Gitlab-Token` — см. [senders](docs/integrations.md#изоляция-команд-senders-несколько-токенов) и пример [examples/gitlab/config-senders.yaml](examples/gitlab/config-senders.yaml).
171171

172172
Пример конфига alertmanager:
173173

docs/commands.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -711,9 +711,9 @@ express-botx config validate --format json
711711

712712
Проверки:
713713
- Неизвестные ключи в YAML (предупреждения)
714-
- Обязательные поля: `host` и `id` для ботов, `secret` или `token` (но не оба)
714+
- Обязательные поля: `host` и `id` для ботов, `secret` или `token` (но не оба); для `server.gitlab``secret`/`secret_token` или непустой `senders` (у каждого sender'а — секрет и непустой `chats`; байт-идентичные значения токенов — одинаковые литералы или одинаковые `env:`/`vault:` ссылки — не дублируются, разные ссылки на одно значение проверяются на старте `serve`)
715715
- Форматы: UUID для `id` и `chat_id`, длительности (`timeout`, `retry_backoff`), допустимые enum-значения (`cache.type`, `queue.driver`, `routing_mode`)
716-
- Перекрёстные ссылки: `bot` в чате ссылается на существующего бота, не более одного чата по умолчанию, `default_chat_id` в alertmanager/grafana/gitlab ссылается на существующий алиас
716+
- Перекрёстные ссылки: `bot` в чате ссылается на существующего бота, не более одного чата по умолчанию, `default_chat_id` в alertmanager/grafana/gitlab и чаты в `server.gitlab.routes`/`senders` ссылаются на существующие алиасы
717717

718718
Флаги:
719719

docs/configuration.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ server:
8282
branch: ["main", "release/*"]
8383
chats: [backend-mrs, releases] # совпало → в оба чата (объединение+дедуп)
8484
stop: true # совпав, оборвать перебор правил
85+
senders: # опционально — изоляция команд по своему X-Gitlab-Token
86+
- secret: env:TEAM_A_GITLAB_TOKEN # токен команды A (literal/env:/vault:)
87+
chats: [team-a] # события по этому токену уходят ТОЛЬКО сюда
88+
- secret: env:TEAM_B_GITLAB_TOKEN
89+
chats: [team-b, team-b-alerts]
8590
```
8691
8792
Секция `server.gitlab` (обязательна для включения эндпоинта `/api/v1/gitlab`).
@@ -92,14 +97,15 @@ server:
9297

9398
| Поле | Описание |
9499
|---|---|
95-
| `secret` / `secret_token` | Ожидаемое значение заголовка `X-Gitlab-Token`. Ссылка `literal` / `env:VAR` / `vault:path#key`. Обязательно. |
100+
| `secret` / `secret_token` | Ожидаемое значение заголовка `X-Gitlab-Token`. Ссылка `literal` / `env:VAR` / `vault:path#key`. Обязателен `secret` **или** непустой `senders` (иначе ручка осталась бы без auth). |
96101
| `default_chat_id` | UUID или алиас чата по умолчанию (должен существовать в `chats`). |
97102
| `events.only` | Allowlist event-ключей. Запись матчит полный `kind.subtype`, голый `kind` или `kind.*`. Пустой → пропускать всё. |
98103
| `events.exclude` | Denylist event-ключей (та же грануляция). Всегда выигрывает над `only`. |
99104
| `templates` | Мапа `event-ключ → inline Go-шаблон`. Переопределяет встроенные дефолты. |
100105
| `template_files` | Мапа `event-ключ → путь к файлу шаблона`. Один ключ нельзя задать и в `templates`, и в `template_files`. |
101106
| `error_events` | Список event-ключей, доставляемых с `notification.status=error` (та же грануляция матчинга). |
102107
| `routes` | Опциональный упорядоченный список правил роутинга. Событие уходит в чаты **всех** совпавших правил (объединение+дедуп), `stop:true` обрывает перебор. Каждое правило: `match` (селектор → паттерны glob/`/regex/`; `event` — по event-ключу), `chats` (непустой список алиасов/UUID), `stop`. Без секции — прежнее поведение (один чат). Подробнее и приоритет чатов — в [docs/integrations.md](integrations.md#роутинг-событий-по-чатам-routes). |
108+
| `senders` | Опциональный список дополнительных входящих токенов с жёсткой привязкой к чатам (изоляция команд). Каждый элемент: `secret`/`secret_token` (ссылка `literal`/`env:`/`vault:`, обязателен) и непустой `chats` (алиасы/UUID существующих чатов). Совпал sender-токен → событие уходит **только** в его `chats`; `?chat_id`, `?bot`, `routes` и `default_chat_id` игнорируются. Глобальные `events.only/exclude`, `templates` и `error_events` применяются как обычно. Дубликаты разрезолвленных токенов (sender↔sender, sender↔`secret`) — ошибка на старте. Подробнее — в [docs/integrations.md](integrations.md#изоляция-команд-senders-несколько-токенов). |
103109

104110
## Переменные окружения
105111

@@ -220,7 +226,7 @@ express-botx config chat list # покажет (defa
220226
Приоритет выбора чата в HTTP-сервере:
221227
- `/send`: `chat_id` из запроса → чат по умолчанию → ошибка
222228
- `/alertmanager`, `/grafana`: `?chat_id=` → `default_chat_id` из конфига вебхука → чат по умолчанию → единственный чат → ошибка
223-
- `/gitlab`: `?chat_id=` → `routes` (все совпавшие правила, объединение+дедуп) → `default_chat_id` → чат по умолчанию → единственный чат → `200 {ignored}`
229+
- `/gitlab`: `?chat_id=` → `routes` (все совпавшие правила, объединение+дедуп) → `default_chat_id` → чат по умолчанию → единственный чат → `200 {ignored}`; при совпадении sender-токена (`server.gitlab.senders`) цели — всегда `chats` этого sender'а, остальное игнорируется
224230

225231
## Формат host
226232

docs/integrations.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ generic-декодером, а событие сводится к **event-клю
174174

175175
Аутентификация — по заголовку `X-Gitlab-Token` (GitLab не умеет ставить
176176
`Authorization`/`X-API-Key`), поэтому эндпоинт не использует обычные `api_keys`.
177+
Помимо общего `secret` можно завести несколько изолированных токенов команд —
178+
см. [senders](#изоляция-команд-senders-несколько-токенов).
177179

178180
### Event-ключ и деривация субтипа
179181

@@ -383,6 +385,61 @@ server:
383385
chats: [hotfixes]
384386
```
385387

388+
### Изоляция команд: senders (несколько токенов)
389+
390+
Когда один эндпоинт обслуживает несколько команд, `?chat_id`/`routes` не дают
391+
изоляции: любой, кто знает общий секрет, может отправить событие в чужой чат.
392+
`server.gitlab.senders` — опциональный список **дополнительных** входящих
393+
токенов, каждый жёстко привязан к своему набору чатов:
394+
395+
```yaml
396+
server:
397+
gitlab:
398+
secret: env:GITLAB_WEBHOOK_TOKEN # общий дефолтный токен (опционален при senders)
399+
default_chat_id: dev
400+
senders:
401+
- secret: env:TEAM_A_GITLAB_TOKEN # literal / env: / vault: — как обычный secret
402+
chats: [team-a]
403+
- secret: env:TEAM_B_GITLAB_TOKEN
404+
chats: [team-b, team-b-alerts]
405+
```
406+
407+
**Резолв аутентификации.** Входящий `X-Gitlab-Token` сверяется со всеми
408+
sender-токенами и с дефолтным `secret` (constant-time, без early-exit):
409+
410+
- совпал **sender-токен** → событие уходит **только** в `chats` этого sender'а
411+
(fan-out, best-effort — как у `routes`); `?chat_id=`, `?bot=`, `routes` и
412+
`default_chat_id` **игнорируются** — команда A не может отправить в чаты
413+
команды B или от имени чужого бота, даже подставив `?chat_id`/`?bot`;
414+
- совпал **дефолтный `secret`** → прежнее поведение без изменений
415+
(`?chat_id` → `routes` → `default_chat_id` → …);
416+
- не совпал ни один → `401`.
417+
418+
Глобальные `events.only/exclude`, `templates`/`template_files` и `error_events`
419+
применяются к sender-событиям как обычно (отфильтрованное событие → `200
420+
{ignored}` и для sender'а). Своих `routes`/фильтров/шаблонов у sender'а нет —
421+
его скоуп только чаты.
422+
423+
**Правила конфигурации:**
424+
425+
- Каждый sender: непустой `secret`/`secret_token` + непустой `chats`
426+
(алиасы/UUID существующих чатов из секции `chats`).
427+
- Должен быть задан общий `secret` **или** хотя бы один sender — иначе ручка
428+
осталась бы без аутентификации (ошибка валидации).
429+
- Дефолтный `secret` опционален: можно оставить только senders (тогда
430+
неизвестный токен всегда получает `401`), а можно смешанный режим — общий
431+
токен для большинства + изолированные senders для чувствительных команд.
432+
- Значения токенов задаются ссылками `env:`/`vault:` — в общем YAML только
433+
ссылки, команды не видят секреты друг друга.
434+
- Дубликаты **разрезолвленных** значений (sender↔sender или sender↔дефолт) —
435+
ошибка на старте: аутентификация была бы неоднозначной. Байт-идентичные
436+
строки в конфиге (один литерал или одна и та же `env:`/`vault:` ссылка
437+
дважды) ловит уже `config validate`.
438+
439+
В GitLab каждая команда настраивает свой webhook как обычно
440+
([Настройка GitLab](#настройка-gitlab)), указывая в **Secret token** значение
441+
своего sender-токена.
442+
386443
### Проверка вручную
387444

388445
```bash

examples/gitlab/README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ then filtered and rendered by a per-event template registry.
1313
- `config-routing.yaml` — same endpoint with `server.gitlab.routes`: one event
1414
fans out to multiple chats by project/event/branch (all-match + `stop`, with a
1515
`default_chat_id` fallback).
16+
- `config-senders.yaml` — same endpoint with `server.gitlab.senders`: two teams
17+
with their own `X-Gitlab-Token` values, each isolated to its own chats, plus
18+
the shared default `secret` (mixed mode).
1619
- `webhook-merge-request-open.json` — MR opened (`merge_request.open`).
1720
- `webhook-merge-request-merge.json` — MR merged (`merge_request.merge`).
1821
- `webhook-note.json` — comment on an MR (`note.MergeRequest`).
@@ -48,7 +51,9 @@ without sending a message.
4851
In a group or project: **Settings → Webhooks → Add new webhook**
4952

5053
- **URL:** `http://express-botx:8080/api/v1/gitlab` (optionally `?chat_id=<alias>`)
51-
- **Secret token:** same value as `server.gitlab.secret`
54+
- **Secret token:** same value as `server.gitlab.secret` — or, when using
55+
`server.gitlab.senders`, your team's own sender token
56+
(see [Per-team tokens](#per-team-tokens-senders))
5257
- **Triggers:** enable whichever events you want — or all of them, since filtering
5358
now happens in express-botx via `events.only` / `events.exclude`.
5459

@@ -78,3 +83,17 @@ back to `default_chat_id`. Delivery is best-effort: `200` with `{results,errors}
7883
once at least one chat is delivered, `502` if they all fail. See the
7984
[routing section](../../docs/integrations.md#роутинг-событий-по-чатам-routes)
8085
for the full model and chat-selection priority.
86+
87+
## Per-team tokens (senders)
88+
89+
`config-senders.yaml` adds `server.gitlab.senders` — extra incoming
90+
`X-Gitlab-Token` values, each hard-bound to its own chats. A request
91+
authenticated with a sender token is delivered **only** to that sender's chats
92+
(`?chat_id=`, `routes` and `default_chat_id` are ignored), so teams sharing one
93+
endpoint cannot post into each other's chats. The global `events` filter,
94+
templates and `error_events` apply as usual; the default `secret` keeps its
95+
ordinary behaviour and may be omitted when only senders are used. Token values
96+
are `env:`/`vault:` references — the shared YAML never contains plaintext
97+
secrets, and duplicate resolved tokens fail at startup. See the
98+
[senders section](../../docs/integrations.md#изоляция-команд-senders-несколько-токенов)
99+
for details.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Example config for the universal GitLab webhook endpoint (/api/v1/gitlab)
2+
# with per-sender tokens (`server.gitlab.senders`) — team isolation.
3+
#
4+
# Several teams share one endpoint, each with its OWN X-Gitlab-Token bound to
5+
# its own chats. An event authenticated with a sender token is delivered ONLY
6+
# to that sender's chats: ?chat_id=, routes and default_chat_id are ignored
7+
# for it, so team A cannot post into team B's chats even on purpose. The
8+
# global events filter, templates and error_events still apply.
9+
#
10+
# The default `secret` stays fully functional (mixed mode): webhooks using it
11+
# keep the ordinary behaviour (?chat_id -> routes -> default_chat_id -> ...).
12+
# It may also be omitted entirely — then only the sender tokens authenticate.
13+
# At least one of `secret` / `senders` is required (the endpoint must not be
14+
# left without auth). Duplicate resolved token values (sender vs sender, or
15+
# sender vs the default secret) fail at startup.
16+
#
17+
# Token values are secret references (env:/vault:), so the shared YAML holds
18+
# only references and teams never see each other's secrets.
19+
20+
bots:
21+
mybot:
22+
host: "env:BOT_HOST"
23+
id: "env:BOT_ID"
24+
secret: "env:BOT_SECRET"
25+
26+
chats:
27+
dev:
28+
id: "env:DEV_CHAT_ID"
29+
bot: mybot
30+
default: true
31+
team-a:
32+
id: "env:TEAM_A_CHAT_ID"
33+
bot: mybot
34+
team-b:
35+
id: "env:TEAM_B_CHAT_ID"
36+
bot: mybot
37+
team-b-alerts:
38+
id: "env:TEAM_B_ALERTS_CHAT_ID"
39+
bot: mybot
40+
41+
server:
42+
listen: ":8080"
43+
base_path: /api/v1
44+
gitlab:
45+
# Shared default token — small teams keep using it as before.
46+
secret: "env:GITLAB_WEBHOOK_TOKEN"
47+
default_chat_id: dev
48+
49+
# Isolated per-team tokens. Each team sets its own value as the webhook's
50+
# "Secret token" in GitLab; events for that token go only to its chats.
51+
senders:
52+
- secret: "env:TEAM_A_GITLAB_TOKEN"
53+
chats: [team-a]
54+
- secret: "env:TEAM_B_GITLAB_TOKEN"
55+
chats: [team-b, team-b-alerts] # fan-out to both, best-effort
56+
57+
# Global filter/templates/error_events apply to sender events too.
58+
events:
59+
only:
60+
- "merge_request.*"
61+
- "pipeline.failed"
62+
- "build.failed"
63+
- "push"
64+
exclude:
65+
- "merge_request.update"
66+
error_events:
67+
- "pipeline.failed"
68+
- "build.failed"

internal/cmd/bot.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -573,10 +573,10 @@ func runBotRm(args []string, deps Deps) error {
573573
}
574574

575575
type botTokenResult struct {
576-
Name string `json:"name,omitempty"`
577-
Token string `json:"token,omitempty"`
578-
Status string `json:"status,omitempty"`
579-
Error string `json:"error,omitempty"`
576+
Name string `json:"name,omitempty"`
577+
Token string `json:"token,omitempty"`
578+
Status string `json:"status,omitempty"`
579+
Error string `json:"error,omitempty"`
580580
}
581581

582582
func runBotToken(args []string, deps Deps) error {

internal/cmd/cmd.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,6 @@ func refreshToken(cfg *config.Config, cache token.Cache) (string, error) {
211211
return tok, nil
212212
}
213213

214-
215214
func newCache(cfg config.CacheConfig) token.Cache {
216215
switch cfg.Type {
217216
case "file":

internal/cmd/send.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ import (
88
"flag"
99
"fmt"
1010
"io"
11-
"sync"
1211
"os"
1312
"path/filepath"
13+
"sync"
1414

1515
"github.com/lavr/express-botx/internal/botapi"
1616
"github.com/lavr/express-botx/internal/config"

0 commit comments

Comments
 (0)