import { stdin, stdout } from "node:process"; import { createInterface } from "node:readline/promises"; import { defineCommand } from "citty"; import { apiRequest, type ApiRequestInit } from "../lib/api"; import { toTable } from "../lib/format"; import type { CreateUserRequest, ApiKey, CreateApiKeyRequest, CreateApiKeyResponse, ListApiKeysResponse, ListUsersResponse, UpdateUserRequest, User, UserResponse, UserRole, } from "../shared/api"; export type UsersApiRequest = ( path: string, init?: ApiRequestInit, ) => Promise; function requireUsername(value: unknown): string { const username = String(value ?? "").trim(); if (!username) throw new Error("Username is required"); return username; } function parseRoles(value: unknown): UserRole[] { const roles = String(value ?? "") .split(",") .map((role) => role.trim()) .filter(Boolean); if (roles.length === 0) { throw new Error( "At least one role is required (for example: --roles admin)", ); } const invalid = roles.find( (role) => !["viewer", "operator", "admin"].includes(role), ); if (invalid) throw new Error(`Unknown role: ${invalid}`); return [...new Set(roles)]; } function parseCapabilities( value: unknown, ): CreateApiKeyRequest["capabilities"] { const capabilities = String(value ?? "") .split(",") .map((capability) => capability.trim()) .filter(Boolean); const allowed = new Set([ "kubernetes:read", "kubernetes:write", "kubernetes:exec", "users:read", "users:write", "sessions:revoke", "platform:adopt", ]); if (!capabilities.length || capabilities.some((item) => !allowed.has(item))) throw new Error( "Provide valid capabilities (for example: --capabilities kubernetes:read,kubernetes:write; choices: kubernetes:read, kubernetes:write, kubernetes:exec, users:read, users:write, sessions:revoke, platform:adopt)", ); return [...new Set(capabilities)] as CreateApiKeyRequest["capabilities"]; } function renderUsers(users: User[]): string { if (users.length === 0) return "No users"; return toTable( users.map((user) => ({ username: user.username, roles: user.roles.join(","), disabled: user.disabled ? "yes" : "no", updated: user.updatedAt, })), ); } async function promptPassword(label = "Password: "): Promise { if (!stdin.isTTY || !stdin.setRawMode) { throw new Error("Password input requires an interactive terminal"); } stdout.write(label); stdin.setRawMode(true); stdin.resume(); return new Promise((resolve, reject) => { let password = ""; const cleanup = () => { stdin.off("data", onData); stdin.setRawMode(false); stdin.pause(); stdout.write("\n"); }; const finish = (error?: Error) => { cleanup(); if (error) reject(error); else if (!password) reject(new Error("Password is required")); else resolve(password); }; const onData = (chunk: Buffer | string) => { for (const value of chunk.toString()) { if (value === "\u0003" || value === "\u0004") { finish(new Error("Password input cancelled")); return; } if (value === "\r" || value === "\n") { finish(); return; } if (value === "\u007f" || value === "\b") password = password.slice(0, -1); else password += value; } }; stdin.on("data", onData); }); } async function confirmDeletion(username: string): Promise { if (!stdin.isTTY) { throw new Error("Confirmation requires an interactive terminal; use --yes"); } const readline = createInterface({ input: stdin, output: stdout }); try { const answer = await readline.question(`Delete user '${username}'? [y/N] `); return answer.trim().toLowerCase() === "y"; } finally { readline.close(); } } export async function listUsers( request: UsersApiRequest = apiRequest, ): Promise { const response = await request("/users"); return renderUsers(response.items); } export async function addUser( username: string, password: string, roles: UserRole[], request: UsersApiRequest = apiRequest, ): Promise { const body: CreateUserRequest = { username, password, roles }; const user = await request("/users", { method: "POST", json: body, }); return renderUsers([user]); } export async function updateUser( username: string, update: UpdateUserRequest, request: UsersApiRequest = apiRequest, ): Promise { if (Object.keys(update).length === 0) throw new Error("No user updates specified"); const user = await request( `/users/${encodeURIComponent(username)}`, { method: "PATCH", json: update }, ); return renderUsers([user]); } export function setUserDisabled( username: string, disabled: boolean, request: UsersApiRequest = apiRequest, ): Promise { return updateUser(username, { disabled }, request); } export async function deleteUser( username: string, confirmed: boolean, request: UsersApiRequest = apiRequest, ): Promise { if (!confirmed) return "Deletion cancelled"; await request(`/users/${encodeURIComponent(username)}`, { method: "DELETE", }); return `Deleted user ${username}`; } export async function revokeUserSessions( username: string, request: UsersApiRequest = apiRequest, ): Promise { const result = await request<{ username: string; revoked: number }>( `/users/${encodeURIComponent(username)}/sessions/revoke`, { method: "POST" }, ); return `Revoked ${result.revoked} session${result.revoked === 1 ? "" : "s"} for ${result.username}`; } function renderApiKeys(keys: ApiKey[]): string { if (!keys.length) return "No API keys"; return toTable( keys.map((key) => ({ id: key.id, username: key.username, capabilities: key.capabilities.join(","), workspace: key.workspace ?? "", expires: key.expiresAt ?? "never", disabled: key.disabled ? "yes" : "no", })), ); } export async function listApiKeys( username?: string, request: UsersApiRequest = apiRequest, ): Promise { const usernames = username ? [username] : (await request("/users")).items.map( (user) => user.username, ); const results = await Promise.all( usernames.map((name) => request(`/users/${encodeURIComponent(name)}/keys`), ), ); return renderApiKeys( results .flatMap((result) => result.items) .sort( (a, b) => a.username.localeCompare(b.username) || a.id.localeCompare(b.id), ), ); } export async function createApiKey( username: string, body: CreateApiKeyRequest, request: UsersApiRequest = apiRequest, ): Promise { const user = await request( `/users/${encodeURIComponent(username)}`, ); if (user.disabled) throw new Error(`Cannot create API key: user '${username}' is inactive`); return request( `/users/${encodeURIComponent(username)}/keys`, { method: "POST", json: body }, ); } export async function revokeApiKey( username: string, id: string, confirmed: boolean, request: UsersApiRequest = apiRequest, ): Promise { if (!confirmed) return "API key revocation cancelled"; await request( `/users/${encodeURIComponent(username)}/keys/${encodeURIComponent(id)}`, { method: "DELETE", }, ); return `Revoked API key ${id} for ${username}`; } const list = defineCommand({ meta: { name: "ls", description: "List users" }, async run() { console.log(await listUsers()); }, }); const add = defineCommand({ meta: { name: "add", description: "Add a user" }, args: { roles: { type: "string", description: "Comma-separated roles" }, }, async run({ args }) { const username = requireUsername(args._[0]); console.log( await addUser(username, await promptPassword(), parseRoles(args.roles)), ); }, }); const update = defineCommand({ meta: { name: "update", description: "Update a user's roles or password" }, args: { roles: { type: "string", description: "Comma-separated roles" }, password: { type: "boolean", description: "Prompt for a new password" }, }, async run({ args }) { const username = requireUsername(args._[0]); const body: UpdateUserRequest = {}; if (args.roles !== undefined) body.roles = parseRoles(args.roles); if (args.password) body.password = await promptPassword("New password: "); console.log(await updateUser(username, body)); }, }); function disabledCommand(name: "disable" | "enable", disabled: boolean) { return defineCommand({ meta: { name, description: `${disabled ? "Disable" : "Enable"} a user` }, async run({ args }) { console.log(await setUserDisabled(requireUsername(args._[0]), disabled)); }, }); } const remove = defineCommand({ meta: { name: "delete", description: "Delete a user" }, args: { yes: { type: "boolean", alias: "y", description: "Delete without confirmation", }, }, async run({ args }) { const username = requireUsername(args._[0]); const confirmed = Boolean(args.yes) || (await confirmDeletion(username)); console.log(await deleteUser(username, confirmed)); }, }); const revoke = defineCommand({ meta: { name: "revoke", description: "Revoke all sessions for a user" }, async run({ args }) { console.log(await revokeUserSessions(requireUsername(args._[0]))); }, }); const keys = defineCommand({ meta: { name: "keys", description: "Manage user API keys" }, subCommands: { ls: defineCommand({ meta: { name: "ls", description: "List all API keys (optional positional username filters the results)", }, async run({ args }) { console.log(await listApiKeys(args._[0])); }, }), create: defineCommand({ meta: { name: "create", description: "Create an API key for a user (e.g. kuber users keys create alice --capabilities kubernetes:read --expires-days none)", }, args: { username: { type: "positional", required: true, description: "Username to own the API key", }, capabilities: { type: "string", required: true, description: "Comma-separated capabilities, e.g. kubernetes:read,kubernetes:write (also kubernetes:exec, users:read, users:write, sessions:revoke, platform:adopt)", }, workspace: { type: "string", description: "Restrict the key to a workspace", }, "expires-days": { type: "string", default: "90", description: "Expiry in days (1-365), or none for a non-expiring key", }, }, async run({ args }) { const days = args["expires-days"] === "none" ? undefined : Number(args["expires-days"]); if ( days !== undefined && (!Number.isSafeInteger(days) || days < 1 || days > 365) ) throw new Error( "--expires-days must be an integer from 1 to 365, or none", ); const key = await createApiKey(requireUsername(args.username), { capabilities: parseCapabilities(args.capabilities), ...(args.workspace && { workspace: args.workspace }), ...(days !== undefined && { expiresAt: new Date( Date.now() + days * 24 * 60 * 60 * 1000, ).toISOString(), }), }); console.log( "Store this API key securely now. It will not be shown again:", ); console.log(key.token); console.log(renderApiKeys([key])); }, }), revoke: defineCommand({ meta: { name: "revoke", description: "Revoke an API key" }, args: { yes: { type: "boolean", alias: "y", description: "Revoke without confirmation", }, }, async run({ args }) { const username = requireUsername(args._[0]); const id = requireUsername(args._[1]); const confirmed = Boolean(args.yes) || (await confirmDeletion(`API key '${id}' for ${username}`)); console.log(await revokeApiKey(username, id, confirmed)); }, }), }, }); export const users = defineCommand({ meta: { name: "users", description: "Administer users" }, subCommands: { add, delete: remove, disable: disabledCommand("disable", true), enable: disabledCommand("enable", false), keys, ls: list, revoke, update, }, });