feat(core): improve mcp management (#15221)
#### PR Dependency Tree * **PR #15221** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added MCP credential management (create/reveal, list, rotate, revoke) with expiration and status tracking. * Introduced read-only vs read/write access modes, with read/write tooling enabled only when permitted. * Added workspace MCP credential configuration UI, including token reveal and setup generation. * Added MCP credential GraphQL APIs to back the UI. * **Changes** * Replaced legacy access-token support with MCP credentials across authentication and realtime updates. * **Bug Fixes** * MCP authentication now reliably rejects revoked, rotated, expired, or disabled-user credentials. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
|
||||
import { createModule } from '../../__tests__/create-module';
|
||||
import { Mockers } from '../../__tests__/mocks';
|
||||
import { Due } from '../../base';
|
||||
import { Models } from '../index';
|
||||
|
||||
const module = await createModule();
|
||||
const models = module.get(Models);
|
||||
|
||||
test.after.always(async () => {
|
||||
await module.close();
|
||||
});
|
||||
|
||||
test('should create access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
t.is(token.userId, user.id);
|
||||
t.is(token.name, 'test');
|
||||
t.truthy(token.token);
|
||||
t.true(token.token.startsWith('ut_'));
|
||||
t.truthy(token.createdAt);
|
||||
t.is(token.expiresAt, null);
|
||||
|
||||
const row = await module.get(PrismaClient).accessToken.findUnique({
|
||||
where: { id: token.id },
|
||||
});
|
||||
t.truthy(row);
|
||||
t.regex(row!.token, /^[0-9a-f]{64}$/);
|
||||
t.not(row!.token, token.token);
|
||||
});
|
||||
|
||||
test('should create access token with expiration', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
expiresAt: Due.after('30d'),
|
||||
});
|
||||
|
||||
t.truthy(token.expiresAt);
|
||||
t.truthy(token.expiresAt! > new Date());
|
||||
});
|
||||
|
||||
test('should list access tokens without token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
await module.create(Mockers.AccessToken, { userId: user.id }, 3);
|
||||
|
||||
const listed = await models.accessToken.list(user.id);
|
||||
t.is(listed.length, 3);
|
||||
// @ts-expect-error not exists
|
||||
t.is(listed[0].token, undefined);
|
||||
});
|
||||
|
||||
test('should not reveal access token value after creation', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const listed = await models.accessToken.list(user.id, true);
|
||||
const found = listed.find(item => item.id === token.id);
|
||||
|
||||
t.truthy(found);
|
||||
t.is(found!.token, '[REDACTED]');
|
||||
t.not(found!.token, token.token);
|
||||
});
|
||||
|
||||
test('should be able to revoke access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await module.create(Mockers.AccessToken, { userId: user.id });
|
||||
|
||||
await models.accessToken.revoke(token.id, user.id);
|
||||
|
||||
const listed = await models.accessToken.list(user.id);
|
||||
t.is(listed.length, 0);
|
||||
});
|
||||
|
||||
test('should be able to get access token by token value', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found?.id, token.id);
|
||||
t.is(found?.userId, user.id);
|
||||
t.is(found?.name, token.name);
|
||||
});
|
||||
|
||||
test('should not get expired access token', async t => {
|
||||
const user = await module.create(Mockers.User);
|
||||
const token = await models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
expiresAt: Due.before('1s'),
|
||||
});
|
||||
|
||||
const found = await models.accessToken.getByToken(token.token);
|
||||
t.is(found, null);
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { CryptoHelper } from '../base';
|
||||
import { BaseModel } from './base';
|
||||
|
||||
const REDACTED_TOKEN = '[REDACTED]';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.access_token.created': {
|
||||
userId: string;
|
||||
};
|
||||
'user.access_token.revoked': {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateAccessTokenInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
type UserAccessToken = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenModel extends BaseModel {
|
||||
constructor(private readonly crypto: CryptoHelper) {
|
||||
super();
|
||||
}
|
||||
|
||||
async list(userId: string, revealed?: false): Promise<UserAccessToken[]>;
|
||||
async list(
|
||||
userId: string,
|
||||
revealed: true
|
||||
): Promise<(UserAccessToken & { token: string })[]>;
|
||||
async list(userId: string, revealed: boolean = false) {
|
||||
const tokens = await this.db.accessToken.findMany({
|
||||
select: { id: true, name: true, createdAt: true, expiresAt: true },
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!revealed) return tokens;
|
||||
|
||||
return tokens.map(row => ({ ...row, token: REDACTED_TOKEN }));
|
||||
}
|
||||
|
||||
async create(input: CreateAccessTokenInput) {
|
||||
const token = `ut_${this.crypto.randomBytes(32).toString('base64url')}`;
|
||||
const tokenHash = this.crypto.sha256(token).toString('hex');
|
||||
|
||||
const created = await this.db.accessToken.create({
|
||||
data: { token: tokenHash, ...input },
|
||||
});
|
||||
|
||||
// NOTE: we only return the plaintext token once, at creation time.
|
||||
return { ...created, token };
|
||||
}
|
||||
|
||||
async revoke(id: string, userId: string) {
|
||||
await this.db.accessToken.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByToken(token: string) {
|
||||
const tokenHash = this.crypto.sha256(token).toString('hex');
|
||||
|
||||
const condition = [{ expiresAt: null }, { expiresAt: { gt: new Date() } }];
|
||||
const found = await this.db.accessToken.findUnique({
|
||||
where: { token: tokenHash, OR: condition },
|
||||
});
|
||||
|
||||
if (found) return found;
|
||||
|
||||
// Compatibility: lazy-migrate old plaintext tokens in DB.
|
||||
const legacy = await this.db.accessToken.findUnique({
|
||||
where: { token, OR: condition },
|
||||
});
|
||||
|
||||
if (!legacy) return null;
|
||||
|
||||
await this.db.accessToken.update({
|
||||
where: { id: legacy.id },
|
||||
data: { token: tokenHash },
|
||||
});
|
||||
|
||||
return { ...legacy, token: tokenHash };
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
|
||||
import { ApplyType } from '../base';
|
||||
import { AccessTokenModel } from './access-token';
|
||||
import { AuthSessionModel } from './auth-session';
|
||||
import { BlobModel } from './blob';
|
||||
import { CalendarAccountModel } from './calendar-account';
|
||||
@@ -31,6 +30,7 @@ import { FeatureModel } from './feature';
|
||||
import { HistoryModel } from './history';
|
||||
import { MagicLinkOtpModel } from './magic-link-otp';
|
||||
import { MailDeliveryModel } from './mail-delivery';
|
||||
import { McpCredentialModel } from './mcp-credential';
|
||||
import { NotificationModel } from './notification';
|
||||
import { PermissionProjectionModel } from './permission-projection';
|
||||
import {
|
||||
@@ -91,7 +91,7 @@ const MODELS = {
|
||||
comment: CommentModel,
|
||||
commentAttachment: CommentAttachmentModel,
|
||||
blob: BlobModel,
|
||||
accessToken: AccessTokenModel,
|
||||
mcpCredential: McpCredentialModel,
|
||||
calendarAccount: CalendarAccountModel,
|
||||
calendarSubscription: CalendarSubscriptionModel,
|
||||
calendarEvent: CalendarEventModel,
|
||||
|
||||
86
packages/backend/server/src/models/mcp-credential.ts
Normal file
86
packages/backend/server/src/models/mcp-credential.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { McpAccessMode } from '@prisma/client';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
export type CreateMcpCredential = {
|
||||
id: string;
|
||||
familyId: string;
|
||||
generation: number;
|
||||
name: string;
|
||||
secretHash: string;
|
||||
fingerprint: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
accessMode: McpAccessMode;
|
||||
expiresAt: Date;
|
||||
graceEndsAt?: Date | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class McpCredentialModel extends BaseModel {
|
||||
list(userId: string, workspaceId: string) {
|
||||
return this.db.mcpCredential.findMany({
|
||||
where: { userId, workspaceId },
|
||||
orderBy: [{ familyId: 'asc' }, { generation: 'desc' }],
|
||||
});
|
||||
}
|
||||
|
||||
create(input: CreateMcpCredential) {
|
||||
return this.db.mcpCredential.create({ data: input });
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.db.mcpCredential.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async revokeFamily(familyId: string, userId: string, workspaceId: string) {
|
||||
return await this.db.mcpCredential.updateMany({
|
||||
where: { familyId, userId, workspaceId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async replace(
|
||||
id: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
replacedById: string,
|
||||
expiresAt: Date
|
||||
) {
|
||||
return await this.db.mcpCredential.updateMany({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
workspaceId,
|
||||
revokedAt: null,
|
||||
replacedById: null,
|
||||
},
|
||||
data: { replacedById, expiresAt },
|
||||
});
|
||||
}
|
||||
|
||||
async authenticate(id: string, workspaceId: string) {
|
||||
const now = new Date();
|
||||
return await this.db.mcpCredential.findFirst({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
revokedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
user: { disabled: false },
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
|
||||
async touch(id: string, before: Date, now: Date) {
|
||||
await this.db.mcpCredential.updateMany({
|
||||
where: {
|
||||
id,
|
||||
OR: [{ lastUsedAt: null }, { lastUsedAt: { lt: before } }],
|
||||
},
|
||||
data: { lastUsedAt: now },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user