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:
78
docs/mcptoken-implementation.md
Normal file
78
docs/mcptoken-implementation.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# mcptoken + HTTP-mode mcplocal — implementation log
|
||||
|
||||
Companion to the approved plan at `/home/michal/.claude/plans/lets-discuss-something-i-bright-lovelace.md`.
|
||||
This file is updated as each milestone lands, so you can review what was actually done vs. what was planned.
|
||||
|
||||
## Context (why)
|
||||
|
||||
You're running your own vLLM inference outside Claude Code and want it to consume mcpctl over MCP with the same UX Claude gets: project-scoped server discovery, proxy models, the pipeline cache. Today `mcplocal` is systemd-only and serves STDIO — unreachable from off-host and unauthenticated. This work adds:
|
||||
|
||||
1. A containerized, network-accessible `mcplocal` serving Streamable HTTP.
|
||||
2. A new `McpToken` resource (CLI: `mcpctl get/create/delete mcptoken`) — project-scoped bearer tokens with the same RBAC stack as users. Hashed at rest; raw value shown once.
|
||||
3. Tokens as a first-class RBAC subject kind (`McpToken:<sha>`), with a creator-permission ceiling so non-admins cannot mint escalated tokens.
|
||||
4. k8s deploy (Service `mcp`, Ingress `mcp.ad.itaz.eu`, PVC-backed `FileCache`).
|
||||
5. A CLI breaking change: `mcpctl create rbac --binding edit:servers` → `--roleBindings role:edit,resource:servers`. You explicitly asked for this; only one command uses it.
|
||||
6. A product-grade `mcpctl test mcp <url>` verb for validating any Streamable-HTTP MCP endpoint, reused by smoke tests.
|
||||
|
||||
## Branch
|
||||
|
||||
All work lives on `feat/mcptoken` (off `main` at `3149ea3`).
|
||||
|
||||
## Pre-work committed to main (outside this branch)
|
||||
|
||||
Before starting the feature, we flushed your in-flight changes to main so they wouldn't travel with the branch:
|
||||
|
||||
- **`3149ea3 fix: MCP proxy resilience — discovery cache, default liveness probes`** — per-server `tools/list` cache in `McpRouter` with positive+negative TTL so dead upstreams only stall the first call; default liveness probe (tools/list through the real production path) applied to any RUNNING instance without an explicit healthCheck. Already pushed to origin.
|
||||
|
||||
## Status legend
|
||||
|
||||
- ✅ done
|
||||
- 🚧 in progress
|
||||
- ⬜ not started
|
||||
|
||||
## PR 1 — Schema + token helpers + mcpd CRUD routes ✅
|
||||
|
||||
| # | Step | Status |
|
||||
|---|---|---|
|
||||
| 1 | `McpToken` Prisma model + Project/User reverse relations; `AuditEvent.tokenName` / `tokenSha` + index | ✅ |
|
||||
| 2 | `src/shared/src/tokens/index.ts` — `generateToken`, `hashToken`, `isMcpToken`, `timingSafeEqualHex`, `TOKEN_PREFIX` | ✅ |
|
||||
| 3 | `src/mcpd/src/repositories/mcp-token.repository.ts` + new interfaces in `repositories/interfaces.ts` | ✅ |
|
||||
| 4 | `src/mcpd/src/services/mcp-token.service.ts` — creator-ceiling via `rbacService.canAccess`/`canRunOperation`, raw token returned only once, auto-creates an `RbacDefinition` with subject `McpToken:<sha>` when bindings are non-empty | ✅ |
|
||||
| 5 | `src/mcpd/src/routes/mcp-tokens.ts` — POST / GET / GET:id / DELETE:id + POST:id/revoke + GET /introspect | ✅ |
|
||||
| 6 | Wired into `main.ts` — repo/service constructed, routes registered, `mcptokens` added to URL→permission map + name resolver; `/mcptokens/introspect` added to auth-skip list so mcplocal can call it with a raw McpToken bearer | ✅ |
|
||||
| 7 | RBAC extensions: new subject kind `McpToken` in `rbac-definition.schema.ts`; `mcptokens` added to `RBAC_RESOURCES` and `RESOURCE_ALIASES`; `rbac.service.ts` threads optional `mcpTokenSha` through `canAccess`, `canRunOperation`, `getAllowedScope`, `getPermissions`; resolver matches `{kind:'McpToken', name: sha}` | ✅ |
|
||||
| 8 | Unit tests — `tests/mcp-token-service.test.ts` covering: empty/clone modes, ceiling rejection, RbacDefinition auto-create with correct `McpToken:<sha>` subject, duplicate-name conflict, introspect valid/revoked/expired/unknown, revoke deletes the RbacDefinition. 11/11 green. Full mcpd suite still 648/648. | ✅ |
|
||||
|
||||
### What this PR does NOT do yet (coming in PR 3)
|
||||
|
||||
- The mcpd **auth middleware** does not yet dispatch on the token prefix. A raw `mcpctl_pat_…` bearer sent to any `/api/v1/*` endpoint (other than `/introspect`) is still rejected as an invalid session. That's intentional — PR 3 extends `middleware/auth.ts` to recognize both session bearers and McpToken bearers.
|
||||
- No CLI yet. Tokens can be created only via `POST /api/v1/mcptokens` for now.
|
||||
|
||||
## PR 2 — RBAC CLI migration
|
||||
|
||||
_(blocked by PR 1 — parser is reused by PR 3)_
|
||||
|
||||
## PR 3 — CLI mcptoken verbs + mcpd auth dispatch + audit
|
||||
|
||||
_(blocked)_
|
||||
|
||||
## PR 4 — HTTP-mode mcplocal + container + `mcpctl test mcp` + smoke
|
||||
|
||||
_(blocked)_
|
||||
|
||||
## Design decisions recap (so you don't have to re-read the plan)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Transport | Streamable HTTP only |
|
||||
| Binary shape | Same `@mcpctl/mcplocal` package, two entry files (`main.ts` STDIO, `serve.ts` HTTP) |
|
||||
| Container runtime | Node (not bun-compiled) — mirrors mcpd |
|
||||
| Cache | PVC at `/var/lib/mcplocal/cache` |
|
||||
| Hostname | k8s Service `mcp`, Ingress `mcp.ad.itaz.eu` |
|
||||
| Token format | `mcpctl_pat_<32-byte base62>`, stored as SHA-256, shown-once at create |
|
||||
| Resource | `McpToken`, CLI noun `mcptoken`, one-project-per-token, FK cascade |
|
||||
| Subject kind | New `McpToken:<sha>` |
|
||||
| TTL | No default. Optional `--ttl 30d` / `never` / ISO date |
|
||||
| Default bindings | `--rbac=empty` (default), `--rbac=clone`, `--bind <kv>` — creator ceiling enforced server-side |
|
||||
| Binding CLI | `--roleBindings role:view,resource:servers[,name:foo]` or `--roleBindings action:logs` |
|
||||
| Project enforcement | Endpoint visibility only (no strict create-time check) — same mechanism Claude uses |
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
83
src/mcpd/src/repositories/mcp-token.repository.ts
Normal file
83
src/mcpd/src/repositories/mcp-token.repository.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
142
src/mcpd/src/routes/mcp-tokens.ts
Normal file
142
src/mcpd/src/routes/mcp-tokens.ts
Normal 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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
222
src/mcpd/src/services/mcp-token.service.ts
Normal file
222
src/mcpd/src/services/mcp-token.service.ts
Normal 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()}`;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
21
src/mcpd/src/validation/mcp-token.schema.ts
Normal file
21
src/mcpd/src/validation/mcp-token.schema.ts
Normal 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>;
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
246
src/mcpd/tests/mcp-token-service.test.ts
Normal file
246
src/mcpd/tests/mcp-token-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
41
src/shared/src/tokens/index.ts
Normal file
41
src/shared/src/tokens/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user