Skip to content

howto develop plugin managed identity

PhuocLe edited this page Jun 26, 2026 · 2 revisions

How To Develop a Dataverse Plugin With Managed Identity

Dataverse plugin managed identity lets a sandbox plugin ask Dataverse for an access token without storing a client secret in plugin code or plugin step configuration. DynamicsCrm.DevKit makes the setup repeatable by generating the Azure application metadata, certificate files, Power Platform federated credentials, and the plugin assembly attribute that DevKit uses during deployment.

This guide was prepared after checking the current Microsoft documentation for Dataverse plugin managed identity:

The example below uses the test project:

D:\github\Dynamics-Crm-DevKit\v5\DynamicsCrm.DevKit.Tests\TestAllInOne\Dev.AllInOne.Server2\Dev.AllInOne.Server2.csproj

What DevKit Automates

Part What happens
Visual Studio project template Creates the plugin project structure.
Item template 12, DevKit files Adds Plugin-Managed-Identity.ps1, Plugin-Managed-Identity-Config.json, and notes.
Setup script Creates or reuses the Azure App Registration, Service Principal, code-signing certificate, and one federated credential per Dataverse environment ID.
AssemblyInfo2.cs Stores the managed identity metadata used by DevKit deployment.
devkit server Detects the managed identity assembly attribute, deploys the plugin assembly and steps, and creates/updates Dataverse managed identity records.

Step 1: Create a Plugin Project

Create a plugin project from the DynamicsCrm.DevKit plugin project template. In this guide the plugin assembly is:

Dev.AllInOne.Server2

The project should reference the shared project that contains DevKit helpers such as DebugContext and the DevKit plugin registration attributes.

Step 2: Add Managed Identity Files

In Visual Studio, add item template number 12, DevKit files, and choose the managed identity option.

Managed identity item template

The template adds the setup files to the plugin project:

File Purpose
Plugin-Managed-Identity-Config.json Input values for the setup script.
Plugin-Managed-Identity.ps1 Creates Azure identity resources and AssemblyInfo2.cs.
Plugin-Managed-Identity.md Local notes generated by the template.

Step 3: Configure Plugin-Managed-Identity-Config.json

Open Plugin-Managed-Identity-Config.json and fill in the plugin identity values. This is the exact configuration used by Dev.AllInOne.Server2:

{
  "CertificateFileName": "Dev.AllInOne.Server2",
  "CertificatePassword": "P@ssw0rd-Dev.AllInOne.Server2",
  "CertificateValidityYears": 10,
  "AppName": "Dev.AllInOne.Server2",
  "EnvironmentIds": [
    "cbba8ce9-ea7c-e440-b083-0b9517496e17",
    "2f985c04-9487-e70c-aa57-dcd6d08f0886"
  ]
}

The EnvironmentIds list is important. Power Platform federated credentials are environment-specific because the subject contains the environment ID. If the same managed identity plugin must run in DEV, TEST, UAT, and PROD, add every target environment ID before running the script. This prevents the common problem where a plugin works in one environment but token acquisition fails after moving the solution to another environment.

This also helps avoid unmanaged customizations in downstream environments. Do not manually create or edit the managed identity record in a target environment after importing the solution. Keep the environment IDs in source-controlled setup config, run the setup script, deploy/import through DevKit or solution ALM, and let DevKit create or update the Dataverse managed identity component consistently.

Step 4: Run the Setup Script

Run the generated script from the plugin project folder:

cd D:\github\Dynamics-Crm-DevKit\v5\DynamicsCrm.DevKit.Tests\TestAllInOne\Dev.AllInOne.Server2
.\Plugin-Managed-Identity.ps1 *> log.txt

The script expects Azure CLI to be logged in to the correct tenant and subscription.

az account show

For this example the setup log is:

========================================================================================================
                    DATAVERSE PLUGIN MANAGED IDENTITY SETUP
========================================================================================================
  This script creates:
    - App Registration + Service Principal
    - Code Signing Certificate (.pfx, .cer)
    - Power Platform Federated Credential
    - AssemblyInfo2.cs for your plugin project
========================================================================================================

  Tenant ID: 49528483-b79b-4b88-b86e-7d882ba68911

[1] AZURE AD APP REGISTRATION + SERVICE PRINCIPAL

  Processing: Dev.AllInOne.Server2
  @ Checking App Registration...
  + SUCCESS: App Registration created.
    - App Name: Dev.AllInOne.Server2
    - App (Client) ID: e923278c-0071-49ae-b850-83604c672650
  @ Checking Service Principal...
  + SUCCESS: Service Principal created.
    - Service Principal Object ID: f57b47bb-a417-4fda-b410-68e27d284662

[2] CODE SIGNING CERTIFICATE
  @ Creating new self-signed code signing certificate...
  + SUCCESS: Certificate created in Windows Certificate Store.
    - Subject: CN=Dev.AllInOne.Server2
    - Thumbprint: 7DE973C2411C56A2567237C6C9C096EBE4EC3CC0
    - Valid Until: 2036-06-26
  @ Exporting private key (.pfx)...
  + SUCCESS: Exported D:\github\Dynamics-Crm-DevKit\v5\DynamicsCrm.DevKit.Tests\TestAllInOne\Dev.AllInOne.Server2\Dev.AllInOne.Server2.pfx
  @ Exporting public key (.cer)...
  + SUCCESS: Exported D:\github\Dynamics-Crm-DevKit\v5\DynamicsCrm.DevKit.Tests\TestAllInOne\Dev.AllInOne.Server2\Dev.AllInOne.Server2.cer
  @ Removing certificate from Windows Certificate Store...
  + SUCCESS: Certificate removed from store (kept in .pfx file).

[3] POWER PLATFORM FEDERATED CREDENTIALS

  Processing Environment: cbba8ce9-ea7c-e440-b083-0b9517496e17
    - Credential Name: PowerPlatform-cbba8ce9
  @ Checking federated credential: PowerPlatform-cbba8ce9
  @ Creating federated credential...
  + SUCCESS: Federated credential created.
    - Issuer: https://login.microsoftonline.com/49528483-b79b-4b88-b86e-7d882ba68911/v2.0
    - Subject: /eid1/c/pub/t/g4RSSZu3iEu4bn2IK6aJEQ/a/qzXoWDkuqUa3l6zM5mM0Rw/n/plugin/e/cbba8ce9-ea7c-e440-b083-0b9517496e17/h/43463414fd747f18d041ac6f9e4b14853515582eabb01e3c0fa3c9b34afdbe6b

  Processing Environment: 2f985c04-9487-e70c-aa57-dcd6d08f0886
    - Credential Name: PowerPlatform-2f985c04
  @ Checking federated credential: PowerPlatform-2f985c04
  @ Creating federated credential...
  + SUCCESS: Federated credential created.
    - Issuer: https://login.microsoftonline.com/49528483-b79b-4b88-b86e-7d882ba68911/v2.0
    - Subject: /eid1/c/pub/t/g4RSSZu3iEu4bn2IK6aJEQ/a/qzXoWDkuqUa3l6zM5mM0Rw/n/plugin/e/2f985c04-9487-e70c-aa57-dcd6d08f0886/h/43463414fd747f18d041ac6f9e4b14853515582eabb01e3c0fa3c9b34afdbe6b

[4] GENERATING ASSEMBLYINFO2.CS
  @ Creating AssemblyInfo2.cs
  + SUCCESS: Saved AssemblyInfo2.cs
    - Path: D:\github\Dynamics-Crm-DevKit\v5\DynamicsCrm.DevKit.Tests\TestAllInOne\Dev.AllInOne.Server2\AssemblyInfo2.cs

[5] SAVING CONFIGURATION
  @ Configuration file update specific skipped.
    - AppId and TenantId are saved in AssemblyInfo2.cs

========================================================================================================
                              SETUP COMPLETED SUCCESSFULLY!
========================================================================================================

  Next Steps:
  1. Add AssemblyInfo2.cs to your plugin project
  2. Add Dev.AllInOne.Server2.pfx to your project (Build Action: None)
  3. Grant your App Registration access to Azure resources:
     - Key Vault: Add Access Policy for App ID
     - Storage: Add RBAC role assignment
     - SQL: Add as external user
  4. Build and deploy your plugin using 'devkit server'

========================================================================================================

After this step, the project has AssemblyInfo2.cs:

using Dev.AllInOne.Shared;

[assembly: DynamicsCrmDevKitPluginManagedIdentityAssembly(
    TenantId = "49528483-b79b-4b88-b86e-7d882ba68911",
    ApplicationIds = "e923278c-0071-49ae-b850-83604c672650",
    CertificateFileName = "Dev.AllInOne.Server2.pfx",
    CertificatePassword = "P@ssw0rd-Dev.AllInOne.Server2"
)]

Step 5: Understand Permissions Before and After the Script

Before the script runs, you do not yet have the App Registration client ID or Service Principal object ID, so you cannot grant that identity access to Azure resources.

After the script runs, use the generated values:

Value Example
App Registration client ID e923278c-0071-49ae-b850-83604c672650

The App Registration client ID is written to AssemblyInfo2.cs as ApplicationIds. If you need the Service Principal object ID later, query it from Azure CLI:

$appId = "e923278c-0071-49ae-b850-83604c672650"
az ad sp show `
  --id $appId `
  --query "{appId:appId, objectId:id, displayName:displayName}" `
  --output table

Example output:

AppId                                 ObjectId                              DisplayName
------------------------------------  ------------------------------------  ---------------------
e923278c-0071-49ae-b850-83604c672650  f57b47bb-a417-4fda-b410-68e27d284662  Dev.AllInOne.Server2

Use the Service Principal objectId when granting Azure Key Vault access policies. Use the app/client ID when a service asks for the application ID.

The developer running the setup needs permission to create or update Azure AD applications, service principals, and federated credentials. The developer granting Key Vault access also needs sufficient Azure permission on the vault, such as Owner, Contributor with Key Vault policy rights, or Key Vault Administrator depending on the vault permission model.

Step 6: Create the Azure Resource the Plugin Will Read

The DevKit setup script intentionally creates only identity resources. It does not create a Key Vault, Storage Account, SQL user, or other target resource because each plugin can need a different Azure service.

For this Key Vault test, create a dedicated vault and secret:

$resourceGroup = "DevAllInOneServer2"
$location = "southeastasia"
$vaultName = "devallinoneserver2"
$secretName = "DevAllInOneServer2"
$secretValue = "HELLO MANAGED IDENTITY FROM Dev.AllInOne.Server2"

az group create `
  --name $resourceGroup `
  --location $location

az keyvault create `
  --name $vaultName `
  --resource-group $resourceGroup `
  --location $location `
  --enable-rbac-authorization false

az keyvault secret set `
  --vault-name $vaultName `
  --name $secretName `
  --value $secretValue

Key Vault DNS names are lowercase. The vault URL for this example is:

https://devallinoneserver2.vault.azure.net/

Step 7: Grant the Plugin Identity Access to Key Vault

Grant the plugin Service Principal permission to read secrets:

$appId = "e923278c-0071-49ae-b850-83604c672650"
$spObjectId = az ad sp show --id $appId --query id --output tsv

az keyvault set-policy `
  --name devallinoneserver2 `
  --resource-group DevAllInOneServer2 `
  --object-id $spObjectId `
  --secret-permissions get list

Verify from your developer machine:

az keyvault secret show `
  --vault-name devallinoneserver2 `
  --name DevAllInOneServer2 `
  --query "{id:id,name:name,value:value}" `
  --output json

Expected result:

{
  "id": "https://devallinoneserver2.vault.azure.net/secrets/DevAllInOneServer2/<version>",
  "name": "DevAllInOneServer2",
  "value": "HELLO MANAGED IDENTITY FROM Dev.AllInOne.Server2"
}

Step 8: Update the Plugin Code

The plugin asks Dataverse for a managed identity token using IManagedIdentityService, then calls Key Vault REST API with that bearer token.

For a test plugin it is acceptable to throw an InvalidPluginExecutionException with the secret value, because that makes the result visible in Plugin Trace Log and the async system job. Do not throw secrets in production code.

Copy this code into Plugins\PostContactUpdateAsynchronous.cs:

using Dev.AllInOne.Shared;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Extensions;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;

namespace Dev.AllInOne.Server2.Plugins
{
    [CrmPluginRegistration("Update", "contact", StageEnum.PostOperation, ExecutionModeEnum.Asynchronous, "firstname", "Dev.AllInOne.Server2.Plugins.PostContactUpdateAsynchronous", 1, IsolationModeEnum.Sandbox, PluginType = PluginType.Plugin, DeleteAsyncOperation = true, Image1Name = "PreImage", Image1Alias = "PreImage", Image1Type = ImageTypeEnum.PreImage, Image1Attributes = "*", Image2Name = "PostImage", Image2Alias = "PostImage", Image2Type = ImageTypeEnum.PostImage, Image2Attributes = "*")]
    public class PostContactUpdateAsynchronous : IPlugin
    {
        /*
        InputParameters:
            Target                             Microsoft.Xrm.Sdk.Entity - require
            SuppressDuplicateDetection         System.Boolean
            CalculateMatchCodeSynchronously    System.Boolean
            SolutionUniqueName                 System.String
            MaintainLegacyAppServerBehavior    System.Boolean
            ConcurrencyBehavior                Microsoft.Xrm.Sdk.ConcurrencyBehavior
            ReturnRowVersion                   System.Boolean
        OutputParameters:
        */

        //private readonly string unSecureConfiguration = null;
        //private readonly string secureConfiguration = null;
        //public PostContactUpdateAsynchronous(string unSecureConfiguration, string secureConfiguration)
        //{
        //    this.unSecureConfiguration = unSecureConfiguration;
        //    this.secureConfiguration = secureConfiguration;
        //}

        public void Execute(IServiceProvider serviceProvider)
        {
            var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
            var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
            if (!int.Equals(context.Stage, (int)StageEnum.PostOperation)) throw new InvalidPluginExecutionException("Stage does not equals PostOperation");
            if (!string.Equals(context.MessageName, "Update", StringComparison.OrdinalIgnoreCase)) throw new InvalidPluginExecutionException("MessageName does not equals Update");
            if (!string.Equals(context.PrimaryEntityName, "contact", StringComparison.OrdinalIgnoreCase)) throw new InvalidPluginExecutionException("PrimaryEntityName does not equals contact");
            if (!int.Equals(context.Mode, (int)ExecutionModeEnum.Asynchronous)) throw new InvalidPluginExecutionException("Execution does not equals Asynchronous");
            var serviceFactory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
            var serviceAdmin = serviceFactory.CreateOrganizationService(null);
            var service = serviceFactory.CreateOrganizationService(context.UserId);

            tracing?.DebugContext(context);

            var identityService = (IManagedIdentityService)serviceProvider.GetService(typeof(IManagedIdentityService));
            var scopes = new List<string> { "https://vault.azure.net/.default" };
            var accessToken = identityService.AcquireToken(scopes);


            ExecutePlugin(context, serviceFactory, serviceAdmin, service, tracing, accessToken);
        }

        private void ExecutePlugin(IPluginExecutionContext context, IOrganizationServiceFactory serviceFactory, IOrganizationService serviceAdmin, IOrganizationService service, ITracingService tracing, string accessToken)
        {
            var targetEntity = context.InputParameterOrDefault<Entity>("Target");
            context.PreEntityImages.TryGetValue("PreImage", out Entity preEntity);
            context.PostEntityImages.TryGetValue("PostImage", out Entity postEntity);
            //YOUR PLUGIN-CODE GO HERE
            var secretValue = GetKeyVaultSecret(
                tracing,
                accessToken,
                "https://devallinoneserver2.vault.azure.net/",
                "DevAllInOneServer2");

            tracing?.Trace($"Managed Identity Key Vault secret value: {secretValue}");
            throw new InvalidPluginExecutionException($"Managed Identity Key Vault secret value: {secretValue}");
        }

        private static string GetKeyVaultSecret(ITracingService tracing, string accessToken, string keyVaultUrl, string secretName)
        {
            if (string.IsNullOrWhiteSpace(accessToken))
                throw new InvalidPluginExecutionException("Managed Identity access token is empty.");

            if (string.IsNullOrWhiteSpace(keyVaultUrl))
                throw new InvalidPluginExecutionException("Key Vault URL is empty.");

            if (string.IsNullOrWhiteSpace(secretName))
                throw new InvalidPluginExecutionException("Key Vault secret name is empty.");

            var normalizedKeyVaultUrl = keyVaultUrl.EndsWith("/", StringComparison.Ordinal) ? keyVaultUrl : keyVaultUrl + "/";
            var secretUrl = $"{normalizedKeyVaultUrl}secrets/{Uri.EscapeDataString(secretName)}?api-version=7.4";

            tracing?.Trace($"Reading Key Vault secret '{secretName}' from '{normalizedKeyVaultUrl}'.");

            using (var httpClient = new HttpClient())
            {
                httpClient.DefaultRequestHeaders.ConnectionClose = true;
                httpClient.Timeout = TimeSpan.FromMinutes(1);
                httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}");

                var response = httpClient.GetAsync(secretUrl).GetAwaiter().GetResult();
                var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();

                if (!response.IsSuccessStatusCode)
                {
                    tracing?.Trace($"Azure Key Vault secret read failed: {(int)response.StatusCode} {response.ReasonPhrase}. Body: {responseBody}");
                    throw new InvalidPluginExecutionException($"Azure Key Vault error: {(int)response.StatusCode} {response.ReasonPhrase}");
                }

                var secret = Deserialize<KeyVaultSecretResponse>(responseBody);
                if (string.IsNullOrEmpty(secret?.Value))
                    throw new InvalidPluginExecutionException("Azure Key Vault returned an empty secret value.");

                tracing?.Trace($"Azure Key Vault secret '{secretName}' read successfully.");
                return secret.Value;
            }
        }

        private static T Deserialize<T>(string json)
        {
            var serializer = new DataContractJsonSerializer(typeof(T));
            using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(json)))
            {
                return (T)serializer.ReadObject(stream);
            }
        }

        [DataContract]
        private sealed class KeyVaultSecretResponse
        {
            [DataMember(Name = "value")]
            public string Value { get; set; }
        }
    }
}

Because this code uses HttpClient and DataContractJsonSerializer, make sure the plugin project has framework references:

<ItemGroup>
  <Reference Include="System.Net.Http" />
  <Reference Include="System.Runtime.Serialization" />
</ItemGroup>

Build only the plugin project:

dotnet build D:\github\Dynamics-Crm-DevKit\v5\DynamicsCrm.DevKit.Tests\TestAllInOne\Dev.AllInOne.Server2\Dev.AllInOne.Server2.csproj --configuration Debug

Step 9: Deploy the Plugin With DevKit

Deploy with the DevKit server command or generated deployment script.

devkit server --auth FromPac --pacprofile DEV --json DynamicsCrm.DevKit.Cli.json --profile DEBUG

In the sample DynamicsCrm.DevKit.Cli.json, the server deployment profile declares the solution:

{
  "servers": [
    {
      "profile": "DEBUG",
      "solution": "all_in_one",
      "folder": "bin\\Debug",
      "includefiles": [
        "Dev.AllInOne.Server*.dll",
        "Dev.AllInOne.Package*.nupkg"
      ],
      "excludefiles": [
        "Dev.AllInOne.*.Test.dll"
      ]
    }
  ]
}

Because solution is configured as all_in_one, DevKit automatically adds created or updated plugin components to that solution during deployment. This includes plugin assemblies, plugin types, plugin steps, images, and DevKit-managed components such as the Dataverse managed identity record.

Solution-aware deployment

When the setup config contains multiple environment IDs, the Azure App Registration receives a Power Platform federated credential for each environment:

Multiple environment federated credentials

This is the expected setup for ALM across multiple environments. Each target Dataverse environment can exchange its plugin assertion for an Azure AD token because its environment ID is already represented in the app's federated credentials.

Step 10: Trigger the Plugin

The sample plugin registration runs on:

Field Value
Message Update
Table contact
Stage PostOperation
Mode Asynchronous
Filtering attribute firstname

Update a Contact row and change firstname. The plugin will:

  1. Acquire a token for https://vault.azure.net/.default.
  2. Call Key Vault secret REST API.
  3. Trace the secret value.
  4. Throw a plugin error with the same value for test visibility.

Step 11: Confirm the Result in Plugin Trace Log

Use the DevKit MCP server to read the Plugin Trace Log record. Given this Dataverse URL:

https://dynamics-crm-devkit-v4.crm.dynamics.com/main.aspx?appid=5fbd4da3-13a5-f011-bbd3-000d3a311238&pagetype=entityrecord&etn=plugintracelog&id=cfacdac8-1bb1-485b-bdcd-5c0b265d6ee7

MCP parses:

{
  "entityName": "plugintracelog",
  "recordId": "cfacdac8-1bb1-485b-bdcd-5c0b265d6ee7",
  "source": "main.aspx (etn+id)"
}

Then get_plugin_trace_logs confirms:

Field Value
Type Dev.AllInOne.Server2.Plugins.PostContactUpdateAsynchronous
Message Update
Primary entity contact
Mode Asynchronous
Depth 1
Duration 2778ms
Correlation ID c3fe9067-a53f-454c-9cda-ea8d45a268d3
Created On 2026-06-26 07:48:01

The trace message contains:

Reading Key Vault secret 'DevAllInOneServer2' from 'https://devallinoneserver2.vault.azure.net/'.
Azure Key Vault secret 'DevAllInOneServer2' read successfully.
Managed Identity Key Vault secret value: HELLO MANAGED IDENTITY FROM Dev.AllInOne.Server2

The exception details also contain:

Managed Identity Key Vault secret value: HELLO MANAGED IDENTITY FROM Dev.AllInOne.Server2

Plugin trace log result

That confirms the plugin acquired a managed identity token, used it against Azure Key Vault, and read the expected secret.

Troubleshooting

Problem Fix
IManagedIdentityService.AcquireToken fails Confirm the plugin assembly has DynamicsCrmDevKitPluginManagedIdentityAssembly, DevKit deployed the managed identity record, and the App Registration has a federated credential for the current environment ID.
Key Vault returns 403 Forbidden Grant the Service Principal object ID get permission on secrets, or add the equivalent RBAC role if the vault uses Azure RBAC permission model.
Key Vault returns 401 Unauthorized Confirm the scope is https://vault.azure.net/.default and the plugin receives a non-empty token.
Plugin works in DEV but not TEST/UAT/PROD Add every target environment ID to EnvironmentIds, rerun Plugin-Managed-Identity.ps1, then redeploy/import through ALM.
az keyvault set-policy cannot find the object ID Get the Service Principal object ID with az ad sp show --id <appId> --query id --output tsv, then use that value with --object-id.
Components appear outside the intended solution Confirm the solution key is set in DynamicsCrm.DevKit.Cli.json for the deployment profile.
Managed identity changes create unmanaged layers in target environments Avoid manual target-environment edits. Generate identity metadata from source configuration and deploy/import through DevKit and solution ALM.

Summary

With DevKit, managed identity plugin development becomes a repeatable flow:

  1. Create the plugin project.
  2. Add DevKit managed identity files from item template 12.
  3. Fill in Plugin-Managed-Identity-Config.json.
  4. Run Plugin-Managed-Identity.ps1.
  5. Create the Azure resource the plugin needs.
  6. Grant access to the generated Service Principal.
  7. Use IManagedIdentityService in plugin code.
  8. Deploy with devkit server.
  9. Trigger the plugin and verify Plugin Trace Log with MCP.

The key is to keep identity setup, environment IDs, solution deployment, and Azure resource permissions explicit and repeatable.

Clone this wiki locally