Skip to content

Commit a026385

Browse files
joshsmithxrmclaude
andauthored
feat(plugins): add step enable/disable and username impersonation support (#490)
* feat(plugins): add step enable/disable and username impersonation support Issue #66: Add step enable/disable support - Add `Enabled` property to PluginStepConfig (default: true) - Use SetStateRequest to change step state after create/update - Query includes StateCode to detect needed state changes Issue #67: Support impersonation by username - If RunAsUser is not a GUID, resolve by domainname or email - Query systemuser with OR filter on DomainName/InternalEMailAddress - Throw PpdsException with UserNotFound code if user not found Closes #66 Closes #67 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(plugins): address bot review feedback - Remove redundant null check for impersonatingUserId (Copilot, Gemini) - Add username.Trim() in ResolveUserIdAsync for robust whitespace handling (Gemini) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1cbb751 commit a026385

File tree

4 files changed

+484
-11
lines changed

4 files changed

+484
-11
lines changed

src/PPDS.Cli/Infrastructure/Errors/ErrorCodes.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,5 +216,8 @@ public static class Plugin
216216

217217
/// <summary>Entity has child components that must be removed first.</summary>
218218
public const string HasChildren = "Plugin.HasChildren";
219+
220+
/// <summary>Specified user for impersonation was not found.</summary>
221+
public const string UserNotFound = "Plugin.UserNotFound";
219222
}
220223
}

src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,19 @@ public sealed class PluginStepConfig
256256

257257
/// <summary>
258258
/// User context to run the plugin as.
259-
/// Use "CallingUser" (default), "System", or a systemuser GUID.
259+
/// Use "CallingUser" (default), "System", a systemuser GUID,
260+
/// a domain name (e.g., "user@domain.com"), or an email address.
260261
/// </summary>
261262
[JsonPropertyName("runAsUser")]
262263
public string? RunAsUser { get; set; }
263264

265+
/// <summary>
266+
/// Whether the step is enabled. Default is true.
267+
/// When false, the step is registered but disabled (won't execute).
268+
/// </summary>
269+
[JsonPropertyName("enabled")]
270+
public bool Enabled { get; set; } = true;
271+
264272
/// <summary>
265273
/// Description of what this step does.
266274
/// </summary>

src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,9 @@ public async Task<Guid> UpsertStepAsync(
11481148
// Check if step exists by name
11491149
var query = new QueryExpression(SdkMessageProcessingStep.EntityLogicalName)
11501150
{
1151-
ColumnSet = new ColumnSet(SdkMessageProcessingStep.Fields.SdkMessageProcessingStepId),
1151+
ColumnSet = new ColumnSet(
1152+
SdkMessageProcessingStep.Fields.SdkMessageProcessingStepId,
1153+
SdkMessageProcessingStep.Fields.StateCode),
11521154
Criteria = new FilterExpression
11531155
{
11541156
Conditions =
@@ -1199,10 +1201,25 @@ public async Task<Guid> UpsertStepAsync(
11991201
if (!string.IsNullOrEmpty(stepConfig.RunAsUser) &&
12001202
!stepConfig.RunAsUser.Equals("CallingUser", StringComparison.OrdinalIgnoreCase))
12011203
{
1202-
if (Guid.TryParse(stepConfig.RunAsUser, out var userId))
1204+
Guid? impersonatingUserId = null;
1205+
1206+
if (Guid.TryParse(stepConfig.RunAsUser, out var parsedGuid))
1207+
{
1208+
impersonatingUserId = parsedGuid;
1209+
}
1210+
else
12031211
{
1204-
entity.ImpersonatingUserId = new EntityReference(SystemUser.EntityLogicalName, userId);
1212+
// Resolve username to GUID (by domain name or email)
1213+
impersonatingUserId = await ResolveUserIdAsync(stepConfig.RunAsUser, client, cancellationToken);
1214+
if (impersonatingUserId == null)
1215+
{
1216+
throw new PpdsException(
1217+
ErrorCodes.Plugin.UserNotFound,
1218+
$"Could not resolve user '{stepConfig.RunAsUser}'. Specify a valid GUID, domain name, or email address.");
1219+
}
12051220
}
1221+
1222+
entity.ImpersonatingUserId = new EntityReference(SystemUser.EntityLogicalName, impersonatingUserId.Value);
12061223
}
12071224

12081225
// Async auto-delete (only applies to async steps)
@@ -1211,23 +1228,41 @@ public async Task<Guid> UpsertStepAsync(
12111228
entity.AsyncAutoDelete = true;
12121229
}
12131230

1231+
Guid stepId;
12141232
if (existing != null)
12151233
{
1216-
entity.Id = existing.Id;
1234+
stepId = existing.Id;
1235+
entity.Id = stepId;
12171236
await UpdateAsync(entity, client, cancellationToken);
12181237

12191238
// Add to solution even on update (handles case where component exists but isn't in solution)
12201239
if (!string.IsNullOrEmpty(solutionName))
12211240
{
1222-
await AddToSolutionAsync(existing.Id, ComponentTypeSdkMessageProcessingStep, solutionName, cancellationToken);
1241+
await AddToSolutionAsync(stepId, ComponentTypeSdkMessageProcessingStep, solutionName, cancellationToken);
12231242
}
12241243

1225-
return existing.Id;
1244+
// Handle state change for existing step if needed
1245+
var targetState = stepConfig.Enabled
1246+
? sdkmessageprocessingstep_statecode.Enabled
1247+
: sdkmessageprocessingstep_statecode.Disabled;
1248+
var currentState = existing.GetAttributeValue<OptionSetValue>(SdkMessageProcessingStep.Fields.StateCode)?.Value ?? 0;
1249+
if (currentState != (int)targetState)
1250+
{
1251+
await SetStepStateAsync(stepId, targetState, client, cancellationToken);
1252+
}
12261253
}
12271254
else
12281255
{
1229-
return await CreateWithSolutionAsync(entity, solutionName, client, cancellationToken);
1256+
stepId = await CreateWithSolutionAsync(entity, solutionName, client, cancellationToken);
1257+
1258+
// New steps are created enabled by default - disable if needed
1259+
if (!stepConfig.Enabled)
1260+
{
1261+
await SetStepStateAsync(stepId, sdkmessageprocessingstep_statecode.Disabled, client, cancellationToken);
1262+
}
12301263
}
1264+
1265+
return stepId;
12311266
}
12321267

12331268
/// <summary>
@@ -2170,6 +2205,61 @@ private static async Task<Entity> RetrieveAsync(
21702205
return await Task.Run(() => client.Retrieve(entityName, id, columnSet), cancellationToken);
21712206
}
21722207

2208+
/// <summary>
2209+
/// Resolves a username (domain name or email) to a systemuser GUID.
2210+
/// </summary>
2211+
/// <param name="username">The username, domain name, or email address.</param>
2212+
/// <param name="client">The Dataverse client.</param>
2213+
/// <param name="cancellationToken">Cancellation token.</param>
2214+
/// <returns>The user's GUID, or null if not found.</returns>
2215+
private static async Task<Guid?> ResolveUserIdAsync(
2216+
string username,
2217+
IOrganizationService client,
2218+
CancellationToken cancellationToken)
2219+
{
2220+
username = username.Trim();
2221+
var query = new QueryExpression(SystemUser.EntityLogicalName)
2222+
{
2223+
ColumnSet = new ColumnSet(SystemUser.Fields.SystemUserId),
2224+
TopCount = 1
2225+
};
2226+
2227+
// Match by domain name OR internal email address
2228+
var filter = new FilterExpression(LogicalOperator.Or);
2229+
filter.AddCondition(SystemUser.Fields.DomainName, ConditionOperator.Equal, username);
2230+
filter.AddCondition(SystemUser.Fields.InternalEMailAddress, ConditionOperator.Equal, username);
2231+
query.Criteria.AddFilter(filter);
2232+
2233+
var result = await RetrieveMultipleAsync(query, client, cancellationToken);
2234+
return result.Entities.FirstOrDefault()?.Id;
2235+
}
2236+
2237+
/// <summary>
2238+
/// Sets the state of a plugin step (enabled or disabled).
2239+
/// </summary>
2240+
/// <param name="stepId">The step ID.</param>
2241+
/// <param name="state">The target state.</param>
2242+
/// <param name="client">The Dataverse client.</param>
2243+
/// <param name="cancellationToken">Cancellation token.</param>
2244+
private static async Task SetStepStateAsync(
2245+
Guid stepId,
2246+
sdkmessageprocessingstep_statecode state,
2247+
IOrganizationService client,
2248+
CancellationToken cancellationToken)
2249+
{
2250+
var statusCode = state == sdkmessageprocessingstep_statecode.Enabled
2251+
? sdkmessageprocessingstep_statuscode.Enabled
2252+
: sdkmessageprocessingstep_statuscode.Disabled;
2253+
2254+
var request = new SetStateRequest
2255+
{
2256+
EntityMoniker = new EntityReference(SdkMessageProcessingStep.EntityLogicalName, stepId),
2257+
State = new OptionSetValue((int)state),
2258+
Status = new OptionSetValue((int)statusCode)
2259+
};
2260+
await ExecuteAsync(request, client, cancellationToken);
2261+
}
2262+
21732263
#endregion
21742264
}
21752265

0 commit comments

Comments
 (0)