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:
Michal
2026-04-17 01:12:43 +01:00
parent efcfeeab65
commit a151b2e756
17 changed files with 539 additions and 13 deletions

View File

@@ -10,11 +10,17 @@ import type { McpdClient } from '../http/mcpd-client.js';
const BATCH_SIZE = 50;
const FLUSH_INTERVAL_MS = 5_000;
interface SessionPrincipal {
userName?: string;
tokenName?: string;
tokenSha?: string;
}
export class AuditCollector {
private queue: AuditEvent[] = [];
private flushTimer: ReturnType<typeof setInterval> | null = null;
private flushing = false;
private sessionUserNames = new Map<string, string>();
private sessionPrincipals = new Map<string, SessionPrincipal>();
constructor(
private readonly mcpdClient: McpdClient,
@@ -25,15 +31,26 @@ export class AuditCollector {
/** Register a userName for a session. All future events for this session auto-fill it. */
setSessionUserName(sessionId: string, userName: string): void {
this.sessionUserNames.set(sessionId, userName);
const existing = this.sessionPrincipals.get(sessionId) ?? {};
this.sessionPrincipals.set(sessionId, { ...existing, userName });
}
/** Queue an audit event. Auto-fills projectName and userName (from session map). */
/** Register McpToken identity for a session (HTTP-mode authenticated requests). */
setSessionMcpToken(sessionId: string, token: { tokenName: string; tokenSha: string }): void {
const existing = this.sessionPrincipals.get(sessionId) ?? {};
this.sessionPrincipals.set(sessionId, { ...existing, tokenName: token.tokenName, tokenSha: token.tokenSha });
}
/** Queue an audit event. Auto-fills projectName, userName, tokenName, and tokenSha. */
emit(event: Omit<AuditEvent, 'projectName'>): void {
const enriched: AuditEvent = { ...event, projectName: this.projectName };
if (!enriched.userName && enriched.sessionId) {
const name = this.sessionUserNames.get(enriched.sessionId);
if (name) enriched.userName = name;
if (enriched.sessionId) {
const principal = this.sessionPrincipals.get(enriched.sessionId);
if (principal) {
if (!enriched.userName && principal.userName) enriched.userName = principal.userName;
if (!enriched.tokenName && principal.tokenName) enriched.tokenName = principal.tokenName;
if (!enriched.tokenSha && principal.tokenSha) enriched.tokenSha = principal.tokenSha;
}
}
this.queue.push(enriched);
if (this.queue.length >= BATCH_SIZE) {

View File

@@ -32,5 +32,9 @@ export interface AuditEvent {
correlationId?: string;
parentEventId?: string;
userName?: string;
/** Set when the session authenticated via an McpToken (HTTP-mode mcplocal). */
tokenName?: string;
/** SHA-256 hash of the McpToken that made the request. */
tokenSha?: string;
payload: Record<string, unknown>;
}