feat: McpToken — HTTP-mode mcplocal, CLI verbs, audit plumbing #50
@@ -175,7 +175,7 @@ _mcpctl() {
|
|||||||
create)
|
create)
|
||||||
local create_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
local create_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
||||||
if [[ -z "$create_sub" ]]; then
|
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
|
else
|
||||||
case "$create_sub" in
|
case "$create_sub" in
|
||||||
server)
|
server)
|
||||||
@@ -196,6 +196,9 @@ _mcpctl() {
|
|||||||
rbac)
|
rbac)
|
||||||
COMPREPLY=($(compgen -W "--subject --roleBindings --force -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--subject --roleBindings --force -h --help" -- "$cur"))
|
||||||
;;
|
;;
|
||||||
|
mcptoken)
|
||||||
|
COMPREPLY=($(compgen -W "-p --project --rbac --bind --ttl --description --force -h --help" -- "$cur"))
|
||||||
|
;;
|
||||||
prompt)
|
prompt)
|
||||||
COMPREPLY=($(compgen -W "-p --project --content --content-file --priority --link -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-p --project --content --content-file --priority --link -h --help" -- "$cur"))
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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'
|
complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity'
|
||||||
|
|
||||||
# create subcommands
|
# 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 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 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 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 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 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 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 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 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)'
|
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 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'
|
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
|
# 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" -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
|
complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content -d 'Prompt content text' -x
|
||||||
|
|||||||
@@ -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 <kv>` flag will reuse.
|
The extracted `parseRoleBinding` helper is what PR 3's `mcpctl create mcptoken --bind <kv>` 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 <name> -p <proj> [--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 <name> -p <proj>` + `mcpctl describe mcptoken <name> -p <proj>` + `mcpctl delete mcptoken <name> -p <proj>`. 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-<id>` 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/<p>/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
|
## PR 4 — HTTP-mode mcplocal + container + `mcpctl test mcp` + smoke
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ const ProjectSpecSchema = z.object({
|
|||||||
servers: z.array(z.string()).default([]),
|
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({
|
const ApplyConfigSchema = z.object({
|
||||||
secrets: z.array(SecretSpecSchema).default([]),
|
secrets: z.array(SecretSpecSchema).default([]),
|
||||||
servers: z.array(ServerSpecSchema).default([]),
|
servers: z.array(ServerSpecSchema).default([]),
|
||||||
@@ -143,6 +152,7 @@ const ApplyConfigSchema = z.object({
|
|||||||
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
|
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
|
||||||
rbac: z.array(RbacBindingSpecSchema).default([]),
|
rbac: z.array(RbacBindingSpecSchema).default([]),
|
||||||
prompts: z.array(PromptSpecSchema).default([]),
|
prompts: z.array(PromptSpecSchema).default([]),
|
||||||
|
mcptokens: z.array(McpTokenSpecSchema).default([]),
|
||||||
}).transform((data) => ({
|
}).transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
// Merge rbac into rbacBindings so both keys work
|
// 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.serverattachments.length > 0) log(` ${config.serverattachments.length} serverattachment(s)`);
|
||||||
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(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.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`);
|
||||||
|
if (config.mcptokens.length > 0) log(` ${config.mcptokens.length} mcptoken(s)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +228,7 @@ const KIND_TO_RESOURCE: Record<string, string> = {
|
|||||||
prompt: 'prompts',
|
prompt: 'prompts',
|
||||||
promptrequest: 'promptrequests',
|
promptrequest: 'promptrequests',
|
||||||
serverattachment: 'serverattachments',
|
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}`);
|
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<Array<{ id: string; name: string; status: string }>>(`/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<string, unknown> = {
|
||||||
|
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<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
|
async function findByField<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export interface AuditEvent {
|
|||||||
serverName: string | null;
|
serverName: string | null;
|
||||||
correlationId: string | null;
|
correlationId: string | null;
|
||||||
parentEventId: string | null;
|
parentEventId: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
tokenName?: string | null;
|
||||||
|
tokenSha?: string | null;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,37 @@ function collect(value: string, prev: string[]): string[] {
|
|||||||
return [...prev, value];
|
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<string, number> = {
|
||||||
|
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 {
|
interface ServerEnvEntry {
|
||||||
name: string;
|
name: string;
|
||||||
value?: 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('<name>', 'Token name (unique within a project)')
|
||||||
|
.requiredOption('-p, --project <name>', 'Project this token is bound to')
|
||||||
|
.option('--rbac <mode>', "Base RBAC: 'empty' (default, no bindings) or 'clone' (snapshot creator's perms)", 'empty')
|
||||||
|
.option(
|
||||||
|
'--bind <entry>',
|
||||||
|
'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 <duration>', "Expiry: '30d', '12h', 'never', or an ISO8601 datetime")
|
||||||
|
.option('--description <text>', '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<string, unknown> = {
|
||||||
|
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<Created> => client.post<Created>('/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<Array<{ id: string; name: string }>>(
|
||||||
|
`/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 ---
|
// --- create prompt ---
|
||||||
cmd.command('prompt')
|
cmd.command('prompt')
|
||||||
.description('Create an approved prompt')
|
.description('Create an approved prompt')
|
||||||
|
|||||||
@@ -29,6 +29,27 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
|
|||||||
return;
|
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<Array<{ id: string; name: string }>>(
|
||||||
|
`/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
|
// Resolve name → ID for any resource type
|
||||||
let id: string;
|
let id: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -503,6 +503,42 @@ function formatRbacDetail(rbac: Record<string, unknown>): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMcpTokenDetail(token: Record<string, unknown>, 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:<sha>) to surface bindings.
|
||||||
|
// We don't know the sha from the describe response — match by convention: name 'mcptoken-<id>'.
|
||||||
|
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<string, unknown>, client?: ApiClient): Promise<string> {
|
async function formatPromptDetail(prompt: Record<string, unknown>, client?: ApiClient): Promise<string> {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`=== Prompt: ${prompt.name} ===`);
|
lines.push(`=== Prompt: ${prompt.name} ===`);
|
||||||
@@ -801,6 +837,14 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
|||||||
case 'prompts':
|
case 'prompts':
|
||||||
deps.log(await formatPromptDetail(item, deps.client));
|
deps.log(await formatPromptDetail(item, deps.client));
|
||||||
break;
|
break;
|
||||||
|
case 'mcptokens': {
|
||||||
|
// Fetch the auto-created RbacDefinition (if any) so bindings are visible in describe.
|
||||||
|
const rbacForToken = await deps.client
|
||||||
|
.get<RbacDef[]>('/api/v1/rbac')
|
||||||
|
.catch(() => [] as RbacDef[]);
|
||||||
|
deps.log(formatMcpTokenDetail(item, rbacForToken));
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
deps.log(formatGenericDetail(item));
|
deps.log(formatGenericDetail(item));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,27 @@ const rbacColumns: Column<RbacRow>[] = [
|
|||||||
{ header: 'ID', key: 'id' },
|
{ 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<McpTokenRow>[] = [
|
||||||
|
{ 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<SecretRow>[] = [
|
const secretColumns: Column<SecretRow>[] = [
|
||||||
{ header: 'NAME', key: 'name' },
|
{ header: 'NAME', key: 'name' },
|
||||||
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
|
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
|
||||||
@@ -242,6 +263,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
|||||||
return serverAttachmentColumns as unknown as Column<Record<string, unknown>>[];
|
return serverAttachmentColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
case 'proxymodels':
|
case 'proxymodels':
|
||||||
return proxymodelColumns as unknown as Column<Record<string, unknown>>[];
|
return proxymodelColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
|
case 'mcptokens':
|
||||||
|
return mcpTokenColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
||||||
@@ -263,6 +286,7 @@ const RESOURCE_KIND: Record<string, string> = {
|
|||||||
prompts: 'prompt',
|
prompts: 'prompt',
|
||||||
promptrequests: 'promptrequest',
|
promptrequests: 'promptrequest',
|
||||||
serverattachments: 'serverattachment',
|
serverattachments: 'serverattachment',
|
||||||
|
mcptokens: 'mcptoken',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
proxymodel: 'proxymodels',
|
proxymodel: 'proxymodels',
|
||||||
proxymodels: 'proxymodels',
|
proxymodels: 'proxymodels',
|
||||||
pm: 'proxymodels',
|
pm: 'proxymodels',
|
||||||
|
mcptoken: 'mcptokens',
|
||||||
|
mcptokens: 'mcptokens',
|
||||||
|
token: 'mcptokens',
|
||||||
|
tokens: 'mcptokens',
|
||||||
all: 'all',
|
all: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,6 +76,21 @@ export function stripInternalFields(obj: Record<string, unknown>): Record<string
|
|||||||
delete result[key];
|
delete result[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// McpToken-specific: promote projectName → project; drop secret/derived fields
|
||||||
|
if ('tokenHash' in result || 'tokenPrefix' in result) {
|
||||||
|
delete result.tokenHash;
|
||||||
|
delete result.tokenPrefix;
|
||||||
|
delete result.lastUsedAt;
|
||||||
|
delete result.revokedAt;
|
||||||
|
delete result.status;
|
||||||
|
delete result.ownerEmail;
|
||||||
|
if (typeof result.projectName === 'string') {
|
||||||
|
result.project = result.projectName;
|
||||||
|
delete result.projectName;
|
||||||
|
delete result.projectId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rename linkTarget → link for cleaner YAML
|
// Rename linkTarget → link for cleaner YAML
|
||||||
if ('linkTarget' in result) {
|
if ('linkTarget' in result) {
|
||||||
result.link = result.linkTarget;
|
result.link = result.linkTarget;
|
||||||
|
|||||||
@@ -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) {
|
if (nameOrId) {
|
||||||
// Glob pattern — use query param filtering
|
// Glob pattern — use query param filtering
|
||||||
if (nameOrId.includes('*')) {
|
if (nameOrId.includes('*')) {
|
||||||
@@ -132,6 +151,19 @@ export function createProgram(): Command {
|
|||||||
return client.get(`/api/v1/${resource}/${match.id as string}`);
|
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;
|
let id: string;
|
||||||
try {
|
try {
|
||||||
id = await resolveNameOrId(client, resource, nameOrId);
|
id = await resolveNameOrId(client, resource, nameOrId);
|
||||||
|
|||||||
@@ -318,6 +318,20 @@ async function main(): Promise<void> {
|
|||||||
// Auth middleware for global hooks
|
// Auth middleware for global hooks
|
||||||
const authMiddleware = createAuthMiddleware({
|
const authMiddleware = createAuthMiddleware({
|
||||||
findSession: (token) => authService.findSession(token),
|
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
|
// Server
|
||||||
@@ -366,9 +380,28 @@ async function main(): Promise<void> {
|
|||||||
const saHeader = request.headers['x-service-account'];
|
const saHeader = request.headers['x-service-account'];
|
||||||
const serviceAccountName = typeof saHeader === 'string' ? saHeader : undefined;
|
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;
|
let allowed: boolean;
|
||||||
if (check.kind === 'operation') {
|
if (check.kind === 'operation') {
|
||||||
allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName);
|
allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName, mcpTokenSha);
|
||||||
} else {
|
} else {
|
||||||
// Resolve CUID → human name for name-scoped RBAC bindings
|
// Resolve CUID → human name for name-scoped RBAC bindings
|
||||||
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
|
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
|
||||||
@@ -378,10 +411,10 @@ async function main(): Promise<void> {
|
|||||||
if (entity) check.resourceName = entity.name;
|
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)
|
// Compute scope for list filtering (used by preSerialization hook)
|
||||||
if (allowed && check.resourceName === undefined) {
|
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) {
|
if (!allowed) {
|
||||||
|
|||||||
@@ -1,13 +1,41 @@
|
|||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
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 {
|
export interface AuthDeps {
|
||||||
findSession: (token: string) => Promise<{ userId: string; expiresAt: Date } | null>;
|
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' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
rbacScope?: { wildcard: boolean; names: Set<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;
|
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);
|
const session = await deps.findSession(token);
|
||||||
if (session === null) {
|
if (session === null) {
|
||||||
reply.code(401).send({ error: 'Invalid token' });
|
reply.code(401).send({ error: 'Invalid token' });
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface AuditEventQueryParams {
|
|||||||
serverName?: string;
|
serverName?: string;
|
||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
tokenName?: string;
|
||||||
|
tokenSha?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -71,6 +73,8 @@ export class AuditEventService {
|
|||||||
if (params.serverName !== undefined) filter.serverName = params.serverName;
|
if (params.serverName !== undefined) filter.serverName = params.serverName;
|
||||||
if (params.correlationId !== undefined) filter.correlationId = params.correlationId;
|
if (params.correlationId !== undefined) filter.correlationId = params.correlationId;
|
||||||
if (params.userName !== undefined) filter.userName = params.userName;
|
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.from !== undefined) filter.from = new Date(params.from);
|
||||||
if (params.to !== undefined) filter.to = new Date(params.to);
|
if (params.to !== undefined) filter.to = new Date(params.to);
|
||||||
if (params.limit !== undefined) filter.limit = params.limit;
|
if (params.limit !== undefined) filter.limit = params.limit;
|
||||||
|
|||||||
@@ -99,3 +99,76 @@ describe('auth middleware', () => {
|
|||||||
expect(findSession).toHaveBeenCalledWith('my-token');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ import type { McpdClient } from '../http/mcpd-client.js';
|
|||||||
const BATCH_SIZE = 50;
|
const BATCH_SIZE = 50;
|
||||||
const FLUSH_INTERVAL_MS = 5_000;
|
const FLUSH_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
interface SessionPrincipal {
|
||||||
|
userName?: string;
|
||||||
|
tokenName?: string;
|
||||||
|
tokenSha?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class AuditCollector {
|
export class AuditCollector {
|
||||||
private queue: AuditEvent[] = [];
|
private queue: AuditEvent[] = [];
|
||||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private flushing = false;
|
private flushing = false;
|
||||||
private sessionUserNames = new Map<string, string>();
|
private sessionPrincipals = new Map<string, SessionPrincipal>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly mcpdClient: McpdClient,
|
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. */
|
/** Register a userName for a session. All future events for this session auto-fill it. */
|
||||||
setSessionUserName(sessionId: string, userName: string): void {
|
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 {
|
emit(event: Omit<AuditEvent, 'projectName'>): void {
|
||||||
const enriched: AuditEvent = { ...event, projectName: this.projectName };
|
const enriched: AuditEvent = { ...event, projectName: this.projectName };
|
||||||
if (!enriched.userName && enriched.sessionId) {
|
if (enriched.sessionId) {
|
||||||
const name = this.sessionUserNames.get(enriched.sessionId);
|
const principal = this.sessionPrincipals.get(enriched.sessionId);
|
||||||
if (name) enriched.userName = name;
|
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);
|
this.queue.push(enriched);
|
||||||
if (this.queue.length >= BATCH_SIZE) {
|
if (this.queue.length >= BATCH_SIZE) {
|
||||||
|
|||||||
@@ -32,5 +32,9 @@ export interface AuditEvent {
|
|||||||
correlationId?: string;
|
correlationId?: string;
|
||||||
parentEventId?: string;
|
parentEventId?: string;
|
||||||
userName?: 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>;
|
payload: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user