326 lines
9.0 KiB
TypeScript
326 lines
9.0 KiB
TypeScript
// SPDX-License-Identifier: MIT
|
|
|
|
import { minimatch } from "minimatch";
|
|
|
|
import { loadPublicConfig } from "../index.ts";
|
|
import { getGraphClient } from "../graph/auth.ts";
|
|
import { acquireResourceTokenFromLogin, login, logout } from "../azure/index.ts";
|
|
import { getDevOpsApiToken } from "../devops/index.ts";
|
|
import {
|
|
listApps,
|
|
listAppPermissions,
|
|
listAppPermissionsResolved,
|
|
listAppGrants,
|
|
listResourcePermissions,
|
|
} from "../graph/app.ts";
|
|
import { readJsonFromStdin } from "./utils.ts";
|
|
|
|
type CommandValues = {
|
|
[key: string]: string | boolean | undefined;
|
|
type?: string;
|
|
method?: string;
|
|
url?: string;
|
|
header?: string;
|
|
resources?: string;
|
|
"use-device-code"?: boolean;
|
|
"no-browser"?: boolean;
|
|
browser?: string;
|
|
"browser-profile"?: string;
|
|
all?: boolean;
|
|
"display-name"?: string;
|
|
"app-id"?: string;
|
|
filter?: string;
|
|
resolve?: boolean;
|
|
};
|
|
|
|
type PermissionRow = {
|
|
permissionValue?: string | null;
|
|
permissionDisplayName?: string | null;
|
|
};
|
|
|
|
type DisplayNameRow = {
|
|
displayName?: string | null;
|
|
};
|
|
|
|
function parseHeaderLine(header?: string): { name: string; value: string } | null {
|
|
if (!header || header.trim() === "") {
|
|
return null;
|
|
}
|
|
|
|
const separatorIndex = header.indexOf(":");
|
|
if (separatorIndex < 1) {
|
|
throw new Error("--header must be in the format 'Name: Value'");
|
|
}
|
|
|
|
const name = header.slice(0, separatorIndex).trim();
|
|
const value = header.slice(separatorIndex + 1).trim();
|
|
if (!name || !value) {
|
|
throw new Error("--header must be in the format 'Name: Value'");
|
|
}
|
|
|
|
return { name, value };
|
|
}
|
|
|
|
function hasAuthorizationHeader(headers: Headers): boolean {
|
|
for (const headerName of headers.keys()) {
|
|
if (headerName.toLowerCase() === "authorization") {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function getAutoAuthorizationHeader(url: URL): Promise<string | null> {
|
|
const host = url.hostname.toLowerCase();
|
|
if (host !== "management.azure.com" && host !== "dev.azure.com") {
|
|
return null;
|
|
}
|
|
|
|
const config = await loadPublicConfig();
|
|
if (!config.tenantId) {
|
|
throw new Error("tenantId is required");
|
|
}
|
|
if (!config.clientId) {
|
|
throw new Error("clientId is required");
|
|
}
|
|
|
|
if (host === "management.azure.com") {
|
|
const result = await acquireResourceTokenFromLogin({
|
|
tenantId: config.tenantId,
|
|
clientId: config.clientId,
|
|
resource: "arm",
|
|
});
|
|
const accessToken = result?.accessToken;
|
|
if (!accessToken) {
|
|
throw new Error("Failed to obtain AzureRM token");
|
|
}
|
|
return `Bearer ${accessToken}`;
|
|
}
|
|
|
|
const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId);
|
|
return `Bearer ${accessToken}`;
|
|
}
|
|
|
|
function filterByPermissionName<T extends PermissionRow>(rows: T[], pattern: string): T[] {
|
|
return rows.filter((item) =>
|
|
minimatch(item.permissionValue ?? "", pattern, { nocase: true })
|
|
|| minimatch(item.permissionDisplayName ?? "", pattern, { nocase: true }),
|
|
);
|
|
}
|
|
|
|
function filterByDisplayName<T extends DisplayNameRow>(rows: T[], pattern: string): T[] {
|
|
return rows.filter((item) =>
|
|
minimatch(item.displayName ?? "", pattern, { nocase: true }),
|
|
);
|
|
}
|
|
|
|
async function getGraphClientFromPublicConfig(): Promise<{ client: any }> {
|
|
const config = await loadPublicConfig();
|
|
return getGraphClient({
|
|
tenantId: config.tenantId,
|
|
clientId: config.clientId,
|
|
});
|
|
}
|
|
|
|
async function runTableCommand(): Promise<unknown> {
|
|
return readJsonFromStdin();
|
|
}
|
|
|
|
async function runLoginCommand(values: CommandValues): Promise<unknown> {
|
|
const config = await loadPublicConfig();
|
|
return login({
|
|
tenantId: config.tenantId,
|
|
clientId: config.clientId,
|
|
resourcesCsv: values.resources,
|
|
useDeviceCode: Boolean(values["use-device-code"]),
|
|
noBrowser: Boolean(values["no-browser"]),
|
|
browser: values.browser,
|
|
browserProfile: values["browser-profile"],
|
|
});
|
|
}
|
|
|
|
async function runLogoutCommand(values: CommandValues): Promise<unknown> {
|
|
const config = await loadPublicConfig();
|
|
return logout({
|
|
tenantId: config.tenantId,
|
|
clientId: config.clientId,
|
|
clearAll: Boolean(values.all),
|
|
});
|
|
}
|
|
|
|
async function runListAppsCommand(values: CommandValues): Promise<unknown> {
|
|
const { client } = await getGraphClientFromPublicConfig();
|
|
let result = await listApps(client, {
|
|
displayName: values["display-name"],
|
|
appId: values["app-id"],
|
|
});
|
|
if (values["app-id"] && result.length > 1) {
|
|
throw new Error(`Expected a single app for --app-id ${values["app-id"]}, but got ${result.length}`);
|
|
}
|
|
if (values.filter) {
|
|
result = filterByDisplayName(result, values.filter);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runListAppPermissionsCommand(values: CommandValues): Promise<unknown> {
|
|
if (!values["app-id"]) {
|
|
throw new Error("--app-id is required for list-app-permissions");
|
|
}
|
|
|
|
const { client } = await getGraphClientFromPublicConfig();
|
|
let result = values.resolve || values.filter
|
|
? await listAppPermissionsResolved(client, values["app-id"])
|
|
: await listAppPermissions(client, values["app-id"]);
|
|
if (values.filter) {
|
|
result = filterByPermissionName(result, values.filter);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runListAppGrantsCommand(values: CommandValues): Promise<unknown> {
|
|
if (!values["app-id"]) {
|
|
throw new Error("--app-id is required for list-app-grants");
|
|
}
|
|
|
|
const { client } = await getGraphClientFromPublicConfig();
|
|
return listAppGrants(client, values["app-id"]);
|
|
}
|
|
|
|
async function runListResourcePermissionsCommand(values: CommandValues): Promise<unknown> {
|
|
if (!values["app-id"] && !values["display-name"]) {
|
|
throw new Error("--app-id or --display-name is required for list-resource-permissions");
|
|
}
|
|
if (values["app-id"] && values["display-name"]) {
|
|
throw new Error("Use either --app-id or --display-name for list-resource-permissions, not both");
|
|
}
|
|
|
|
const { client } = await getGraphClientFromPublicConfig();
|
|
let result = await listResourcePermissions(client, {
|
|
appId: values["app-id"],
|
|
displayName: values["display-name"],
|
|
});
|
|
if (values.filter) {
|
|
result = filterByPermissionName(result, values.filter);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runGetTokenCommand(values: CommandValues): Promise<unknown> {
|
|
const tokenType = (values.type ?? "").toString().trim().toLowerCase();
|
|
if (!tokenType) {
|
|
throw new Error("--type is required for get-token (allowed: azurerm, devops)");
|
|
}
|
|
|
|
const config = await loadPublicConfig();
|
|
if (!config.tenantId) {
|
|
throw new Error("tenantId is required");
|
|
}
|
|
if (!config.clientId) {
|
|
throw new Error("clientId is required");
|
|
}
|
|
|
|
if (tokenType === "azurerm") {
|
|
const result = await acquireResourceTokenFromLogin({
|
|
tenantId: config.tenantId,
|
|
clientId: config.clientId,
|
|
resource: "arm",
|
|
});
|
|
|
|
const accessToken = result?.accessToken;
|
|
if (!accessToken) {
|
|
throw new Error("Failed to obtain AzureRM token");
|
|
}
|
|
|
|
return {
|
|
tokenType,
|
|
accessToken,
|
|
};
|
|
}
|
|
|
|
if (tokenType === "devops") {
|
|
const accessToken = await getDevOpsApiToken(config.tenantId, config.clientId);
|
|
return {
|
|
tokenType,
|
|
accessToken,
|
|
};
|
|
}
|
|
|
|
throw new Error(`Invalid --type '${values.type}'. Allowed: azurerm, devops`);
|
|
}
|
|
|
|
async function runRestCommand(values: CommandValues): Promise<unknown> {
|
|
const method = (values.method ?? "GET").toString().trim().toUpperCase() || "GET";
|
|
const urlValue = (values.url ?? "").toString().trim();
|
|
|
|
if (!urlValue) {
|
|
throw new Error("--url is required for rest");
|
|
}
|
|
|
|
let targetUrl: URL;
|
|
try {
|
|
targetUrl = new URL(urlValue);
|
|
} catch {
|
|
throw new Error(`Invalid --url '${urlValue}'`);
|
|
}
|
|
|
|
const headers = new Headers();
|
|
const customHeader = parseHeaderLine(values.header);
|
|
if (customHeader) {
|
|
headers.set(customHeader.name, customHeader.value);
|
|
}
|
|
|
|
if (!hasAuthorizationHeader(headers)) {
|
|
const authorization = await getAutoAuthorizationHeader(targetUrl);
|
|
if (authorization) {
|
|
headers.set("Authorization", authorization);
|
|
}
|
|
}
|
|
|
|
const response = await fetch(targetUrl, {
|
|
method,
|
|
headers,
|
|
});
|
|
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
let body: unknown;
|
|
if (contentType.toLowerCase().includes("application/json")) {
|
|
body = await response.json();
|
|
} else {
|
|
body = await response.text();
|
|
}
|
|
|
|
return {
|
|
ok: response.ok,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
body,
|
|
};
|
|
}
|
|
|
|
export async function runCommand(command: string, values: CommandValues): Promise<unknown> {
|
|
switch (command) {
|
|
case "login":
|
|
return runLoginCommand(values);
|
|
case "logout":
|
|
return runLogoutCommand(values);
|
|
case "table":
|
|
return runTableCommand();
|
|
case "list-apps":
|
|
return runListAppsCommand(values);
|
|
case "list-app-permissions":
|
|
return runListAppPermissionsCommand(values);
|
|
case "list-app-grants":
|
|
return runListAppGrantsCommand(values);
|
|
case "list-resource-permissions":
|
|
return runListResourcePermissionsCommand(values);
|
|
case "get-token":
|
|
return runGetTokenCommand(values);
|
|
case "rest":
|
|
return runRestCommand(values);
|
|
default:
|
|
throw new Error(`Unknown command: ${command}`);
|
|
}
|
|
}
|