14 KiB
Безопасность 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_versionsmcp:write:<brain>— всё из read + put_page, add_tag, remove_tag, add_link, remove_link, add_timeline_entry, sync_brainmcp:admin:<brain>— всё из write + delete_page, revert_version
Wildcards:
mcp:read:*— read доступ ко всем брейнам (только owner)mcp:*:zetit— все права на zetit брейн
Один токен может иметь несколько scope, например:
{
"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.
Алгоритм валидации
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
Поддерживаемые провайдеры
- Yandex (основной)
- GitHub (резервный)
- 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.failureuser.logoutuser.oauth.link/user.oauth.unlinkuser.totp.enable/user.totp.disable
User management:
user.createuser.updateuser.deleteuser.role.change
Brain management:
brain.createbrain.updatebrain.deletebrain.sync.start/brain.sync.complete/brain.sync.fail
Source management:
source.addsource.updatesource.delete
Token management:
token.create(с указанием scopes)token.revoke
MCP usage:
mcp.call(rate-sampled: 1 запись на 10 вызовов на токен в минуту)
Admin actions:
admin.cache.invalidateadmin.config.update
Формат
{
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 на каждый запрос:
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