diff --git a/package-lock.json b/package-lock.json index 9a609207..e7aed4a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "@microsoft/vscode-azext-utils": "^2.5.1", "@octokit/rest": "^20.1.1", "@vscode/extension-telemetry": "^0.9.6", - "cross-fetch": "^4.0.0", "decompress": "^4.2.1", "js-yaml": "^4.1.0", "move-file": "^3.1.0", @@ -2917,33 +2916,6 @@ "version": "1.0.2", "license": "MIT" }, - "node_modules/cross-fetch": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", - "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", - "dependencies": { - "node-fetch": "^2.6.12" - } - }, - "node_modules/cross-fetch/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/cross-spawn": { "version": "7.0.3", "dev": true, diff --git a/package.json b/package.json index 9bcc8849..8310b02e 100644 --- a/package.json +++ b/package.json @@ -604,7 +604,6 @@ "@microsoft/vscode-azext-utils": "^2.5.1", "@octokit/rest": "^20.1.1", "@vscode/extension-telemetry": "^0.9.6", - "cross-fetch": "^4.0.0", "decompress": "^4.2.1", "js-yaml": "^4.1.0", "move-file": "^3.1.0", diff --git a/src/commands/azureServiceOperators/installAzureServiceOperator.ts b/src/commands/azureServiceOperators/installAzureServiceOperator.ts index 22756a24..c10a0bb5 100644 --- a/src/commands/azureServiceOperators/installAzureServiceOperator.ts +++ b/src/commands/azureServiceOperators/installAzureServiceOperator.ts @@ -54,7 +54,7 @@ export default async function installAzureServiceOperator(_context: IActionConte kubeConfigFile.filePath, clusterInfo.result.name, ); - const panel = new AzureServiceOperatorPanel(extension.result.extensionUri); + const panel = new AzureServiceOperatorPanel(extension.result.extensionUri); panel.show(dataProvider, kubeConfigFile); } diff --git a/src/commands/utils/azureAccount.ts b/src/commands/utils/azureAccount.ts deleted file mode 100644 index f50b5cf8..00000000 --- a/src/commands/utils/azureAccount.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { TokenCredential } from "@azure/core-auth"; -import { combine, Errorable, failed, getErrorMessage } from "./errorable"; -import { ClientSecretCredential } from "@azure/identity"; -import "cross-fetch/polyfill"; // Needed by the graph client: https://github.com/microsoftgraph/msgraph-sdk-javascript/blob/dev/README.md#via-npm -import { Client as GraphClient } from "@microsoft/microsoft-graph-client"; -import { - TokenCredentialAuthenticationProvider, - TokenCredentialAuthenticationProviderOptions, -} from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials"; -import { AuthorizationManagementClient } from "@azure/arm-authorization"; -import { RoleAssignment } from "@azure/arm-authorization"; -import { getDefaultScope, getEnvironment } from "../../auth/azureAuth"; -import { DefinedSubscription, getSubscriptions, SelectionType } from "./subscriptions"; -import { ReadyAzureSessionProvider } from "../../auth/types"; - -export interface ServicePrincipalAccess { - readonly cloudName: string; - readonly tenantId: string; - readonly subscriptions: { - readonly id: string; - readonly name: string; - }[]; -} - -interface SubscriptionAccessResult { - readonly subscription: DefinedSubscription; - readonly hasRoleAssignment: boolean; -} - -interface ServicePrincipalInfo { - readonly id: string; - readonly displayName: string; - readonly credential: TokenCredential; - readonly tenantId: string; -} - -export async function getServicePrincipalAccess( - sessionProvider: ReadyAzureSessionProvider, - appId: string, - secret: string, -): Promise> { - const cloudName = getEnvironment().name; - const filteredSubscriptions = await getSubscriptions(sessionProvider, SelectionType.Filtered); - if (failed(filteredSubscriptions)) { - return filteredSubscriptions; - } - - const session = await sessionProvider.getAuthSession(); - if (failed(session)) { - return session; - } - - const spInfo = await getServicePrincipalInfo(session.result.tenantId, appId, secret); - if (failed(spInfo)) { - return spInfo; - } - - const promiseResults = await Promise.all( - filteredSubscriptions.result.map((s) => getSubscriptionAccess(spInfo.result.credential, s, spInfo.result)), - ); - - const ownershipResults = combine(promiseResults); - if (failed(ownershipResults)) { - return ownershipResults; - } - - const subscriptions = ownershipResults.result - .filter((r) => r.hasRoleAssignment) - .map((r) => ({ - id: r.subscription.subscriptionId, - name: r.subscription.displayName, - })); - - return { succeeded: true, result: { cloudName, tenantId: spInfo.result.tenantId, subscriptions } }; -} - -type ServicePrincipalSearchResult = { - value?: { - id: string; - displayName: string; - }[]; -}; - -async function getServicePrincipalInfo( - tenantId: string, - appId: string, - appSecret: string, -): Promise> { - // Use the MS Graph API to retrieve the object ID and display name of the service principal, - // using its own password as the credential. - const baseUrl = getMicrosoftGraphClientBaseUrl(); - const graphClientOptions: TokenCredentialAuthenticationProviderOptions = { - scopes: [getDefaultScope(baseUrl)], - }; - - const credential = new ClientSecretCredential(tenantId, appId, appSecret); - - const graphClient = GraphClient.initWithMiddleware({ - baseUrl, - authProvider: new TokenCredentialAuthenticationProvider(credential, graphClientOptions), - }); - - let spSearchResults: ServicePrincipalSearchResult; - try { - spSearchResults = await graphClient - .api("/servicePrincipals") - .filter(`appId eq '${appId}'`) - .select(["id", "displayName"]) - .get(); - } catch (e) { - return { succeeded: false, error: `Failed to retrieve service principal: ${getErrorMessage(e)}` }; - } - - if (!spSearchResults.value || spSearchResults.value.length !== 1) { - return { - succeeded: false, - error: `Expected service principal search result to contain value with one item. Actual result: ${JSON.stringify( - spSearchResults, - )}`, - }; - } - - const searchResult = spSearchResults.value[0]; - const spInfo = { - id: searchResult.id, - displayName: searchResult.displayName, - credential, - tenantId, - }; - - return { succeeded: true, result: spInfo }; -} - -function getMicrosoftGraphClientBaseUrl(): string { - const environment = getEnvironment(); - // Environments are from here: https://github.com/Azure/ms-rest-azure-env/blob/6fa17ce7f36741af6ce64461735e6c7c0125f0ed/lib/azureEnvironment.ts#L266-L346 - // They do not contain the MS Graph endpoints, whose values are here: - // https://github.com/microsoftgraph/msgraph-sdk-javascript/blob/d365ab1d68f90f2c38c67a5a7c7fe54acfc2584e/src/Constants.ts#L28 - switch (environment.name) { - case "AzureChinaCloud": - return "https://microsoftgraph.chinacloudapi.cn"; - case "AzureUSGovernment": - return "https://graph.microsoft.us"; - case "AzureGermanCloud": - return "https://graph.microsoft.de"; - } - - return "https://graph.microsoft.com"; -} - -async function getSubscriptionAccess( - credential: TokenCredential, - subscription: DefinedSubscription, - spInfo: ServicePrincipalInfo, -): Promise> { - if (!subscription.subscriptionId) { - return { succeeded: true, result: { subscription, hasRoleAssignment: false } }; - } - - const client = new AuthorizationManagementClient(credential, subscription.subscriptionId); - const roleAssignments: RoleAssignment[] = []; - try { - const iterator = client.roleAssignments.listForSubscription({ filter: `principalId eq '${spInfo.id}'` }); - for await (const pageRoleAssignments of iterator.byPage()) { - roleAssignments.push(...pageRoleAssignments); - } - } catch (e) { - if (isUnauthorizedError(e)) { - return { succeeded: true, result: { subscription, hasRoleAssignment: false } }; - } - - return { succeeded: false, error: getErrorMessage(e) }; - } - - // The service principal needs *some* permissions in the subscription, but Contributor is not - // necessarily required. See: https://azure.github.io/azure-service-operator/#installation - return { succeeded: true, result: { subscription, hasRoleAssignment: roleAssignments.length > 0 } }; -} - -function isUnauthorizedError(e: unknown): boolean { - return ( - typeof e === "object" && - e !== null && - "code" in e && - "statusCode" in e && - e.code === "AuthorizationFailed" && - e.statusCode === 403 - ); -} diff --git a/src/panels/AzureServiceOperatorPanel.ts b/src/panels/AzureServiceOperatorPanel.ts index 9d9200ca..c6f08914 100644 --- a/src/panels/AzureServiceOperatorPanel.ts +++ b/src/panels/AzureServiceOperatorPanel.ts @@ -2,17 +2,17 @@ import * as vscode from "vscode"; import * as k8s from "vscode-kubernetes-tools-api"; import { BasePanel, PanelDataProvider } from "./BasePanel"; import { MessageHandler, MessageSink } from "../webview-contract/messaging"; -import { failed, getErrorMessage, map as errmap, combine } from "../commands/utils/errorable"; +import { failed, getErrorMessage, map as errmap, combine, Errorable } from "../commands/utils/errorable"; import { ASOCloudName, AzureCloudName, CommandResult, InitialState, + Subscription, ToVsCodeMsgDef, ToWebViewMsgDef, azureToASOCloudMap, } from "../webview-contract/webviewDefinitions/azureServiceOperator"; -import { getServicePrincipalAccess } from "../commands/utils/azureAccount"; import { invokeKubectlCommand } from "../commands/utils/kubectl"; import path from "path"; import * as fs from "fs/promises"; @@ -20,6 +20,8 @@ import { createTempFile } from "../commands/utils/tempfile"; import { TelemetryDefinition } from "../webview-contract/webviewTypes"; import { ReadyAzureSessionProvider } from "../auth/types"; import { NonZeroExitCodeBehaviour } from "../commands/utils/shell"; +import { getEnvironment } from "../auth/azureAuth"; +import { SelectionType, getSubscriptions } from "../commands/utils/subscriptions"; export class AzureServiceOperatorPanel extends BasePanel<"aso"> { constructor(extensionUri: vscode.Uri) { @@ -66,7 +68,7 @@ export class AzureServiceOperatorDataProvider implements PanelDataProvider<"aso" getMessageHandler(webview: MessageSink): MessageHandler { return { - checkSPRequest: (args) => this.handleCheckSPRequest(args.appId, args.appSecret, webview), + checkSPRequest: () => this.handleCheckSPRequest(webview), installCertManagerRequest: () => this.handleInstallCertManagerRequest(webview), waitForCertManagerRequest: () => this.handleWaitForCertManagerRequest(webview), installOperatorRequest: () => this.handleInstallOperatorRequest(webview), @@ -83,16 +85,12 @@ export class AzureServiceOperatorDataProvider implements PanelDataProvider<"aso" }; } - private async handleCheckSPRequest( - appId: string, - appSecret: string, - webview: MessageSink, - ): Promise { - const servicePrincipalAccess = await getServicePrincipalAccess(this.sessionProvider, appId, appSecret); - if (failed(servicePrincipalAccess)) { + private async handleCheckSPRequest(webview: MessageSink): Promise { + const subscriptions = await this.getSubscriptionsForServicePrincipal(); + if (failed(subscriptions)) { webview.postCheckSPResponse({ succeeded: false, - errorMessage: servicePrincipalAccess.error, + errorMessage: subscriptions.error, commandResults: [], cloudName: null, subscriptions: [], @@ -105,12 +103,32 @@ export class AzureServiceOperatorDataProvider implements PanelDataProvider<"aso" succeeded: true, errorMessage: null, commandResults: [], - cloudName: servicePrincipalAccess.result.cloudName as AzureCloudName, - subscriptions: servicePrincipalAccess.result.subscriptions, - tenantId: servicePrincipalAccess.result.tenantId, + cloudName: getEnvironment().name as AzureCloudName, + subscriptions: subscriptions.result, + tenantId: this.sessionProvider.selectedTenant.id, }); } + private async getSubscriptionsForServicePrincipal(): Promise> { + // TODO: This *should* return all the subscriptions that are accessible to the service principal. + // However, doing that requires querying graph APIs, which requires delegated permissions that + // the default VS Code client application does not have. + // For this and other future work, we should create a new first party client application that has + // the appropriate graph permissions. But for now, we will just return all the subscriptions that + // the user has access to. + const allSubscriptions = await getSubscriptions(this.sessionProvider, SelectionType.All); + if (failed(allSubscriptions)) { + return allSubscriptions; + } + + const result: Subscription[] = allSubscriptions.result.map((s) => ({ + id: s.subscriptionId, + name: s.displayName, + })); + + return { succeeded: true, result }; + } + private async handleInstallCertManagerRequest(webview: MessageSink): Promise { // From installation instructions: // https://azure.github.io/azure-service-operator/#installation