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

@@ -99,6 +99,25 @@ export function createProgram(): Command {
}
}
// --project scoping for mcptokens
if (!nameOrId && resource === 'mcptokens' && projectName) {
return client.get<unknown[]>(`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`);
}
// Name-based lookup for mcptokens: names are unique only within a project
if (nameOrId && resource === 'mcptokens' && !/^c[a-z0-9]{24}/.test(nameOrId)) {
if (!projectName) {
throw new Error('mcptoken names are scoped to a project — pass --project <name> or use the token id (cuid)');
}
const items = await client.get<Array<{ id: string; name: string }>>(
`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`,
);
const match = items.find((i) => i.name === nameOrId);
if (!match) throw new Error(`mcptoken '${nameOrId}' not found in project '${projectName}'`);
const item = await client.get(`/api/v1/mcptokens/${match.id}`);
return [item];
}
if (nameOrId) {
// Glob pattern — use query param filtering
if (nameOrId.includes('*')) {
@@ -132,6 +151,19 @@ export function createProgram(): Command {
return client.get(`/api/v1/${resource}/${match.id as string}`);
}
// Mcptokens: names are project-scoped. CUIDs pass straight through.
if (resource === 'mcptokens' && !/^c[a-z0-9]{24}/.test(nameOrId)) {
if (!projectName) {
throw new Error('mcptoken names are scoped to a project — pass --project <name> or use the token id (cuid)');
}
const items = await client.get<Array<Record<string, unknown>>>(
`/api/v1/mcptokens?projectName=${encodeURIComponent(projectName)}`,
);
const match = items.find((item) => item.name === nameOrId);
if (!match) throw new Error(`mcptoken '${nameOrId}' not found in project '${projectName}'`);
return client.get(`/api/v1/mcptokens/${match.id as string}`);
}
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);