pulumi / pulumi-azure-native

Azure Native Provider
Apache License 2.0
125 stars 33 forks source link

Cannot create a ManagementGroup at ManagementGroup Level #553

Open boillodmanuel opened 3 years ago

boillodmanuel commented 3 years ago

With ARM, management group can be created at tenant level or at managementGroup level.

It seems that pulumi next gen management.v20200501.ManagementGroup only supports tenant level. And tenant level requires very high permission (actually Management Group Contributor at the root scope /).

Indeed, when I tried to create a "mg-test" Management Group, child of the "Tenant root group", I got the following error:

    error: cannot check existence of resource '/providers/Microsoft.Management/managementGroups/mg-test': status code 403, {"error":{"code":"AuthorizationFailed","message":"
    The client '<HIDDEN>' with object id '<HIDDEN>' does not have authorization to perform action 'Microsoft.Management/managementGroups/read' over scope '/providers/Microsoft.Management/managementGroups/mg-test' or the scope is invalid. If access was recently granted, please refresh your credentials.

You can notice that error did not report correctly the scope, because the scope should be / in this case.

Pulumi code :

new azure_nextgen.management.v20200501.ManagementGroup("mg-test", {
    displayName: "Test",
    groupId: "mg-test",
    // It didn't matter if you specify or not the parent id with: 
    // details: {
    //   parent: {
    //     id: `/providers/Microsoft.Management/managementGroups/${tenantId}`
    //   }
    // }`
}

I notice you create a new resource PolicyDefinitionAtManagementGroup (for similar issue?)

mikhailshilkov commented 3 years ago

So, we are talking about this API endpoint which has a fixed path /providers/Microsoft.Management/managementGroups/{groupId} irrespective of the management group's parent.

Before creating a group, we fire a GET request to see if the endpoint is available, which causes your error.

Do you know what is wrong here? If you create a group at "managementGroup" level - is its URL still the same? Will you have access to it at that URL?

boillodmanuel commented 3 years ago

I think I got it.

Using az cli from my user with rights at the root management group level

Full details :

$ az account management-group show --name /providers/Microsoft.Management/managementGroups/mg-test

code: AuthorizationFailed - , The client 'HIDDEN' with object id 'HIDDEN' does not have authorization to perform action 'Microsoft.Management/managementGroups/Microsoft.Management/mg-test/read' over scope '/providers/Microsoft.Management/managementGroups/providers/Microsoft.Management/managementGroups' or the scope is invalid. If access was recently granted, please refresh your credentials.

$ az account management-group show --name mg-test
{
  "id": "/providers/Microsoft.Management/managementGroups/mg-test",
  "name": "mg-test",
...
}

The former (using the full resource id) works only if you have permission at the tenant level (scope /) (I didn't test with az-cli, but I'm pretty confident with this)

I did the same test with js SDK @azure/arm-managementgroups, and got the same result

mikhailshilkov commented 3 years ago

I suspect the first command is invalid: the scope /providers/Microsoft.Management/managementGroups/providers/Microsoft.Management/managementGroups in the error is clearly wrong...

mikhailshilkov commented 3 years ago

What do you get if you run az account management-group show --name non-existing?

mikhailshilkov commented 3 years ago

I found the upstream issue which is probably causing this https://github.com/Azure/azure-rest-api-specs/issues/9549

boillodmanuel commented 3 years ago

What do you get if you run az account management-group show --name non-existing?

I got the following:

az account management-group show --name non-existing
code: AuthorizationFailed - , The client 'hidden' with object id 'hidden' does not have authorization to perform action 'Microsoft.Management/managementGroups/read' over scope '/providers/Microsoft.Management/managementGroups/non-existing' or the scope is invalid. If access was recently granted, please refresh your credentials.

So, you're right, the issue is caused by the upstream.

I remember that I commented a terraform issue about this: https://github.com/terraform-providers/terraform-provider-azurerm/issues/6091. They patch it on their side.

boillodmanuel commented 3 years ago

Same issue with management.v20200501.ManagementGroupSubscription (but this time, we can not fallback to azure provider because this association doesn't exist)

boillodmanuel commented 3 years ago

I share my workaround for the issue with management.v20200501.ManagementGroupSubscription. I use Azure SDK and wrap it in a pulumi.dynamic.Resource.

[Edit]: 🐞 This version does not work - See this version in next comment for a working version

async function createManagementGroupSubscription() { // call to getClientToken should be done outside the pulumi.dynamic.Resource // see https://github.com/pulumi/pulumi/issues/2580#issuecomment-781559171 const clientToken = await getClientToken()

    // usage of management_v20200501.ManagementGroupSubscription from next-gen provider is blocked by https://github.com/pulumi/pulumi-azure-native/issues/553
    new ManagementGroupSubscription(
        `mg-sub-1`,
        {
            groupId: <groupId>,
            subscriptionId: <subscriptionId>,
        },
        clientToken.token,
    )

- `ManagementGroupSubscription.ts`:

```ts
import * as pulumi from '@pulumi/pulumi'
import { ManagementGroupsAPI } from '@azure/arm-managementgroups'
// NPM alias to be able to instal version 1 in addition to version 2. 
// Command: npm install @azure/ms-rest-js1@npm:@azure/ms-rest-js@1
import { TokenCredentials } from '@azure/ms-rest-js1'

// fix for https://github.com/pulumi/pulumi-azure-native/issues/553

function getClient(token: string): ManagementGroupsAPI {
    const credentials = new TokenCredentials(token)
    return new ManagementGroupsAPI(credentials)
}

/**
 * The set of arguments for constructing a ManagementGroupSubscription resource.
 */
export interface ManagementGroupSubscriptionInputs {
    /**
     * Management Group ID.
     */
    groupId: string
    /**
     * Subscription ID.
     */
    subscriptionId: string
}
/**
 * The set of arguments for constructing a ManagementGroupSubscription resource.
 */
export interface ManagementGroupSubscriptionArgs {
    /**
     * Management Group ID.
     */
    readonly groupId: pulumi.Input<string>
    /**
     * Subscription ID.
     */
    readonly subscriptionId: pulumi.Input<string>
}

class ManagementGroupSubscriptionProvider implements pulumi.dynamic.ResourceProvider {
    private token: string

    constructor(token: string) {
        this.token = token
    }

    async check(
        olds: ManagementGroupSubscriptionInputs,
        news: ManagementGroupSubscriptionInputs,
    ): Promise<pulumi.dynamic.CheckResult> {
        return { inputs: news }
    }

    async create(inputs: ManagementGroupSubscriptionInputs): Promise<pulumi.dynamic.CreateResult> {
        const client = getClient(this.token)
        await client.managementGroupSubscriptions.create(inputs.groupId, inputs.subscriptionId)
        // do not use the result as it is empty!?!
        const id = `/providers/Microsoft.Management/managementGroups/${inputs.groupId}/subscriptions/${inputs.subscriptionId}`
        return { id, outs: { groupId: inputs.groupId, subscriptionId: inputs.subscriptionId } }
    }

    async diff(
        id: pulumi.ID,
        olds: ManagementGroupSubscriptionInputs,
        news: ManagementGroupSubscriptionInputs,
    ): Promise<pulumi.dynamic.DiffResult> {
        if (olds.groupId === news.groupId && olds.subscriptionId === news.subscriptionId) {
            return {
                changes: false,
                stables: ['groupId', 'subscriptionId'],
            }
        }
        throw new Error(
            `Error: ManagementGroupSubscription resource does not support changes. Please delete the resource and create a new one`,
        )
    }

    async delete(id: pulumi.ID, props: ManagementGroupSubscriptionInputs): Promise<void> {
        const client = getClient(this.token)
        await client.managementGroupSubscriptions.deleteMethod(props.groupId, props.subscriptionId)
        return
    }
}

export class ManagementGroupSubscription extends pulumi.dynamic.Resource {
    public readonly groupId!: pulumi.Output<string>
    public readonly subscriptionId!: pulumi.Output<number>

    constructor(
        name: string,
        args: ManagementGroupSubscriptionArgs,
        token: string,
        opts?: pulumi.CustomResourceOptions,
    ) {
        super(new ManagementGroupSubscriptionProvider(token), name, args, opts)
    }
}

@mikhailshilkov, another (a bit complex) example of GetClientToken usage // #601

boillodmanuel commented 3 years ago

@mikhailshilkov, I just figured out that my previous code doesn't work as the pulumi token is saved in pulumi state. So, when the resource has to be deleted, it uses this token which have expired since. The code is not working!

When I was calling the getClientToken() from inside the ManagementGroupSubscriptionProvider, I got the pulumi.errors.RunError: Program run without the Pulumi engine available; re-run using the 'pulumi' CLI error as mentioned in this comment

Do you have any solution to make it working? I miss something. Thank you

boillodmanuel commented 3 years ago

I think I should look at this example: https://github.com/pulumi/examples/blob/master/classic-azure-ts-dynamicresource/cdnCustomDomain.ts

boillodmanuel commented 3 years ago

ManagementGroupSubscription dynamic resource

Here is a fixed version of the ManagementGroupSubscription dynamic resource, as a workaround of current issue with management.v20200501.ManagementGroupSubscription (described above)

Fixes:

Source files

// usage of management_v20200501.ManagementGroupSubscription from next-gen provider is blocked by https://github.com/pulumi/pulumi-azure-native/issues/553 new ManagementGroupSubscription( mg-sub-${subscriptionName}, { groupId: , subscriptionId: , }, )


- `ManagementGroupSubscription.ts`:

```ts
import * as pulumi from '@pulumi/pulumi'
import * as azure from '@pulumi/azure'
import { ManagementGroupsAPI } from '@azure/arm-managementgroups'
import { AzureCliCredential, ClientSecretCredential, TokenCredential } from '@azure/identity'
import { RestError, WebResource } from '@azure/ms-rest-js1'
import { AzureIdentityCredentialAdapter } from './AzureIdentityCredentialAdapter'

// fix for https://github.com/pulumi/pulumi-azure-native/issues/553
function getClient(): ManagementGroupsAPI {
    const clientId = process.env['ARM_CLIENT_ID'] || azure.config.clientId
    const clientSecret = process.env['ARM_CLIENT_SECRET'] || azure.config.clientSecret
    const tenantId = process.env['ARM_TENANT_ID'] || azure.config.tenantId

    let tokenCredential: TokenCredential

    // If at least one of them is empty, try looking at the env vars.
    if (!clientId || !clientSecret || !tenantId) {
        pulumi.log.debug('ManagementGroupsAPI: Login with Az CLI.', undefined, undefined, true)
        tokenCredential = new AzureCliCredential()
    } else {
        pulumi.log.debug('ManagementGroupsAPI: Login with clientId and clientSecret.', undefined, undefined, true)
        tokenCredential = new ClientSecretCredential(tenantId, clientId, clientSecret)
    }

    const serviceClientCredentials = new AzureIdentityCredentialAdapter(tokenCredential)
    return new ManagementGroupsAPI(serviceClientCredentials)
}

async function getSubscriptionUnderManagementGroupUsingWebResource(
    client: ManagementGroupsAPI,
    groupId: string,
    subscriptionId: string,
): Promise<void> {
    const url =
        'https://management.azure.com' +
        '/providers/Microsoft.Management/managementGroups/' +
        groupId +
        '/subscriptions/' +
        subscriptionId +
        '?api-version=2020-05-01'

    const webResource = new WebResource(
        url, // url
        'GET', // method
        undefined, // body
        undefined, // query
        undefined, // headers
        undefined, // streamResponseBody
        true, // withCredentials
        undefined, // abortSignal
        30000, // timeout in ms
    )
    const response = await client.sendRequest(webResource)
    if (response.status !== 200) {
        throw new RestError(
            `subscriptions '${subscriptionId}' not found under management group '${groupId}. Cause: ${response.bodyAsText}'`,
            undefined,
            response.status,
            webResource,
            response,
        )
    }
    return
}

/**
 * The set of arguments for constructing a ManagementGroupSubscription resource.
 */
export interface ManagementGroupSubscriptionInputs {
    /**
     * Management Group ID.
     */
    groupId: string
    /**
     * Subscription ID.
     */
    subscriptionId: string
}
/**
 * The set of arguments for constructing a ManagementGroupSubscription resource.
 */
export interface ManagementGroupSubscriptionArgs {
    /**
     * Management Group ID.
     */
    readonly groupId: pulumi.Input<string>
    /**
     * Subscription ID.
     */
    readonly subscriptionId: pulumi.Input<string>
}

class ManagementGroupSubscriptionProvider implements pulumi.dynamic.ResourceProvider {
    /**
     * Creates an ID that does not change when management group changes (uses only subscriptionId)
     */
    buildId(_groupId: string, subscriptionId: string): string {
        return `/providers/Microsoft.Management/managementGroups/subscriptions/${subscriptionId}`
    }

    async check(
        _olds: ManagementGroupSubscriptionInputs,
        news: ManagementGroupSubscriptionInputs,
    ): Promise<pulumi.dynamic.CheckResult> {
        return { inputs: news }
    }

    async read(_id: pulumi.ID, props: ManagementGroupSubscriptionInputs): Promise<pulumi.dynamic.ReadResult> {
        const client = getClient()
        await getSubscriptionUnderManagementGroupUsingWebResource(client, props.groupId, props.subscriptionId)
        return {
            id: this.buildId(props.groupId, props.subscriptionId),
            props,
        }
    }

    async create(inputs: ManagementGroupSubscriptionInputs): Promise<pulumi.dynamic.CreateResult> {
        const client = getClient()
        await client.managementGroupSubscriptions.create(inputs.groupId, inputs.subscriptionId)
        const id = this.buildId(inputs.groupId, inputs.subscriptionId)
        return { id, outs: { groupId: inputs.groupId, subscriptionId: inputs.subscriptionId } }
    }

    async diff(
        _id: pulumi.ID,
        olds: ManagementGroupSubscriptionInputs,
        news: ManagementGroupSubscriptionInputs,
    ): Promise<pulumi.dynamic.DiffResult> {
        if (olds.subscriptionId === news.subscriptionId) {
            return {
                changes: olds.groupId !== news.groupId,
                stables: ['subscriptionId'],
            }
        }
        throw new Error(
            `Error: ManagementGroupSubscription resource does not support change of 'subscriptionId' input. Please delete the resource and create a new one`,
        )
    }

    async update(
        _id: pulumi.ID,
        _olds: ManagementGroupSubscriptionInputs,
        news: ManagementGroupSubscriptionInputs,
    ): Promise<pulumi.dynamic.UpdateResult> {
        const client = getClient()
        await client.managementGroupSubscriptions.create(news.groupId, news.subscriptionId)
        return { outs: { groupId: news.groupId, subscriptionId: news.subscriptionId } }
    }

    async delete(_id: pulumi.ID, props: ManagementGroupSubscriptionInputs): Promise<void> {
        const client = getClient()
        await client.managementGroupSubscriptions.deleteMethod(props.groupId, props.subscriptionId)
        return
    }
}

export class ManagementGroupSubscription extends pulumi.dynamic.Resource {
    public readonly groupId!: pulumi.Output<string>
    public readonly subscriptionId!: pulumi.Output<number>

    constructor(name: string, args: ManagementGroupSubscriptionArgs, opts?: pulumi.CustomResourceOptions) {
        super(new ManagementGroupSubscriptionProvider(), name, args, opts)
    }
}
// File duplicated from '@azure/arm-managementgroups/node_modules/@azure/ms-rest-js/es/lib/credentials/azureIdentityTokenCredentialAdapter'
// Because class is not exported at module level

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
import { TokenCredential } from '@azure/identity'
// NPM alias to be able to instal version 1 in addition to version 2.
// Command: npm install @azure/ms-rest-js1@npm:@azure/ms-rest-js@1
import { ServiceClientCredentials, WebResource, Constants } from '@azure/ms-rest-js1'

// import { ServiceClientCredentials } from "./serviceClientCredentials";
// import { Constants as MSRestConstants } from "../util/constants";
// import { WebResource } from "../webResource";

// import { TokenCredential } from "@azure/core-auth";
// import { TokenResponse } from "./tokenResponse";

const DEFAULT_AUTHORIZATION_SCHEME = 'Bearer'

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

/**
 * TokenResponse is defined in `@azure/ms-rest-nodeauth` and is copied here to not
 * add an unnecessary dependency.
 */
interface TokenResponse {
    readonly tokenType: string
    readonly accessToken: string
    readonly [x: string]: unknown
}

/**
 * This class provides a simple extension to use {@link TokenCredential} from `@azure/identity` library to
 * use with legacy Azure SDKs that accept {@link ServiceClientCredentials} family of credentials for authentication.
 */
export class AzureIdentityCredentialAdapter implements ServiceClientCredentials {
    private azureTokenCredential: TokenCredential
    private scopes: string | string[]

    constructor(
        azureTokenCredential: TokenCredential,
        scopes: string | string[] = 'https://management.azure.com/.default',
    ) {
        this.azureTokenCredential = azureTokenCredential
        this.scopes = scopes
    }

    public async getToken(): Promise<TokenResponse> {
        const accessToken = await this.azureTokenCredential.getToken(this.scopes)
        if (accessToken !== null) {
            const result: TokenResponse = {
                accessToken: accessToken.token,
                tokenType: DEFAULT_AUTHORIZATION_SCHEME,
                expiresOn: accessToken.expiresOnTimestamp,
            }
            return result
        } else {
            throw new Error('Could find token for scope')
        }
    }

    public async signRequest(webResource: WebResource): Promise<WebResource> {
        const tokenResponse = await this.getToken()
        webResource.headers.set(
            Constants.HeaderConstants.AUTHORIZATION,
            `${tokenResponse.tokenType} ${tokenResponse.accessToken}`,
        )
        return webResource
    }
}
bkrugerp99 commented 2 years ago

Any traction on this? Apparently still a bug in 2022. :/

Apparently, TF provider accepted a bug for this: https://github.com/hashicorp/terraform-provider-azurerm/pull/6668/files

mobato217 commented 3 months ago

The issue still occurs. Is there any chance to progress on this one?