feat: mcpctl mcptoken verbs + mcpd auth dispatch + audit plumbing
Adds the end-to-end CLI surface for McpTokens and the mcpd auth dispatch
that recognizes them.
mcpd auth middleware:
- Dispatch on the `mcpctl_pat_` bearer prefix. McpToken bearers resolve
through a new `findMcpToken(hash)` dep, populating `request.mcpToken`
and `request.userId = ownerId`. Everything else follows the existing
session path.
- Returns 401 for revoked / expired / unknown tokens.
- Global RBAC hook now threads `mcpTokenSha` into `canAccess` /
`canRunOperation` / `getAllowedScope`, and enforces a hard
project-scope check: a McpToken principal can only hit
`/api/v1/projects/<its-project>/...`.
CLI verbs:
- `mcpctl create mcptoken <name> -p <proj> [--rbac empty|clone]
[--bind role:view,resource:servers] [--ttl 30d|never|ISO]
[--description ...] [--force]` — returns the raw token once.
- `mcpctl get mcptokens [-p <proj>]` — table with
NAME/PROJECT/PREFIX/CREATED/LAST USED/EXPIRES/STATUS.
- `mcpctl get mcptoken <name> -p <proj>` and
`mcpctl describe mcptoken <name> -p <proj>` — describe surfaces the
auto-created RBAC bindings.
- `mcpctl delete mcptoken <name> -p <proj>`.
- `apply -f` support with `kind: mcptoken`. Tokens are immutable, so
apply creates if missing and skips if the name is already active.
Audit plumbing:
- `AuditEvent` / collector now carry optional `tokenName` / `tokenSha`.
`setSessionMcpToken` sits alongside `setSessionUserName`; both feed a
per-session principal map used at emit time.
- `AuditEventService` query accepts `tokenName` / `tokenSha` filters.
- Console `AuditEvent` type carries the new fields so a follow-up can
add a TOKEN column.
Completions regenerated. 1764/1764 tests pass workspace-wide.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -318,6 +318,20 @@ async function main(): Promise<void> {
|
||||
// Auth middleware for global hooks
|
||||
const authMiddleware = createAuthMiddleware({
|
||||
findSession: (token) => authService.findSession(token),
|
||||
findMcpToken: async (tokenHash) => {
|
||||
const row = await mcpTokenRepo.findByHash(tokenHash);
|
||||
if (row === null) return null;
|
||||
return {
|
||||
tokenId: row.id,
|
||||
tokenName: row.name,
|
||||
tokenSha: row.tokenHash,
|
||||
projectId: row.projectId,
|
||||
projectName: row.project.name,
|
||||
ownerId: row.ownerId,
|
||||
expiresAt: row.expiresAt,
|
||||
revokedAt: row.revokedAt,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Server
|
||||
@@ -366,9 +380,28 @@ async function main(): Promise<void> {
|
||||
const saHeader = request.headers['x-service-account'];
|
||||
const serviceAccountName = typeof saHeader === 'string' ? saHeader : undefined;
|
||||
|
||||
// McpToken principal (set by authMiddleware when the bearer was mcpctl_pat_…)
|
||||
const mcpTokenSha = request.mcpToken?.tokenSha;
|
||||
|
||||
// Second layer of project-scope enforcement: a McpToken principal can only
|
||||
// hit resources inside its bound project.
|
||||
if (request.mcpToken !== undefined) {
|
||||
const projectMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)/);
|
||||
if (projectMatch?.[1]) {
|
||||
let targetProjectName = projectMatch[1];
|
||||
if (CUID_RE.test(targetProjectName)) {
|
||||
const entity = await projectRepo.findById(targetProjectName);
|
||||
if (entity) targetProjectName = entity.name;
|
||||
}
|
||||
if (targetProjectName !== request.mcpToken.projectName) {
|
||||
return reply.code(403).send({ error: 'Token is not valid for this project' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let allowed: boolean;
|
||||
if (check.kind === 'operation') {
|
||||
allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName);
|
||||
allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName, mcpTokenSha);
|
||||
} else {
|
||||
// Resolve CUID → human name for name-scoped RBAC bindings
|
||||
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
|
||||
@@ -378,10 +411,10 @@ async function main(): Promise<void> {
|
||||
if (entity) check.resourceName = entity.name;
|
||||
}
|
||||
}
|
||||
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName, serviceAccountName);
|
||||
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName, serviceAccountName, mcpTokenSha);
|
||||
// Compute scope for list filtering (used by preSerialization hook)
|
||||
if (allowed && check.resourceName === undefined) {
|
||||
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource, serviceAccountName);
|
||||
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource, serviceAccountName, mcpTokenSha);
|
||||
}
|
||||
}
|
||||
if (!allowed) {
|
||||
|
||||
@@ -1,13 +1,41 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { isMcpToken, hashToken } from '@mcpctl/shared';
|
||||
|
||||
export interface McpTokenPrincipal {
|
||||
tokenId: string;
|
||||
tokenName: string;
|
||||
tokenSha: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface McpTokenLookup {
|
||||
tokenId: string;
|
||||
tokenName: string;
|
||||
tokenSha: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
ownerId: string;
|
||||
expiresAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface AuthDeps {
|
||||
findSession: (token: string) => Promise<{ userId: string; expiresAt: Date } | null>;
|
||||
/**
|
||||
* Look up an McpToken by SHA-256 hash. Optional — when absent, Bearer tokens
|
||||
* that look like `mcpctl_pat_…` are rejected (400).
|
||||
*/
|
||||
findMcpToken?: (tokenHash: string) => Promise<McpTokenLookup | null>;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
userId?: string;
|
||||
rbacScope?: { wildcard: boolean; names: Set<string> };
|
||||
/** Set by the auth hook when the caller authenticated via a McpToken bearer (prefix `mcpctl_pat_`). */
|
||||
mcpToken?: McpTokenPrincipal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +53,37 @@ export function createAuthMiddleware(deps: AuthDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch on the prefix: `mcpctl_pat_…` → McpToken path; anything else → session path.
|
||||
if (isMcpToken(token)) {
|
||||
if (deps.findMcpToken === undefined) {
|
||||
reply.code(401).send({ error: 'McpToken auth not enabled' });
|
||||
return;
|
||||
}
|
||||
const row = await deps.findMcpToken(hashToken(token));
|
||||
if (row === null) {
|
||||
reply.code(401).send({ error: 'Invalid token' });
|
||||
return;
|
||||
}
|
||||
if (row.revokedAt !== null) {
|
||||
reply.code(401).send({ error: 'Token revoked' });
|
||||
return;
|
||||
}
|
||||
if (row.expiresAt !== null && row.expiresAt < new Date()) {
|
||||
reply.code(401).send({ error: 'Token expired' });
|
||||
return;
|
||||
}
|
||||
request.userId = row.ownerId;
|
||||
request.mcpToken = {
|
||||
tokenId: row.tokenId,
|
||||
tokenName: row.tokenName,
|
||||
tokenSha: row.tokenSha,
|
||||
projectId: row.projectId,
|
||||
projectName: row.projectName,
|
||||
ownerId: row.ownerId,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await deps.findSession(token);
|
||||
if (session === null) {
|
||||
reply.code(401).send({ error: 'Invalid token' });
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface AuditEventQueryParams {
|
||||
serverName?: string;
|
||||
correlationId?: string;
|
||||
userName?: string;
|
||||
tokenName?: string;
|
||||
tokenSha?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
@@ -71,6 +73,8 @@ export class AuditEventService {
|
||||
if (params.serverName !== undefined) filter.serverName = params.serverName;
|
||||
if (params.correlationId !== undefined) filter.correlationId = params.correlationId;
|
||||
if (params.userName !== undefined) filter.userName = params.userName;
|
||||
if (params.tokenName !== undefined) filter.tokenName = params.tokenName;
|
||||
if (params.tokenSha !== undefined) filter.tokenSha = params.tokenSha;
|
||||
if (params.from !== undefined) filter.from = new Date(params.from);
|
||||
if (params.to !== undefined) filter.to = new Date(params.to);
|
||||
if (params.limit !== undefined) filter.limit = params.limit;
|
||||
|
||||
@@ -99,3 +99,76 @@ describe('auth middleware', () => {
|
||||
expect(findSession).toHaveBeenCalledWith('my-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth middleware — McpToken dispatch', () => {
|
||||
async function setupAppWithMcpToken(deps: Parameters<typeof createAuthMiddleware>[0]) {
|
||||
app = Fastify({ logger: false });
|
||||
const authMiddleware = createAuthMiddleware(deps);
|
||||
app.addHook('preHandler', authMiddleware);
|
||||
app.get('/protected', async (request) => ({
|
||||
userId: request.userId,
|
||||
mcpToken: request.mcpToken,
|
||||
}));
|
||||
return app.ready();
|
||||
}
|
||||
|
||||
it('routes mcpctl_pat_ bearers to findMcpToken and skips findSession', async () => {
|
||||
const findSession = vi.fn(async () => null);
|
||||
const findMcpToken = vi.fn(async () => ({
|
||||
tokenId: 'ctok1',
|
||||
tokenName: 'mytok',
|
||||
tokenSha: 'deadbeef',
|
||||
projectId: 'cproj1',
|
||||
projectName: 'myproj',
|
||||
ownerId: 'cuser1',
|
||||
expiresAt: null,
|
||||
revokedAt: null,
|
||||
}));
|
||||
await setupAppWithMcpToken({ findSession, findMcpToken });
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/protected',
|
||||
headers: { authorization: 'Bearer mcpctl_pat_abcdefghij' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(findSession).not.toHaveBeenCalled();
|
||||
expect(findMcpToken).toHaveBeenCalledTimes(1);
|
||||
const body = res.json<{ userId: string; mcpToken: { tokenName: string; projectName: string } }>();
|
||||
expect(body.userId).toBe('cuser1');
|
||||
expect(body.mcpToken.tokenName).toBe('mytok');
|
||||
expect(body.mcpToken.projectName).toBe('myproj');
|
||||
});
|
||||
|
||||
it('returns 401 for a revoked McpToken', async () => {
|
||||
await setupAppWithMcpToken({
|
||||
findSession: async () => null,
|
||||
findMcpToken: async () => ({
|
||||
tokenId: 'ctok1',
|
||||
tokenName: 'mytok',
|
||||
tokenSha: 'x',
|
||||
projectId: 'p',
|
||||
projectName: 'p',
|
||||
ownerId: 'u',
|
||||
expiresAt: null,
|
||||
revokedAt: new Date(),
|
||||
}),
|
||||
});
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/protected',
|
||||
headers: { authorization: 'Bearer mcpctl_pat_revoked' },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json<{ error: string }>().error).toContain('revoked');
|
||||
});
|
||||
|
||||
it('returns 401 when a mcpctl_pat_ bearer arrives but findMcpToken is not configured', async () => {
|
||||
await setupAppWithMcpToken({ findSession: async () => null });
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/protected',
|
||||
headers: { authorization: 'Bearer mcpctl_pat_no-lookup-wired' },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user