358 lines
14 KiB
Markdown
358 lines
14 KiB
Markdown
# Безопасность ZBrain
|
||
|
||
## Модель угроз
|
||
|
||
**Что защищаем:**
|
||
- Содержимое брейнов (особенно TeleraPharma — есть NDA и чувствительная инфраструктурная информация)
|
||
- Учётные записи пользователей
|
||
- API ключи (OpenAI, Anthropic) — финансовые потери при утечке
|
||
- MCP токены — могут давать доступ к чтению/записи брейнов
|
||
|
||
**От кого защищаем:**
|
||
- **Внешний атакующий** через публичный endpoint brain.zetit.ru
|
||
- **Скомпрометированный сервер клиента** с украденным MCP токеном
|
||
- **Случайная утечка** через копирование токена в git, log файлы, скриншот
|
||
- **Внутренний пользователь** с правами viewer, который хочет получить admin
|
||
|
||
**От чего не защищаемся (нет ресурсов):**
|
||
- Физический доступ к VM (предполагаем что гипервизор/датацентр доверенный)
|
||
- Скомпрометированный root на самой VM
|
||
- 0-day в Postgres, Node.js, библиотеках
|
||
|
||
## Роли пользователей
|
||
|
||
| Роль | Управление пользователями | Управление брейнами | Управление источниками | Управление токенами | Просмотр audit | UI |
|
||
|------|---------------------------|---------------------|------------------------|--------------------|-----|---|
|
||
| **owner** | ✓ Полное | ✓ Полное | ✓ Полное | ✓ Полное | ✓ Все | ✓ |
|
||
| **admin** | Только viewer/editor | ✓ Полное | ✓ Полное | ✓ Свои + смотреть чужие | ✓ Все | ✓ |
|
||
| **editor** | ✗ | ✗ Создавать нельзя; редактировать содержимое — да | ✓ В назначенных брейнах | ✓ Свои с scope :read и :write | ✓ Свои действия | ✓ |
|
||
| **viewer** | ✗ | ✗ | ✗ | ✓ Свои только :read | ✓ Свои действия | ✓ Read-only |
|
||
| **agent** (не человек) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ MCP only |
|
||
|
||
В первой версии у нас один owner — это ты. Остальные роли — для будущего.
|
||
|
||
## MCP Токены
|
||
|
||
### Формат
|
||
|
||
```
|
||
brain_pat_<random40chars>
|
||
```
|
||
|
||
Префикс `brain_pat_` (Personal Access Token) — чтобы легко grep'ать в логах и автоматических сканерах секретов.
|
||
|
||
### Хранение
|
||
|
||
- Plain text токен **никогда** не хранится
|
||
- Хранится `token_hash = SHA-256(token)` и `token_prefix = первые 8 символов после brain_pat_` для UI
|
||
- При создании токена UI показывает full token **один раз**, потом только prefix
|
||
|
||
### Scope формат
|
||
|
||
```
|
||
mcp:<level>:<brain-name>
|
||
```
|
||
|
||
Уровни:
|
||
- `mcp:read:<brain>` — query, search, get_page, get_tags, get_links, get_backlinks, get_timeline, get_stats, get_health, get_versions
|
||
- `mcp:write:<brain>` — всё из read + put_page, add_tag, remove_tag, add_link, remove_link, add_timeline_entry, sync_brain
|
||
- `mcp:admin:<brain>` — всё из write + delete_page, revert_version
|
||
|
||
Wildcards:
|
||
- `mcp:read:*` — read доступ ко всем брейнам (только owner)
|
||
- `mcp:*:zetit` — все права на zetit брейн
|
||
|
||
Один токен может иметь несколько scope, например:
|
||
```json
|
||
{
|
||
"scopes": ["mcp:read:zetit", "mcp:write:personal"]
|
||
}
|
||
```
|
||
|
||
### IP Allowlist
|
||
|
||
Опционально для каждого токена — список IP/CIDR откуда разрешён доступ.
|
||
|
||
Для production токенов (на серверах клиентов) — **обязательно** указывать IP клиентского сервера.
|
||
|
||
### TTL
|
||
|
||
- По умолчанию: 90 дней
|
||
- Опции в UI: 7 дней, 30 дней, 90 дней, 1 год, без срока
|
||
- "Без срока" доступен только owner и admin
|
||
|
||
### Revocation
|
||
|
||
Soft delete: `revoked_at = now()`.
|
||
|
||
Кэш в brainhub имеет TTL 30 секунд, поэтому отзыв вступает в силу в течение 30 секунд.
|
||
|
||
Для немедленного отзыва — `POST /api/admin/cache/invalidate-token`.
|
||
|
||
### Алгоритм валидации
|
||
|
||
```typescript
|
||
async function validateToken(token: string, brain: string, requiredScope: 'read'|'write'|'admin'): Promise<TokenInfo> {
|
||
// 1. Проверка формата
|
||
if (!token.startsWith('brain_pat_')) throw new Error('Invalid token format');
|
||
|
||
const tokenHash = sha256(token);
|
||
|
||
// 2. Проверка в кэше
|
||
const cached = tokenCache.get(tokenHash);
|
||
if (cached) {
|
||
if (cached.error) throw new Error(cached.error);
|
||
return validateScope(cached, brain, requiredScope);
|
||
}
|
||
|
||
// 3. DB lookup
|
||
const dbToken = await db.tokens.findByHash(tokenHash);
|
||
if (!dbToken) {
|
||
tokenCache.set(tokenHash, {error: 'Not found'}, 30_000);
|
||
throw new Error('Token not found');
|
||
}
|
||
|
||
// 4. Проверки статуса
|
||
if (dbToken.revoked_at) throw new Error('Token revoked');
|
||
if (dbToken.expires_at && dbToken.expires_at < new Date()) throw new Error('Token expired');
|
||
|
||
// 5. IP allowlist
|
||
if (dbToken.ip_allowlist?.length > 0) {
|
||
const clientIp = getClientIp();
|
||
if (!isIpInAllowlist(clientIp, dbToken.ip_allowlist)) {
|
||
throw new Error('IP not allowed');
|
||
}
|
||
}
|
||
|
||
// 6. Scope check
|
||
validateScope(dbToken, brain, requiredScope);
|
||
|
||
// 7. Кэшируем positive result
|
||
tokenCache.set(tokenHash, dbToken, 30_000);
|
||
|
||
// 8. Throttled last_used update
|
||
scheduleLastUsedUpdate(dbToken.id);
|
||
|
||
return dbToken;
|
||
}
|
||
```
|
||
|
||
## Сессии
|
||
|
||
### Cookie
|
||
|
||
```
|
||
Name: zbrain_session
|
||
Value: <jwt-signed session id>
|
||
HttpOnly: true
|
||
Secure: true (только в production)
|
||
SameSite: Lax
|
||
Path: /
|
||
Domain: brain.zetit.local или brain.zetit.ru
|
||
```
|
||
|
||
JWT внутри cookie - только session id, не данные пользователя. Все данные подтягиваются из БД по session id.
|
||
|
||
### Refresh tokens
|
||
|
||
- Access JWT: 15 минут
|
||
- Refresh token: 30 дней, ротируется при каждом использовании
|
||
- Refresh token хранится в БД как hash (см. таблицу `sessions`)
|
||
|
||
### Logout
|
||
|
||
- Удаление cookie
|
||
- `sessions.revoked_at = now()` для всех сессий пользователя (если "logout everywhere")
|
||
|
||
## OAuth
|
||
|
||
### Поддерживаемые провайдеры
|
||
|
||
1. **Yandex** (основной)
|
||
2. **GitHub** (резервный)
|
||
3. **Local email/password** (всегда доступен для owner)
|
||
|
||
### Привязка к существующему пользователю
|
||
|
||
Если OAuth возвращает email, который уже есть в users:
|
||
- Если у пользователя нет привязки OAuth → требуется подтверждение пароля
|
||
- Если есть привязка к другому провайдеру → ошибка ("Already linked to GitHub")
|
||
- Если есть привязка к этому же провайдеру → login успешный
|
||
|
||
### Новые пользователи через OAuth
|
||
|
||
По умолчанию **отключено** (флаг `OAUTH_AUTO_REGISTER=false`).
|
||
|
||
В первой версии — только invite-based: owner создаёт пользователя с email, тот логинится через OAuth, происходит match по email.
|
||
|
||
## 2FA (TOTP)
|
||
|
||
Опционально, в Sprint 7.
|
||
|
||
- Секрет генерируется при включении, показывается QR-код для Google Authenticator / Authy
|
||
- Сохраняется в `users.totp_secret` зашифрованным
|
||
- 8 backup codes генерируются и показываются один раз
|
||
- При логине после успешного password check → требуется TOTP код
|
||
- Для OAuth-логина 2FA не запрашивается (полагаемся на 2FA провайдера)
|
||
|
||
## Audit Log
|
||
|
||
### Что пишем
|
||
|
||
Категории событий:
|
||
|
||
**Auth:**
|
||
- `user.login.success` / `user.login.failure`
|
||
- `user.logout`
|
||
- `user.oauth.link` / `user.oauth.unlink`
|
||
- `user.totp.enable` / `user.totp.disable`
|
||
|
||
**User management:**
|
||
- `user.create`
|
||
- `user.update`
|
||
- `user.delete`
|
||
- `user.role.change`
|
||
|
||
**Brain management:**
|
||
- `brain.create`
|
||
- `brain.update`
|
||
- `brain.delete`
|
||
- `brain.sync.start` / `brain.sync.complete` / `brain.sync.fail`
|
||
|
||
**Source management:**
|
||
- `source.add`
|
||
- `source.update`
|
||
- `source.delete`
|
||
|
||
**Token management:**
|
||
- `token.create` (с указанием scopes)
|
||
- `token.revoke`
|
||
|
||
**MCP usage:**
|
||
- `mcp.call` (rate-sampled: 1 запись на 10 вызовов на токен в минуту)
|
||
|
||
**Admin actions:**
|
||
- `admin.cache.invalidate`
|
||
- `admin.config.update`
|
||
|
||
### Формат
|
||
|
||
```typescript
|
||
{
|
||
id: bigint, // serial
|
||
actor_type: 'user' | 'token',
|
||
actor_id: uuid, // user.id или token.id
|
||
action: string, // 'brain.create', 'mcp.call', etc.
|
||
resource_type: string?, // 'brain', 'token', 'source', etc.
|
||
resource_id: string?, // ID объекта
|
||
payload: jsonb, // дополнительные данные
|
||
ip: inet?,
|
||
user_agent: text?,
|
||
created_at: timestamptz
|
||
}
|
||
```
|
||
|
||
### Хранение
|
||
|
||
- Хранятся **навсегда** в первой версии (для compliance)
|
||
- В будущем — архив старее года в отдельную холодную БД
|
||
- Индексы: `(actor_type, actor_id, created_at DESC)`, `(resource_type, resource_id, created_at DESC)`
|
||
|
||
### Batch insert
|
||
|
||
MCP-запросов будет много (тысячи в день). Не делаем INSERT на каждый запрос:
|
||
|
||
```typescript
|
||
const auditQueue: AuditEntry[] = [];
|
||
|
||
// API endpoint:
|
||
function logAudit(entry: AuditEntry) {
|
||
auditQueue.push(entry);
|
||
// не ждём insert
|
||
}
|
||
|
||
// Worker раз в секунду:
|
||
setInterval(async () => {
|
||
if (auditQueue.length === 0) return;
|
||
const batch = auditQueue.splice(0, 1000);
|
||
await db.audit_log.insertMany(batch);
|
||
}, 1000);
|
||
|
||
// graceful shutdown:
|
||
process.on('SIGTERM', async () => {
|
||
if (auditQueue.length > 0) {
|
||
await db.audit_log.insertMany(auditQueue);
|
||
}
|
||
});
|
||
```
|
||
|
||
## Сетевая безопасность
|
||
|
||
### Внутренний контур (brain.zetit.local)
|
||
|
||
- Доступ только из сети ZETIT или через VPN
|
||
- Plain HTTP допустим (доверенная сеть)
|
||
- Postgres слушает только localhost (127.0.0.1)
|
||
- UFW: открыты только 22 (SSH), 80, 443
|
||
|
||
### Публичный контур (brain.zetit.ru)
|
||
|
||
- Только HTTPS, TLS 1.2+ с safe ciphers
|
||
- Rate limiting на nginx уровне:
|
||
- 30 r/s per IP (защита от подбора токенов)
|
||
- 100 r/s per token (защита от runaway script)
|
||
- Доступны только `/mcp/*`, `/oauth/*`, `/health`
|
||
- Любой другой path → 404 без подсказок
|
||
|
||
### SSH
|
||
|
||
- Только ключи, password auth отключён
|
||
- `PermitRootLogin no`
|
||
- Fail2ban на /var/log/auth.log
|
||
|
||
## Защита секретов
|
||
|
||
### В коде
|
||
|
||
- НИКОГДА не коммитим `.env`, `*.key`, `*.pem`
|
||
- `.gitignore` строгий
|
||
- Pre-commit hook с git-secrets или talisman (опционально)
|
||
|
||
### На VM
|
||
|
||
- `/etc/zbrain/.env` — права 600, owner zbrain
|
||
- Postgres пароли каждого брейна — в `/var/lib/zbrain/brains/<name>/config.json` (600, owner zbrain)
|
||
- API ключи OpenAI/Anthropic — в .env, не в коде
|
||
- TLS приватные ключи в `/etc/letsencrypt/live/` — стандартные права Let's Encrypt
|
||
|
||
### В БД
|
||
|
||
- Пароли пользователей — bcrypt с cost=12
|
||
- MCP токены — SHA-256 hash (HMAC не нужен, потому что коллизии не критичны)
|
||
- TOTP secrets — AES-128-GCM с ключом из TOKEN_ENCRYPTION_KEY
|
||
- OAuth subjects — открытым текстом (это не секрет)
|
||
|
||
### Backup
|
||
|
||
- Полный бэкап шифруется age (или GPG) перед сохранением
|
||
- Публичный ключ для шифрования — на VM в /etc/zbrain/backup.age.pub
|
||
- Приватный ключ для расшифровки — **НЕ на VM**, хранится отдельно (KeePass / safe / другая машина)
|
||
|
||
## Чек-лист безопасности перед production
|
||
|
||
- [ ] Все пароли Postgres сильные (≥24 символа, generated)
|
||
- [ ] `/etc/zbrain/.env` с правами 600
|
||
- [ ] UFW активен, открыты только нужные порты
|
||
- [ ] Fail2ban настроен для SSH и nginx
|
||
- [ ] TLS сертификат валиден (A+ rating на ssllabs.com)
|
||
- [ ] Postgres listen_addresses = 'localhost'
|
||
- [ ] Создан age ключ для бэкапов, приватная часть НЕ на этой VM
|
||
- [ ] Скрипт backup.sh в cron, протестирован restore
|
||
- [ ] OAuth приложения зарегистрированы, callback URL'ы правильные
|
||
- [ ] Owner аккаунт создан, password сильный, 2FA включена (после Sprint 7)
|
||
- [ ] INITIAL_OWNER_PASSWORD сменён после первого логина
|
||
- [ ] Audit log пишется и читается через UI
|
||
- [ ] Test: попытка доступа к /api/* через brain.zetit.ru → 404
|
||
- [ ] Test: запрос с revoked токеном → 401 в течение 30 секунд
|
||
- [ ] Test: запрос с неверным scope → 403
|