# Безопасность 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_ ``` Префикс `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:: ``` Уровни: - `mcp:read:` — query, search, get_page, get_tags, get_links, get_backlinks, get_timeline, get_stats, get_health, get_versions - `mcp:write:` — всё из read + put_page, add_tag, remove_tag, add_link, remove_link, add_timeline_entry, sync_brain - `mcp:admin:` — всё из 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 { // 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: 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//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