-
Notifications
You must be signed in to change notification settings - Fork 16
howto develop plugin 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:
- Set up managed identity for Dataverse plug-ins
- Power Platform managed identity overview
- managedidentity table/entity reference
- Power Platform CLI
pac 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
| 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. |
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.
In Visual Studio, add item template number 12, DevKit files, and choose the managed identity option.

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. |
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.
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.txtThe script expects Azure CLI to be logged in to the correct tenant and subscription.
az account showFor 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"
)]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 tableExample 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.
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 $secretValueKey Vault DNS names are lowercase. The vault URL for this example is:
https://devallinoneserver2.vault.azure.net/
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 listVerify from your developer machine:
az keyvault secret show `
--vault-name devallinoneserver2 `
--name DevAllInOneServer2 `
--query "{id:id,name:name,value:value}" `
--output jsonExpected result:
{
"id": "https://devallinoneserver2.vault.azure.net/secrets/DevAllInOneServer2/<version>",
"name": "DevAllInOneServer2",
"value": "HELLO MANAGED IDENTITY FROM Dev.AllInOne.Server2"
}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 DebugDeploy with the DevKit server command or generated deployment script.
devkit server --auth FromPac --pacprofile DEV --json DynamicsCrm.DevKit.Cli.json --profile DEBUGIn 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.

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

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.
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:
- Acquire a token for
https://vault.azure.net/.default. - Call Key Vault secret REST API.
- Trace the secret value.
- Throw a plugin error with the same value for test visibility.
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

That confirms the plugin acquired a managed identity token, used it against Azure Key Vault, and read the expected secret.
| 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. |
With DevKit, managed identity plugin development becomes a repeatable flow:
- Create the plugin project.
- Add DevKit managed identity files from item template 12.
- Fill in
Plugin-Managed-Identity-Config.json. - Run
Plugin-Managed-Identity.ps1. - Create the Azure resource the plugin needs.
- Grant access to the generated Service Principal.
- Use
IManagedIdentityServicein plugin code. - Deploy with
devkit server. - 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.