This commit is contained in:
zuevav
2026-05-20 19:33:02 +03:00
commit f4bca8449e
30 changed files with 4152 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
# Безопасность 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