feat: McpToken — HTTP-mode mcplocal, CLI verbs, audit plumbing #50
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[]
|
auditLogs AuditLog[]
|
||||||
ownedProjects Project[]
|
ownedProjects Project[]
|
||||||
groupMemberships GroupMember[]
|
groupMemberships GroupMember[]
|
||||||
|
mcpTokens McpToken[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@@ -187,6 +188,7 @@ model Project {
|
|||||||
servers ProjectServer[]
|
servers ProjectServer[]
|
||||||
prompts Prompt[]
|
prompts Prompt[]
|
||||||
promptRequests PromptRequest[]
|
promptRequests PromptRequest[]
|
||||||
|
mcpTokens McpToken[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@ -204,6 +206,36 @@ model ProjectServer {
|
|||||||
@@unique([projectId, serverId])
|
@@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) ──
|
// ── MCP Instances (running containers) ──
|
||||||
|
|
||||||
model McpInstance {
|
model McpInstance {
|
||||||
@@ -288,6 +320,8 @@ model AuditEvent {
|
|||||||
correlationId String?
|
correlationId String?
|
||||||
parentEventId String?
|
parentEventId String?
|
||||||
userName String?
|
userName String?
|
||||||
|
tokenName String?
|
||||||
|
tokenSha String?
|
||||||
payload Json
|
payload Json
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -297,6 +331,7 @@ model AuditEvent {
|
|||||||
@@index([timestamp])
|
@@index([timestamp])
|
||||||
@@index([eventKind])
|
@@index([eventKind])
|
||||||
@@index([userName])
|
@@index([userName])
|
||||||
|
@@index([tokenSha])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backup Pending Queue ──
|
// ── Backup Pending Queue ──
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
UserRepository,
|
UserRepository,
|
||||||
GroupRepository,
|
GroupRepository,
|
||||||
AuditEventRepository,
|
AuditEventRepository,
|
||||||
|
McpTokenRepository,
|
||||||
} from './repositories/index.js';
|
} from './repositories/index.js';
|
||||||
import { PromptRepository } from './repositories/prompt.repository.js';
|
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
UserService,
|
UserService,
|
||||||
GroupService,
|
GroupService,
|
||||||
AuditEventService,
|
AuditEventService,
|
||||||
|
McpTokenService,
|
||||||
} from './services/index.js';
|
} from './services/index.js';
|
||||||
import type { RbacAction } from './services/index.js';
|
import type { RbacAction } from './services/index.js';
|
||||||
import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js';
|
import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js';
|
||||||
@@ -62,6 +64,7 @@ import {
|
|||||||
registerUserRoutes,
|
registerUserRoutes,
|
||||||
registerGroupRoutes,
|
registerGroupRoutes,
|
||||||
registerAuditEventRoutes,
|
registerAuditEventRoutes,
|
||||||
|
registerMcpTokenRoutes,
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
import { registerPromptRoutes } from './routes/prompts.js';
|
import { registerPromptRoutes } from './routes/prompts.js';
|
||||||
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
import { registerGitBackupRoutes } from './routes/git-backup.js';
|
||||||
@@ -104,6 +107,7 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
'mcp': 'servers',
|
'mcp': 'servers',
|
||||||
'prompts': 'prompts',
|
'prompts': 'prompts',
|
||||||
'promptrequests': 'promptrequests',
|
'promptrequests': 'promptrequests',
|
||||||
|
'mcptokens': 'mcptokens',
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = resourceMap[segment];
|
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] };
|
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
|
// Special case: /api/v1/projects/:name/prompts/visible → view prompts
|
||||||
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
|
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
|
||||||
if (visiblePromptsMatch?.[1]) {
|
if (visiblePromptsMatch?.[1]) {
|
||||||
@@ -259,6 +269,7 @@ async function main(): Promise<void> {
|
|||||||
const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
|
const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
|
||||||
const userRepo = new UserRepository(prisma);
|
const userRepo = new UserRepository(prisma);
|
||||||
const groupRepo = new GroupRepository(prisma);
|
const groupRepo = new GroupRepository(prisma);
|
||||||
|
const mcpTokenRepo = new McpTokenRepository(prisma);
|
||||||
|
|
||||||
// CUID detection for RBAC name resolution
|
// CUID detection for RBAC name resolution
|
||||||
const CUID_RE = /^c[^\s-]{8,}$/i;
|
const CUID_RE = /^c[^\s-]{8,}$/i;
|
||||||
@@ -267,6 +278,7 @@ async function main(): Promise<void> {
|
|||||||
secrets: secretRepo,
|
secrets: secretRepo,
|
||||||
projects: projectRepo,
|
projects: projectRepo,
|
||||||
groups: groupRepo,
|
groups: groupRepo,
|
||||||
|
mcptokens: mcpTokenRepo,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Migrate legacy 'admin' role → granular roles
|
// Migrate legacy 'admin' role → granular roles
|
||||||
@@ -292,6 +304,7 @@ async function main(): Promise<void> {
|
|||||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
||||||
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
|
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
|
||||||
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
|
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
|
||||||
|
const mcpTokenService = new McpTokenService(mcpTokenRepo, projectRepo, rbacDefinitionRepo, rbacService);
|
||||||
const userService = new UserService(userRepo);
|
const userService = new UserService(userRepo);
|
||||||
const groupService = new GroupService(groupRepo, userRepo);
|
const groupService = new GroupService(groupRepo, userRepo);
|
||||||
const promptRepo = new PromptRepository(prisma);
|
const promptRepo = new PromptRepository(prisma);
|
||||||
@@ -329,6 +342,8 @@ async function main(): Promise<void> {
|
|||||||
const url = request.url;
|
const url = request.url;
|
||||||
// Skip auth for health, auth, and root
|
// Skip auth for health, auth, and root
|
||||||
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
|
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;
|
if (!url.startsWith('/api/v1/')) return;
|
||||||
|
|
||||||
// Run auth middleware
|
// Run auth middleware
|
||||||
@@ -393,6 +408,7 @@ async function main(): Promise<void> {
|
|||||||
registerRbacRoutes(app, rbacDefinitionService);
|
registerRbacRoutes(app, rbacDefinitionService);
|
||||||
registerUserRoutes(app, userService);
|
registerUserRoutes(app, userService);
|
||||||
registerGroupRoutes(app, groupService);
|
registerGroupRoutes(app, groupService);
|
||||||
|
registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
|
||||||
registerPromptRoutes(app, promptService, projectRepo);
|
registerPromptRoutes(app, promptService, projectRepo);
|
||||||
|
|
||||||
// ── Git-based backup ──
|
// ── Git-based backup ──
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export class AuditEventRepository implements IAuditEventRepository {
|
|||||||
correlationId: e.correlationId ?? null,
|
correlationId: e.correlationId ?? null,
|
||||||
parentEventId: e.parentEventId ?? null,
|
parentEventId: e.parentEventId ?? null,
|
||||||
userName: e.userName ?? null,
|
userName: e.userName ?? null,
|
||||||
|
tokenName: e.tokenName ?? null,
|
||||||
|
tokenSha: e.tokenSha ?? null,
|
||||||
payload: e.payload as Prisma.InputJsonValue,
|
payload: e.payload as Prisma.InputJsonValue,
|
||||||
}));
|
}));
|
||||||
const result = await this.prisma.auditEvent.createMany({ data });
|
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.serverName !== undefined) where.serverName = filter.serverName;
|
||||||
if (filter.correlationId !== undefined) where.correlationId = filter.correlationId;
|
if (filter.correlationId !== undefined) where.correlationId = filter.correlationId;
|
||||||
if (filter.userName !== undefined) where.userName = filter.userName;
|
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) {
|
if (filter.from !== undefined || filter.to !== undefined) {
|
||||||
const timestamp: Prisma.DateTimeFilter = {};
|
const timestamp: Prisma.DateTimeFilter = {};
|
||||||
|
|||||||
@@ -15,3 +15,5 @@ export type { IGroupRepository, GroupWithMembers } from './group.repository.js';
|
|||||||
export { GroupRepository } from './group.repository.js';
|
export { GroupRepository } from './group.repository.js';
|
||||||
export type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput } from './interfaces.js';
|
export type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput } from './interfaces.js';
|
||||||
export { AuditEventRepository } from './audit-event.repository.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 { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
||||||
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
|
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
|
||||||
|
|
||||||
@@ -57,6 +57,8 @@ export interface AuditEventFilter {
|
|||||||
serverName?: string;
|
serverName?: string;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
tokenName?: string;
|
||||||
|
tokenSha?: string;
|
||||||
from?: Date;
|
from?: Date;
|
||||||
to?: Date;
|
to?: Date;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -74,6 +76,8 @@ export interface AuditEventCreateInput {
|
|||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
parentEventId?: string;
|
parentEventId?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
tokenName?: string;
|
||||||
|
tokenSha?: string;
|
||||||
payload: Record<string, unknown>;
|
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[]>;
|
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>;
|
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 { registerUserRoutes } from './users.js';
|
||||||
export { registerGroupRoutes } from './groups.js';
|
export { registerGroupRoutes } from './groups.js';
|
||||||
export { registerAuditEventRoutes } from './audit-events.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 { GroupService } from './group.service.js';
|
||||||
export { AuditEventService } from './audit-event.service.js';
|
export { AuditEventService } from './audit-event.service.js';
|
||||||
export type { AuditEventQueryParams } 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'],
|
expose: ['expose', 'view'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Exported alias for permission-ceiling checks elsewhere (e.g. McpTokenService). */
|
||||||
|
export const ROLE_ACTIONS_FOR_CEILING = ROLE_ACTIONS;
|
||||||
|
|
||||||
export class RbacService {
|
export class RbacService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rbacRepo: IRbacDefinitionRepository,
|
private readonly rbacRepo: IRbacDefinitionRepository,
|
||||||
@@ -50,8 +53,8 @@ export class RbacService {
|
|||||||
* If provided, name-scoped bindings only match when their name equals this.
|
* If provided, name-scoped bindings only match when their name equals this.
|
||||||
* If omitted (listing), name-scoped bindings still grant access.
|
* If omitted (listing), name-scoped bindings still grant access.
|
||||||
*/
|
*/
|
||||||
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string): Promise<boolean> {
|
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<boolean> {
|
||||||
const permissions = await this.getPermissions(userId, serviceAccountName);
|
const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
|
||||||
const normalized = normalizeResource(resource);
|
const normalized = normalizeResource(resource);
|
||||||
|
|
||||||
for (const perm of permissions) {
|
for (const perm of permissions) {
|
||||||
@@ -73,8 +76,8 @@ export class RbacService {
|
|||||||
* Check whether a user is allowed to perform a named operation.
|
* Check whether a user is allowed to perform a named operation.
|
||||||
* Operations require an explicit 'run' role binding with a matching action.
|
* Operations require an explicit 'run' role binding with a matching action.
|
||||||
*/
|
*/
|
||||||
async canRunOperation(userId: string, operation: string, serviceAccountName?: string): Promise<boolean> {
|
async canRunOperation(userId: string, operation: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<boolean> {
|
||||||
const permissions = await this.getPermissions(userId, serviceAccountName);
|
const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
|
||||||
|
|
||||||
for (const perm of permissions) {
|
for (const perm of permissions) {
|
||||||
if ('action' in perm && perm.role === 'run' && perm.action === operation) {
|
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: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.
|
* 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> {
|
async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string, mcpTokenSha?: string): Promise<AllowedScope> {
|
||||||
const permissions = await this.getPermissions(userId, serviceAccountName);
|
const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha);
|
||||||
const normalized = normalizeResource(resource);
|
const normalized = normalizeResource(resource);
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
|
||||||
@@ -113,13 +116,13 @@ export class RbacService {
|
|||||||
/**
|
/**
|
||||||
* Collect all permissions for a user across all matching RbacDefinitions.
|
* 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
|
// 1. Resolve user email
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { email: true },
|
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
|
// 2. Resolve group names the user belongs to
|
||||||
let groupNames: string[] = [];
|
let groupNames: string[] = [];
|
||||||
@@ -142,6 +145,7 @@ export class RbacService {
|
|||||||
if (s.kind === 'User') return user !== null && s.name === user.email;
|
if (s.kind === 'User') return user !== null && s.name === user.email;
|
||||||
if (s.kind === 'Group') return groupNames.includes(s.name);
|
if (s.kind === 'Group') return groupNames.includes(s.name);
|
||||||
if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName;
|
if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName;
|
||||||
|
if (s.kind === 'McpToken') return mcpTokenSha !== undefined && s.name === mcpTokenSha;
|
||||||
return false;
|
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';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
|
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. */
|
/** Singular→plural map for resource names. */
|
||||||
const RESOURCE_ALIASES: Record<string, string> = {
|
const RESOURCE_ALIASES: Record<string, string> = {
|
||||||
@@ -14,6 +14,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
group: 'groups',
|
group: 'groups',
|
||||||
prompt: 'prompts',
|
prompt: 'prompts',
|
||||||
promptrequest: 'promptrequests',
|
promptrequest: 'promptrequests',
|
||||||
|
mcptoken: 'mcptokens',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Normalize a resource name to its canonical plural form. */
|
/** Normalize a resource name to its canonical plural form. */
|
||||||
@@ -22,7 +23,7 @@ export function normalizeResource(resource: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RbacSubjectSchema = z.object({
|
export const RbacSubjectSchema = z.object({
|
||||||
kind: z.enum(['User', 'Group', 'ServiceAccount']),
|
kind: z.enum(['User', 'Group', 'ServiceAccount', 'McpToken']),
|
||||||
name: z.string().min(1),
|
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 './constants/index.js';
|
||||||
export * from './utils/index.js';
|
export * from './utils/index.js';
|
||||||
export * from './secrets/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