441 lines
13 KiB
TypeScript
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,
|
|
},
|
|
});
|