diff --git a/azure/index.d.ts b/azure/index.d.ts index a74cf1e13b..b685101e15 100644 --- a/azure/index.d.ts +++ b/azure/index.d.ts @@ -5,6 +5,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { type RoleDefinition } from '@azure/arm-authorization'; +import { Identity } from '@azure/arm-msi'; import type { ExtendedLocation, ResourceGroup } from '@azure/arm-resources'; import type { Location } from '@azure/arm-resources-subscriptions'; import type { StorageAccount } from '@azure/arm-storage'; @@ -212,6 +214,13 @@ export interface IResourceGroupWizardContext extends ILocationWizardContext, IRe */ suppress403Handling?: boolean; + /** + * The managed identity that will be assigned to the resource such as a function app or container app + * If you need to grant access to a resource, such as a storage account or SQL database, you can use this managed identity to create a role assignment + * with the RoleAssignmentExecuteStep + */ + managedIdentity?: Identity; + ui: IAzureUserInput; } @@ -343,6 +352,43 @@ export declare class StorageAccountCreateStep extends AzureWizardPromptStep { + public constructor(suppressCreate?: boolean); + + public prompt(wizardContext: T): Promise; + public shouldPrompt(wizardContext: T): boolean; +} + +export declare class UserAssignedIdentityCreateStep extends AzureWizardExecuteStep { + /** + * 140 + */ + public priority: number; + public constructor(); + + public execute(wizardContext: T, progress: Progress<{ message?: string; increment?: number }>): Promise; + public shouldExecute(wizardContext: T): boolean; +} + +export declare class RoleAssignmentExecuteStep extends AzureWizardExecuteStep { + /** + * 900 + */ + public priority: number; + /** + * @param getScopeId A function that returns the scope id for the role assignment. + * The scope ID is the Azure ID of the resource that we are granting access to such as a storage account. + * Example: `/subscriptions/xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx/resourceGroups/rgName/providers/Microsoft.Storage/storageAccounts/resourceName` + * This typically won't exist until _after_ the wizard executes and the resource is created, so we need to pass in a function that returns the ID. + * If the scope ID is undefined, the step will throw an error. + * @param roleDefinition The ARM role definition to assign. Use CommonRoleDefinition constant for role defintions that don't require user input. + * */ + public constructor(getScopeId: () => string | undefined, roleDefinition: RoleDefinition); + + public execute(wizardContext: T, progress: Progress<{ message?: string; increment?: number }>): Promise; + public shouldExecute(wizardContext: T): boolean; +} + export interface IAzureUtilsExtensionVariables extends UIExtensionVariables { prefix: string; } @@ -448,3 +494,14 @@ export function setupAzureLogger(logOutputChannel: LogOutputChannel): Disposable * @param password - Password. Gets encoded before being set in the header */ export function addBasicAuthenticationCredentialsToClient(client: ServiceClient, userName: string, password: string): void; + +export declare const CommonRoleDefinitions: { + readonly storageBlobDataContributor: { + readonly id: "/subscriptions/9b5c7ccb-9857-4307-843b-8875e83f65e9/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe"; + readonly name: "ba92f5b4-2d11-453d-a403-e96b0029c9fe"; + readonly type: "Microsoft.Authorization/roleDefinitions"; + readonly roleName: "Storage Blob Data Contributor"; + readonly description: "Allows for read, write and delete access to Azure Storage blob containers and data"; + readonly roleType: "BuiltInRole"; + }; +}; diff --git a/azure/package-lock.json b/azure/package-lock.json index fb1d10e141..0b32fe1bf3 100644 --- a/azure/package-lock.json +++ b/azure/package-lock.json @@ -1,14 +1,17 @@ { "name": "@microsoft/vscode-azext-azureutils", - "version": "3.0.1", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@microsoft/vscode-azext-azureutils", - "version": "3.0.1", + "version": "3.1.0", "license": "MIT", "dependencies": { + "@azure/arm-authorization": "^9.0.0", + "@azure/arm-authorization-profile-2020-09-01-hybrid": "^2.1.0", + "@azure/arm-msi": "^2.1.0", "@azure/arm-resources": "^5.0.0", "@azure/arm-resources-profile-2020-09-01-hybrid": "^2.0.0", "@azure/arm-resources-subscriptions": "^2.0.0", @@ -57,6 +60,51 @@ "node": ">=12.0.0" } }, + "node_modules/@azure/arm-authorization": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@azure/arm-authorization/-/arm-authorization-9.0.0.tgz", + "integrity": "sha512-GdiCA8IA1gO+qcCbFEPj+iLC4+3ByjfKzmeAnkP7MdlL84Yo30Huo/EwbZzwRjYybXYUBuFxGPBB+yeTT4Ebxg==", + "dependencies": { + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.7.0", + "@azure/core-paging": "^1.2.0", + "@azure/core-rest-pipeline": "^1.8.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/arm-authorization-profile-2020-09-01-hybrid": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@azure/arm-authorization-profile-2020-09-01-hybrid/-/arm-authorization-profile-2020-09-01-hybrid-2.1.0.tgz", + "integrity": "sha512-uOXhcj6Dv+TB8Yn2fguQQhoBZhafTf0ir5/QIZ8C7Rb2vS0nLcnoeesWRj9jmS0SerE7y2AF3qEhrowEZotX1Q==", + "dependencies": { + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.6.1", + "@azure/core-paging": "^1.2.0", + "@azure/core-rest-pipeline": "^1.8.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@azure/arm-msi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@azure/arm-msi/-/arm-msi-2.1.0.tgz", + "integrity": "sha512-qP2BXpXkZ/I5gO479Dj6HFVkgSrAlYOG2AGVTqxTu0YIVdZdmrlZLH7SNl1Um9id+p7uNGNRdVKIBO5rcQgeJw==", + "dependencies": { + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.6.1", + "@azure/core-paging": "^1.2.0", + "@azure/core-rest-pipeline": "^1.8.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@azure/arm-resources": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@azure/arm-resources/-/arm-resources-5.2.0.tgz", diff --git a/azure/package.json b/azure/package.json index ca4ff01c6d..ca3b41858b 100644 --- a/azure/package.json +++ b/azure/package.json @@ -1,7 +1,7 @@ { "name": "@microsoft/vscode-azext-azureutils", "author": "Microsoft Corporation", - "version": "3.0.1", + "version": "3.1.0", "description": "Common Azure utils for developing Azure extensions for VS Code", "tags": [ "azure", @@ -31,6 +31,9 @@ "package": "npm pack" }, "dependencies": { + "@azure/arm-authorization": "^9.0.0", + "@azure/arm-authorization-profile-2020-09-01-hybrid": "^2.1.0", + "@azure/arm-msi": "^2.1.0", "@azure/arm-resources": "^5.0.0", "@azure/arm-resources-profile-2020-09-01-hybrid": "^2.0.0", "@azure/arm-resources-subscriptions": "^2.0.0", diff --git a/azure/src/clients.ts b/azure/src/clients.ts index 666af4bd2e..0901b4584f 100644 --- a/azure/src/clients.ts +++ b/azure/src/clients.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { AuthorizationManagementClient } from '@azure/arm-authorization'; +import { ManagedServiceIdentityClient } from '@azure/arm-msi'; import type { ResourceManagementClient } from '@azure/arm-resources'; -import type { StorageManagementClient } from '@azure/arm-storage'; import type { SubscriptionClient } from '@azure/arm-resources-subscriptions'; +import type { StorageManagementClient } from '@azure/arm-storage'; import { createAzureClient, createAzureSubscriptionClient, InternalAzExtClientContext, parseClientContext } from './createAzureClient'; // Lazy-load @azure packages to improve startup performance. @@ -27,6 +29,18 @@ export async function createResourcesClient(context: InternalAzExtClientContext) } } +export async function createManagedServiceIdentityClient(context: InternalAzExtClientContext): Promise { + return createAzureClient(context, (await import('@azure/arm-msi')).ManagedServiceIdentityClient); +} + +export async function createAuthorizationManagementClient(context: InternalAzExtClientContext): Promise { + if (parseClientContext(context).isCustomCloud) { + return createAzureClient(context, (await import('@azure/arm-authorization-profile-2020-09-01-hybrid')).AuthorizationManagementClient); + } else { + return createAzureClient(context, (await import('@azure/arm-authorization')).AuthorizationManagementClient); + } +} + export async function createSubscriptionsClient(context: InternalAzExtClientContext): Promise { return createAzureSubscriptionClient(context, (await import('@azure/arm-resources-subscriptions')).SubscriptionClient); } diff --git a/azure/src/constants.ts b/azure/src/constants.ts index 7d72a88850..fb9128a1b1 100644 --- a/azure/src/constants.ts +++ b/azure/src/constants.ts @@ -3,6 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type RoleDefinition } from "@azure/arm-authorization"; + export const resourcesProvider: string = 'Microsoft.Resources'; export const storageProvider: string = 'Microsoft.Storage'; -export const storageProviderType = "Microsoft.Storage/storageAccounts"; \ No newline at end of file +export const storageProviderType = "Microsoft.Storage/storageAccounts"; + +export const CommonRoleDefinitions = { + storageBlobDataContributor: { + id: "/subscriptions/9b5c7ccb-9857-4307-843b-8875e83f65e9/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe", + name: "ba92f5b4-2d11-453d-a403-e96b0029c9fe", + type: "Microsoft.Authorization/roleDefinitions", + roleName: "Storage Blob Data Contributor", + description: "Allows for read, write and delete access to Azure Storage blob containers and data", + roleType: "BuiltInRole" + } as RoleDefinition +} as const; diff --git a/azure/src/index.ts b/azure/src/index.ts index bd13471f3f..1a31fedc0c 100644 --- a/azure/src/index.ts +++ b/azure/src/index.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +export { CommonRoleDefinitions as const } from './constants'; export * from './createAzureClient'; export * from './openInPortal'; export * from './tree/AzureAccountTreeItemBase'; @@ -14,9 +15,12 @@ export * from './wizard/LocationListStep'; export * from './wizard/ResourceGroupCreateStep'; export * from './wizard/ResourceGroupListStep'; export * from './wizard/ResourceGroupNameStep'; +export * from './wizard/RoleAssignmentExecuteStep'; export * from './wizard/StorageAccountCreateStep'; export * from './wizard/StorageAccountListStep'; export * from './wizard/StorageAccountNameStep'; +export * from './wizard/UserAssignedIdentityCreateStep'; +export * from './wizard/UserAssignedIdentityListStep'; export * from './wizard/VerifyProvidersStep'; export * from './utils/setupAzureLogger'; export { registerAzureUtilsExtensionVariables } from './extensionVariables'; diff --git a/azure/src/wizard/RoleAssignmentExecuteStep.ts b/azure/src/wizard/RoleAssignmentExecuteStep.ts new file mode 100644 index 0000000000..25fc20d646 --- /dev/null +++ b/azure/src/wizard/RoleAssignmentExecuteStep.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type RoleDefinition } from '@azure/arm-authorization'; +import { AzureWizardExecuteStep, nonNullValueAndProp } from '@microsoft/vscode-azext-utils'; +import { randomUUID } from 'crypto'; +import { l10n, Progress } from 'vscode'; +import * as types from '../../index'; +import { createAuthorizationManagementClient } from '../clients'; +import { ext } from '../extensionVariables'; + +export class RoleAssignmentExecuteStep extends AzureWizardExecuteStep { + public priority: number = 900; + private getScopeId: () => string | undefined; + private _roleDefinition: RoleDefinition; + public constructor(getScopeId: () => string | undefined, roleDefinition: RoleDefinition) { + super(); + this.getScopeId = getScopeId; + this._roleDefinition = roleDefinition; + } + + public async execute(wizardContext: T, progress: Progress<{ message?: string; increment?: number }>): Promise { + const amClient = await createAuthorizationManagementClient(wizardContext) + const scope = this.getScopeId(); + if (!scope) { + throw new Error(l10n.t('No scope was provided for the role assignment.')); + } + const scopeSplitArr = scope.split('/'); + const resourceName = scopeSplitArr[scopeSplitArr.length - 1] ?? ''; + const resourceType = scopeSplitArr[scopeSplitArr.length - 2] ?? ''; + + const guid = randomUUID(); + const roleDefinitionId = this._roleDefinition.id as string; + const principalId = nonNullValueAndProp(wizardContext.managedIdentity, 'principalId'); + + await amClient.roleAssignments.create(scope, guid, { roleDefinitionId, principalId }); + const roleAssignmentCreated = l10n.t('Role assignment "{0}" created for the {2} resource "{1}".', this._roleDefinition.roleName ?? '', resourceName, resourceType); + progress.report({ message: roleAssignmentCreated }); + ext.outputChannel.appendLog(roleAssignmentCreated); + } + + public shouldExecute(wizardContext: T): boolean { + return !!wizardContext.managedIdentity; + } +} diff --git a/azure/src/wizard/UserAssignedIdentityCreateStep.ts b/azure/src/wizard/UserAssignedIdentityCreateStep.ts new file mode 100644 index 0000000000..71a1c9b357 --- /dev/null +++ b/azure/src/wizard/UserAssignedIdentityCreateStep.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ManagedServiceIdentityClient } from '@azure/arm-msi'; +import { AzureWizardExecuteStep, nonNullValueAndProp } from '@microsoft/vscode-azext-utils'; +import { l10n, Progress } from 'vscode'; +import * as types from '../../index'; +import { createManagedServiceIdentityClient } from '../clients'; +import { storageProvider } from '../constants'; +import { ext } from '../extensionVariables'; +import { LocationListStep } from './LocationListStep'; + +/** + * Naming constraints: + * The resource name must start with a letter or number, + * have a length between 3 and 128 characters and + * can only contain a combination of alphanumeric characters, hyphens and underscores + * But since we are appending "-identities" to the resource group name and that has the same constraints and a 90 character limit, + * we don't need to do any verification + **/ +export class UserAssignedIdentityCreateStep extends AzureWizardExecuteStep { + public priority: number = 140; + + public constructor() { + super(); + } + + public async execute(wizardContext: T, progress: Progress<{ message?: string; increment?: number }>): Promise { + const newLocation: string = (await LocationListStep.getLocation(wizardContext, storageProvider)).name; + const rgName: string = nonNullValueAndProp(wizardContext.resourceGroup, 'name'); + const newName: string = `${rgName}-identities`; + const creatingUserAssignedIdentity: string = l10n.t('Creating user assigned identity "{0}" in location "{1}""...', newName, newLocation); + ext.outputChannel.appendLog(creatingUserAssignedIdentity); + progress.report({ message: creatingUserAssignedIdentity }); + const msiClient: ManagedServiceIdentityClient = await createManagedServiceIdentityClient(wizardContext); + wizardContext.managedIdentity = await msiClient.userAssignedIdentities.createOrUpdate( + rgName, + newName, + { + location: newLocation + } + ); + const createdUserAssignedIdentity: string = l10n.t('Successfully created user assigned identity "{0}".', newName); + ext.outputChannel.appendLog(createdUserAssignedIdentity); + } + + public shouldExecute(wizardContext: T): boolean { + return !wizardContext.managedIdentity; + } +} diff --git a/azure/src/wizard/UserAssignedIdentityListStep.ts b/azure/src/wizard/UserAssignedIdentityListStep.ts new file mode 100644 index 0000000000..cd6407e05d --- /dev/null +++ b/azure/src/wizard/UserAssignedIdentityListStep.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Identity } from '@azure/arm-msi'; +import { AzureWizardPromptStep, IAzureQuickPickItem, IAzureQuickPickOptions, IWizardOptions } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import * as types from '../../index'; +import { createManagedServiceIdentityClient } from '../clients'; +import { uiUtils } from '../utils/uiUtils'; +import { UserAssignedIdentityCreateStep } from './UserAssignedIdentityCreateStep'; + +export class UserAssignedIdentityListStep extends AzureWizardPromptStep { + private _suppressCreate: boolean | undefined; + + public constructor(suppressCreate?: boolean) { + super(); + this._suppressCreate = suppressCreate; + } + + public async prompt(wizardContext: T): Promise { + const options: IAzureQuickPickOptions = { placeHolder: 'Select a user assigned identity.', id: `UserAssignedIdentityListStep` }; + wizardContext.managedIdentity = (await wizardContext.ui.showQuickPick(this.getQuickPicks(wizardContext), options)).data; + } + + public shouldPrompt(wizardContext: T): boolean { + return !wizardContext.managedIdentity; + } + + public async getSubWizard(wizardContext: T): Promise | undefined> { + if (!wizardContext.managedIdentity) { + return { + executeSteps: [new UserAssignedIdentityCreateStep()] + } + } + + return undefined; + } + + private async getQuickPicks(wizardContext: T): Promise[]> { + const picks: IAzureQuickPickItem[] = []; + const miClient = await createManagedServiceIdentityClient(wizardContext); + const uai = await uiUtils.listAllIterator(miClient.userAssignedIdentities.listBySubscription()); + + if (!this._suppressCreate) { + picks.push({ + label: vscode.l10n.t('$(plus) Create new user assigned identity'), + description: '', + data: undefined + }); + } + + return picks.concat(uai.map((i: Identity) => { + return { + id: i.id, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + label: i.name!, + description: i.location, + data: i + }; + })); + } +}