Files
kuber/command/users.ts
T

441 lines
13 KiB
TypeScript

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 = <T>(
path: string,
init?: ApiRequestInit,
) => Promise<T>;
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<string> {
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<boolean> {
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<string> {
const response = await request<ListUsersResponse>("/users");
return renderUsers(response.items);
}
export async function addUser(
username: string,
password: string,
roles: UserRole[],
request: UsersApiRequest = apiRequest,
): Promise<string> {
const body: CreateUserRequest = { username, password, roles };
const user = await request<UserResponse>("/users", {
method: "POST",
json: body,
});
return renderUsers([user]);
}
export async function updateUser(
username: string,
update: UpdateUserRequest,
request: UsersApiRequest = apiRequest,
): Promise<string> {
if (Object.keys(update).length === 0)
throw new Error("No user updates specified");
const user = await request<UserResponse>(
`/users/${encodeURIComponent(username)}`,
{ method: "PATCH", json: update },
);
return renderUsers([user]);
}
export function setUserDisabled(
username: string,
disabled: boolean,
request: UsersApiRequest = apiRequest,
): Promise<string> {
return updateUser(username, { disabled }, request);
}
export async function deleteUser(
username: string,
confirmed: boolean,
request: UsersApiRequest = apiRequest,
): Promise<string> {
if (!confirmed) return "Deletion cancelled";
await request<void>(`/users/${encodeURIComponent(username)}`, {
method: "DELETE",
});
return `Deleted user ${username}`;
}
export async function revokeUserSessions(
username: string,
request: UsersApiRequest = apiRequest,
): Promise<string> {
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<string> {
const usernames = username
? [username]
: (await request<ListUsersResponse>("/users")).items.map(
(user) => user.username,
);
const results = await Promise.all(
usernames.map((name) =>
request<ListApiKeysResponse>(`/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<CreateApiKeyResponse> {
const user = await request<UserResponse>(
`/users/${encodeURIComponent(username)}`,
);
if (user.disabled)
throw new Error(`Cannot create API key: user '${username}' is inactive`);
return request<CreateApiKeyResponse>(
`/users/${encodeURIComponent(username)}/keys`,
{ method: "POST", json: body },
);
}
export async function revokeApiKey(
username: string,
id: string,
confirmed: boolean,
request: UsersApiRequest = apiRequest,
): Promise<string> {
if (!confirmed) return "API key revocation cancelled";
await request<void>(
`/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,
},
});