Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ dotnet tool install --global --add-source ./XrmSync/nupkg XrmSync
# Plugin sync
dotnet run --project XrmSync -- plugins --assembly "path/to/plugin.dll" --solution-name "MySolution"

# Plugin sync, also ensuring a managed identity is bound to the assembly
dotnet run --project XrmSync -- plugins --assembly "path/to/plugin.dll" --solution-name "MySolution" --client-id "<app-id>" --tenant-id "<tenant-id>"

# Webresource sync
dotnet run --project XrmSync -- webresources --folder "path/to/webresources" --solution-name "MySolution"

Expand Down Expand Up @@ -78,6 +81,15 @@ The solution is organized into distinct layers with clear separation of concerns
4. Calculate operations (create/update/delete) based on presence and content differences
5. Execute operations via `IWebresourceWriter`

**Managed Identity Handling**:
- A managed identity can be bound to a plugin assembly either as part of plugin sync or via the standalone `identity` command
- Plugin sync ensures the identity when `ManagedIdentityClientId`/`ManagedIdentityTenantId` are set on the `PluginSyncItem` (or `--client-id`/`--tenant-id` on the `plugins` command). The reconcile runs after the assembly upsert and regardless of whether the assembly binary changed, since the identity configuration can drift independently
- Reconcile semantics (shared `IManagedIdentityReconciler`):
- **Ensure**: creates and links a new identity when none is bound, or **updates the existing record in place** (application id, tenant id, name) when it has drifted — it does not delete identities
- **Remove** (standalone `identity --operation Remove`): deletes the linked identity; a missing assembly logs a warning instead of failing
- The standalone `identity` command remains available for explicit Ensure/Remove operations
- The identity is named `"{SolutionName} Managed Identity"` and is linked via the `PluginAssembly.ManagedIdentityId` lookup

**Configuration System**:
- Profile-based configuration under `XrmSync` section in `appsettings.json`
- Global settings (DryRun, LogLevel, CiMode) apply to all profiles
Expand All @@ -100,7 +112,9 @@ The solution is organized into distinct layers with clear separation of concerns
"Sync": [
{
"Type": "Plugin",
"AssemblyPath": "../path/to/plugin.dll"
"AssemblyPath": "../path/to/plugin.dll",
"ManagedIdentityClientId": "00000000-0000-0000-0000-000000000000",
"ManagedIdentityTenantId": "00000000-0000-0000-0000-000000000000"
},
{
"Type": "Webresource",
Expand Down
2 changes: 2 additions & 0 deletions Dataverse/DataverseReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ internal sealed class DataverseReader(IOrganizationServiceProvider serviceProvid

public IQueryable<PluginAssembly> PluginAssemblies => DataverseContext.PluginAssemblySet;

public IQueryable<ManagedIdentity> ManagedIdentities => DataverseContext.ManagedIdentitySet;

public IQueryable<CustomAPI> CustomApis => DataverseContext.CustomAPISet;

public IQueryable<CustomAPIRequestParameter> CustomApiRequestParameters => DataverseContext.CustomAPIRequestParameterSet;
Expand Down
1 change: 1 addition & 0 deletions Dataverse/Interfaces/IDataverseReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public interface IDataverseReader
IQueryable<SolutionComponent> SolutionComponents { get; }
IQueryable<Publisher> Publishers { get; }
IQueryable<PluginAssembly> PluginAssemblies { get; }
IQueryable<ManagedIdentity> ManagedIdentities { get; }
IQueryable<CustomAPI> CustomApis { get; }
IQueryable<CustomAPIRequestParameter> CustomApiRequestParameters { get; }
IQueryable<CustomAPIResponseProperty> CustomApiResponseProperties { get; }
Expand Down
6 changes: 6 additions & 0 deletions Dataverse/Interfaces/IManagedIdentityReader.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
using Microsoft.Xrm.Sdk;
using XrmSync.Model.Identity;

namespace XrmSync.Dataverse.Interfaces;

public interface IManagedIdentityReader
{
(Guid AssemblyId, EntityReference? ManagedIdentityRef)? GetPluginAssemblyManagedIdentity(Guid solutionId, string assemblyName);

/// <summary>
/// Retrieves the currently registered state of a managed identity record, or null when it no longer exists.
/// </summary>
ManagedIdentityInfo? GetManagedIdentity(Guid managedIdentityId);
}
1 change: 1 addition & 0 deletions Dataverse/Interfaces/IManagedIdentityWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public interface IManagedIdentityWriter
{
void Remove(Guid managedIdentityId);
Guid Create(string name, Guid applicationId, Guid tenantId);
void Update(Guid managedIdentityId, string name, Guid applicationId, Guid tenantId);
void LinkToAssembly(Guid assemblyId, Guid managedIdentityId);
}
9 changes: 9 additions & 0 deletions Dataverse/ManagedIdentityReader.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.Xrm.Sdk;
using XrmSync.Dataverse.Interfaces;
using XrmSync.Model.Identity;

namespace XrmSync.Dataverse;

Expand All @@ -18,4 +19,12 @@ join sc in reader.SolutionComponents on pa.Id equals sc.ObjectId
? (result.Id, result.ManagedIdentityId)
: null;
}

public ManagedIdentityInfo? GetManagedIdentity(Guid managedIdentityId)
{
return (from mi in reader.ManagedIdentities
where mi.Id == managedIdentityId
select new ManagedIdentityInfo(mi.Id, mi.Name, mi.ApplicationId, mi.TenantId))
.FirstOrDefault();
}
}
10 changes: 10 additions & 0 deletions Dataverse/ManagedIdentityWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ public Guid Create(string name, Guid applicationId, Guid tenantId)
}, null);
}

public void Update(Guid managedIdentityId, string name, Guid applicationId, Guid tenantId)
{
writer.Update(new ManagedIdentity(managedIdentityId)
{
Name = name,
ApplicationId = applicationId,
TenantId = tenantId
});
}

public void LinkToAssembly(Guid assemblyId, Guid managedIdentityId)
{
writer.Update(new PluginAssembly(assemblyId)
Expand Down
6 changes: 6 additions & 0 deletions Model/Identity/ManagedIdentityInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace XrmSync.Model.Identity;

/// <summary>
/// The currently registered state of a managed identity record in Dataverse.
/// </summary>
public record ManagedIdentityInfo(Guid Id, string? Name, Guid? ApplicationId, Guid? TenantId);
14 changes: 12 additions & 2 deletions Model/Plugin/PluginSyncCommandOptions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
namespace XrmSync.Model.Plugin;

// Command-specific options that can be populated from CLI or profile
public record PluginSyncCommandOptions(string AssemblyPath, string SolutionName)
public record PluginSyncCommandOptions(
string AssemblyPath,
string SolutionName,
string? ManagedIdentityClientId = null,
string? ManagedIdentityTenantId = null)
{
public static PluginSyncCommandOptions Empty => new(string.Empty, string.Empty);
}

/// <summary>
/// True when either managed identity value has been supplied, indicating the
/// managed identity should be ensured as part of the plugin sync.
/// </summary>
public bool HasManagedIdentity =>
!string.IsNullOrWhiteSpace(ManagedIdentityClientId) || !string.IsNullOrWhiteSpace(ManagedIdentityTenantId);
}
2 changes: 1 addition & 1 deletion Model/XrmSyncOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public abstract record SyncItem
public abstract string SyncType { get; }
}

public record PluginSyncItem(string AssemblyPath) : SyncItem
public record PluginSyncItem(string AssemblyPath, string? ManagedIdentityClientId = null, string? ManagedIdentityTenantId = null) : SyncItem
{
public const string TypeName = "Plugin";
public static PluginSyncItem Empty => new(string.Empty);
Expand Down
54 changes: 48 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ XrmSync is a powerful tool that helps you manage and synchronize your Microsoft
- **Intelligent Synchronization**: Compares local definitions with Dataverse and performs only necessary changes
- **Custom API Support**: Handles custom API definitions, request parameters, and response properties
- **Webresource Sync**: Synchronizes HTML, CSS, JavaScript, images, and other webresources from local folders
- **Managed Identity Management**: Link or remove Azure AD managed identities for plugin assemblies
- **Managed Identity Management**: Link, update, or remove Azure AD managed identities for plugin assemblies — either inline as part of plugin sync or via the standalone `identity` command
- **Dry Run Mode**: Preview changes without actually modifying your Dataverse environment
- **Solution-aware**: Deploys plugins and webresources to specific Dataverse solutions
- **Flexible Connection**: Supports connection string and URL-based Dataverse connections
Expand Down Expand Up @@ -122,8 +122,8 @@ The root command accepts the following override options in addition to `--dry-ru
| `--folder` | Webresource sync |
| `--file-extensions` | Webresource sync |
| `--prefix` | Plugin analysis |
| `--client-id` | Identity (Ensure) |
| `--tenant-id` | Identity (Ensure) |
| `--client-id` | Plugin sync, Identity (Ensure) |
| `--tenant-id` | Plugin sync, Identity (Ensure) |

### Command Line Options

Expand All @@ -133,12 +133,15 @@ The root command accepts the following override options in addition to `--dry-ru
|--------|-------|-------------|----------|
| `--assembly` | `-a` | Path to the plugin assembly (*.dll) | Yes* |
| `--solution-name` | `-n` | Name of the target Dataverse solution | Yes* |
| `--client-id` | `--cid` | Azure AD application (client) ID for the managed identity to ensure on the assembly | No** |
| `--tenant-id` | `--tid` | Azure AD tenant ID for the managed identity to ensure on the assembly | No** |
| `--dry-run` | | Perform a dry run without making changes | No |
| `--log-level` | `-l` | Set the minimum log level (Trace, Debug, Information, Warning, Error, Critical) | No |
| `--ci-mode` | `--ci` | Enable CI mode which prefixes all warnings and errors | No |
| `--profile` | `-p`, `--profile-name` | Name of the profile to load from appsettings.json | No |

*Required when not present in appsettings.json
**Optional; when either is supplied the managed identity is ensured as part of the sync, and both are required together. See [Managed Identity Management](#managed-identity-management).

#### Webresources Command

Expand Down Expand Up @@ -193,9 +196,21 @@ The webresource name in Dataverse is determined by the file path relative to the

### Managed Identity Management

Link or remove a managed identity for a plugin assembly already deployed in Dataverse.
A managed identity can be bound to a plugin assembly either **as part of plugin sync** or via the standalone **`identity`** command. The identity is created with the name `"{SolutionName} Managed Identity"` and linked through the assembly's managed identity lookup.

**Ensure** — creates a managed identity and links it to the assembly if one is not already linked:
#### As part of plugin sync (recommended)

Supply `--client-id` and `--tenant-id` to the `plugins` command (or set `ManagedIdentityClientId`/`ManagedIdentityTenantId` on the Plugin sync item in `appsettings.json`). The managed identity is ensured right after the assembly is upserted — and runs regardless of whether the assembly binary changed, so a credential change applies even when the assembly itself is unchanged:

```bash
xrmsync plugins --assembly "path/to/your/plugin.dll" --solution-name "YourSolutionName" --client-id "<azure-app-client-id>" --tenant-id "<azure-tenant-id>"
```

#### Standalone command

Manage the identity for an assembly already deployed in Dataverse.

**Ensure** — creates and links a managed identity when none is linked, or **updates the existing one in place** when its application id, tenant id, or name has drifted:
```bash
xrmsync identity --operation Ensure --assembly "path/to/your/plugin.dll" --solution-name "YourSolutionName" --client-id "<azure-app-client-id>" --tenant-id "<azure-tenant-id>"
```
Expand All @@ -221,7 +236,7 @@ xrmsync identity --operation Remove --assembly "path/to/your/plugin.dll" --solut

*Required when not present in appsettings.json

> **Note**: The `--assembly` option is used to locate the plugin assembly that is already registered in Dataverse. The `Ensure` operation is idempotent — if a managed identity is already linked, no action is taken.
> **Note**: The `--assembly` option is used to locate the plugin assembly that is already registered in Dataverse. `Ensure` is idempotent — if a managed identity is already linked and matches the supplied client/tenant, no change is made; if it has drifted, the existing record is updated in place (it is never deleted by `Ensure`). `Remove` does not fail when the assembly cannot be found — it logs a warning and exits successfully, so it's safe to run in teardown pipelines.

### Assembly Analysis

Expand Down Expand Up @@ -429,6 +444,8 @@ Each sync item must have a `Type` property indicating the sync type:
|----------|------|-------------|---------|
| `Type` | string | Must be "Plugin" | Required |
| `AssemblyPath` | string | Path to the plugin assembly (*.dll) | Required |
| `ManagedIdentityClientId` | string | Azure AD application (client) ID (GUID). When set, a managed identity is ensured on the assembly as part of the sync | null |
| `ManagedIdentityTenantId` | string | Azure AD tenant ID (GUID). Required together with `ManagedIdentityClientId` | null |

**Webresource Sync Item (Type: "Webresource")**

Expand Down Expand Up @@ -521,6 +538,31 @@ Each sync item must have a `Type` property indicating the sync type:
}
```

**Plugin sync with an inline managed identity:**

Instead of a separate `Identity` sync item, the managed identity can be ensured directly as part of the plugin sync. The credentials can be kept out of source control and supplied at runtime via `--client-id`/`--tenant-id`.

```json
{
"XrmSync": {
"Profiles": [
{
"Name": "default",
"SolutionName": "MyCustomSolution",
"Sync": [
{
"Type": "Plugin",
"AssemblyPath": "bin/Release/net462/MyPlugin.dll",
"ManagedIdentityClientId": "d3b5e6a1-2c4f-4a8b-9e1d-7f3c6b8a2e4d",
"ManagedIdentityTenantId": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d"
}
]
}
]
}
}
```

**Webresource-only configuration:**
```json
{
Expand Down
2 changes: 2 additions & 0 deletions SyncService/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public static IServiceCollection AddPluginSyncService(this IServiceCollection se
return services
.AddShared()
.AddSingleton<ISyncService, PluginSyncService>()
.AddSingleton<IManagedIdentityReconciler, ManagedIdentityReconciler>()
.AddSingleton<IDifferenceCalculator, DifferenceCalculator>()
.AddSingleton<IValidator<PluginDefinition>, PluginValidator>()
.AddSingleton<IValidator<CustomApiDefinition>, CustomApiValidator>()
Expand All @@ -47,6 +48,7 @@ public static IServiceCollection AddIdentityService(this IServiceCollection serv
return services
.AddDataverseConnection()
.AddSingleton<IPrintService, PrintService>()
.AddSingleton<IManagedIdentityReconciler, ManagedIdentityReconciler>()
.AddSingleton<ISyncService, IdentitySyncService>();
}

Expand Down
42 changes: 10 additions & 32 deletions SyncService/IdentitySyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace XrmSync.SyncService;
internal class IdentitySyncService(
ISolutionReader solutionReader,
IManagedIdentityReader managedIdentityReader,
IManagedIdentityWriter managedIdentityWriter,
IManagedIdentityReconciler managedIdentityService,
IOptions<IdentityCommandOptions> configuration,
ILogger<IdentitySyncService> log) : ISyncService
{
Expand All @@ -23,13 +23,13 @@ public Task Sync(CancellationToken cancellation)

return options.Operation switch
{
IdentityOperation.Remove => Remove(cancellation),
IdentityOperation.Ensure => Ensure(cancellation),
IdentityOperation.Remove => Remove(),
IdentityOperation.Ensure => Ensure(),
_ => throw new XrmSyncException($"Unknown identity operation: {options.Operation}")
};
}

private Task Remove(CancellationToken cancellation)
private Task Remove()
{
var assemblyName = Path.GetFileNameWithoutExtension(options.AssemblyPath);
log.LogInformation("Removing managed identity for assembly '{assemblyName}' in solution '{solutionName}'",
Expand All @@ -39,26 +39,18 @@ private Task Remove(CancellationToken cancellation)
var result = managedIdentityReader.GetPluginAssemblyManagedIdentity(solutionId, assemblyName);

if (result == null)
throw new XrmSyncException($"Plugin assembly '{assemblyName}' not found in solution '{options.SolutionName}'.");

var (_, managedIdentityRef) = result.Value;

if (managedIdentityRef == null)
{
log.LogWarning("No managed identity linked to assembly '{assemblyName}'. Nothing to remove.", assemblyName);
// A missing assembly must not block removal — there is nothing to clean up.
log.LogWarning("Plugin assembly '{assemblyName}' not found in solution '{solutionName}'. Nothing to remove.",
assemblyName, options.SolutionName);
return Task.CompletedTask;
}

log.LogInformation("Deleting managed identity '{managedIdentityName}' linked to assembly '{assemblyName}'",
managedIdentityRef.Name, assemblyName);

managedIdentityWriter.Remove(managedIdentityRef.Id);

log.LogInformation("Successfully removed managed identity from assembly '{assemblyName}'", assemblyName);
managedIdentityService.Remove(result.Value.ManagedIdentityRef, assemblyName);
return Task.CompletedTask;
}

private Task Ensure(CancellationToken cancellation)
private Task Ensure()
{
var assemblyName = Path.GetFileNameWithoutExtension(options.AssemblyPath);
log.LogInformation("Ensuring managed identity for assembly '{assemblyName}' in solution '{solutionName}'",
Expand All @@ -82,21 +74,7 @@ private Task Ensure(CancellationToken cancellation)

var (assemblyId, managedIdentityRef) = result.Value;

if (managedIdentityRef != null)
{
log.LogInformation("Managed identity already linked to assembly '{assemblyName}'. No action needed.", assemblyName);
return Task.CompletedTask;
}

var miName = $"{options.SolutionName} Managed Identity";
log.LogInformation("Creating managed identity '{miName}' for assembly '{assemblyName}'", miName, assemblyName);

var managedIdentityId = managedIdentityWriter.Create(miName, clientId, tenantId);

log.LogInformation("Linking managed identity '{managedIdentityId}' to assembly '{assemblyName}'",
managedIdentityId, assemblyName);

managedIdentityWriter.LinkToAssembly(assemblyId, managedIdentityId);
managedIdentityService.Ensure(assemblyId, managedIdentityRef, options.SolutionName, clientId, tenantId);

log.LogInformation("Successfully ensured managed identity for assembly '{assemblyName}'", assemblyName);
return Task.CompletedTask;
Expand Down
Loading