0ddeaadeee
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>
183 lines
6.9 KiB
TypeScript
183 lines
6.9 KiB
TypeScript
import { Extension, onAuthenticatePayload } from '@hocuspocus/server';
|
|
import {
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import { TokenService } from '../../core/auth/services/token.service';
|
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
|
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
|
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
|
|
import { SpaceRole } from '../../common/helpers/types/permission';
|
|
import { isUserDisabled } from '../../common/helpers';
|
|
import { getPageId } from '../collaboration.util';
|
|
import {
|
|
JwtApiKeyPayload,
|
|
JwtCollabPayload,
|
|
JwtType,
|
|
} from '../../core/auth/dto/jwt-payload';
|
|
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
|
|
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
|
|
import { ApiKeyService } from '../../core/api-key/api-key.service';
|
|
|
|
// Max lifetime of a collab token (generateCollabToken uses expiresIn '24h'). Used
|
|
// as the rollout grace window below: once this long has elapsed since this
|
|
// process started serving the #501 code, every STILL-VALID collab token was
|
|
// necessarily minted post-rollout and MUST carry the `principal` discriminator,
|
|
// so a claimless one is a bug and is rejected (fail-closed) rather than trusted.
|
|
const COLLAB_TOKEN_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
|
|
@Injectable()
|
|
export class AuthenticationExtension implements Extension {
|
|
private readonly logger = new Logger(AuthenticationExtension.name);
|
|
|
|
// Reference instant for the claimless-rejection grace window. Overridable so a
|
|
// unit test can drive the pre-/post-grace boundary without wall-clock waits.
|
|
protected rolloutAt = Date.now();
|
|
|
|
constructor(
|
|
private tokenService: TokenService,
|
|
private userRepo: UserRepo,
|
|
private pageRepo: PageRepo,
|
|
private readonly spaceMemberRepo: SpaceMemberRepo,
|
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
|
private readonly apiKeyService: ApiKeyService,
|
|
) {}
|
|
|
|
async onAuthenticate(data: onAuthenticatePayload) {
|
|
// #402 — time the whole auth (verify + user/page/permission lookups) into
|
|
// collab_auth_duration_seconds. finally so failed auths are timed too.
|
|
// No-op when METRICS_PORT is unset. Behavior unchanged.
|
|
const start = performance.now();
|
|
try {
|
|
return await this.doAuthenticate(data);
|
|
} finally {
|
|
observeCollabAuth((performance.now() - start) / 1000);
|
|
}
|
|
}
|
|
|
|
private async doAuthenticate(data: onAuthenticatePayload) {
|
|
const { documentName, token } = data;
|
|
const pageId = getPageId(documentName);
|
|
|
|
let jwtPayload: JwtCollabPayload;
|
|
|
|
try {
|
|
jwtPayload = await this.tokenService.verifyJwt(token, JwtType.COLLAB);
|
|
} catch (error) {
|
|
throw new UnauthorizedException('Invalid collab token');
|
|
}
|
|
|
|
// #501 — fail-closed api-key laundering guard. A collab token minted by an
|
|
// api-key principal carries principal='api_key' + apiKeyId; re-check the key
|
|
// on connect so a REVOKED key gets NO new collab connections (a collab token
|
|
// outlives its 24h, but a revoked key can no longer open fresh ones). An
|
|
// api-key token missing its apiKeyId is malformed → reject. A claimless token
|
|
// (no recognized principal) is trusted only DURING the rollout grace window
|
|
// (a legacy pre-rollout session token, which api keys could never mint);
|
|
// once the grace has elapsed every valid token must carry the discriminator,
|
|
// so a claimless one is a bug and is rejected (not silently trusted for 24h).
|
|
const principal = jwtPayload.principal;
|
|
if (principal === 'api_key') {
|
|
if (!jwtPayload.apiKeyId) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
// Row-check via the SHARED validator: throws Unauthorized on a revoked/
|
|
// expired/disabled key; an infra error propagates (not masked). No new
|
|
// connection for a dead key.
|
|
await this.apiKeyService.validate({
|
|
sub: jwtPayload.sub,
|
|
workspaceId: jwtPayload.workspaceId,
|
|
apiKeyId: jwtPayload.apiKeyId,
|
|
type: JwtType.API_KEY,
|
|
} as JwtApiKeyPayload);
|
|
} else if (principal !== 'session') {
|
|
// Unrecognized/absent discriminator: reject once past the grace window.
|
|
if (Date.now() - this.rolloutAt >= COLLAB_TOKEN_GRACE_MS) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
}
|
|
|
|
const userId = jwtPayload.sub;
|
|
const workspaceId = jwtPayload.workspaceId;
|
|
|
|
const user = await this.userRepo.findById(userId, workspaceId, {
|
|
includeIsAgent: true,
|
|
});
|
|
|
|
if (!user) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
if (isUserDisabled(user)) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
const page = await this.pageRepo.findById(pageId);
|
|
if (!page) {
|
|
this.logger.debug(`Page not found: ${pageId}`);
|
|
throw new NotFoundException('Page not found');
|
|
}
|
|
|
|
const userSpaceRoles = await this.spaceMemberRepo.getUserSpaceRoles(
|
|
user.id,
|
|
page.spaceId,
|
|
);
|
|
|
|
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
|
|
|
|
if (!userSpaceRole) {
|
|
this.logger.warn(`User not authorized to access page: ${pageId}`);
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
// Check page-level permissions
|
|
const { hasAnyRestriction, canAccess, canEdit } =
|
|
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
|
|
|
if (hasAnyRestriction) {
|
|
if (!canAccess) {
|
|
this.logger.warn(
|
|
`User ${user.id} denied page-level access to page: ${pageId}`,
|
|
);
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
if (!canEdit) {
|
|
data.connectionConfig.readOnly = true;
|
|
this.logger.debug(
|
|
`User ${user.id} granted readonly access to restricted page: ${pageId}`,
|
|
);
|
|
}
|
|
} else {
|
|
// No restrictions - use space-level permissions
|
|
if (userSpaceRole === SpaceRole.READER) {
|
|
data.connectionConfig.readOnly = true;
|
|
this.logger.debug(`User granted readonly access to page: ${pageId}`);
|
|
}
|
|
}
|
|
|
|
if (page.deletedAt) {
|
|
data.connectionConfig.readOnly = true;
|
|
}
|
|
|
|
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
|
|
|
|
// Carry the agent-edit provenance into the hocuspocus connection context
|
|
// (§6.6 / §15 C2), derived via the SAME resolver as the REST seam so the two
|
|
// can't drift. An is_agent service account (e.g. the MCP bot) is attributed
|
|
// 'agent' here too, so its page-content edits over collab persist as
|
|
// lastUpdatedSource='agent' (#143 review Arch A) — not just its REST writes.
|
|
// The human collab path carries no claim and is not flagged → actor='user'.
|
|
const provenance = resolveProvenance(user, jwtPayload);
|
|
return {
|
|
user,
|
|
actor: provenance.actor,
|
|
aiChatId: provenance.aiChatId,
|
|
};
|
|
}
|
|
}
|