feat(mcpd): McpToken schema + CRUD routes + introspection

Adds a new McpToken Prisma model (project-scoped, SHA-256 hashed at rest,
optional expiry, revocable) plus backing repository, service, and REST
routes. Tokens are a first-class RBAC subject: new 'McpToken' kind is
added to the subject enum and the service auto-creates an RbacDefinition
with subject McpToken:<sha> when bindings are provided.

Creator-permission ceiling: the service rejects any requested binding
the creator cannot already satisfy themselves (re-uses
rbacService.canAccess / canRunOperation). rbacMode=clone snapshots the
creator's full permissions into the token.

Routes:
  POST   /api/v1/mcptokens              create (returns raw token once)
  GET    /api/v1/mcptokens              list (filter by project)
  GET    /api/v1/mcptokens/:id          describe (no secret in response)
  POST   /api/v1/mcptokens/:id/revoke   soft-delete + remove RbacDef
  DELETE /api/v1/mcptokens/:id          hard-delete
  GET    /api/v1/mcptokens/introspect   validate raw bearer (used by mcplocal)

Extends AuditEvent with optional tokenName/tokenSha fields (indexed) so
token-driven activity can be filtered later. Adds token helpers in
@mcpctl/shared: TOKEN_PREFIX='mcpctl_pat_', generateToken, hashToken,
isMcpToken, timingSafeEqualHex.

Follow-up PRs add the auth-hook dispatch on the prefix, the CLI verbs,
and the HTTP-mode mcplocal that calls /introspect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-17 01:00:04 +01:00
parent 3149ea3ae7
commit 2ddb493bb0
17 changed files with 949 additions and 11 deletions

View File

@@ -25,6 +25,7 @@ model User {
auditLogs AuditLog[]
ownedProjects Project[]
groupMemberships GroupMember[]
mcpTokens McpToken[]
@@index([email])
}
@@ -187,6 +188,7 @@ model Project {
servers ProjectServer[]
prompts Prompt[]
promptRequests PromptRequest[]
mcpTokens McpToken[]
@@index([name])
@@index([ownerId])
@@ -204,6 +206,36 @@ model ProjectServer {
@@unique([projectId, serverId])
}
// ── MCP Tokens (bearer credentials for HTTP-mode mcplocal) ──
//
// Raw value format: `mcpctl_pat_<32 base62 chars>`. The raw value is shown
// exactly once at create time; only the SHA-256 hash is persisted. Tokens are
// scoped to exactly one project — they're only valid at
// `/projects/<that-project>/mcp`. Creator's RBAC is the ceiling; the service
// rejects bindings that exceed what the creator themselves can do.
model McpToken {
id String @id @default(cuid())
name String
projectId String
tokenHash String @unique
tokenPrefix String
ownerId String
description String @default("")
createdAt DateTime @default(now())
expiresAt DateTime?
lastUsedAt DateTime?
revokedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
@@unique([name, projectId])
@@index([tokenHash])
@@index([projectId])
@@index([ownerId])
}
// ── MCP Instances (running containers) ──
model McpInstance {
@@ -288,6 +320,8 @@ model AuditEvent {
correlationId String?
parentEventId String?
userName String?
tokenName String?
tokenSha String?
payload Json
createdAt DateTime @default(now())
@@ -297,6 +331,7 @@ model AuditEvent {
@@index([timestamp])
@@index([eventKind])
@@index([userName])
@@index([tokenSha])
}
// ── Backup Pending Queue ──

View File

@@ -18,6 +18,7 @@ import {
UserRepository,
GroupRepository,
AuditEventRepository,
McpTokenRepository,
} from './repositories/index.js';
import { PromptRepository } from './repositories/prompt.repository.js';
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
@@ -43,6 +44,7 @@ import {
UserService,
GroupService,
AuditEventService,
McpTokenService,
} from './services/index.js';
import type { RbacAction } from './services/index.js';
import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js';
@@ -62,6 +64,7 @@ import {
registerUserRoutes,
registerGroupRoutes,
registerAuditEventRoutes,
registerMcpTokenRoutes,
} from './routes/index.js';
import { registerPromptRoutes } from './routes/prompts.js';
import { registerGitBackupRoutes } from './routes/git-backup.js';
@@ -104,6 +107,7 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
'mcp': 'servers',
'prompts': 'prompts',
'promptrequests': 'promptrequests',
'mcptokens': 'mcptokens',
};
const resource = resourceMap[segment];
@@ -116,6 +120,12 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] };
}
// Special case: /api/v1/mcptokens/:id/revoke → treated as 'delete' on the token.
const revokeMatch = url.match(/^\/api\/v1\/mcptokens\/([^/?]+)\/revoke/);
if (revokeMatch?.[1]) {
return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] };
}
// Special case: /api/v1/projects/:name/prompts/visible → view prompts
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
if (visiblePromptsMatch?.[1]) {
@@ -259,6 +269,7 @@ async function main(): Promise<void> {
const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
const userRepo = new UserRepository(prisma);
const groupRepo = new GroupRepository(prisma);
const mcpTokenRepo = new McpTokenRepository(prisma);
// CUID detection for RBAC name resolution
const CUID_RE = /^c[^\s-]{8,}$/i;
@@ -267,6 +278,7 @@ async function main(): Promise<void> {
secrets: secretRepo,
projects: projectRepo,
groups: groupRepo,
mcptokens: mcpTokenRepo,
};
// Migrate legacy 'admin' role → granular roles
@@ -292,6 +304,7 @@ async function main(): Promise<void> {
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
const mcpTokenService = new McpTokenService(mcpTokenRepo, projectRepo, rbacDefinitionRepo, rbacService);
const userService = new UserService(userRepo);
const groupService = new GroupService(groupRepo, userRepo);
const promptRepo = new PromptRepository(prisma);
@@ -329,6 +342,8 @@ async function main(): Promise<void> {
const url = request.url;
// Skip auth for health, auth, and root
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
// Introspection authenticates via the McpToken bearer itself — route handles its own auth.
if (url.startsWith('/api/v1/mcptokens/introspect')) return;
if (!url.startsWith('/api/v1/')) return;
// Run auth middleware
@@ -393,6 +408,7 @@ async function main(): Promise<void> {
registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService);
registerGroupRoutes(app, groupService);
registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
registerPromptRoutes(app, promptService, projectRepo);
// ── Git-based backup ──

View File

@@ -30,6 +30,8 @@ export class AuditEventRepository implements IAuditEventRepository {
correlationId: e.correlationId ?? null,
parentEventId: e.parentEventId ?? null,
userName: e.userName ?? null,
tokenName: e.tokenName ?? null,
tokenSha: e.tokenSha ?? null,
payload: e.payload as Prisma.InputJsonValue,
}));
const result = await this.prisma.auditEvent.createMany({ data });
@@ -132,6 +134,8 @@ function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput {
if (filter.serverName !== undefined) where.serverName = filter.serverName;
if (filter.correlationId !== undefined) where.correlationId = filter.correlationId;
if (filter.userName !== undefined) where.userName = filter.userName;
if (filter.tokenName !== undefined) where.tokenName = filter.tokenName;
if (filter.tokenSha !== undefined) where.tokenSha = filter.tokenSha;
if (filter.from !== undefined || filter.to !== undefined) {
const timestamp: Prisma.DateTimeFilter = {};

View File

@@ -15,3 +15,5 @@ export type { IGroupRepository, GroupWithMembers } from './group.repository.js';
export { GroupRepository } from './group.repository.js';
export type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput } from './interfaces.js';
export { AuditEventRepository } from './audit-event.repository.js';
export type { IMcpTokenRepository, McpTokenFilter, McpTokenWithRelations, CreateMcpTokenRepoInput } from './interfaces.js';
export { McpTokenRepository } from './mcp-token.repository.js';

View File

@@ -1,4 +1,4 @@
import type { McpServer, McpInstance, AuditLog, AuditEvent, Secret, InstanceStatus } from '@prisma/client';
import type { McpServer, McpInstance, AuditLog, AuditEvent, McpToken, Secret, InstanceStatus } from '@prisma/client';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
@@ -57,6 +57,8 @@ export interface AuditEventFilter {
serverName?: string;
correlationId?: string;
userName?: string;
tokenName?: string;
tokenSha?: string;
from?: Date;
to?: Date;
limit?: number;
@@ -74,6 +76,8 @@ export interface AuditEventCreateInput {
correlationId?: string;
parentEventId?: string;
userName?: string;
tokenName?: string;
tokenSha?: string;
payload: Record<string, unknown>;
}
@@ -95,3 +99,37 @@ export interface IAuditEventRepository {
listSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise<number>;
}
// ── MCP Tokens ──
export interface McpTokenFilter {
projectId?: string;
ownerId?: string;
includeRevoked?: boolean;
}
export interface CreateMcpTokenRepoInput {
name: string;
projectId: string;
ownerId: string;
tokenHash: string;
tokenPrefix: string;
description?: string;
expiresAt?: Date | null;
}
export type McpTokenWithRelations = McpToken & {
project: { id: string; name: string };
owner: { id: string; email: string };
};
export interface IMcpTokenRepository {
findAll(filter?: McpTokenFilter): Promise<McpTokenWithRelations[]>;
findById(id: string): Promise<McpTokenWithRelations | null>;
findByHash(tokenHash: string): Promise<McpTokenWithRelations | null>;
findByNameAndProject(name: string, projectId: string): Promise<McpTokenWithRelations | null>;
create(data: CreateMcpTokenRepoInput): Promise<McpTokenWithRelations>;
revoke(id: string): Promise<McpTokenWithRelations>;
touchLastUsed(id: string): Promise<void>;
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,83 @@
import type { PrismaClient } from '@prisma/client';
import type {
IMcpTokenRepository,
McpTokenFilter,
McpTokenWithRelations,
CreateMcpTokenRepoInput,
} from './interfaces.js';
const INCLUDE_RELATIONS = {
project: { select: { id: true, name: true } },
owner: { select: { id: true, email: true } },
} as const;
export class McpTokenRepository implements IMcpTokenRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(filter?: McpTokenFilter): Promise<McpTokenWithRelations[]> {
const where: Record<string, unknown> = {};
if (filter?.projectId !== undefined) where['projectId'] = filter.projectId;
if (filter?.ownerId !== undefined) where['ownerId'] = filter.ownerId;
if (!filter?.includeRevoked) where['revokedAt'] = null;
return this.prisma.mcpToken.findMany({
where,
include: INCLUDE_RELATIONS,
orderBy: { createdAt: 'desc' },
}) as Promise<McpTokenWithRelations[]>;
}
async findById(id: string): Promise<McpTokenWithRelations | null> {
return this.prisma.mcpToken.findUnique({
where: { id },
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations | null>;
}
async findByHash(tokenHash: string): Promise<McpTokenWithRelations | null> {
return this.prisma.mcpToken.findUnique({
where: { tokenHash },
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations | null>;
}
async findByNameAndProject(name: string, projectId: string): Promise<McpTokenWithRelations | null> {
return this.prisma.mcpToken.findUnique({
where: { name_projectId: { name, projectId } },
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations | null>;
}
async create(data: CreateMcpTokenRepoInput): Promise<McpTokenWithRelations> {
return this.prisma.mcpToken.create({
data: {
name: data.name,
projectId: data.projectId,
ownerId: data.ownerId,
tokenHash: data.tokenHash,
tokenPrefix: data.tokenPrefix,
description: data.description ?? '',
expiresAt: data.expiresAt ?? null,
},
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations>;
}
async revoke(id: string): Promise<McpTokenWithRelations> {
return this.prisma.mcpToken.update({
where: { id },
data: { revokedAt: new Date() },
include: INCLUDE_RELATIONS,
}) as Promise<McpTokenWithRelations>;
}
async touchLastUsed(id: string): Promise<void> {
await this.prisma.mcpToken.update({
where: { id },
data: { lastUsedAt: new Date() },
});
}
async delete(id: string): Promise<void> {
await this.prisma.mcpToken.delete({ where: { id } });
}
}

View File

@@ -18,3 +18,5 @@ export { registerRbacRoutes } from './rbac-definitions.js';
export { registerUserRoutes } from './users.js';
export { registerGroupRoutes } from './groups.js';
export { registerAuditEventRoutes } from './audit-events.js';
export { registerMcpTokenRoutes } from './mcp-tokens.js';
export type { McpTokenRouteDeps } from './mcp-tokens.js';

View File

@@ -0,0 +1,142 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { isMcpToken } from '@mcpctl/shared';
import type { McpTokenService } from '../services/mcp-token.service.js';
import { PermissionCeilingError } from '../services/mcp-token.service.js';
import { NotFoundError, ConflictError } from '../services/mcp-server.service.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
export interface McpTokenRouteDeps {
tokenService: McpTokenService;
projectRepo: IProjectRepository;
}
export function registerMcpTokenRoutes(app: FastifyInstance, deps: McpTokenRouteDeps): void {
const { tokenService, projectRepo } = deps;
// ── List ─────────────────────────────────────────────────────────────
app.get<{ Querystring: { projectId?: string; projectName?: string; includeRevoked?: string } }>(
'/api/v1/mcptokens',
async (request) => {
const { projectId, projectName, includeRevoked } = request.query;
// Allow filtering by project name for CLI ergonomics.
let resolvedProjectId = projectId;
if (resolvedProjectId === undefined && projectName !== undefined) {
const project = await projectRepo.findByName(projectName);
if (project === null) throw new NotFoundError(`Project not found: ${projectName}`);
resolvedProjectId = project.id;
}
const filter: { projectId?: string; includeRevoked?: boolean } = {};
if (resolvedProjectId !== undefined) filter.projectId = resolvedProjectId;
if (includeRevoked === 'true') filter.includeRevoked = true;
const rows = await tokenService.list(filter);
return rows.map(toListResponse);
},
);
// ── Describe ─────────────────────────────────────────────────────────
app.get<{ Params: { id: string } }>('/api/v1/mcptokens/:id', async (request) => {
const row = await tokenService.getById(request.params.id);
return toListResponse(row);
});
// ── Create ───────────────────────────────────────────────────────────
app.post('/api/v1/mcptokens', async (request, reply) => {
const userId = request.userId;
if (userId === undefined) {
reply.code(401);
return { error: 'Not authenticated' };
}
try {
// Accept projectName OR projectId for CLI ergonomics.
const body = (request.body ?? {}) as Record<string, unknown>;
if (typeof body['projectName'] === 'string' && typeof body['projectId'] !== 'string') {
const project = await projectRepo.findByName(body['projectName']);
if (project === null) throw new NotFoundError(`Project not found: ${body['projectName']}`);
body['projectId'] = project.id;
}
const result = await tokenService.create(userId, body);
reply.code(201);
return {
...toListResponse(result.mcpToken),
token: result.raw,
};
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
return { error: err.message };
}
if (err instanceof ConflictError) {
reply.code(409);
return { error: err.message };
}
if (err instanceof PermissionCeilingError) {
reply.code(403);
return { error: err.message };
}
throw err;
}
});
// ── Revoke (soft-delete) ────────────────────────────────────────────
app.post<{ Params: { id: string } }>('/api/v1/mcptokens/:id/revoke', async (request) => {
const row = await tokenService.revoke(request.params.id);
return toListResponse(row);
});
// ── Delete (hard) ────────────────────────────────────────────────────
app.delete<{ Params: { id: string } }>('/api/v1/mcptokens/:id', async (request, reply) => {
await tokenService.delete(request.params.id);
reply.code(204);
});
// ── Introspect ───────────────────────────────────────────────────────
// Called by mcplocal's HTTP-mode auth preHandler to resolve a raw bearer
// to principal info. Accepts a McpToken bearer directly — bypasses the
// session-auth path.
app.get('/api/v1/mcptokens/introspect', async (request: FastifyRequest, reply: FastifyReply) => {
const header = request.headers.authorization;
if (header === undefined || !header.startsWith('Bearer ')) {
reply.code(401);
return { ok: false, error: 'Missing Authorization' };
}
const token = header.slice(7);
if (!isMcpToken(token)) {
reply.code(401);
return { ok: false, error: 'Not a mcptoken bearer' };
}
const result = await tokenService.introspectRaw(token);
if (!result.ok) {
reply.code(401);
}
return result;
});
}
function toListResponse(row: import('../repositories/interfaces.js').McpTokenWithRelations): Record<string, unknown> {
return {
id: row.id,
name: row.name,
projectId: row.projectId,
projectName: row.project.name,
tokenPrefix: row.tokenPrefix,
ownerId: row.ownerId,
ownerEmail: row.owner.email,
description: row.description,
createdAt: row.createdAt,
expiresAt: row.expiresAt,
lastUsedAt: row.lastUsedAt,
revokedAt: row.revokedAt,
status: statusOf(row),
};
}
function statusOf(row: import('../repositories/interfaces.js').McpTokenWithRelations): 'active' | 'revoked' | 'expired' {
if (row.revokedAt !== null) return 'revoked';
if (row.expiresAt !== null && row.expiresAt < new Date()) return 'expired';
return 'active';
}

View File

@@ -34,3 +34,5 @@ export { UserService } from './user.service.js';
export { GroupService } from './group.service.js';
export { AuditEventService } from './audit-event.service.js';
export type { AuditEventQueryParams } from './audit-event.service.js';
export { McpTokenService, PermissionCeilingError } from './mcp-token.service.js';
export type { CreateMcpTokenResult, IntrospectResult } from './mcp-token.service.js';

View File

@@ -0,0 +1,222 @@
import { generateToken, hashToken } from '@mcpctl/shared';
import type { McpToken } from '@prisma/client';
import type { IMcpTokenRepository, McpTokenWithRelations, McpTokenFilter } from '../repositories/interfaces.js';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
import { CreateMcpTokenSchema } from '../validation/mcp-token.schema.js';
import { isResourceBinding, type RbacRoleBinding, type RbacSubject } from '../validation/rbac-definition.schema.js';
import type { RbacService, Permission } from './rbac.service.js';
import { ROLE_ACTIONS_FOR_CEILING } from './rbac.service.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
/** Thrown when the requesting user tries to mint a token with bindings they cannot grant themselves. */
export class PermissionCeilingError extends Error {
constructor(message: string) {
super(message);
this.name = 'PermissionCeilingError';
}
}
export interface CreateMcpTokenResult {
/** The database row (with project/owner relations). */
mcpToken: McpTokenWithRelations;
/** The raw bearer token — shown exactly once. */
raw: string;
}
export interface IntrospectResult {
ok: boolean;
tokenId?: string;
tokenName?: string;
tokenSha?: string;
projectId?: string;
projectName?: string;
ownerId?: string;
expired?: boolean;
revoked?: boolean;
}
export class McpTokenService {
constructor(
private readonly tokenRepo: IMcpTokenRepository,
private readonly projectRepo: IProjectRepository,
private readonly rbacRepo: IRbacDefinitionRepository,
private readonly rbacService: RbacService,
) {}
async list(filter?: McpTokenFilter): Promise<McpTokenWithRelations[]> {
return this.tokenRepo.findAll(filter);
}
async getById(id: string): Promise<McpTokenWithRelations> {
const row = await this.tokenRepo.findById(id);
if (row === null) throw new NotFoundError(`McpToken not found: ${id}`);
return row;
}
/** Hash + lookup a raw bearer. Returns the row if valid and active; null if missing, revoked, or expired. */
async introspectRaw(raw: string): Promise<IntrospectResult> {
const hash = hashToken(raw);
const row = await this.tokenRepo.findByHash(hash);
if (row === null) return { ok: false };
const now = new Date();
const revoked = row.revokedAt !== null;
const expired = row.expiresAt !== null && row.expiresAt < now;
if (revoked || expired) {
return {
ok: false,
tokenId: row.id,
tokenName: row.name,
tokenSha: row.tokenHash,
revoked,
expired,
};
}
// Best-effort last-used tracking (don't block on this).
this.tokenRepo.touchLastUsed(row.id).catch(() => { /* ignore */ });
return {
ok: true,
tokenId: row.id,
tokenName: row.name,
tokenSha: row.tokenHash,
projectId: row.projectId,
projectName: row.project.name,
ownerId: row.ownerId,
expired: false,
revoked: false,
};
}
async create(creatorUserId: string, input: unknown): Promise<CreateMcpTokenResult> {
const data = CreateMcpTokenSchema.parse(input);
const project = await this.projectRepo.findById(data.projectId);
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
const existing = await this.tokenRepo.findByNameAndProject(data.name, data.projectId);
if (existing !== null && existing.revokedAt === null) {
throw new ConflictError(`McpToken already exists: ${data.name} in project ${project.name}`);
}
// Resolve the effective bindings:
// base = rbacMode === 'clone' ? snapshot(creator) : []
// effective = base + explicit bindings
const basePerms = data.rbacMode === 'clone'
? await this.rbacService.getPermissions(creatorUserId)
: [];
const baseBindings = basePerms.map(permissionToBinding);
const effectiveBindings: RbacRoleBinding[] = [...baseBindings, ...data.bindings];
// Creator ceiling: every effective binding must be within what creator can do.
// Cloned bindings are trivially satisfied; explicit ones may not be.
for (const binding of data.bindings) {
const violation = await this.checkCeiling(creatorUserId, binding);
if (violation !== null) throw new PermissionCeilingError(violation);
}
// Generate the token
const { raw, hash, prefix } = generateToken();
// Normalize expiresAt
let expiresAt: Date | null = null;
if (data.expiresAt !== undefined && data.expiresAt !== null) {
expiresAt = typeof data.expiresAt === 'string' ? new Date(data.expiresAt) : data.expiresAt;
}
const createArgs: {
name: string;
projectId: string;
ownerId: string;
tokenHash: string;
tokenPrefix: string;
description?: string;
expiresAt: Date | null;
} = {
name: data.name,
projectId: data.projectId,
ownerId: creatorUserId,
tokenHash: hash,
tokenPrefix: prefix,
expiresAt,
};
if (data.description !== undefined) createArgs.description = data.description;
const row = await this.tokenRepo.create(createArgs);
// If the token has bindings, auto-create an RbacDefinition so the token is a real RBAC principal.
if (effectiveBindings.length > 0) {
const subject: RbacSubject = { kind: 'McpToken', name: hash };
await this.rbacRepo.create({
name: rbacDefNameFor(row),
subjects: [subject],
roleBindings: effectiveBindings,
});
}
return { mcpToken: row, raw };
}
async revoke(id: string): Promise<McpTokenWithRelations> {
const existing = await this.getById(id);
const row = await this.tokenRepo.revoke(id);
// Remove the RBAC definition so the token's bindings stop resolving immediately.
await this.deleteRbacDefinitionFor(existing).catch(() => { /* ignore */ });
return row;
}
async delete(id: string): Promise<void> {
const existing = await this.getById(id);
await this.deleteRbacDefinitionFor(existing).catch(() => { /* ignore */ });
await this.tokenRepo.delete(id);
}
private async deleteRbacDefinitionFor(row: McpToken): Promise<void> {
const name = rbacDefNameFor(row);
const existing = await this.rbacRepo.findByName(name);
if (existing === null) return;
await this.rbacRepo.delete(existing.id);
}
/**
* For a single requested binding, return null if the creator can grant it,
* or a human-readable reason string if they cannot.
*/
private async checkCeiling(creatorUserId: string, binding: RbacRoleBinding): Promise<string | null> {
if (isResourceBinding(binding)) {
const grantedActions = ROLE_ACTIONS_FOR_CEILING[binding.role] ?? [];
for (const action of grantedActions) {
const ok = await this.rbacService.canAccess(
creatorUserId,
action,
binding.resource,
binding.name,
);
if (!ok) {
return `Ceiling violation: you do not have permission '${action}' on ${binding.resource}${binding.name !== undefined ? `/${binding.name}` : ''}`;
}
}
return null;
}
// Operation binding
const ok = await this.rbacService.canRunOperation(creatorUserId, binding.action);
if (!ok) return `Ceiling violation: you cannot run operation '${binding.action}'`;
return null;
}
}
function permissionToBinding(p: Permission): RbacRoleBinding {
if ('resource' in p) {
return p.name !== undefined
? { role: p.role as RbacRoleBinding extends { role: infer R } ? R : never, resource: p.resource, name: p.name } as RbacRoleBinding
: { role: p.role, resource: p.resource } as RbacRoleBinding;
}
return { role: 'run', action: p.action };
}
function rbacDefNameFor(row: { id: string }): string {
// Must match the regex in CreateRbacDefinitionSchema (lowercase alphanumeric with hyphens).
return `mcptoken-${row.id.toLowerCase()}`;
}

View File

@@ -38,6 +38,9 @@ const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
expose: ['expose', 'view'],
};
/** Exported alias for permission-ceiling checks elsewhere (e.g. McpTokenService). */
export const ROLE_ACTIONS_FOR_CEILING = ROLE_ACTIONS;
export class RbacService {
constructor(
private readonly rbacRepo: IRbacDefinitionRepository,
@@ -50,8 +53,8 @@ export class RbacService {
* If provided, name-scoped bindings only match when their name equals this.
* If omitted (listing), name-scoped bindings still grant access.
*/
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId, serviceAccountName);
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
const normalized = normalizeResource(resource);
for (const perm of permissions) {
@@ -73,8 +76,8 @@ export class RbacService {
* Check whether a user is allowed to perform a named operation.
* Operations require an explicit 'run' role binding with a matching action.
*/
async canRunOperation(userId: string, operation: string, serviceAccountName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId, serviceAccountName);
async canRunOperation(userId: string, operation: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
for (const perm of permissions) {
if ('action' in perm && perm.role === 'run' && perm.action === operation) {
@@ -90,8 +93,8 @@ export class RbacService {
* Returns wildcard:true if any matching binding is unscoped (no name constraint).
* Returns wildcard:false with a set of allowed names if all bindings are name-scoped.
*/
async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string): Promise<AllowedScope> {
const permissions = await this.getPermissions(userId, serviceAccountName);
async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<AllowedScope> {
const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
const normalized = normalizeResource(resource);
const names = new Set<string>();
@@ -113,13 +116,13 @@ export class RbacService {
/**
* Collect all permissions for a user across all matching RbacDefinitions.
*/
async getPermissions(userId: string, serviceAccountName?: string): Promise<Permission[]> {
async getPermissions(userId: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<Permission[]> {
// 1. Resolve user email
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (user === null && serviceAccountName === undefined) return [];
if (user === null && serviceAccountName === undefined && mcpTokenSha === undefined) return [];
// 2. Resolve group names the user belongs to
let groupNames: string[] = [];
@@ -142,6 +145,7 @@ export class RbacService {
if (s.kind === 'User') return user !== null && s.name === user.email;
if (s.kind === 'Group') return groupNames.includes(s.name);
if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName;
if (s.kind === 'McpToken') return mcpTokenSha !== undefined && s.name === mcpTokenSha;
return false;
});

View File

@@ -0,0 +1,21 @@
import { z } from 'zod';
import { RbacRoleBindingSchema } from './rbac-definition.schema.js';
export const McpTokenRbacMode = z.enum(['empty', 'clone']);
export type McpTokenRbacMode = z.infer<typeof McpTokenRbacMode>;
export const CreateMcpTokenSchema = z.object({
name: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
projectId: z.string().min(1),
description: z.string().optional(),
expiresAt: z.union([z.string().datetime(), z.date(), z.null()]).optional(),
rbacMode: McpTokenRbacMode.default('empty'),
/** Explicit bindings, added on top of the `rbacMode` base (empty or clone). */
bindings: z.array(RbacRoleBindingSchema).default([]),
});
export type CreateMcpTokenInput = z.infer<typeof CreateMcpTokenSchema>;

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const;
/** Singular→plural map for resource names. */
const RESOURCE_ALIASES: Record<string, string> = {
@@ -14,6 +14,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
group: 'groups',
prompt: 'prompts',
promptrequest: 'promptrequests',
mcptoken: 'mcptokens',
};
/** Normalize a resource name to its canonical plural form. */
@@ -22,7 +23,7 @@ export function normalizeResource(resource: string): string {
}
export const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group', 'ServiceAccount']),
kind: z.enum(['User', 'Group', 'ServiceAccount', 'McpToken']),
name: z.string().min(1),
});

View File

@@ -0,0 +1,246 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpTokenService, PermissionCeilingError } from '../src/services/mcp-token.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpTokenRepository, McpTokenWithRelations } from '../src/repositories/interfaces.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
import type { RbacService } from '../src/services/rbac.service.js';
import { hashToken, isMcpToken, TOKEN_PREFIX } from '@mcpctl/shared';
const PROJECT = { id: 'cproj1', name: 'myproj' };
function makeRow(overrides: Partial<McpTokenWithRelations> = {}): McpTokenWithRelations {
return {
id: 'ctok1',
name: 'mytok',
projectId: PROJECT.id,
tokenHash: 'deadbeef',
tokenPrefix: 'mcpctl_pat_abcd',
ownerId: 'cuser1',
description: '',
createdAt: new Date(),
expiresAt: null,
lastUsedAt: null,
revokedAt: null,
project: PROJECT,
owner: { id: 'cuser1', email: 'alice@example.com' },
...overrides,
};
}
function mockTokenRepo(): IMcpTokenRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByHash: vi.fn(async () => null),
findByNameAndProject: vi.fn(async () => null),
create: vi.fn(async (input) => makeRow({
name: input.name,
projectId: input.projectId,
tokenHash: input.tokenHash,
tokenPrefix: input.tokenPrefix,
ownerId: input.ownerId,
description: input.description ?? '',
expiresAt: input.expiresAt ?? null,
})),
revoke: vi.fn(async (id) => makeRow({ id, revokedAt: new Date() })),
touchLastUsed: vi.fn(async () => {}),
delete: vi.fn(async () => {}),
};
}
function mockProjectRepo(): IProjectRepository {
return {
findById: vi.fn(async (id) => (id === PROJECT.id ? PROJECT : null)),
findByName: vi.fn(async (name) => (name === PROJECT.name ? PROJECT : null)),
// minimal stubs for the rest — not exercised in these tests
findAll: vi.fn(async () => []),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
attachServer: vi.fn(),
detachServer: vi.fn(),
listServers: vi.fn(async () => []),
} as unknown as IProjectRepository;
}
function mockRbacRepo(): IRbacDefinitionRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({ id: 'rbac-1', name: 'x', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date() })),
update: vi.fn(),
delete: vi.fn(async () => {}),
};
}
function mockRbacService(overrides: Partial<RbacService> = {}): RbacService {
return {
canAccess: vi.fn(async () => true),
canRunOperation: vi.fn(async () => true),
getAllowedScope: vi.fn(async () => ({ wildcard: true, names: new Set() })),
getPermissions: vi.fn(async () => []),
...overrides,
} as unknown as RbacService;
}
describe('McpTokenService.create', () => {
let tokenRepo: ReturnType<typeof mockTokenRepo>;
let projectRepo: IProjectRepository;
let rbacRepo: ReturnType<typeof mockRbacRepo>;
let rbacService: RbacService;
let service: McpTokenService;
beforeEach(() => {
tokenRepo = mockTokenRepo();
projectRepo = mockProjectRepo();
rbacRepo = mockRbacRepo();
rbacService = mockRbacService();
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
});
it('creates a token with no bindings (rbacMode=empty, default)', async () => {
const result = await service.create('cuser1', {
name: 'mytok',
projectId: PROJECT.id,
});
expect(result.raw).toMatch(new RegExp(`^${TOKEN_PREFIX}`));
expect(isMcpToken(result.raw)).toBe(true);
expect(tokenRepo.create).toHaveBeenCalledTimes(1);
// Hash must be persisted, never raw
const args = vi.mocked(tokenRepo.create).mock.calls[0]![0];
expect(args.tokenHash).toBe(hashToken(result.raw));
expect(args.tokenPrefix).toBe(result.raw.slice(0, 16));
// No RBAC definition should be created when there are no bindings
expect(rbacRepo.create).not.toHaveBeenCalled();
});
it('creates an RbacDefinition with subject McpToken:<sha> when bindings are given', async () => {
const result = await service.create('cuser1', {
name: 'mytok',
projectId: PROJECT.id,
bindings: [{ role: 'view', resource: 'servers' }],
});
expect(rbacRepo.create).toHaveBeenCalledTimes(1);
const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0];
const subjects = defArgs.subjects as Array<{ kind: string; name: string }>;
expect(subjects).toEqual([{ kind: 'McpToken', name: hashToken(result.raw) }]);
expect(defArgs.roleBindings).toEqual([{ role: 'view', resource: 'servers' }]);
});
it('rejects bindings the creator does not have (ceiling violation)', async () => {
rbacService = mockRbacService({
canAccess: vi.fn(async () => false),
} as Partial<RbacService>);
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
await expect(
service.create('cuser1', {
name: 'mytok',
projectId: PROJECT.id,
bindings: [{ role: 'edit', resource: 'servers' }],
}),
).rejects.toThrow(PermissionCeilingError);
expect(tokenRepo.create).not.toHaveBeenCalled();
});
it('clones the creator\'s permissions when rbacMode=clone', async () => {
rbacService = mockRbacService({
getPermissions: vi.fn(async () => [
{ role: 'view', resource: 'servers' },
{ role: 'run', action: 'logs' },
]),
} as Partial<RbacService>);
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
await service.create('cuser1', {
name: 'mytok',
projectId: PROJECT.id,
rbacMode: 'clone',
});
expect(rbacRepo.create).toHaveBeenCalledTimes(1);
const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0];
expect(defArgs.roleBindings).toEqual([
{ role: 'view', resource: 'servers' },
{ role: 'run', action: 'logs' },
]);
});
it('throws NotFoundError if project does not exist', async () => {
await expect(
service.create('cuser1', { name: 'mytok', projectId: 'nope' }),
).rejects.toThrow(NotFoundError);
});
it('throws ConflictError if active token with same name in same project exists', async () => {
vi.mocked(tokenRepo.findByNameAndProject).mockResolvedValueOnce(makeRow());
await expect(
service.create('cuser1', { name: 'mytok', projectId: PROJECT.id }),
).rejects.toThrow(ConflictError);
});
});
describe('McpTokenService.introspectRaw', () => {
let tokenRepo: ReturnType<typeof mockTokenRepo>;
let service: McpTokenService;
beforeEach(() => {
tokenRepo = mockTokenRepo();
service = new McpTokenService(tokenRepo, mockProjectRepo(), mockRbacRepo(), mockRbacService());
});
it('returns ok=false for unknown tokens', async () => {
const result = await service.introspectRaw(`${TOKEN_PREFIX}unknown`);
expect(result.ok).toBe(false);
expect(result.tokenName).toBeUndefined();
});
it('returns ok=true and principal info for active tokens, and updates lastUsedAt', async () => {
const raw = `${TOKEN_PREFIX}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`;
const hash = hashToken(raw);
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hash }));
const result = await service.introspectRaw(raw);
expect(result.ok).toBe(true);
expect(result.projectName).toBe(PROJECT.name);
expect(result.tokenName).toBe('mytok');
expect(tokenRepo.touchLastUsed).toHaveBeenCalled();
});
it('rejects revoked tokens', async () => {
const raw = `${TOKEN_PREFIX}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`;
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), revokedAt: new Date() }));
const result = await service.introspectRaw(raw);
expect(result.ok).toBe(false);
expect(result.revoked).toBe(true);
});
it('rejects expired tokens', async () => {
const raw = `${TOKEN_PREFIX}cccccccccccccccccccccccccccccccc`;
const past = new Date(Date.now() - 60_000);
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), expiresAt: past }));
const result = await service.introspectRaw(raw);
expect(result.ok).toBe(false);
expect(result.expired).toBe(true);
});
});
describe('McpTokenService.revoke', () => {
it('marks revokedAt and removes the auto-created RbacDefinition', async () => {
const tokenRepo = mockTokenRepo();
const rbacRepo = mockRbacRepo();
const service = new McpTokenService(tokenRepo, mockProjectRepo(), rbacRepo, mockRbacService());
const row = makeRow();
vi.mocked(tokenRepo.findById).mockResolvedValue(row);
vi.mocked(rbacRepo.findByName).mockResolvedValue({
id: 'rbac-ctok1', name: 'mcptoken-ctok1', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.revoke('ctok1');
expect(tokenRepo.revoke).toHaveBeenCalledWith('ctok1');
expect(rbacRepo.findByName).toHaveBeenCalledWith('mcptoken-ctok1');
expect(rbacRepo.delete).toHaveBeenCalledWith('rbac-ctok1');
});
});

View File

@@ -3,3 +3,4 @@ export * from './validation/index.js';
export * from './constants/index.js';
export * from './utils/index.js';
export * from './secrets/index.js';
export * from './tokens/index.js';

View File

@@ -0,0 +1,41 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
export const TOKEN_PREFIX = 'mcpctl_pat_';
// base62 alphabet (URL/header safe, no ambiguous chars across all positions)
const BASE62 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
export interface GeneratedToken {
/** The raw token — `mcpctl_pat_<32 base62 chars>`. Shown once at create time; never stored. */
raw: string;
/** SHA-256 hex digest of the raw value. Persist this, not the raw value. */
hash: string;
/** First 16 chars of the raw token, safe to display (e.g. in `mcpctl get mcptoken`). */
prefix: string;
}
export function generateToken(): GeneratedToken {
const bytes = randomBytes(24);
let body = '';
for (const b of bytes) body += BASE62[b % 62];
const raw = TOKEN_PREFIX + body;
return { raw, hash: hashToken(raw), prefix: raw.slice(0, 16) };
}
export function hashToken(raw: string): string {
return createHash('sha256').update(raw).digest('hex');
}
export function isMcpToken(bearer: string): boolean {
return bearer.startsWith(TOKEN_PREFIX);
}
/** Constant-time compare two equal-length hex strings. Returns false on length mismatch. */
export function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
} catch {
return false;
}
}