Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a86a789
Add: Support for adding and removing Managed Identity records associa…
mkholt Mar 27, 2026
c8564bc
Do not commit settings.local with all the hook stuff
mkholt Mar 27, 2026
d4dc984
Add better error messages, and make operation mandatory so we don't d…
mkholt Mar 30, 2026
3a5f23d
Add description of managed identity configuration to README
mkholt Apr 1, 2026
e331413
Merge branch 'main' into 122927-managed-identities-a9e
mkholt Apr 8, 2026
ed6cd82
Add: --add option to the config validate command
mkholt Apr 8, 2026
0dc59f1
Merge branch 'main' into 122927-managed-identities-a9e
mkholt Apr 8, 2026
d45b5e8
Fix: Print the full informational header including preview identifier
mkholt Apr 9, 2026
605c7ae
Test: Reduce output during testing
mkholt Apr 9, 2026
9306fb3
Fix: Print header information in root command instead of in the sync …
mkholt Apr 9, 2026
2bb8415
Add: Identity handling to config validation
mkholt Apr 9, 2026
3ce3940
Add: Validate profile before running root command
mkholt Apr 9, 2026
ab91162
Validation now happens eagerly in ExecuteAsync, before the service pr…
mkholt Apr 9, 2026
105451f
Allow pass-through of properties
mkholt Apr 10, 2026
6b5625d
Fix: Better error message on no profiles found
mkholt Apr 13, 2026
71d26eb
Remove unnessary test
mkholt Apr 13, 2026
4c7a30b
De-duplicate validation logic
mkholt Apr 13, 2026
d7ca717
Update documentation
mkholt Apr 13, 2026
f45e1e7
Merge branch 'fix/security-patch-xml' into 122927-managed-identities-a9e
mkholt Apr 20, 2026
2b1b5b6
Print the managed identity name instead of the ID
mkholt Apr 20, 2026
668fcb9
Merge branch 'main' into 122927-managed-identities-a9e
mkholt Apr 20, 2026
b6bc671
Refactor: Always use the CreateOption helper
mkholt Apr 20, 2026
5ab84ca
Refactor: Always use the CreateOption helper
mkholt Apr 20, 2026
61b010e
Fix: Mention --all when validation doesn't specify a profile
mkholt Apr 20, 2026
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
2 changes: 0 additions & 2 deletions .claude/settings.local.json → .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
"allow": [
"Bash(dotnet test:*)",
"Bash(dotnet build:*)",
"Read(//usr/bin/**)",
"WebSearch",
"WebFetch(domain:www.nuget.org)",
"WebFetch(domain:github.com)",
"Bash(dotnet restore:*)"
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,6 @@ test-outputs/
*.dll
!tools/*.exe
!tools/*.dll

# Claude files
.claude/*.local.json
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ The solution is organized into distinct layers with clear separation of concerns
- Commands registered via `CommandLineBuilder` pattern
- Root command handler (`XrmSyncRootCommand`) can execute all configured sub-commands in sequence
- Dependency injection container built per command execution
- `CliOptionDescriptor` (in `Constants/`) is the single source of truth for each option's primary name, aliases, and description; it exposes `CreateOption<T>()` to eliminate duplication across command constructors
- Commands advertise their runtime-overridable options to the root command via `IXrmSyncCommand.GetProfileOverrides(assembly, solution)`, which returns a `ProfileOverrideProvider` containing the options to add to the root command and a callback to merge CLI values into a profile sync item before execution

**Multi-Framework Plugin Support**:
The analyzer supports three plugin attribute patterns through strategy pattern:
Expand Down Expand Up @@ -165,9 +167,10 @@ See [DataverseConnection docs](https://github.com/delegateas/DataverseConnection
### Adding New Commands

1. Create command class implementing `IXrmSyncCommand` extending `XrmSyncCommandBase`
2. Define options using System.CommandLine `Option<T>` instances
2. Define options using `CliOptions.<Group>.CreateOption<T>()` (add a new `CliOptionDescriptor` field to `CliOptions` if needed)
3. Implement execution logic by building DI container with required services
4. Register command in `Program.cs` via `CommandLineBuilder.AddCommand()`
5. Override `GetProfileOverrides(assembly, solution)` to advertise any options that should be settable on the root command when running via `--profile`, and provide a merge callback that applies those values into the relevant `SyncItem` subtype

### Adding Validation Rules

Expand Down
14 changes: 14 additions & 0 deletions Dataverse/Context/OptionSets/managedidentity_credentialsource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Runtime.Serialization;

namespace XrmSync.Dataverse.Context;

[System.CodeDom.Compiler.GeneratedCode("DataverseProxyGenerator", "4.0.0.21")]
[DataContract]
#pragma warning disable CS8981
public enum managedidentity_credentialsource
#pragma warning restore CS8981
{
[EnumMember]
[OptionSetMetadata("Managed Identity", 1033)]
ManagedIdentity = 2,
}
14 changes: 14 additions & 0 deletions Dataverse/Context/OptionSets/managedidentity_subjectscope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Runtime.Serialization;

namespace XrmSync.Dataverse.Context;

[System.CodeDom.Compiler.GeneratedCode("DataverseProxyGenerator", "4.0.0.21")]
[DataContract]
#pragma warning disable CS8981
public enum managedidentity_subjectscope
#pragma warning restore CS8981
{
[EnumMember]
[OptionSetMetadata("Environment", 1033)]
Environment = 1,
}
107 changes: 107 additions & 0 deletions Dataverse/Context/tables/ManagedIdentity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
using Microsoft.Xrm.Sdk;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using System.Runtime.Serialization;
using Microsoft.Xrm.Sdk.Client;

namespace XrmSync.Dataverse.Context;

/// <summary>
/// <para>Managed Identity for plugin assemblies.</para>
/// <para>Display Name: Managed Identity</para>
/// </summary>
[System.CodeDom.Compiler.GeneratedCode("DataverseProxyGenerator", "4.0.0.21")]
[EntityLogicalName("managedidentity")]
[DebuggerDisplay("{DebuggerDisplay,nq}")]
[DataContract]
#pragma warning disable CS8981 // Allows: Only lowercase characters
public partial class ManagedIdentity : ExtendedEntity
#pragma warning restore CS8981
{
public const string EntityLogicalName = "managedidentity";

public ManagedIdentity() : base(EntityLogicalName) { }
public ManagedIdentity(Guid id) : base(EntityLogicalName, id) { }

private string DebuggerDisplay => GetDebuggerDisplay("name");

[AttributeLogicalName("managedidentityid")]
public override Guid Id
{
get => base.Id;
set => SetId("managedidentityid", value);
}

/// <summary>
/// <para>Display Name: Name</para>
/// </summary>
[AttributeLogicalName("name")]
[DisplayName("Name")]
[MaxLength(256)]
public string Name
{
get => GetAttributeValue<string>("name");
set => SetAttributeValue("name", value);
}

/// <summary>
/// <para>Azure AD application (client) ID.</para>
/// <para>Display Name: Application Id</para>
/// </summary>
[AttributeLogicalName("applicationid")]
[DisplayName("Application Id")]
public Guid? ApplicationId
{
get => GetAttributeValue<Guid?>("applicationid");
set => SetAttributeValue("applicationid", value);
}

/// <summary>
/// <para>Azure AD tenant ID.</para>
/// <para>Display Name: Tenant Id</para>
/// </summary>
[AttributeLogicalName("tenantid")]
[DisplayName("Tenant Id")]
public Guid? TenantId
{
get => GetAttributeValue<Guid?>("tenantid");
set => SetAttributeValue("tenantid", value);
}

/// <summary>
/// <para>Credential source for the managed identity.</para>
/// <para>Display Name: Credential Source</para>
/// </summary>
[AttributeLogicalName("credentialsource")]
[DisplayName("Credential Source")]
public managedidentity_credentialsource? CredentialSource
{
get => this.GetOptionSetValue<managedidentity_credentialsource>("credentialsource");
set => this.SetOptionSetValue("credentialsource", value);
}

/// <summary>
/// <para>Subject scope for the managed identity.</para>
/// <para>Display Name: Subject Scope</para>
/// </summary>
[AttributeLogicalName("subjectscope")]
[DisplayName("Subject Scope")]
public managedidentity_subjectscope? SubjectScope
{
get => this.GetOptionSetValue<managedidentity_subjectscope>("subjectscope");
set => this.SetOptionSetValue("subjectscope", value);
}

/// <summary>
/// <para>Version of the managed identity.</para>
/// <para>Display Name: Managed Identity Version</para>
/// </summary>
[AttributeLogicalName("managedidentityversion")]
[DisplayName("Managed Identity Version")]
public int? ManagedIdentityVersion
{
get => GetAttributeValue<int?>("managedidentityversion");
set => SetAttributeValue("managedidentityversion", value);
}
}
5 changes: 5 additions & 0 deletions Dataverse/DataverseWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public void Update(Entity entity)
service.Update(entity);
}

public void Delete(Entity entity)
{
service.Delete(entity.LogicalName, entity.Id);
}

public void UpdateMultiple<TEntity>(IEnumerable<TEntity> entities) where TEntity : Entity
=> PerformAsBulk(entities.Select(e => new UpdateRequest { Target = e }));

Expand Down
5 changes: 5 additions & 0 deletions Dataverse/DryRunDataverseWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public void Update(Entity entity)
LogOperation(entity);
}

public void Delete(Entity entity)
{
LogOperation(entity);
}

public void UpdateMultiple<TEntity>(IEnumerable<TEntity> entities) where TEntity : Entity
{
PerformAsBulk(entities.Select(e => new UpdateRequest { Target = e }));
Expand Down
3 changes: 3 additions & 0 deletions Dataverse/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public static IServiceCollection AddDataverseServices(this IServiceCollection se
services.AddSingleton<IWebresourceReader, WebresourceReader>();
services.AddSingleton<IWebresourceWriter, WebresourceWriter>();

services.AddSingleton<IManagedIdentityReader, ManagedIdentityReader>();
services.AddSingleton<IManagedIdentityWriter, ManagedIdentityWriter>();

return services;
}

Expand Down
1 change: 1 addition & 0 deletions Dataverse/Interfaces/IDataverseWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public interface IDataverseWriter
{
Guid Create(Entity entity, IDictionary<string, object>? parameters);
void Update(Entity entity);
void Delete(Entity entity);
void UpdateMultiple<TEntity>(IEnumerable<TEntity> entities) where TEntity : Entity;
void DeleteMultiple<TEntity>(IEnumerable<TEntity> entities) where TEntity : Entity;
void DeleteMultiple(IEnumerable<DeleteRequest> deleteRequests);
Expand Down
8 changes: 8 additions & 0 deletions Dataverse/Interfaces/IManagedIdentityReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Microsoft.Xrm.Sdk;

namespace XrmSync.Dataverse.Interfaces;

public interface IManagedIdentityReader
{
(Guid AssemblyId, EntityReference? ManagedIdentityRef)? GetPluginAssemblyManagedIdentity(Guid solutionId, string assemblyName);
}
8 changes: 8 additions & 0 deletions Dataverse/Interfaces/IManagedIdentityWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace XrmSync.Dataverse.Interfaces;

public interface IManagedIdentityWriter
{
void Remove(Guid managedIdentityId);
Guid Create(string name, Guid applicationId, Guid tenantId);
void LinkToAssembly(Guid assemblyId, Guid managedIdentityId);
}
21 changes: 21 additions & 0 deletions Dataverse/ManagedIdentityReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Microsoft.Xrm.Sdk;
using XrmSync.Dataverse.Interfaces;

namespace XrmSync.Dataverse;

internal class ManagedIdentityReader(IDataverseReader reader) : IManagedIdentityReader
{
public (Guid AssemblyId, EntityReference? ManagedIdentityRef)? GetPluginAssemblyManagedIdentity(Guid solutionId, string assemblyName)
{
return (from pa in reader.PluginAssemblies
join sc in reader.SolutionComponents on pa.Id equals sc.ObjectId
where sc.SolutionId != null && sc.SolutionId.Id == solutionId && pa.Name == assemblyName
select new
{
pa.Id,
pa.ManagedIdentityId
}).FirstOrDefault() is { } result
? (result.Id, result.ManagedIdentityId)
: null;
}
}
34 changes: 34 additions & 0 deletions Dataverse/ManagedIdentityWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Xrm.Sdk;
using XrmSync.Dataverse.Context;
using XrmSync.Dataverse.Interfaces;

namespace XrmSync.Dataverse;

internal class ManagedIdentityWriter(IDataverseWriter writer) : IManagedIdentityWriter
{
public void Remove(Guid managedIdentityId)
{
writer.Delete(new ManagedIdentity(managedIdentityId));
}

public Guid Create(string name, Guid applicationId, Guid tenantId)
{
return writer.Create(new ManagedIdentity
{
Name = name,
ApplicationId = applicationId,
TenantId = tenantId,
CredentialSource = managedidentity_credentialsource.ManagedIdentity,
SubjectScope = managedidentity_subjectscope.Environment,
ManagedIdentityVersion = 1
}, null);
}

public void LinkToAssembly(Guid assemblyId, Guid managedIdentityId)
{
writer.Update(new PluginAssembly(assemblyId)
{
ManagedIdentityId = new EntityReference(ManagedIdentity.EntityLogicalName, managedIdentityId)
});
}
}
36 changes: 30 additions & 6 deletions Model/XrmSyncOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,10 @@ public record ProfileConfiguration(string Name, string SolutionName, List<SyncIt
}

[JsonPolymorphic(TypeDiscriminatorPropertyName = "Type")]
[JsonDerivedType(typeof(PluginSyncItem), typeDiscriminator: "Plugin")]
[JsonDerivedType(typeof(PluginAnalysisSyncItem), typeDiscriminator: "PluginAnalysis")]
[JsonDerivedType(typeof(WebresourceSyncItem), typeDiscriminator: "Webresource")]
[JsonDerivedType(typeof(PluginSyncItem), typeDiscriminator: PluginSyncItem.TypeName)]
[JsonDerivedType(typeof(PluginAnalysisSyncItem), typeDiscriminator: PluginAnalysisSyncItem.TypeName)]
[JsonDerivedType(typeof(WebresourceSyncItem), typeDiscriminator: WebresourceSyncItem.TypeName)]
[JsonDerivedType(typeof(IdentitySyncItem), typeDiscriminator: IdentitySyncItem.TypeName)]
public abstract record SyncItem
{
[JsonIgnore]
Expand All @@ -66,26 +67,29 @@ public abstract record SyncItem

public record PluginSyncItem(string AssemblyPath) : SyncItem
{
public const string TypeName = "Plugin";
public static PluginSyncItem Empty => new(string.Empty);

[JsonIgnore]
public override string SyncType => "Plugin";
public override string SyncType => TypeName;
}

public record PluginAnalysisSyncItem(string AssemblyPath, string PublisherPrefix, bool PrettyPrint) : SyncItem
{
public const string TypeName = "PluginAnalysis";
public static PluginAnalysisSyncItem Empty => new(string.Empty, "new", false);

[JsonIgnore]
public override string SyncType => "PluginAnalysis";
public override string SyncType => TypeName;
}

public record WebresourceSyncItem(string FolderPath, List<string>? FileExtensions = null) : SyncItem
{
public const string TypeName = "Webresource";
public static WebresourceSyncItem Empty => new(string.Empty);

[JsonIgnore]
public override string SyncType => "Webresource";
public override string SyncType => TypeName;
}

public record SharedOptions(string? ProfileName)
Expand All @@ -109,6 +113,26 @@ public record WebresourceSyncCommandOptions(string FolderPath, string SolutionNa
public static WebresourceSyncCommandOptions Empty => new(string.Empty, string.Empty);
}

public enum IdentityOperation
{
Remove,
Ensure
}

public record IdentitySyncItem(IdentityOperation Operation, string AssemblyPath, string? ClientId = null, string? TenantId = null) : SyncItem
{
public const string TypeName = "Identity";
public static IdentitySyncItem Empty => new(IdentityOperation.Remove, string.Empty);

[JsonIgnore]
public override string SyncType => $"{TypeName} ({Operation})";
}

public record IdentityCommandOptions(IdentityOperation Operation, string AssemblyPath, string SolutionName, string? ClientId = null, string? TenantId = null)
{
public static IdentityCommandOptions Empty => new(IdentityOperation.Remove, string.Empty, string.Empty);
}

public record ExecutionModeOptions(bool DryRun)
{
public static ExecutionModeOptions Empty => new(false);
Expand Down
Loading