Add markdown files describing the per‑user authentication mechanism and the ability to expand or collapse all nodes in the page tree, improving guidance for developers working with the MCP backlog feature.
417 lines
34 KiB
Markdown
417 lines
34 KiB
Markdown
# Встроенный `/mcp`: авторизация под текущим пользователем (а не сервисным аккаунтом)
|
|
|
|
Статус: **план, код не менялся.** Фича сервер (`apps/server` + `packages/mcp`).
|
|
Затрагивает безопасность — менять аккуратно.
|
|
|
|
**Решение принято: основной путь — логин/пароль текущего пользователя через
|
|
HTTP Basic** (`Authorization: Basic base64(email:password)`). Токен-варианты
|
|
(Bearer access-JWT / community PAT / OAuth) описаны ниже как альтернативы и
|
|
возможные доработки, но делаем именно логин/пароль.
|
|
|
|
## Суть
|
|
|
|
Сейчас встроенный MCP-сервер на `/mcp` ходит в Docmost **под одним сервисным
|
|
аккаунтом** (`MCP_DOCMOST_EMAIL` / `MCP_DOCMOST_PASSWORD`). Любой клиент,
|
|
подключившийся к `/mcp`, действует с правами этого аккаунта — независимо от того,
|
|
кто реально сидит за MCP-клиентом. Это значит: единые CASL-права на всех, нет
|
|
атрибуции правок конкретному человеку (в истории страниц всё — от сервисного
|
|
юзера), и без env-кредов фича вообще не поднимается (отдаёт `503 "MCP is not
|
|
configured"`).
|
|
|
|
Хотим: чтобы `/mcp` авторизовался **под текущим пользователем** (его логином и
|
|
паролем) — тогда каждый запрос исполняется под его CASL-правами, правки
|
|
атрибутируются ему, и сервисный аккаунт перестаёт быть обязательным.
|
|
|
|
## Почему сейчас сервисный аккаунт (контекст)
|
|
|
|
`/mcp` — **внешний протокольный эндпоинт** (MCP Streamable-HTTP / JSON-RPC). В
|
|
сессии MCP нет личности Docmost: сессия идентифицируется случайным UUID
|
|
([http.ts:68-74](packages/mcp/src/http.ts#L68-L74), `sessionIdGenerator: () =>
|
|
randomUUID()`) и заголовком `mcp-session-id`, а транспорт **не несёт JWT/куку
|
|
пользователя**. Поэтому пакет `@docmost/mcp` спроектирован как standalone-клиент:
|
|
логинится один раз по `email/password` ([auth-utils.ts:41-86](packages/mcp/src/lib/auth-utils.ts#L41-L86),
|
|
достаёт куку `authToken`) и дальше ходит в REST + collab как обычный внешний
|
|
клиент.
|
|
|
|
Контраст — встроенный AI-чат: он крутится **внутри авторизованного NestJS-запроса**,
|
|
поэтому чеканит loopback-токен именно текущего пользователя и каждый инструмент
|
|
исполняется под его CASL ([ai-chat-tools.service.ts:54-85](apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts#L54-L85)).
|
|
Наша задача — принести эту же модель «per-user токен» во внешний `/mcp`.
|
|
|
|
**Хорошая новость: клиентская половина уже готова.** `DocmostClient` принимает
|
|
union-конфиг — либо `{email,password}` (сервис-аккаунт, вызывает `performLogin`),
|
|
либо `{getToken}` (берёт **готовый bare-JWT** пользователя как Bearer и **не**
|
|
логинится) ([client.ts:99-160](packages/mcp/src/client.ts#L99-L160),
|
|
[client.ts:223-241](packages/mcp/src/client.ts#L223-L241)). Этот `getToken`-вариант
|
|
уже используется внутренним AI-чатом. Не хватает только **связки в самом
|
|
`/mcp`-хендлере** — он сейчас строит конфиг статически из env.
|
|
|
|
## Где сейчас живёт код (точные места)
|
|
|
|
### Хендлер `/mcp` (NestJS-обвязка)
|
|
- [mcp.service.ts:114-144](apps/server/src/integrations/mcp/mcp.service.ts#L114-L144)
|
|
`handle(req, res)`: (1) опц. статичный гард `MCP_TOKEN` против
|
|
`Authorization: Bearer` (стр. 118-125); (2) `isEnabled()` — тумблер воркспейса
|
|
`ai.mcp` (403 если выкл.); (3) `credsConfigured()` — наличие env-кредов (**это
|
|
и есть источник твоего `503`**, стр. 132-144); (4) `res.hijack()` и проброс
|
|
raw req/res в MCP-транспорт.
|
|
- [mcp.service.ts:47-64](apps/server/src/integrations/mcp/mcp.service.ts#L47-L64)
|
|
`getEmail/getPassword/getApiUrl/credsConfigured` — читают env.
|
|
- [mcp.service.ts:85-112](apps/server/src/integrations/mcp/mcp.service.ts#L85-L112)
|
|
`getHandler()` — лениво создаёт **один** HTTP-handler через
|
|
`createMcpHttpHandler({apiUrl,email,password})` и кэширует его.
|
|
|
|
### MCP-пакет
|
|
- [http.ts:13](packages/mcp/src/http.ts#L13) `createMcpHttpHandler(config:
|
|
DocmostMcpConfig)` — принимает **один статический** конфиг; создаёт по
|
|
`McpServer` + транспорту **на каждую сессию** при `initialize`
|
|
([http.ts:68-82](packages/mcp/src/http.ts#L68-L82): `createDocmostMcpServer(config)`
|
|
→ `server.connect(transport)`). Идентичность сессии фиксируется здесь, на
|
|
инициализации.
|
|
- [index.ts:50-54](packages/mcp/src/index.ts#L50-L54) `createDocmostMcpServer(config)`
|
|
— пробрасывает union-конфиг в `new DocmostClient(config)`.
|
|
- [client.ts:99-160](packages/mcp/src/client.ts#L99-L160) `DocmostMcpConfig` =
|
|
`{email,password} | {getToken}` (+ опц. `getCollabToken`); конструктор
|
|
ветвится: `getToken`-вариант не логинится, использует bare-JWT как Bearer.
|
|
|
|
### Auth / токены (сервер)
|
|
- [token.service.ts:30-54](apps/server/src/core/auth/services/token.service.ts#L30-L54)
|
|
`generateAccessToken(user, sessionId, provenance?)` → JWT `type=ACCESS`.
|
|
- [token.service.ts:119-138](apps/server/src/core/auth/services/token.service.ts#L119-L138)
|
|
`generateApiToken({apiKeyId,user,workspaceId,expiresIn})` → JWT `type=API_KEY`.
|
|
- [token.service.ts:164-176](apps/server/src/core/auth/services/token.service.ts#L164-L176)
|
|
`verifyJwt(token, type)` — проверка подписи + типа.
|
|
- [jwt.strategy.ts:26-34](apps/server/src/core/auth/strategies/jwt.strategy.ts#L26-L34)
|
|
`jwtFromRequest = cookie authToken || Bearer` — **bearer уже принимается** на
|
|
`/api`.
|
|
- [jwt.strategy.ts:80-81](apps/server/src/core/auth/strategies/jwt.strategy.ts#L80-L81)
|
|
провенанс: токен без `actor` → `'user'` (нам и нужно — правки как пользователя).
|
|
- [jwt.strategy.ts:86-109](apps/server/src/core/auth/strategies/jwt.strategy.ts#L86-L109)
|
|
`validateApiKey` — путь `type=API_KEY` **требует EE-модуль**
|
|
(`ee/api-key/api-key.service`), которого в форке нет → бросает «Enterprise API
|
|
Key module missing». То есть полноценных PAT сейчас **нет**.
|
|
- [auth.controller.ts:184-193](apps/server/src/core/auth/auth.controller.ts#L184-L193)
|
|
`POST /auth/collab-token` под `JwtAuthGuard` — выдаёт collab-токен по
|
|
bearer/cookie (этим уже пользуется и сервис-аккаунт, и AI-чат).
|
|
- [environment.service.ts:63-64](apps/server/src/integrations/environment/environment.service.ts#L63-L64)
|
|
`JWT_TOKEN_EXPIRES_IN` по умолчанию **`90d`** — access-JWT долгоживущий, годится
|
|
как «токен пользователя».
|
|
- [utils.ts:109](apps/server/src/common/helpers/utils.ts#L109)
|
|
`extractBearerTokenFromHeader(req)` — переиспользуемый парсер `Authorization`.
|
|
- [migration 20250912T101500-api-keys.ts](apps/server/src/database/migrations/20250912T101500-api-keys.ts)
|
|
— таблица `api_keys` (`id, name, creator_id, workspace_id, expires_at,
|
|
last_used_at, deleted_at`) **уже существует**, но community-сервиса под неё нет.
|
|
- [.env.example:72-79](.env.example#L72) — `MCP_DOCMOST_EMAIL/PASSWORD`,
|
|
`MCP_DOCMOST_API_URL`, `MCP_TOKEN`, `MCP_SESSION_IDLE_MS`.
|
|
|
|
## Как именно логиниться под пользователем — варианты
|
|
|
|
Пользователь подключает к `/mcp` внешний MCP-клиент (Claude Desktop и т.п.).
|
|
Авторизоваться «под текущим пользователем» можно несколькими путями с разной
|
|
ценой и безопасностью. Все они сводятся к одному и тому же на уровне клиента:
|
|
получить пользовательский JWT и ходить под ним; разница — **откуда** берётся
|
|
токен (приносит пользователь / логинит сервер / выдаёт OAuth).
|
|
|
|
### Вариант L — логин/пароль пользователя через HTTP Basic ✅ ВЫБРАН
|
|
MCP-клиент шлёт `Authorization: Basic base64(email:password)`; `/mcp` декодит и
|
|
строит per-session конфиг `{email, password}` → `DocmostClient` сам делает
|
|
`performLogin` (`POST /auth/login`) и дальше ходит под этим пользователем. Это
|
|
**ровно тот же путь, что у сервисного аккаунта сегодня**, только с кредами
|
|
текущего пользователя — клиентская механика уже готова
|
|
([client.ts:99-160](packages/mcp/src/client.ts#L99-L160),
|
|
[auth-utils.ts:41-86](packages/mcp/src/lib/auth-utils.ts#L41-L86)).
|
|
|
|
- **Плюсы:** минимум нового кода (переиспользуется `{email,password}`-ветка
|
|
`performLogin`); пользователю не надо доставать токен — привычные логин/пароль;
|
|
сервисный аккаунт становится необязательным.
|
|
- **Минусы:** **сырой пароль лежит в конфиге MCP-клиента** и уходит на сервер при
|
|
каждом коннекте (токен безопаснее — отзываем/скоупится без смены пароля);
|
|
**не работает с MFA** (статические креды не пройдут интерактивный челлендж) —
|
|
в этом форке MFA-модуль удалён (EE), поэтому сейчас вопрос моот, но при
|
|
возврате MFA или `workspace.enforceMfa` ([auth.controller.ts:64-103](apps/server/src/core/auth/auth.controller.ts#L64-L103))
|
|
путь сломается; **SSO/OIDC**-пользователи могут не иметь локального пароля;
|
|
логин жмёт `/auth/login` throttle ([AUTH_THROTTLER](apps/server/src/core/auth/auth.controller.ts#L41),
|
|
раз на сессию + переавторизация на 401).
|
|
- **Вывод:** хорош для single-user self-host без MFA; как дефолт лучше токен.
|
|
|
|
### Вариант A — pass-through access-JWT (альтернатива / возможна параллельно)
|
|
MCP-клиент шлёт `Authorization: Bearer <access-JWT>`, где токен — это значение
|
|
куки `authToken` пользователя (валиден 90 дней). `/mcp` извлекает его, валидирует
|
|
как `ACCESS`-JWT и передаёт в `DocmostClient` как `getToken`. Все REST + collab
|
|
идут под CASL этого пользователя; правки атрибутируются ему (`actor='user'`).
|
|
|
|
- **Плюсы:** минимальный диф, переиспользует уже готовый `getToken`-путь клиента;
|
|
bearer уже принимается на `/api`; сервисный аккаунт становится необязательным.
|
|
- **Минусы:** токен надо достать руками (DevTools → Cookies → `authToken`),
|
|
токен привязан к сессии (логаут/revoke сессии убивает его), он же даёт полный
|
|
доступ как у пользователя (не сужен скоупом). Приемлемо для self-host, но это
|
|
не «красивый» PAT.
|
|
|
|
### Вариант B — community PAT / API-keys (доработка на будущее)
|
|
Реализовать сообществом то, что было в EE: пользователь создаёт в настройках
|
|
**именованный, отзываемый, с TTL** персональный токен; его и кладёт в MCP-клиент.
|
|
|
|
- Таблица `api_keys` уже есть; `JwtApiKeyPayload`+`generateApiToken` есть; не
|
|
хватает **community `ApiKeyService`** (хранить хеш/строку ключа, валидировать
|
|
по `apiKeyId` из JWT, обновлять `last_used_at`, проверять `expires_at`/
|
|
`deleted_at`) + CRUD-эндпоинты + UI выдачи/отзыва.
|
|
- Поправить [jwt.strategy.ts:86-109](apps/server/src/core/auth/strategies/jwt.strategy.ts#L86-L109):
|
|
путь `API_KEY` должен звать community-сервис вместо `require('./../../../ee/...')`.
|
|
- **Плюсы:** стабильный, отзываемый, именованный токен; не завязан на браузерную
|
|
сессию; виден и управляем в UI. Это «правильный» долгоживущий ответ.
|
|
- **Минусы:** заметно больше работы (сервис + контроллер + миграция типов + UI),
|
|
и это самостоятельная фича auth, шире чем сам `/mcp`.
|
|
|
|
### Вариант C — OAuth 2.1 для MCP (доработка на будущее, «с логином» из коробки)
|
|
MCP-спека описывает авторизацию через OAuth 2.1: Docmost поднимает
|
|
authorization-server metadata + token endpoint, а MCP-клиент (Claude Desktop)
|
|
делает **интерактивный логин** и сам получает токен — это и есть «mcp с логином».
|
|
|
|
- **Плюсы:** самый стандартный и удобный UX (логин в браузере, без копипасты
|
|
токенов), refresh из коробки.
|
|
- **Минусы:** самый большой объём (discovery-эндпоинты, согласие, refresh,
|
|
привязка к существующему JWT-стеку). Избыточно для текущего запроса.
|
|
|
|
> **Решение:** делаем **L** (логин/пароль через HTTP Basic) основным и
|
|
> единственным путём на этот заход. Это закрывает «авторизация под текущим
|
|
> пользователем» минимальным кодом (переиспользуется `performLogin`) и привычным
|
|
> для пользователя способом — логин/пароль. **A/B/C** оставляем в доке как
|
|
> совместимые доработки на будущее: все варианты сходятся в одной точке —
|
|
> per-session `DocmostClient` под пользовательским JWT, отличается лишь источник
|
|
> токена (`performLogin` от сервера / Bearer от пользователя / PAT / OAuth), так
|
|
> что добавить их позже можно поверх той же связки без переделки.
|
|
|
|
## Детальный дизайн выбранного пути — логин/пароль (HTTP Basic)
|
|
|
|
Идея: вместо **одного статического** конфига хендлер получает **резолвер конфига
|
|
от запроса**, который на инициализации каждой MCP-сессии решает, под кем ходить.
|
|
Для выбранного пути резолвер читает `Authorization: Basic`, **валидирует
|
|
логин/пароль на сервере** и строит per-session `DocmostClient`, ходящий под этим
|
|
пользователем.
|
|
|
|
### 1) `packages/mcp/src/http.ts` — принять резолвер конфига
|
|
```ts
|
|
// Accept either a static config (service-account / stdio, unchanged) OR a
|
|
// per-request resolver. The resolver runs once per MCP session, at initialize,
|
|
// so the session's DocmostClient is bound to that request's identity.
|
|
export type McpConfigResolver = (
|
|
req: IncomingMessage,
|
|
) => DocmostMcpConfig | Promise<DocmostMcpConfig>;
|
|
|
|
export function createMcpHttpHandler(
|
|
config: DocmostMcpConfig | McpConfigResolver,
|
|
) { /* ... */ }
|
|
|
|
// inside handleRequest, at session init (POST initialize, http.ts:68-82):
|
|
const sessionConfig =
|
|
typeof config === "function" ? await config(req) : config;
|
|
const server = createDocmostMcpServer(sessionConfig);
|
|
```
|
|
Обратная совместимость полная: stdio ([stdio.ts](packages/mcp/src/stdio.ts)) и
|
|
существующий вызов с объектом-конфигом работают как раньше (это не функция →
|
|
ветка `else`).
|
|
|
|
### 2) `apps/server/.../mcp.service.ts` — разобрать Basic, провалидировать креды, выпустить токен
|
|
Креды валидируем **на сервере** через `AuthService.login` и в конфиг кладём
|
|
**уже выпущенный пользовательский JWT** (`getToken`-вариант), а не сам пароль —
|
|
тогда пароль не уходит дальше в loopback-клиент, а ошибки логина видны сразу,
|
|
чистым JSON-ответом до `res.hijack()`.
|
|
```ts
|
|
// Resolve the per-session identity from the request. Primary path: HTTP Basic
|
|
// (current user's email:password) -> validate on the server -> issue the user's
|
|
// JWT -> client acts as that user. Bearer (variant A) and the service account
|
|
// (back-compat) are accepted as fallbacks.
|
|
private async resolveSessionConfig(req): Promise<DocmostMcpConfig> {
|
|
const auth = req.headers['authorization'] as string | undefined;
|
|
|
|
// --- chosen path: Basic login/password ---
|
|
if (auth?.startsWith('Basic ')) {
|
|
const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
|
|
const sep = decoded.indexOf(':'); // password may contain ':'
|
|
const email = decoded.slice(0, sep);
|
|
const password = decoded.slice(sep + 1);
|
|
// Single-workspace assumption (loopback) — same as the AI-chat tools path.
|
|
const workspace = await this.workspaceRepo.findFirst();
|
|
// Throws UnauthorizedException('Email or password does not match') on bad
|
|
// creds -> surfaced as a specific 401 (never a generic error). NOTE: calling
|
|
// AuthService.login directly BYPASSES the controller's throttle + MFA gate
|
|
// (both EE/controller-level) — see Security below.
|
|
const authToken = await this.authService.login({ email, password }, workspace.id);
|
|
return { apiUrl: this.getApiUrl(), getToken: async () => authToken };
|
|
}
|
|
|
|
// --- fallback A: Bearer access-JWT (user-supplied token) ---
|
|
const bearer = extractBearerTokenFromHeader(req); // utils.ts:109
|
|
if (bearer) {
|
|
await this.tokenService.verifyJwt(bearer, JwtType.ACCESS); // specific 401
|
|
return { apiUrl: this.getApiUrl(), getToken: async () => bearer };
|
|
}
|
|
|
|
// --- fallback B: service account (existing behaviour, optional) ---
|
|
if (this.credsConfigured()) {
|
|
return { apiUrl: this.getApiUrl(), email: this.getEmail()!, password: this.getPassword()! };
|
|
}
|
|
|
|
throw new UnauthorizedException(
|
|
'MCP requires Basic auth (email:password) or a Bearer token, ' +
|
|
'or a configured MCP_DOCMOST_EMAIL/PASSWORD service account.',
|
|
);
|
|
}
|
|
```
|
|
- `getHandler()` зовёт `createMcpHttpHandler((req) => this.resolveSessionConfig(req))`
|
|
(резолвер, не статический объект).
|
|
- Auth-разбор (Basic decode + `AuthService.login` / `verifyJwt`) делать в
|
|
`handle()` **до** `res.hijack()`, чтобы на плохих кредах вернуть чистый
|
|
`401 {error: "..."}`, а не рвать hijack-нутый ответ. Резолвер тогда может
|
|
просто отдать уже посчитанный конфиг (напр. через `(req.raw as any).__mcpConfig`).
|
|
- Проверку `credsConfigured()` (стр. 132-144) **заменить** на «есть Basic ИЛИ
|
|
Bearer ИЛИ env-креды», иначе осмысленный `401/503` (не глотать).
|
|
- Инжектнуть в `McpService` `AuthService` (для `login`) и `TokenService` (для
|
|
`verifyJwt` в fallback A); `WorkspaceRepo` уже есть. Подтянуть нужные модули в
|
|
`integrations/mcp`.
|
|
|
|
### 3) Гард `MCP_TOKEN` — развести с пользовательскими кредами
|
|
Сейчас `MCP_TOKEN` едет в `Authorization: Bearer`
|
|
([mcp.service.ts:118-125](apps/server/src/integrations/mcp/mcp.service.ts#L118-L125)).
|
|
В per-user режиме `Authorization` занят кредами/токеном пользователя. Решение:
|
|
- в per-user режиме **убрать** статичный `MCP_TOKEN`-гард на `Authorization`
|
|
(аутентификацией служат сами креды; эндпоинт по-прежнему закрыт тумблером
|
|
воркспейса и сетевой изоляцией), **или**
|
|
- если нужен доп. общий шлагбаум — перенести `MCP_TOKEN` в **отдельный заголовок**
|
|
(`X-MCP-Token`), чтобы не конфликтовал с `Authorization`.
|
|
|
|
### 4) Collab / провенанс — ничего лишнего не нужно
|
|
`getCollabToken`-провайдер **не задаём**: `DocmostClient` сам сходит в
|
|
`POST /auth/collab-token` с выпущенным пользовательским JWT
|
|
([auth.controller.ts:184-193](apps/server/src/core/auth/auth.controller.ts#L184-L193))
|
|
и получит обычный пользовательский collab-токен. Так правки через collab
|
|
атрибутируются пользователю (`actor='user'` по умолчанию,
|
|
[jwt.strategy.ts:80-81](apps/server/src/core/auth/strategies/jwt.strategy.ts#L80-L81)).
|
|
Никакого «AI agent»-бейджа здесь не вешаем — это живой человек.
|
|
|
|
> **Альтернатива по объёму (если не хочется тянуть `AuthService` в McpService):**
|
|
> отдать креды как есть в конфиг `{ email, password }` — `DocmostClient` сам
|
|
> сделает `performLogin` по loopback (это буквально путь сервис-аккаунта). Минус:
|
|
> пароль идёт в loopback-клиент и ошибка логина всплывает позже, из пакета, после
|
|
> hijack. Серверная валидация (вариант выше) чище и безопаснее — её и берём.
|
|
|
|
## Тонкие моменты / edge cases
|
|
|
|
- **Идентичность привязана к сессии.** `DocmostClient` создаётся один раз на
|
|
MCP-сессию (на `initialize`) и кэширует токен; последующие запросы той же
|
|
`mcp-session-id` пойдут под пользователем, зафиксированным при инициализации.
|
|
Грань безопасности: на повторных запросах **проверять, что предъявленные креды/
|
|
токен резолвятся в того же пользователя** (`email`/`sub`), что и при инициализации
|
|
сессии, иначе `401` — чтобы нельзя было «подсесть» в чужую сессию (session
|
|
fixation / подмена кред).
|
|
- **Новая Docmost-сессия на каждый логин.** `AuthService.login` →
|
|
`sessionService.createSessionAndToken` ([auth.service.ts:97](apps/server/src/core/auth/services/auth.service.ts#L97))
|
|
создаёт **запись пользовательской сессии** на каждый MCP-логин. При частых
|
|
реконнектах сессии копятся (idle-eviction MCP-сессий их не чистит). Прикинуть:
|
|
переиспользовать токен в пределах MCP-сессии (одна сессия = один логин, уже так),
|
|
и/или TTL/чистку висящих сессий — отдельной заботой.
|
|
- **Истечение токена.** Выпущенный access-JWT живёт 90 дней — на 401 от loopback
|
|
клиент перезайдёт. Удобство Basic: креды у клиента постоянны, поэтому
|
|
переавторизация прозрачна (повторный `login`), в отличие от вручную вставленного
|
|
токена. Опционально — per-session mutable-холдер токена, чтобы переавторизация
|
|
не пересоздавала MCP-сессию.
|
|
- **Откат на сервис-аккаунт.** Сохранить как опцию (нет bearer + есть env-креды →
|
|
старое поведение). Это не ломает существующие инсталляции и даёт «безличный»
|
|
режим, где он нужен (CI, скрипты). Если откат нежелателен — сделать его
|
|
переключаемым (`MCP_REQUIRE_USER_TOKEN=true`).
|
|
- **Мульти-тенантность / loopback.** `127.0.0.1` не резолвит воркспейс по
|
|
субдомену → таргетится дефолтный воркспейс (та же single-workspace-оговорка,
|
|
что и у сервис-аккаунта и AI-чата, см.
|
|
[ai-chat-tools.service.ts:25-28](apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts#L25-L28)).
|
|
`jwt.strategy` сверяет `req.raw.workspaceId` с `payload.workspaceId`
|
|
([jwt.strategy.ts:41-43](apps/server/src/core/auth/strategies/jwt.strategy.ts#L41-L43));
|
|
на loopback `req.raw.workspaceId` не выставлен → проверка проходит. Для
|
|
мульти-воркспейс деплоя нужен явный workspace-скоуп (отдельная задача).
|
|
- **Idle-eviction.** Сессии чистятся по `MCP_SESSION_IDLE_MS` (30 мин)
|
|
([http.ts:21-39](packages/mcp/src/http.ts#L21-L39)) — без изменений; protected
|
|
per-user сессии тоже истекают по бездействию, это ок.
|
|
- **Ошибки не глотать.** Невалидный/просроченный токен → `console`/logger с
|
|
полной ошибкой **и** конкретный текст в ответе (реальная причина), не «MCP
|
|
error» (CLAUDE.md «Errors must never be swallowed»). Текущее одноразовое
|
|
warning про отсутствие кредов — оставить/адаптировать.
|
|
- **Логи/PII.** Не логировать сам токен. Сейчас `auth-utils` прячет тело ответа
|
|
за `DEBUG` — сохранить этот принцип.
|
|
|
|
## Безопасность (на ревью проверить отдельно)
|
|
|
|
- **Прямой `AuthService.login` обходит throttle и MFA-гейт.** Контроллерный
|
|
`/auth/login` защищён `ThrottlerGuard` и (в EE) MFA-проверкой
|
|
([auth.controller.ts:41](apps/server/src/core/auth/auth.controller.ts#L41),
|
|
[:64-103](apps/server/src/core/auth/auth.controller.ts#L64-L103)); вызывая
|
|
`authService.login` напрямую, мы их минуем. Следствия: (1) **brute-force через
|
|
`/mcp`** — добавить свой rate-limit на неудачные логины `/mcp` (по IP/почте);
|
|
(2) если MFA когда-либо вернётся/`enforceMfa` — Basic-путь должен **повторить
|
|
MFA-гейт или быть запрещён** для MFA-пользователей, а не молча пускать.
|
|
- **Креды в логах/трейсах.** Никогда не логировать `Authorization`, decoded
|
|
`email:password` и тело ответа логина (`auth-utils` уже прячет тело за `DEBUG`
|
|
— держать тот же принцип). На ошибке логина — конкретный `401`, но без эха
|
|
пароля.
|
|
- Per-user CASL: убедиться, что **все** инструменты идут только через loopback
|
|
REST/collab под пользовательским JWT и нигде не остаётся фолбэка на
|
|
сервис-аккаунт внутри уже инициализированной per-user сессии.
|
|
- Привязка к сессии (см. edge case) — анти-fixation проверка `email`/`sub`.
|
|
- `MCP_TOKEN`-развод: не оставить «дыру», где `Authorization` молча игнорируется.
|
|
- SSO/OIDC-пользователи без локального пароля: Basic для них не сработает —
|
|
вернуть понятный `401`, а не generic (и направить на токен-путь, если он есть).
|
|
- Доработка B (PAT): ключ хранить **хешем**, `last_used_at` обновлять, отзыв
|
|
(`deleted_at`) и `expires_at` проверять в `validateApiKey`.
|
|
|
|
## Миграции / конфиг / env / docs
|
|
|
|
- **Выбранный путь (Basic):** миграций нет. Обновить
|
|
[.env.example:72-79](.env.example#L72): пометить `MCP_DOCMOST_EMAIL/PASSWORD`
|
|
как **опциональные** (теперь это фолбэк-сервис-аккаунт, а не обязательный),
|
|
описать per-user Basic-режим и (если выбран) `X-MCP-Token`/
|
|
`MCP_REQUIRE_USER_TOKEN`. Обновить README: как прописать в MCP-клиенте
|
|
`Authorization: Basic` (свои email:password) — у клиентов это обычно поле
|
|
«headers» в конфиге сервера.
|
|
- **Доработка B (PAT):** `api_keys` таблица уже есть; добавить типы в `db.d.ts`
|
|
(`migration:codegen`), при необходимости — индексы; новый модуль/сервис/контроллер
|
|
и клиентский UI в `apps/client/src/features/.../settings`.
|
|
|
|
## Тесты / проверка
|
|
|
|
- **Сервер (`pnpm --filter server test`):**
|
|
- `mcp.service` резолвер: `Basic email:password` → `AuthService.login` зовётся
|
|
с дефолтным воркспейсом → `getToken`-конфиг с выпущенным токеном; неверные
|
|
креды → `401` с конкретным сообщением (не generic); Bearer-fallback →
|
|
`verifyJwt(ACCESS)`; нет ничего + есть env-креды → сервис-аккаунт; нет ничего
|
|
→ осмысленный 401/503.
|
|
- **пароль с `:`** парсится корректно (split по первому `:`).
|
|
- анти-fixation: второй запрос с кредами другого пользователя в той же сессии
|
|
→ 401.
|
|
- **MCP-пакет (`pnpm --filter @docmost/mcp test`):** `createMcpHttpHandler`
|
|
принимает и статический конфиг, и резолвер; резолвер зовётся один раз на
|
|
инициализацию сессии; статический путь (stdio/сервис-аккаунт) не задет.
|
|
- **Ручная:** прописать в MCP-клиенте `Authorization: Basic base64(email:pass)`
|
|
своего юзера → проверить, что (1) видны только доступные пользователю спейсы/
|
|
страницы (CASL), (2) правки в истории атрибутируются этому пользователю, а не
|
|
сервисному, (3) без env-кредов `/mcp` работает по логину/паролю, (4) неверный
|
|
пароль → понятная ошибка, а не generic, (5) залогировано без утечки пароля.
|
|
|
|
## Открытые вопросы
|
|
|
|
1. ~~Какой путь делаем~~ — **решено: логин/пароль через HTTP Basic** (вариант L).
|
|
A/B/C — совместимые доработки на будущее.
|
|
2. **Сервис-аккаунт:** оставить как откат (нет Basic/Bearer → старое поведение)
|
|
или полностью убрать в пользу обязательного per-user логина
|
|
(`MCP_REQUIRE_USER_TOKEN`)?
|
|
3. **`MCP_TOKEN`:** убрать в per-user режиме или перенести в отдельный заголовок
|
|
`X-MCP-Token` как доп. общий шлагбаум?
|
|
4. **Brute-force / throttle:** добавлять ли свой rate-limit на неудачные логины
|
|
`/mcp` (прямой `AuthService.login` минует контроллерный `ThrottlerGuard`)?
|
|
5. **Накопление сессий:** нужно ли чистить/ограничивать Docmost-сессии, создаваемые
|
|
`AuthService.login` на каждый MCP-логин, или достаточно «одна MCP-сессия = один
|
|
логин»?
|
|
6. **Серверная валидация vs pass-through:** валидировать креды через
|
|
`AuthService.login` (чище/безопаснее, тянет сервис в McpService) или отдать
|
|
`{email,password}` в `performLogin` пакета (минимум кода)? В дизайне выбрана
|
|
серверная валидация.
|
|
7. **Мульти-воркспейс:** loopback таргетит дефолтный воркспейс (как у AI-чата).
|
|
Нужен ли явный workspace-скоуп для мульти-тенант деплоя — или отдельная задача?
|