Skip to content

Commit 0e8ca4c

Browse files
mkholtClaude <noreply@anthropic.com> via Conducktor
andauthored
Align plugin Target with Dataverse for system-managed fields (#344)
System fields were leaked into the plugin Target during pre-stages: ownerid was injected before PreValidation, and created/modified timestamps before PreOperation. Dataverse resolves these during the main operation, exposing them only via the post-image and the post-operation Target. - Core: stop injecting ownerid in HandleInternalPreOperations (keep id); extend CopySystemAttributes to reflect ownerid/ownership/statecode/ statuscode onto the post-operation Target (add OptionSetValue support). - CreateRequestHandler: move created/modified stamping from the pre-stage into the main operation on the persisted clone; resolve the effective owner on a local clone for the access checks only. - UpdateRequestHandler: drop the pre-stage modified stamping (Utility.Touch already sets the persisted values). - Tests: add ContactSystemFieldsProbePlugin and assertions that system fields are absent from the pre-stage Target, a caller-set ownerid stays visible, and the fields are present in the post-image and post-Target. Co-authored-by: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com>
1 parent 578f6b8 commit 0e8ca4c

5 files changed

Lines changed: 195 additions & 63 deletions

File tree

src/XrmMockup365/Core.cs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,15 @@ private void InitializeCore(CoreInitializationData initData)
174174
workflowManager.AsynchronousWorkflowCount,
175175
customApiManager.RegisteredApiCount);
176176

177-
systemAttributeNames = new List<string>() { "createdon", "createdby", "modifiedon", "modifiedby" };
177+
// System-managed attributes that Dataverse resolves during the main operation. They are
178+
// not present in the Target during the pre-stages, but are copied back onto the Target for
179+
// post-operation plugins (and are always available via the post-image).
180+
systemAttributeNames = new List<string>()
181+
{
182+
"createdon", "createdby", "modifiedon", "modifiedby",
183+
"ownerid", "owningbusinessunit", "owninguser", "owningteam",
184+
"statecode", "statuscode"
185+
};
178186

179187
RequestHandlers = GetRequestHandlers(db);
180188
InitializeDB();
@@ -785,15 +793,17 @@ public void CopySystemAttributes(Entity postImage, Entity target)
785793
{
786794
if (postImage.Contains(systemAttributeName))
787795
{
788-
if (postImage[systemAttributeName] is EntityReference)
796+
if (postImage[systemAttributeName] is EntityReference reference)
797+
{
798+
target[systemAttributeName] = new EntityReference(reference.LogicalName, reference.Id);
799+
}
800+
else if (postImage[systemAttributeName] is DateTime dateTime)
789801
{
790-
target[systemAttributeName] = new EntityReference(
791-
postImage.GetAttributeValue<EntityReference>(systemAttributeName).LogicalName,
792-
postImage.GetAttributeValue<EntityReference>(systemAttributeName).Id);
802+
target[systemAttributeName] = dateTime;
793803
}
794-
else if (postImage[systemAttributeName] is DateTime)
804+
else if (postImage[systemAttributeName] is OptionSetValue optionSet)
795805
{
796-
target[systemAttributeName] = postImage.GetAttributeValue<DateTime>(systemAttributeName);
806+
target[systemAttributeName] = new OptionSetValue(optionSet.Value);
797807
}
798808
}
799809
}
@@ -807,11 +817,11 @@ public void HandleInternalPreOperations(OrganizationRequest request, EntityRefer
807817
if (entity.Id == Guid.Empty)
808818
entity.Id = Guid.NewGuid();
809819

810-
if (entity.GetAttributeValue<EntityReference>("ownerid") == null &&
811-
Utility.IsValidAttribute("ownerid", metadata.EntityMetadata.GetMetadata(entity.LogicalName)))
812-
{
813-
entity["ownerid"] = userRef;
814-
}
820+
// Note: ownerid is intentionally NOT defaulted here. In Dataverse the owner is
821+
// resolved during the main operation (stage 30), so it must not be visible in the
822+
// Target during the PreValidation/PreOperation stages. The persisted default (calling
823+
// user) is applied by CreateRequestHandler.Execute, and the resolved value surfaces
824+
// afterwards via the post-image and the post-operation Target.
815825
}
816826
}
817827

src/XrmMockup365/Requests/CreateRequestHandler.cs

Lines changed: 44 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ internal override void CheckSecurity(OrganizationRequest orgRequest, EntityRefer
3232
clonedEntity.Attributes = new AttributeCollection();
3333
clonedEntity.Attributes.AddRange(validAttributes);
3434

35+
// Resolve the effective owner for the access checks only. The persisted default is applied in
36+
// Execute and the plugin-visible Target is intentionally left without an ownerid, matching
37+
// Dataverse. Ownership-based checks (e.g. user-level Append) need the effective owner here.
38+
if (!clonedEntity.Attributes.ContainsKey("ownerid") &&
39+
Utility.IsValidAttribute("ownerid", entityMetadata))
40+
{
41+
clonedEntity["ownerid"] = userRef;
42+
}
43+
3544
if (userRef != null && userRef.Id != Guid.Empty)
3645
{
3746
if (!security.HasPermission(clonedEntity, AccessRights.CreateAccess, userRef))
@@ -75,63 +84,62 @@ internal override void CheckSecurity(OrganizationRequest orgRequest, EntityRefer
7584
}
7685
}
7786

78-
internal override void InitializePreOperation(OrganizationRequest orgRequest, EntityReference userRef, Entity preImage)
87+
internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef)
7988
{
80-
var entity = orgRequest["Target"] as Entity;
81-
var logicalName = metadata.EntityMetadata.GetMetadata(entity.LogicalName);
82-
var createdOn = DateTime.UtcNow.Add(core.TimeOffset);
89+
var request = MakeRequest<CreateRequest>(orgRequest);
90+
var resp = new CreateResponse();
91+
var settings = MockupExecutionContext.GetSettings(request);
92+
var entity = request.Target;
93+
if (entity.LogicalName == null) throw new MockupException("Entity needs a logical name");
94+
95+
var entityMetadata = metadata.EntityMetadata.GetMetadata(entity.LogicalName);
96+
var clonedEntity = entity.CloneEntity(entityMetadata, new ColumnSet(true));
97+
var validAttributes = clonedEntity.Attributes.Where(x => x.Value != null);
98+
clonedEntity.Attributes = new AttributeCollection();
99+
clonedEntity.Attributes.AddRange(validAttributes);
100+
101+
if (Utility.HasCircularReference(metadata.EntityMetadata, clonedEntity))
102+
{
103+
throw new FaultException(
104+
$"Trying to create entity '{clonedEntity.LogicalName}', but the attributes had a circular reference");
105+
}
83106

84-
if (Utility.IsValidAttribute("createdon", logicalName))
107+
// Stamp the created/modified system attributes on the record being persisted. This runs as
108+
// part of the main operation (not a pre-stage) so the values surface via the post-image and
109+
// the post-operation Target, matching Dataverse — they are absent from the pre-stage Target.
110+
var createdOn = DateTime.UtcNow.Add(core.TimeOffset);
111+
if (Utility.IsValidAttribute("createdon", entityMetadata))
85112
{
86-
if (Utility.IsValidAttribute("overriddencreatedon", logicalName) && entity.Attributes.ContainsKey("overriddencreatedon") && entity["overriddencreatedon"] is DateTime overriddencreatedon)
113+
if (Utility.IsValidAttribute("overriddencreatedon", entityMetadata) &&
114+
clonedEntity.Attributes.ContainsKey("overriddencreatedon") &&
115+
clonedEntity["overriddencreatedon"] is DateTime overriddencreatedon)
87116
{
88117
if (overriddencreatedon > createdOn)
89118
{
90119
throw new FaultException(
91-
$"Trying to create entity '{entity.LogicalName}', but overriddencreatedon cannot be set to a date in the future");
120+
$"Trying to create entity '{clonedEntity.LogicalName}', but overriddencreatedon cannot be set to a date in the future");
92121
}
93122

94-
entity["overriddencreatedon"] = createdOn;
123+
clonedEntity["overriddencreatedon"] = createdOn;
95124
createdOn = overriddencreatedon;
96125
}
97126

98-
entity["createdon"] = createdOn;
99-
}
100-
101-
if (Utility.IsValidAttribute("createdby", logicalName))
102-
{
103-
entity["createdby"] = userRef;
127+
clonedEntity["createdon"] = createdOn;
104128
}
105129

106-
if (Utility.IsValidAttribute("modifiedon", logicalName))
130+
if (Utility.IsValidAttribute("createdby", entityMetadata))
107131
{
108-
entity["modifiedon"] = createdOn;
132+
clonedEntity["createdby"] = userRef;
109133
}
110134

111-
if (Utility.IsValidAttribute("modifiedby", logicalName))
135+
if (Utility.IsValidAttribute("modifiedon", entityMetadata))
112136
{
113-
entity["modifiedby"] = userRef;
137+
clonedEntity["modifiedon"] = createdOn;
114138
}
115-
}
116-
117-
internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef)
118-
{
119-
var request = MakeRequest<CreateRequest>(orgRequest);
120-
var resp = new CreateResponse();
121-
var settings = MockupExecutionContext.GetSettings(request);
122-
var entity = request.Target;
123-
if (entity.LogicalName == null) throw new MockupException("Entity needs a logical name");
124139

125-
var entityMetadata = metadata.EntityMetadata.GetMetadata(entity.LogicalName);
126-
var clonedEntity = entity.CloneEntity(entityMetadata, new ColumnSet(true));
127-
var validAttributes = clonedEntity.Attributes.Where(x => x.Value != null);
128-
clonedEntity.Attributes = new AttributeCollection();
129-
clonedEntity.Attributes.AddRange(validAttributes);
130-
131-
if (Utility.HasCircularReference(metadata.EntityMetadata, clonedEntity))
140+
if (Utility.IsValidAttribute("modifiedby", entityMetadata))
132141
{
133-
throw new FaultException(
134-
$"Trying to create entity '{clonedEntity.LogicalName}', but the attributes had a circular reference");
142+
clonedEntity["modifiedby"] = userRef;
135143
}
136144

137145
var transactioncurrencyId = "transactioncurrencyid";

src/XrmMockup365/Requests/UpdateRequestHandler.cs

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,6 @@ internal override void CheckSecurity(OrganizationRequest orgRequest, EntityRefer
6969
}
7070
}
7171

72-
internal override void InitializePreOperation(OrganizationRequest orgRequest, EntityReference userRef, Entity preImage)
73-
{
74-
var entity = orgRequest["Target"] as Entity;
75-
76-
if (Utility.IsValidAttribute("modifiedon", metadata.EntityMetadata.GetMetadata(entity.LogicalName)))
77-
{
78-
entity["modifiedon"] = preImage.Contains("modifiedon") ? preImage["modifiedon"] : null;
79-
}
80-
81-
if (Utility.IsValidAttribute("modifiedby", metadata.EntityMetadata.GetMetadata(entity.LogicalName)))
82-
{
83-
entity["modifiedby"] = preImage.Contains("modifiedby") ? preImage["modifiedby"] : null;
84-
}
85-
}
86-
8772
internal override OrganizationResponse Execute(OrganizationRequest orgRequest, EntityReference userRef)
8873
{
8974
var request = MakeRequest<UpdateRequest>(orgRequest);
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
namespace DG.Some.Namespace
2+
{
3+
using System.Linq;
4+
using Microsoft.Xrm.Sdk;
5+
using DG.XrmFramework.BusinessDomain.ServiceContext;
6+
using XrmPluginCore;
7+
using XrmPluginCore.Enums;
8+
9+
/// <summary>
10+
/// Diagnostic plugin used by the system-field alignment tests. When a Contact is created with
11+
/// <see cref="Marker"/> as its first name, it records which system-managed attributes are present
12+
/// in the Target at each stage (and in the post-image) into observable string fields:
13+
/// address1_line1 -> PreValidation Target
14+
/// address1_line2 -> PreOperation Target
15+
/// address1_line3 -> PostOperation post-image
16+
/// address1_city -> PostOperation Target
17+
/// This lets tests assert that system fields are absent from the pre-stage Target (matching
18+
/// Dataverse) but present in the post-image and post-operation Target.
19+
/// </summary>
20+
public class ContactSystemFieldsProbePlugin : Plugin
21+
{
22+
public const string Marker = "ProbeSystemFields";
23+
24+
private static readonly string[] SystemFields =
25+
{ "ownerid", "createdon", "createdby", "modifiedon", "modifiedby", "statecode", "statuscode" };
26+
27+
public ContactSystemFieldsProbePlugin()
28+
{
29+
#pragma warning disable CS0618 // Type or member is obsolete - disabled for testing purposes
30+
RegisterPluginStep<Contact>(EventOperation.Create, ExecutionStage.PreValidation, ExecutePreValidation);
31+
RegisterPluginStep<Contact>(EventOperation.Create, ExecutionStage.PreOperation, ExecutePreOperation);
32+
RegisterPluginStep<Contact>(EventOperation.Create, ExecutionStage.PostOperation, ExecutePostOperation)
33+
.WithPostImage();
34+
#pragma warning restore CS0618
35+
}
36+
37+
internal static string Flags(Entity entity) =>
38+
string.Join(",", SystemFields.Where(entity.Contains));
39+
40+
private static Entity GetProbeTarget(LocalPluginContext localContext)
41+
{
42+
var target = localContext.PluginExecutionContext.InputParameters["Target"] as Entity;
43+
return target?.GetAttributeValue<string>("firstname") == Marker ? target : null;
44+
}
45+
46+
private void ExecutePreValidation(LocalPluginContext localContext)
47+
{
48+
var target = GetProbeTarget(localContext);
49+
if (target == null) return;
50+
target["address1_line1"] = Flags(target);
51+
}
52+
53+
private void ExecutePreOperation(LocalPluginContext localContext)
54+
{
55+
var target = GetProbeTarget(localContext);
56+
if (target == null) return;
57+
target["address1_line2"] = Flags(target);
58+
}
59+
60+
private void ExecutePostOperation(LocalPluginContext localContext)
61+
{
62+
var target = GetProbeTarget(localContext);
63+
if (target == null) return;
64+
65+
var postImage = localContext.PluginExecutionContext.PostEntityImages.Values.First();
66+
67+
// Post-operation writes to Target are not persisted, so record the observations via an update.
68+
var record = new Entity(target.LogicalName, target.Id)
69+
{
70+
["address1_line3"] = Flags(postImage),
71+
["address1_city"] = Flags(target),
72+
};
73+
localContext.OrganizationService.Update(record);
74+
}
75+
}
76+
}

tests/XrmMockup365Test/TestPlugins.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.Xrm.Sdk;
77
using Xunit;
88
using TestPluginAssembly365.Plugins.LegacyDaxif;
9+
using DG.Some.Namespace;
910

1011
namespace DG.XrmMockupTest
1112
{
@@ -112,6 +113,58 @@ public void TestSystemAttributesAddedToTargetForPostOperationStepPlugins()
112113
}
113114
}
114115

116+
[Fact]
117+
public void TestSystemFieldsAbsentFromPreStageTargetOnCreate()
118+
{
119+
// Matches Dataverse: system-managed fields the caller did not set must not appear in the
120+
// Target during the PreValidation/PreOperation stages.
121+
var contact = new Contact { FirstName = ContactSystemFieldsProbePlugin.Marker };
122+
contact.Id = orgAdminService.Create(contact);
123+
124+
var probed = Contact.Retrieve(orgAdminService, contact.Id,
125+
x => x.Address1_Line1, x => x.Address1_Line2);
126+
127+
// Empty flag strings are normalised to null on create, confirming no system fields were present.
128+
Assert.True(string.IsNullOrEmpty(probed.Address1_Line1)); // PreValidation Target
129+
Assert.True(string.IsNullOrEmpty(probed.Address1_Line2)); // PreOperation Target
130+
}
131+
132+
[Fact]
133+
public void TestCallerSetOwnerVisibleInPreStageTargetOnCreate()
134+
{
135+
// A system field the caller DID set stays visible in the pre-stage Target.
136+
var contact = new Contact
137+
{
138+
FirstName = ContactSystemFieldsProbePlugin.Marker,
139+
OwnerId = testUser1.ToEntityReference(),
140+
};
141+
contact.Id = orgAdminService.Create(contact);
142+
143+
var probed = Contact.Retrieve(orgAdminService, contact.Id,
144+
x => x.Address1_Line1, x => x.Address1_Line2);
145+
146+
Assert.Contains("ownerid", probed.Address1_Line1); // PreValidation Target
147+
Assert.Contains("ownerid", probed.Address1_Line2); // PreOperation Target
148+
Assert.DoesNotContain("createdon", probed.Address1_Line1); // still resolved later
149+
}
150+
151+
[Fact]
152+
public void TestSystemFieldsPresentInPostImageAndPostTargetOnCreate()
153+
{
154+
var contact = new Contact { FirstName = ContactSystemFieldsProbePlugin.Marker };
155+
contact.Id = orgAdminService.Create(contact);
156+
157+
var probed = Contact.Retrieve(orgAdminService, contact.Id,
158+
x => x.Address1_Line3, x => x.Address1_City);
159+
160+
foreach (var field in new[]
161+
{ "ownerid", "createdon", "createdby", "modifiedon", "modifiedby", "statecode", "statuscode" })
162+
{
163+
Assert.Contains(field, probed.Address1_Line3); // post-image
164+
Assert.Contains(field, probed.Address1_City); // post-operation Target
165+
}
166+
}
167+
115168
[Fact]
116169
public void TestPluginTrigger()
117170
{

0 commit comments

Comments
 (0)