diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 39ec089..85fa86c 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -175,7 +175,7 @@ _mcpctl() { create) local create_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$create_sub" ]]; then - COMPREPLY=($(compgen -W "server secret project user group rbac prompt serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "server secret project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in server) @@ -196,6 +196,9 @@ _mcpctl() { rbac) COMPREPLY=($(compgen -W "--subject --roleBindings --force -h --help" -- "$cur")) ;; + mcptoken) + COMPREPLY=($(compgen -W "-p --project --rbac --bind --ttl --description --force -h --help" -- "$cur")) + ;; prompt) COMPREPLY=($(compgen -W "-p --project --content --content-file --priority --link -h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 736eb78..9c55f7c 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -280,13 +280,14 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' # create subcommands -set -l create_cmds server secret project user group rbac prompt serverattachment promptrequest +set -l create_cmds server secret project user group rbac mcptoken prompt serverattachment promptrequest complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create an MCP server definition' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a rbac -d 'Create an RBAC binding definition' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a mcptoken -d 'Create a project-scoped API token for HTTP-mode mcplocal. The raw token is printed once.' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a prompt -d 'Create an approved prompt' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a serverattachment -d 'Attach a server to a project' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a promptrequest -d 'Create a prompt request (pending proposal that needs approval)' @@ -335,6 +336,14 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create rbac" -l subject -d 'Subjec complete -c mcpctl -n "__mcpctl_subcmd_active create rbac" -l roleBindings -d 'Role binding as key:value pairs, e.g. "role:view,resource:servers" or "role:view,resource:servers,name:my-ha" or "action:logs" (repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create rbac" -l force -d 'Update if already exists' +# create mcptoken options +complete -c mcpctl -n "__mcpctl_subcmd_active create mcptoken" -s p -l project -d 'Project this token is bound to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create mcptoken" -l rbac -d 'Base RBAC: \'empty\' (default, no bindings) or \'clone\' (snapshot creator\'s perms)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create mcptoken" -l bind -d 'Additional role binding as key:value pairs, e.g. "role:view,resource:servers" or "action:logs" (repeat for multiple). Creator perms are the ceiling.' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create mcptoken" -l ttl -d 'Expiry: \'30d\', \'12h\', \'never\', or an ISO8601 datetime' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create mcptoken" -l description -d 'Freeform description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create mcptoken" -l force -d 'Revoke any existing active token with this name, then create a new one' + # create prompt options complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -s p -l project -d 'Project name to scope the prompt to' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content -d 'Prompt content text' -x diff --git a/docs/mcptoken-implementation.md b/docs/mcptoken-implementation.md index 85b5cdb..e586f34 100644 --- a/docs/mcptoken-implementation.md +++ b/docs/mcptoken-implementation.md @@ -82,9 +82,30 @@ Full CLI suite still 406/406 green. On-disk YAML shape (`roleBindings: [...]`) i The extracted `parseRoleBinding` helper is what PR 3's `mcpctl create mcptoken --bind ` flag will reuse. -## PR 3 — CLI mcptoken verbs + mcpd auth dispatch + audit +## PR 3 — CLI mcptoken verbs + mcpd auth dispatch + audit ✅ -_(blocked)_ +| # | Step | Status | +|---|---|---| +| 1 | `src/mcpd/src/middleware/auth.ts` — dispatch on the bearer prefix. `mcpctl_pat_…` → new `findMcpToken(hash)` dep → populates `request.mcpToken` + `request.userId = ownerId`. Other bearers → existing `findSession` path. Returns 401 for revoked, expired, or unknown tokens. Fastify module augmentation adds `request.mcpToken?: McpTokenPrincipal`. | ✅ | +| 2 | `src/mcpd/src/main.ts` — wires `findMcpToken: mcpTokenRepo.findByHash`. Threads `mcpTokenSha` into `canAccess` / `canRunOperation` / `getAllowedScope`. Adds a second project-scope check: `McpToken` principals can only reach resources inside their bound project (additional guard on top of the route handler checks). | ✅ | +| 3 | New auth tests (`tests/auth.test.ts`) — 3 McpToken dispatch cases: happy path sets userId + mcpToken, revoked → 401, no findMcpToken wired → 401. Session path unchanged. | ✅ | +| 4 | `mcpctl create mcptoken -p [--rbac empty\|clone] [--bind …] [--ttl …]` — new subcommand. Reuses `parseRoleBinding` from PR 2. `parseTtl` helper accepts `30d`/`12h`/`never`/ISO8601. `--force` revokes the existing active token and creates a new one. Raw token is printed once with a "copy now" banner. | ✅ | +| 5 | `mcpctl get mcptokens` + `mcpctl get mcptoken -p ` + `mcpctl describe mcptoken -p ` + `mcpctl delete mcptoken -p `. Names are project-scoped, so all verbs require `-p` unless a CUID is passed. Table columns: NAME / PROJECT / PREFIX / CREATED / LAST USED / EXPIRES / STATUS. Describe surfaces the auto-created RbacDefinition's bindings (matched by `mcptoken-` name convention). | ✅ | +| 6 | `mcpctl apply -f` — added `McpTokenSpecSchema`, `mcpton: 'mcptokens'` in `KIND_TO_RESOURCE`, and an applier that creates if missing or logs "already active — skipped" (tokens are immutable). Raw token printed on create. | ✅ | +| 7 | Resource aliases — `mcptoken`/`mcptokens`/`token`/`tokens` all resolve to `mcptokens`. `stripInternalFields` scrubs the secret and derived fields and promotes `projectName` → `project` for YAML round-trip. | ✅ | +| 8 | Audit pipeline — `src/mcplocal/src/audit/types.ts` gains `tokenName?`/`tokenSha?`; collector gets `setSessionMcpToken(sessionId, {tokenName, tokenSha})` alongside `setSessionUserName`, both merged into a per-session principal map. `src/mcpd/src/services/audit-event.service.ts` accepts `tokenName` and `tokenSha` query params (repo already extended in PR 1). `console/audit-types.ts` carries the new optional fields so the TUI can surface them in a follow-up. | ✅ | +| 9 | Shell completions regenerated — `mcpctl create mcptoken` flags (`--project`, `--rbac`, `--bind`, `--ttl`, `--description`, `--force`) and the new resource alias land in both bash and fish completions. `completions.test.ts` freshness check passes. | ✅ | + +### What this PR does NOT do yet (coming in PR 4) + +- No HTTP-mode mcplocal binary yet. Tokens can be used to hit mcpd directly via `/api/v1/…` with `Authorization: Bearer mcpctl_pat_…`, but the containerized `/projects/

/mcp` endpoint and its token-auth preHandler don't exist yet. +- The audit-console TUI still shows only `userName` columns; adding a `TOKEN` column is a UI polish follow-up. + +### Test stats + +- 1764/1764 tests pass workspace-wide (up from ~1750 before PR 3). +- Build clean across all 5 packages. +- Completions freshness check green. ## PR 4 — HTTP-mode mcplocal + container + `mcpctl test mcp` + smoke diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 973157d..9c5e0d7 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -132,6 +132,15 @@ const ProjectSpecSchema = z.object({ servers: z.array(z.string()).default([]), }); +const McpTokenSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + project: z.string().min(1), + description: z.string().default(''), + expiresAt: z.union([z.string().datetime(), z.null()]).optional(), + rbacMode: z.enum(['empty', 'clone']).default('empty'), + bindings: z.array(RbacRoleBindingSchema).default([]), +}); + const ApplyConfigSchema = z.object({ secrets: z.array(SecretSpecSchema).default([]), servers: z.array(ServerSpecSchema).default([]), @@ -143,6 +152,7 @@ const ApplyConfigSchema = z.object({ rbacBindings: z.array(RbacBindingSpecSchema).default([]), rbac: z.array(RbacBindingSpecSchema).default([]), prompts: z.array(PromptSpecSchema).default([]), + mcptokens: z.array(McpTokenSpecSchema).default([]), }).transform((data) => ({ ...data, // Merge rbac into rbacBindings so both keys work @@ -182,6 +192,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command { if (config.serverattachments.length > 0) log(` ${config.serverattachments.length} serverattachment(s)`); if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`); if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`); + if (config.mcptokens.length > 0) log(` ${config.mcptokens.length} mcptoken(s)`); return; } @@ -217,6 +228,7 @@ const KIND_TO_RESOURCE: Record = { prompt: 'prompts', promptrequest: 'promptrequests', serverattachment: 'serverattachments', + mcptoken: 'mcptokens', }; /** @@ -529,6 +541,46 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args log(`Error applying prompt '${prompt.name}': ${err instanceof Error ? err.message : err}`); } } + + // --- McpTokens --- + // Apply semantics: tokens are immutable (their secret is minted once). If an + // active token with the same name+project already exists we skip, logging the + // state. Otherwise we create and log the raw token (shown exactly once). + for (const tok of config.mcptokens) { + try { + const proj = await cachedFindByName('projects', tok.project); + if (!proj) { + log(`Error applying mcptoken '${tok.name}': project '${tok.project}' not found`); + continue; + } + + // Check if an active one already exists + const existing = await client + .get>(`/api/v1/mcptokens?projectName=${encodeURIComponent(tok.project)}`) + .catch(() => []); + const active = existing.find((t) => t.name === tok.name && t.status === 'active'); + if (active) { + log(`mcptoken '${tok.name}' already active in project '${tok.project}' — skipped (tokens are immutable)`); + continue; + } + + const body: Record = { + name: tok.name, + projectId: proj.id, + description: tok.description, + rbacMode: tok.rbacMode, + bindings: tok.bindings, + }; + if (tok.expiresAt !== undefined) body.expiresAt = tok.expiresAt; + + const created = await withRetry(() => client.post<{ id: string; name: string; token: string }>('/api/v1/mcptokens', body)); + log(`Created mcptoken: ${tok.name} (project: ${tok.project})`); + log(` token: ${created.token}`); + log(' (raw token shown once — copy it now)'); + } catch (err) { + log(`Error applying mcptoken '${tok.name}': ${err instanceof Error ? err.message : err}`); + } + } } async function findByField(client: ApiClient, resource: string, field: T, value: string): Promise { diff --git a/src/cli/src/commands/console/audit-types.ts b/src/cli/src/commands/console/audit-types.ts index 23be66f..6044e02 100644 --- a/src/cli/src/commands/console/audit-types.ts +++ b/src/cli/src/commands/console/audit-types.ts @@ -23,6 +23,9 @@ export interface AuditEvent { serverName: string | null; correlationId: string | null; parentEventId: string | null; + userName?: string | null; + tokenName?: string | null; + tokenSha?: string | null; payload: Record; } diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index b863181..42be2cd 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -11,6 +11,37 @@ function collect(value: string, prev: string[]): string[] { return [...prev, value]; } +/** + * Parse a `--ttl` value. + * + * - `"never"` → null (no expiry) + * - `"30d"`, `"12h"`, `"2w"`, `"90m"`, `"60s"` → ISO8601 string relative to now + * - An ISO8601 datetime → returned as-is + */ +function parseTtl(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.toLowerCase() === 'never') return null; + const match = trimmed.match(/^(\d+)([smhdw])$/i); + if (match) { + const amount = Number(match[1]); + const unit = match[2]!.toLowerCase(); + const multipliers: Record = { + s: 1000, + m: 60 * 1000, + h: 3600 * 1000, + d: 86400 * 1000, + w: 7 * 86400 * 1000, + }; + return new Date(Date.now() + amount * multipliers[unit]!).toISOString(); + } + // Try to parse as ISO8601 + const parsed = new Date(trimmed); + if (isNaN(parsed.getTime())) { + throw new Error(`Invalid --ttl '${value}'. Expected 'never', a duration like '30d' / '12h', or an ISO8601 datetime.`); + } + return parsed.toISOString(); +} + interface ServerEnvEntry { name: string; value?: string; @@ -372,6 +403,83 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { } }); + // --- create mcptoken --- + cmd.command('mcptoken') + .description('Create a project-scoped API token for HTTP-mode mcplocal. The raw token is printed once.') + .argument('', 'Token name (unique within a project)') + .requiredOption('-p, --project ', 'Project this token is bound to') + .option('--rbac ', "Base RBAC: 'empty' (default, no bindings) or 'clone' (snapshot creator's perms)", 'empty') + .option( + '--bind ', + 'Additional role binding as key:value pairs, e.g. "role:view,resource:servers" or "action:logs" (repeat for multiple). Creator perms are the ceiling.', + collect, + [], + ) + .option('--ttl ', "Expiry: '30d', '12h', 'never', or an ISO8601 datetime") + .option('--description ', 'Freeform description') + .option('--force', 'Revoke any existing active token with this name, then create a new one') + .action(async (name: string, opts) => { + // Resolve project name → id (mcpd's create route accepts either, but resolve client-side for clearer errors) + const projectId = await resolveNameOrId(client, 'projects', opts.project as string); + + const bindings = (opts.bind as string[]).map((entry: string) => parseRoleBinding(entry)); + + const rbacMode = (opts.rbac as string).toLowerCase(); + if (rbacMode !== 'empty' && rbacMode !== 'clone') { + throw new Error(`--rbac must be 'empty' or 'clone' (got '${opts.rbac as string}')`); + } + + let expiresAt: string | null | undefined; + if (opts.ttl !== undefined) { + expiresAt = parseTtl(opts.ttl as string); + } + + const body: Record = { + name, + projectId, + rbacMode, + bindings, + }; + if (expiresAt !== undefined) body.expiresAt = expiresAt; + if (opts.description !== undefined) body.description = opts.description; + + type Created = { + id: string; + name: string; + projectName: string; + tokenPrefix: string; + token: string; + expiresAt: string | null; + }; + + const doCreate = async (): Promise => client.post('/api/v1/mcptokens', body); + + let created: Created; + try { + created = await doCreate(); + } catch (err) { + if (err instanceof ApiError && err.status === 409 && opts.force) { + // Find the existing active token by name+project and revoke it, then retry. + const existing = (await client.get>( + `/api/v1/mcptokens?projectName=${encodeURIComponent(opts.project as string)}`, + )).find((r) => r.name === name); + if (!existing) throw err; + await client.post(`/api/v1/mcptokens/${existing.id}/revoke`, {}); + created = await doCreate(); + } else { + throw err; + } + } + + log(`mcptoken '${created.name}' created (project: ${created.projectName}, id: ${created.id})`); + log(''); + log('Copy this token now — it will NOT be shown again:'); + log(''); + log(` ${created.token}`); + log(''); + log(`Export it with: export MCPCTL_TOKEN=${created.token}`); + }); + // --- create prompt --- cmd.command('prompt') .description('Create an approved prompt') diff --git a/src/cli/src/commands/delete.ts b/src/cli/src/commands/delete.ts index cb73fd2..cd5af79 100644 --- a/src/cli/src/commands/delete.ts +++ b/src/cli/src/commands/delete.ts @@ -29,6 +29,27 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command { return; } + // Mcptokens: names are scoped to a project, so require --project unless the caller passes a CUID + if (resource === 'mcptokens') { + let tokenId: string; + if (/^c[a-z0-9]{24}/.test(idOrName)) { + tokenId = idOrName; + } else { + if (!opts.project) { + throw new Error('--project is required to delete an mcptoken by name (or pass the id).'); + } + const items = await client.get>( + `/api/v1/mcptokens?projectName=${encodeURIComponent(opts.project)}`, + ); + const match = items.find((i) => i.name === idOrName); + if (!match) throw new Error(`mcptoken '${idOrName}' not found in project '${opts.project}'`); + tokenId = match.id; + } + await client.delete(`/api/v1/mcptokens/${tokenId}`); + log(`mcptoken '${idOrName}' deleted.`); + return; + } + // Resolve name → ID for any resource type let id: string; try { diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 9fc1d24..a3f2b3c 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -503,6 +503,42 @@ function formatRbacDetail(rbac: Record): string { return lines.join('\n'); } +function formatMcpTokenDetail(token: Record, allRbac: RbacDef[]): string { + const lines: string[] = []; + lines.push(`=== McpToken: ${token.name} ===`); + lines.push(`${pad('Name:')}${token.name}`); + lines.push(`${pad('Project:')}${token.projectName ?? token.projectId ?? '-'}`); + lines.push(`${pad('Status:')}${token.status ?? '-'}`); + lines.push(`${pad('Prefix:')}${token.tokenPrefix ?? '-'}`); + if (token.description) lines.push(`${pad('Description:')}${token.description}`); + lines.push(`${pad('Owner:')}${token.ownerEmail ?? token.ownerId ?? '-'}`); + lines.push(`${pad('Created:')}${token.createdAt ?? '-'}`); + lines.push(`${pad('Last Used:')}${token.lastUsedAt ?? 'never'}`); + lines.push(`${pad('Expires:')}${token.expiresAt ?? 'never'}`); + if (token.revokedAt) lines.push(`${pad('Revoked At:')}${token.revokedAt}`); + + // Find the auto-created RbacDefinition (subject McpToken:) to surface bindings. + // We don't know the sha from the describe response — match by convention: name 'mcptoken-'. + const rbacDef = allRbac.find((r) => r.name === `mcptoken-${token.id as string}`); + if (rbacDef && Array.isArray(rbacDef.roleBindings) && rbacDef.roleBindings.length > 0) { + lines.push(''); + lines.push('Bindings:'); + for (const b of rbacDef.roleBindings as Array<{ role: string; resource?: string; action?: string; name?: string }>) { + if (b.action !== undefined) { + lines.push(` run ${b.action}`); + } else if (b.resource !== undefined) { + lines.push(` ${b.role} ${b.resource}${b.name !== undefined ? `/${b.name}` : ''}`); + } + } + } + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${token.id}`); + + return lines.join('\n'); +} + async function formatPromptDetail(prompt: Record, client?: ApiClient): Promise { const lines: string[] = []; lines.push(`=== Prompt: ${prompt.name} ===`); @@ -801,6 +837,14 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { case 'prompts': deps.log(await formatPromptDetail(item, deps.client)); break; + case 'mcptokens': { + // Fetch the auto-created RbacDefinition (if any) so bindings are visible in describe. + const rbacForToken = await deps.client + .get('/api/v1/rbac') + .catch(() => [] as RbacDef[]); + deps.log(formatMcpTokenDetail(item, rbacForToken)); + break; + } default: deps.log(formatGenericDetail(item)); } diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 30c38cd..1777db5 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -119,6 +119,27 @@ const rbacColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +interface McpTokenRow { + id: string; + name: string; + projectName: string; + tokenPrefix: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; + status: 'active' | 'revoked' | 'expired'; +} + +const mcpTokenColumns: Column[] = [ + { header: 'NAME', key: 'name', width: 24 }, + { header: 'PROJECT', key: 'projectName', width: 20 }, + { header: 'PREFIX', key: 'tokenPrefix', width: 18 }, + { header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 }, + { header: 'LAST USED', key: (r) => r.lastUsedAt ? new Date(r.lastUsedAt).toLocaleString() : '-', width: 20 }, + { header: 'EXPIRES', key: (r) => r.expiresAt ? new Date(r.expiresAt).toLocaleString() : 'never', width: 20 }, + { header: 'STATUS', key: 'status', width: 10 }, +]; + const secretColumns: Column[] = [ { header: 'NAME', key: 'name' }, { header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 }, @@ -242,6 +263,8 @@ function getColumnsForResource(resource: string): Column return serverAttachmentColumns as unknown as Column>[]; case 'proxymodels': return proxymodelColumns as unknown as Column>[]; + case 'mcptokens': + return mcpTokenColumns as unknown as Column>[]; default: return [ { header: 'ID', key: 'id' as keyof Record }, @@ -263,6 +286,7 @@ const RESOURCE_KIND: Record = { prompts: 'prompt', promptrequests: 'promptrequest', serverattachments: 'serverattachment', + mcptokens: 'mcptoken', }; /** diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index ded9aad..abc5584 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -27,6 +27,10 @@ export const RESOURCE_ALIASES: Record = { proxymodel: 'proxymodels', proxymodels: 'proxymodels', pm: 'proxymodels', + mcptoken: 'mcptokens', + mcptokens: 'mcptokens', + token: 'mcptokens', + tokens: 'mcptokens', all: 'all', }; @@ -72,6 +76,21 @@ export function stripInternalFields(obj: Record): Record(`/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 or use the token id (cuid)'); + } + const items = await client.get>( + `/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 or use the token id (cuid)'); + } + const items = await client.get>>( + `/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); diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 95d9a34..4f7ce38 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -318,6 +318,20 @@ async function main(): Promise { // 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 { 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 { 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) { diff --git a/src/mcpd/src/middleware/auth.ts b/src/mcpd/src/middleware/auth.ts index 6d7dd97..0bc3a3c 100644 --- a/src/mcpd/src/middleware/auth.ts +++ b/src/mcpd/src/middleware/auth.ts @@ -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; } declare module 'fastify' { interface FastifyRequest { userId?: string; rbacScope?: { wildcard: boolean; names: Set }; + /** 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' }); diff --git a/src/mcpd/src/services/audit-event.service.ts b/src/mcpd/src/services/audit-event.service.ts index 43d6793..e5b3ec7 100644 --- a/src/mcpd/src/services/audit-event.service.ts +++ b/src/mcpd/src/services/audit-event.service.ts @@ -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; diff --git a/src/mcpd/tests/auth.test.ts b/src/mcpd/tests/auth.test.ts index 938b8a2..389ded8 100644 --- a/src/mcpd/tests/auth.test.ts +++ b/src/mcpd/tests/auth.test.ts @@ -99,3 +99,76 @@ describe('auth middleware', () => { expect(findSession).toHaveBeenCalledWith('my-token'); }); }); + +describe('auth middleware — McpToken dispatch', () => { + async function setupAppWithMcpToken(deps: Parameters[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); + }); +}); diff --git a/src/mcplocal/src/audit/collector.ts b/src/mcplocal/src/audit/collector.ts index 082faec..a3b8aa8 100644 --- a/src/mcplocal/src/audit/collector.ts +++ b/src/mcplocal/src/audit/collector.ts @@ -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 | null = null; private flushing = false; - private sessionUserNames = new Map(); + private sessionPrincipals = new Map(); 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): 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) { diff --git a/src/mcplocal/src/audit/types.ts b/src/mcplocal/src/audit/types.ts index 8a17701..68d102a 100644 --- a/src/mcplocal/src/audit/types.ts +++ b/src/mcplocal/src/audit/types.ts @@ -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; }