Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

azure: Add wizard steps to list/create UserAssignedIdentities and execute role definitions #1757

Merged
merged 9 commits into from
Jul 25, 2024
45 changes: 45 additions & 0 deletions azure/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

/* eslint-disable @typescript-eslint/no-explicit-any */

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';
Expand Down Expand Up @@ -212,6 +213,12 @@ export interface IResourceGroupWizardContext extends ILocationWizardContext, IRe
*/
suppress403Handling?: boolean;

/**
* The managed identity to be used for the new target resource
* Service resource, such as storage, should be add a role assignment
*/
managedIdentity?: Identity;

alexweininger marked this conversation as resolved.
Show resolved Hide resolved
ui: IAzureUserInput;
}

Expand Down Expand Up @@ -343,6 +350,40 @@ export declare class StorageAccountCreateStep<T extends IStorageAccountWizardCon
public shouldExecute(wizardContext: T): boolean;
}

export declare class UserAssignedIdentityListStep<T extends IResourceGroupWizardContext> extends AzureWizardPromptStep<T> {
public constructor(suppressCreate?: boolean);

public prompt(wizardContext: T): Promise<void>;
public shouldPrompt(wizardContext: T): boolean;
}

export declare class UserAssignedIdentityCreateStep<T extends IResourceGroupWizardContext> extends AzureWizardExecuteStep<T> {
/**
* 140
*/
public priority: number;
public constructor();

public execute(wizardContext: T, progress: Progress<{ message?: string; increment?: number }>): Promise<void>;
public shouldExecute(wizardContext: T): boolean;
}

export declare class RoleAssignmentExecuteStep<T extends IResourceGroupWizardContext, TKey extends keyof T> extends AzureWizardExecuteStep<T> {
/**
* 900
*/
public priority: number;
/**
* @param getScopeId A function that returns the scope id for the role assignment. This typically won't exist until _after_ the resource is created,
* which is why it's a function that returns a string. If the scope id is undefined, the step will throw an error.
* @param roleDefinitionId The id of the role definition to assign. Use RoleDefinitionId enum for common role definitions
* */
public constructor(getScopeId: () => string | undefined, roleDefinitionId: RoleDefinitionId);

public execute(wizardContext: T, progress: Progress<{ message?: string; increment?: number }>): Promise<void>;
public shouldExecute(wizardContext: T): boolean;
}

export interface IAzureUtilsExtensionVariables extends UIExtensionVariables {
prefix: string;
}
Expand Down Expand Up @@ -448,3 +489,7 @@ 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 enum RoleDefinitionId {
alexweininger marked this conversation as resolved.
Show resolved Hide resolved
'Storage Blob Data Contributor' = '/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe'
}
52 changes: 50 additions & 2 deletions azure/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion azure/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
alexweininger marked this conversation as resolved.
Show resolved Hide resolved
"@azure/arm-resources": "^5.0.0",
"@azure/arm-resources-profile-2020-09-01-hybrid": "^2.0.0",
"@azure/arm-resources-subscriptions": "^2.0.0",
Expand Down
16 changes: 15 additions & 1 deletion azure/src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,6 +29,18 @@ export async function createResourcesClient(context: InternalAzExtClientContext)
}
}

export async function createManagedServiceIdentityClient(context: InternalAzExtClientContext): Promise<ManagedServiceIdentityClient> {
return createAzureClient(context, (await import('@azure/arm-msi')).ManagedServiceIdentityClient);
}

export async function createAuthorizationManagementClient(context: InternalAzExtClientContext): Promise<AuthorizationManagementClient> {
if (parseClientContext(context).isCustomCloud) {
return <AuthorizationManagementClient><unknown>createAzureClient(context, (await import('@azure/arm-authorization-profile-2020-09-01-hybrid')).AuthorizationManagementClient);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh nice, so the hybrid profile version was made to support custom clouds?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a downside to just always using the one that supports custom clouds?

Copy link
Member Author

@nturinski nturinski Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, but the api-versions for custom clouds aren't kept up to date as quickly as standard Azure SDKs.

} else {
return createAzureClient(context, (await import('@azure/arm-authorization')).AuthorizationManagementClient);
}
}

export async function createSubscriptionsClient(context: InternalAzExtClientContext): Promise<SubscriptionClient> {
return createAzureSubscriptionClient(context, (await import('@azure/arm-resources-subscriptions')).SubscriptionClient);
}
2 changes: 1 addition & 1 deletion azure/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@

export const resourcesProvider: string = 'Microsoft.Resources';
export const storageProvider: string = 'Microsoft.Storage';
export const storageProviderType = "Microsoft.Storage/storageAccounts";
export const storageProviderType = "Microsoft.Storage/storageAccounts";
3 changes: 3 additions & 0 deletions azure/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,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';
Expand Down
51 changes: 51 additions & 0 deletions azure/src/wizard/RoleAssignmentExecuteStep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

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 enum RoleDefinitionId {
'Storage Blob Data Contributor' = '/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe'
}

export class RoleAssignmentExecuteStep<T extends types.IResourceGroupWizardContext> extends AzureWizardExecuteStep<T> {
public priority: number = 900;
private getScopeId: () => string | undefined;
private _roleDefinitionId: types.RoleDefinitionId;
public constructor(getScopeId: () => string | undefined, roleDefinitionId: types.RoleDefinitionId) {
super();
this.getScopeId = getScopeId;
this._roleDefinitionId = roleDefinitionId;
}

public async execute(wizardContext: T, progress: Progress<{ message?: string; increment?: number }>): Promise<void> {
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 scopeSplit = scope.split('/');
const resourceName = scopeSplit[scopeSplit.length - 1] ?? '';
const resourceType = scopeSplit[scopeSplit.length - 2] ?? '';

const guid = randomUUID();

const roleDefinitionDisplayName = Object.keys(RoleDefinitionId)[Object.values(RoleDefinitionId).indexOf(this._roleDefinitionId)];
const roleDefinitionId = this._roleDefinitionId as unknown 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 resource "{1}" with provider "{2}".', roleDefinitionDisplayName, resourceName, resourceType);
progress.report({ message: roleAssignmentCreated });
ext.outputChannel.appendLog(roleAssignmentCreated);
}

public shouldExecute(wizardContext: T): boolean {
return !!wizardContext.managedIdentity;
}
}
52 changes: 52 additions & 0 deletions azure/src/wizard/UserAssignedIdentityCreateStep.ts
Original file line number Diff line number Diff line change
@@ -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<T extends types.IResourceGroupWizardContext> extends AzureWizardExecuteStep<T> {
public priority: number = 140;

public constructor() {
super();
}

public async execute(wizardContext: T, progress: Progress<{ message?: string; increment?: number }>): Promise<void> {
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;
}
}
69 changes: 69 additions & 0 deletions azure/src/wizard/UserAssignedIdentityListStep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*---------------------------------------------------------------------------------------------
* 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, nonNullProp } 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 { LocationListStep } from './LocationListStep';
import { UserAssignedIdentityCreateStep } from './UserAssignedIdentityCreateStep';

export class UserAssignedIdentityListStep<T extends types.IResourceGroupWizardContext> extends AzureWizardPromptStep<T> {
private _suppressCreate: boolean | undefined;

public constructor(suppressCreate?: boolean) {
super();
this._suppressCreate = suppressCreate;
}

public async prompt(wizardContext: T): Promise<void> {
// Cache resource group separately per subscription
const options: IAzureQuickPickOptions = { placeHolder: 'Select a resource group for new resources.', id: `ResourceGroupListStep/${wizardContext.subscriptionId}` };
wizardContext.managedIdentity = (await wizardContext.ui.showQuickPick(this.getQuickPicks(wizardContext), options)).data;
if (wizardContext.managedIdentity && !LocationListStep.hasLocation(wizardContext)) {
await LocationListStep.setLocation(wizardContext, nonNullProp(wizardContext.managedIdentity, 'location'));
}
}

public shouldPrompt(wizardContext: T): boolean {
return !wizardContext.managedIdentity;
}

public async getSubWizard(wizardContext: T): Promise<IWizardOptions<T> | undefined> {
if (!wizardContext.managedIdentity) {
return {
executeSteps: [new UserAssignedIdentityCreateStep()]
}
}

return undefined;
}

private async getQuickPicks(wizardContext: T): Promise<IAzureQuickPickItem<Identity | undefined>[]> {
const picks: IAzureQuickPickItem<Identity | undefined>[] = [];
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
};
}));
}
}
Loading