feat(collab): закрытие api-key→collab отмывания (fail-closed дискриминатор)

api_key-принципал стал полноправным REST-принципалом → мог начеканить 24-ч
COLLAB-токен, который AuthenticationExtension не сверял с api_keys, и ревокация
не доставала websocket-редактирование. Блокировать /collab-token для api_key
нельзя (это правки агентов). Решение fail-closed:

- JwtCollabPayload расширен полем principal ('session'|'api_key') + apiKeyId.
  generateCollabToken штампует его в КАЖДЫЙ токен: 'api_key'+apiKeyId, когда
  чеканит api-key-принципал (внешний MCP-агент), иначе 'session'. Дискриминатор
  ключуется на ОРИГИНЕ (наличии api-key), НЕ на actor:'agent' — внутренний
  session-backed AI-агент остаётся 'session' и на no-check пути.
- /auth/collab-token читает подписью-выведенные req.raw.authType/apiKeyId
  (не тело) и прокидывает origin через getCollabToken → generateCollabToken.
- doAuthenticate: при principal='api_key' на connect прогоняет
  ApiKeyService.validate (отозванный ключ → новых collab-подключений нет; инфра
  пробрасывается). api_key без apiKeyId — reject. Claimless-токен доверяется
  только В ПРЕДЕЛАХ 24-ч grace-окна роллаута (легаси session-токен, api_key его
  начеканить не мог); после окна всякий валидный токен обязан нести
  дискриминатор, поэтому claimless — баг и отвергается (не молча-доверие 24ч).

CollaborationModule импортирует ApiKeyModule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 00:54:03 +03:00
parent bc632f9d56
commit 0ddeaadeee
8 changed files with 210 additions and 3 deletions
+13 -1
View File
@@ -207,8 +207,20 @@ export class AuthController {
async collabToken(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
return this.authService.getCollabToken(user, workspace.id);
// Thread the api-key origin (#501): when the requester authenticated with an
// api key (jwt.strategy stamped req.raw.authType/apiKeyId), the minted collab
// token carries principal='api_key' + apiKeyId so a later revoke of the key
// rejects NEW collab connections. A normal session request mints a
// principal='session' token. Reading the SIGNED-derived req.raw fields (never
// a client body) keeps it unspoofable.
const raw = req.raw as { authType?: string; apiKeyId?: string };
const apiKey =
raw.authType === 'api_key' && raw.apiKeyId
? { apiKeyId: raw.apiKeyId }
: undefined;
return this.authService.getCollabToken(user, workspace.id, apiKey);
}
@SkipThrottle({ [AUTH_THROTTLER]: true })