diff --git a/Fake4DataverseAbstractions/Fake4Dataverse.Abstractions/ICallerProperties.cs b/Fake4DataverseAbstractions/Fake4Dataverse.Abstractions/ICallerProperties.cs index f2fa9033..604c5de4 100644 --- a/Fake4DataverseAbstractions/Fake4Dataverse.Abstractions/ICallerProperties.cs +++ b/Fake4DataverseAbstractions/Fake4Dataverse.Abstractions/ICallerProperties.cs @@ -6,5 +6,22 @@ public interface ICallerProperties { EntityReference CallerId { get; set; } EntityReference BusinessUnitId { get; set; } + + /// + /// Gets or sets the ID of the user to impersonate when making requests. + /// When set, this user's identity is used for security checks and audit fields, + /// while CallerId represents the actual calling user. + /// Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + /// + EntityReference ImpersonatedUserId { get; set; } + + /// + /// Gets the effective user for operations. + /// Returns ImpersonatedUserId if impersonation is active, otherwise CallerId. + /// This is the user identity used for security checks, audit fields, and ownership. + /// Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + /// When impersonating, operations are performed as if the impersonated user made them. + /// + EntityReference GetEffectiveUser(); } } diff --git a/Fake4DataverseCore/Fake4Dataverse.Core.Tests/Security/ImpersonationTests.cs b/Fake4DataverseCore/Fake4Dataverse.Core.Tests/Security/ImpersonationTests.cs new file mode 100644 index 00000000..5693502b --- /dev/null +++ b/Fake4DataverseCore/Fake4Dataverse.Core.Tests/Security/ImpersonationTests.cs @@ -0,0 +1,277 @@ +using Fake4Dataverse.Security; +using Microsoft.Xrm.Sdk; +using System; +using System.Linq; +using Xunit; + +namespace Fake4Dataverse.Core.Tests.Security +{ + /// + /// Tests for impersonation functionality. + /// Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + /// + /// Impersonation allows a user with the prvActOnBehalfOfAnotherUser privilege to perform operations + /// as if they were another user. The impersonated user's identity is used for security checks and audit fields, + /// while the actual calling user is recorded in createdonbehalfof/modifiedonbehalfof fields. + /// + public class ImpersonationTests + { + [Fact] + public void Should_Allow_SystemAdministrator_To_Impersonate() + { + // Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + // The System Administrator role has the prvActOnBehalfOfAnotherUser privilege by default. + + // Arrange + var context = new XrmFakedContext(); + context.SecurityConfiguration.SecurityEnabled = true; + var service = context.GetOrganizationService(); + + var adminUserId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var businessUnitId = context.SecurityManager.RootBusinessUnitId; + + // Create admin user and assign System Administrator role + var adminUser = new Entity("systemuser") + { + Id = adminUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Admin User" + }; + context.AddEntity(adminUser); + + // Create target user + var targetUser = new Entity("systemuser") + { + Id = targetUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Target User" + }; + context.AddEntity(targetUser); + + // Assign System Administrator role to admin + service.Associate("systemuser", adminUserId, new Relationship("systemuserroles_association"), + new EntityReferenceCollection { new EntityReference("role", context.SecurityManager.SystemAdministratorRoleId) }); + + // Set caller as admin + context.CallerProperties.CallerId = new EntityReference("systemuser", adminUserId); + + // Set impersonation + context.CallerProperties.ImpersonatedUserId = new EntityReference("systemuser", targetUserId); + + // Act - Create an account (should succeed with no exception) + var account = new Entity("account") + { + Id = Guid.NewGuid(), + ["name"] = "Test Account" + }; + + // Assert - Should not throw + var exception = Record.Exception(() => service.Create(account)); + Assert.Null(exception); + } + + [Fact] + public void Should_Set_CreatedBy_To_ImpersonatedUser() + { + // Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + // When impersonating, createdby should be set to the impersonated user. + + // Arrange + var context = new XrmFakedContext(); + context.SecurityConfiguration.SecurityEnabled = true; + var service = context.GetOrganizationService(); + + var adminUserId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var businessUnitId = context.SecurityManager.RootBusinessUnitId; + + // Create users + var adminUser = new Entity("systemuser") + { + Id = adminUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Admin User" + }; + context.AddEntity(adminUser); + + var targetUser = new Entity("systemuser") + { + Id = targetUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Target User" + }; + context.AddEntity(targetUser); + + // Assign System Administrator role + service.Associate("systemuser", adminUserId, new Relationship("systemuserroles_association"), + new EntityReferenceCollection { new EntityReference("role", context.SecurityManager.SystemAdministratorRoleId) }); + + // Set impersonation + context.CallerProperties.CallerId = new EntityReference("systemuser", adminUserId); + context.CallerProperties.ImpersonatedUserId = new EntityReference("systemuser", targetUserId); + + // Act - Create an account + var accountId = Guid.NewGuid(); + var account = new Entity("account") + { + Id = accountId, + ["name"] = "Test Account" + }; + service.Create(account); + + // Assert - createdby should be target user + var retrieved = service.Retrieve("account", accountId, new Microsoft.Xrm.Sdk.Query.ColumnSet(true)); + Assert.NotNull(retrieved.GetAttributeValue("createdby")); + Assert.Equal(targetUserId, retrieved.GetAttributeValue("createdby").Id); + } + + [Fact] + public void Should_Set_CreatedOnBehalfOf_To_CallingUser() + { + // Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + // When impersonating, createdonbehalfof should be set to the actual calling user (the impersonator). + + // Arrange + var context = new XrmFakedContext(); + context.SecurityConfiguration.SecurityEnabled = true; + var service = context.GetOrganizationService(); + + var adminUserId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var businessUnitId = context.SecurityManager.RootBusinessUnitId; + + // Create users + var adminUser = new Entity("systemuser") + { + Id = adminUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Admin User" + }; + context.AddEntity(adminUser); + + var targetUser = new Entity("systemuser") + { + Id = targetUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Target User" + }; + context.AddEntity(targetUser); + + // Assign System Administrator role + service.Associate("systemuser", adminUserId, new Relationship("systemuserroles_association"), + new EntityReferenceCollection { new EntityReference("role", context.SecurityManager.SystemAdministratorRoleId) }); + + // Set impersonation + context.CallerProperties.CallerId = new EntityReference("systemuser", adminUserId); + context.CallerProperties.ImpersonatedUserId = new EntityReference("systemuser", targetUserId); + + // Act - Create an account + var accountId = Guid.NewGuid(); + var account = new Entity("account") + { + Id = accountId, + ["name"] = "Test Account" + }; + service.Create(account); + + // Assert - createdonbehalfof should be admin user (the impersonator) + var retrieved = service.Retrieve("account", accountId, new Microsoft.Xrm.Sdk.Query.ColumnSet(true)); + Assert.True(retrieved.Contains("createdonbehalfof")); + Assert.NotNull(retrieved.GetAttributeValue("createdonbehalfof")); + Assert.Equal(adminUserId, retrieved.GetAttributeValue("createdonbehalfof").Id); + } + + [Fact] + public void Should_Deny_Impersonation_Without_Privilege() + { + // Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + // A user without the prvActOnBehalfOfAnotherUser privilege cannot impersonate. + + // Arrange + var context = new XrmFakedContext(); + context.SecurityConfiguration.SecurityEnabled = true; + var service = context.GetOrganizationService(); + + var regularUserId = Guid.NewGuid(); + var targetUserId = Guid.NewGuid(); + var businessUnitId = context.SecurityManager.RootBusinessUnitId; + + // Create regular user (no special privileges) + var regularUser = new Entity("systemuser") + { + Id = regularUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Regular User" + }; + context.AddEntity(regularUser); + + // Create target user + var targetUser = new Entity("systemuser") + { + Id = targetUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Target User" + }; + context.AddEntity(targetUser); + + // Set caller as regular user + context.CallerProperties.CallerId = new EntityReference("systemuser", regularUserId); + + // Set impersonation + context.CallerProperties.ImpersonatedUserId = new EntityReference("systemuser", targetUserId); + + // Act & Assert - Should throw UnauthorizedAccessException + var account = new Entity("account") + { + Id = Guid.NewGuid(), + ["name"] = "Test Account" + }; + + var exception = Assert.Throws(() => service.Create(account)); + Assert.Contains("prvActOnBehalfOfAnotherUser", exception.Message); + } + + [Fact] + public void Should_Not_Set_CreatedOnBehalfOf_Without_Impersonation() + { + // When not impersonating, createdonbehalfof should not be set. + + // Arrange + var context = new XrmFakedContext(); + context.SecurityConfiguration.SecurityEnabled = true; + var service = context.GetOrganizationService(); + + var adminUserId = Guid.NewGuid(); + var businessUnitId = context.SecurityManager.RootBusinessUnitId; + + // Create admin user + var adminUser = new Entity("systemuser") + { + Id = adminUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Admin User" + }; + context.AddEntity(adminUser); + service.Associate("systemuser", adminUserId, new Relationship("systemuserroles_association"), + new EntityReferenceCollection { new EntityReference("role", context.SecurityManager.SystemAdministratorRoleId) }); + + // Set caller (no impersonation) + context.CallerProperties.CallerId = new EntityReference("systemuser", adminUserId); + context.CallerProperties.ImpersonatedUserId = null; // Explicitly no impersonation + + // Act - Create an account + var accountId = Guid.NewGuid(); + var account = new Entity("account") + { + Id = accountId, + ["name"] = "Test Account" + }; + service.Create(account); + + // Assert - createdonbehalfof should NOT be set + var retrieved = service.Retrieve("account", accountId, new Microsoft.Xrm.Sdk.Query.ColumnSet(true)); + Assert.False(retrieved.Contains("createdonbehalfof")); + } + } +} diff --git a/Fake4DataverseCore/Fake4Dataverse.Core/CallerProperties.cs b/Fake4DataverseCore/Fake4Dataverse.Core/CallerProperties.cs index 0d1c7024..af1ea2c4 100644 --- a/Fake4DataverseCore/Fake4Dataverse.Core/CallerProperties.cs +++ b/Fake4DataverseCore/Fake4Dataverse.Core/CallerProperties.cs @@ -8,11 +8,38 @@ public class CallerProperties : ICallerProperties { public EntityReference CallerId { get; set; } public EntityReference BusinessUnitId { get; set; } + + /// + /// Gets or sets the ID of the user to impersonate when making requests. + /// When set, this user's identity is used for security checks and audit fields, + /// while CallerId represents the actual calling user. + /// Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + /// The impersonating user must have the prvActOnBehalfOfAnotherUser privilege. + /// + public EntityReference ImpersonatedUserId { get; set; } public CallerProperties() { CallerId = new EntityReference("systemuser", Guid.NewGuid()); BusinessUnitId = new EntityReference("businessunit", Guid.NewGuid()); } + + /// + /// Gets the effective user ID for operations. + /// Returns ImpersonatedUserId if impersonation is active, otherwise CallerId. + /// This is the user identity used for security checks, audit fields, and ownership. + /// Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + /// When impersonating, operations are performed as if the impersonated user made them. + /// + public EntityReference GetEffectiveUser() + { + // Ensure CallerId is never null + if (CallerId == null) + { + CallerId = new EntityReference("systemuser", Guid.NewGuid()); + } + + return ImpersonatedUserId ?? CallerId; + } } } diff --git a/Fake4DataverseCore/Fake4Dataverse.Core/Security/Middleware/SecurityMiddleware.cs b/Fake4DataverseCore/Fake4Dataverse.Core/Security/Middleware/SecurityMiddleware.cs index d939ccec..47349b8d 100644 --- a/Fake4DataverseCore/Fake4Dataverse.Core/Security/Middleware/SecurityMiddleware.cs +++ b/Fake4DataverseCore/Fake4Dataverse.Core/Security/Middleware/SecurityMiddleware.cs @@ -36,12 +36,44 @@ public static IMiddlewareBuilder AddSecurity(this IMiddlewareBuilder builder) return next(context, request); } - // Check if user is System Administrator - they bypass all security - var callerId = context.CallerProperties.CallerId; + // Validate impersonation if active (must be done before sys admin bypass) + // Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + // The calling user must have the prvActOnBehalfOfAnotherUser privilege to impersonate another user. + // Even System Administrators need to perform this check on the CALLING user, not the impersonated user. + if (context.CallerProperties.ImpersonatedUserId != null) + { + var callerId = context.CallerProperties.CallerId; + if (callerId == null) + { + throw new UnauthorizedAccessException("Cannot impersonate without a calling user context."); + } + + // Check if the CALLING user (not impersonated) is System Administrator or has the privilege + bool isCallerSystemAdmin = context.SecurityManager.IsSystemAdministrator(callerId.Id); + + if (!isCallerSystemAdmin) + { + // Check if the calling user has the prvActOnBehalfOfAnotherUser privilege + var hasPrivilege = context.SecurityManager.PrivilegeManager.HasPrivilege( + callerId.Id, + PrivilegeManager.ActOnBehalfOfAnotherUserPrivilege, + PrivilegeManager.PrivilegeDepthGlobal); + + if (!hasPrivilege) + { + throw new UnauthorizedAccessException( + $"User {callerId.Id} does not have the '{PrivilegeManager.ActOnBehalfOfAnotherUserPrivilege}' privilege required for impersonation."); + } + } + } + + // Check if EFFECTIVE user is System Administrator - they bypass all OTHER security + // The effective user is the impersonated user if impersonating, otherwise the caller + var effectiveUser = context.CallerProperties.ImpersonatedUserId ?? context.CallerProperties.CallerId; - if (callerId != null && callerId.LogicalName == "systemuser") + if (effectiveUser != null && effectiveUser.LogicalName == "systemuser") { - if (context.SecurityManager.IsSystemAdministrator(callerId.Id)) + if (context.SecurityManager.IsSystemAdministrator(effectiveUser.Id)) { // System Administrators bypass all security checks return next(context, request); diff --git a/Fake4DataverseCore/Fake4Dataverse.Core/Security/PrivilegeManager.cs b/Fake4DataverseCore/Fake4Dataverse.Core/Security/PrivilegeManager.cs index b524b4b9..cb465346 100644 --- a/Fake4DataverseCore/Fake4Dataverse.Core/Security/PrivilegeManager.cs +++ b/Fake4DataverseCore/Fake4Dataverse.Core/Security/PrivilegeManager.cs @@ -26,6 +26,12 @@ public class PrivilegeManager : IPrivilegeManager public const int PrivilegeDepthLocal = 2; // User's business unit public const int PrivilegeDepthDeep = 4; // User's business unit and child business units public const int PrivilegeDepthGlobal = 8; // Organization-wide + + // Special privilege names + // Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + // The prvActOnBehalfOfAnotherUser privilege is required to impersonate other users. + // This privilege is typically granted to the System Administrator role. + public const string ActOnBehalfOfAnotherUserPrivilege = "prvActOnBehalfOfAnotherUser"; public PrivilegeManager(IXrmFakedContext context) { diff --git a/Fake4DataverseCore/Fake4Dataverse.Core/Services/EntityInitializer/DefaultEntityInitializerService.cs b/Fake4DataverseCore/Fake4Dataverse.Core/Services/EntityInitializer/DefaultEntityInitializerService.cs index 605a8054..8adb3378 100644 --- a/Fake4DataverseCore/Fake4Dataverse.Core/Services/EntityInitializer/DefaultEntityInitializerService.cs +++ b/Fake4DataverseCore/Fake4Dataverse.Core/Services/EntityInitializer/DefaultEntityInitializerService.cs @@ -44,6 +44,17 @@ public Entity Initialize(Entity e, Guid gCallerId, XrmFakedContext ctx, bool isM } var CallerId = new EntityReference("systemuser", gCallerId); //Create a new instance by default + + // Handle impersonation for audit fields + // Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + // When impersonating: + // - createdby/modifiedby = impersonated user (passed in as gCallerId - the effective user) + // - createdonbehalfof/modifiedonbehalfof = actual calling user (from CallerProperties.CallerId) + var callerProperties = ctx.CallerProperties as CallerProperties; + var isImpersonating = callerProperties?.ImpersonatedUserId != null; + + // The gCallerId parameter already represents the effective user (impersonated or actual caller) + var effectiveUser = CallerId; var now = DateTime.UtcNow; @@ -56,9 +67,19 @@ public Entity Initialize(Entity e, Guid gCallerId, XrmFakedContext ctx, bool isM } e.SetValueIfEmpty("modifiedon", now); - e.SetValueIfEmpty("createdby", CallerId); - e.SetValueIfEmpty("modifiedby", CallerId); - e.SetValueIfEmpty("ownerid", CallerId); + e.SetValueIfEmpty("createdby", effectiveUser); + e.SetValueIfEmpty("modifiedby", effectiveUser); + e.SetValueIfEmpty("ownerid", effectiveUser); + + // Set createdonbehalfof when impersonating + // Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api + // The createdonbehalfof field stores the actual user who initiated the operation (the impersonator) + if (isImpersonating && callerProperties?.CallerId != null) + { + e.SetValueIfEmpty("createdonbehalfof", callerProperties.CallerId); + e.SetValueIfEmpty("modifiedonbehalfof", callerProperties.CallerId); + } + e.SetValueIfEmpty("statecode", new OptionSetValue(0)); //Active by default // Process auto number fields diff --git a/Fake4DataverseCore/Fake4Dataverse.Core/XrmFakedContext.Audit.cs b/Fake4DataverseCore/Fake4Dataverse.Core/XrmFakedContext.Audit.cs index 95e3cf67..079dfdaf 100644 --- a/Fake4DataverseCore/Fake4Dataverse.Core/XrmFakedContext.Audit.cs +++ b/Fake4DataverseCore/Fake4Dataverse.Core/XrmFakedContext.Audit.cs @@ -129,6 +129,9 @@ private bool ShouldAuditEntity(string entityLogicalName) /// For Create operations in Dataverse: /// - OldValue is an empty entity (the record didn't exist) /// - NewValue contains the created entity's attributes + /// + /// When impersonating, the audit uses the impersonated user's ID. + /// Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api /// internal void RecordCreateAudit(Entity entity) { @@ -138,7 +141,8 @@ internal void RecordCreateAudit(Entity entity) } var auditRepository = GetProperty(); - var userId = CallerProperties?.CallerId?.Id ?? Guid.Empty; + var effectiveUser = (CallerProperties as CallerProperties)?.GetEffectiveUser() ?? CallerProperties?.CallerId; + var userId = effectiveUser?.Id ?? Guid.Empty; var objectRef = new EntityReference(entity.LogicalName, entity.Id); // Pass the created entity so audit details can capture the new values @@ -152,6 +156,9 @@ internal void RecordCreateAudit(Entity entity) /// /// Records an audit entry for an Update operation + /// + /// When impersonating, the audit uses the impersonated user's ID. + /// Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api /// internal void RecordUpdateAudit(Entity oldEntity, Entity newEntity) { @@ -161,7 +168,8 @@ internal void RecordUpdateAudit(Entity oldEntity, Entity newEntity) } var auditRepository = GetProperty(); - var userId = CallerProperties?.CallerId?.Id ?? Guid.Empty; + var effectiveUser = (CallerProperties as CallerProperties)?.GetEffectiveUser() ?? CallerProperties?.CallerId; + var userId = effectiveUser?.Id ?? Guid.Empty; var objectRef = new EntityReference(newEntity.LogicalName, newEntity.Id); // System-managed attributes that should not be audited as user changes @@ -208,6 +216,9 @@ internal void RecordUpdateAudit(Entity oldEntity, Entity newEntity) /// /// Records an audit entry for a Delete operation + /// + /// When impersonating, the audit uses the impersonated user's ID. + /// Reference: https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api /// internal void RecordDeleteAudit(EntityReference entityRef) { @@ -217,7 +228,8 @@ internal void RecordDeleteAudit(EntityReference entityRef) } var auditRepository = GetProperty(); - var userId = CallerProperties?.CallerId?.Id ?? Guid.Empty; + var effectiveUser = (CallerProperties as CallerProperties)?.GetEffectiveUser() ?? CallerProperties?.CallerId; + var userId = effectiveUser?.Id ?? Guid.Empty; auditRepository.CreateAuditRecord( AuditAction.Delete, diff --git a/Fake4DataverseCore/Fake4Dataverse.Core/XrmFakedContext.Crud.cs b/Fake4DataverseCore/Fake4Dataverse.Core/XrmFakedContext.Crud.cs index 78cac71f..86c5806e 100644 --- a/Fake4DataverseCore/Fake4Dataverse.Core/XrmFakedContext.Crud.cs +++ b/Fake4DataverseCore/Fake4Dataverse.Core/XrmFakedContext.Crud.cs @@ -392,11 +392,12 @@ public void DeleteEntity(EntityReference er) public void AddEntityDefaultAttributes(Entity e) { - // Ensure we have a valid caller - if ( CallerProperties?.CallerId == null) + // Add createdon, modifiedon, createdby, modifiedby properties + // Ensure CallerProperties.CallerId is set if not already + if (CallerProperties != null && CallerProperties.CallerId == null) { - CallerProperties.CallerId = new EntityReference("systemuser", Guid.NewGuid()); // Create a new instance by default - } + CallerProperties.CallerId = new EntityReference("systemuser", Guid.NewGuid()); + // Ensure the systemuser entity exists for the caller var systemUserLock = _entityLocks.GetOrAdd("systemuser", _ => new object()); @@ -405,13 +406,26 @@ public void AddEntityDefaultAttributes(Entity e) var systemUserCollection = Data.GetOrAdd("systemuser", _ => new Dictionary()); if (!systemUserCollection.ContainsKey( CallerProperties.CallerId.Id)) { - systemUserCollection.Add( CallerProperties.CallerId.Id, new Entity("systemuser") { Id = CallerProperties.CallerId.Id }); + var systemUserLock = _entityLocks.GetOrAdd("systemuser", _ => new object()); + lock (systemUserLock) + { + var systemUserCollection = Data.GetOrAdd("systemuser", _ => new Dictionary()); + if (!systemUserCollection.ContainsKey(CallerProperties.CallerId.Id)) + { + systemUserCollection.Add(CallerProperties.CallerId.Id, new Entity("systemuser") { Id = CallerProperties.CallerId.Id }); + } + } } } - + var isManyToManyRelationshipEntity = e.LogicalName != null && this._relationships.ContainsKey(e.LogicalName); - EntityInitializerService.Initialize(e, CallerProperties.CallerId.Id, this, isManyToManyRelationshipEntity); + // Get the effective user ID for entity initialization + // When impersonating, use the impersonated user's ID, otherwise use the caller's ID + var effectiveUser = CallerProperties?.GetEffectiveUser(); + var effectiveUserId = effectiveUser?.Id ?? Guid.Empty; + + EntityInitializerService.Initialize(e, effectiveUserId, this, isManyToManyRelationshipEntity); } protected void ValidateEntity(Entity e) diff --git a/docs/README.md b/docs/README.md index b41b9a78..f3c4e710 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ Welcome to the Fake4Dataverse documentation! This testing framework allows you t - [Testing Workflows](./usage/testing-workflows.md) - Custom workflow activity testing - **[Security Model](./usage/security-model.md)** - Complete Dataverse security implementation ✅ **NEW** - [Security and Permissions](./usage/security-permissions.md) - Testing security roles and access +- **[Impersonation](./usage/impersonation.md)** - Perform operations on behalf of other users ✅ **NEW** - [ExecuteMultiple and Transactions](./usage/batch-operations.md) - Batch operations and transactions - [Auto Number Fields](./usage/auto-number-fields.md) - Auto-generated field values ✅ **NEW** - [Metadata Validation](./usage/metadata-validation.md) - IsValidForCreate/Update/Read enforcement ✅ **NEW** @@ -43,6 +44,7 @@ Welcome to the Fake4Dataverse documentation! This testing framework allows you t ### 🌐 [Network Testing](.) - [Fake4DataverseService](./service.md) - Network-accessible SOAP/WCF service for integration testing ✅ **NEW** - [REST/OData API Endpoints](./rest-api.md) - OData v4.0 endpoints with advanced query support ✅ **NEW** +- **[Model-Driven App Interface](./usage/mda-interface.md)** - Web UI for visual testing and user impersonation ✅ **NEW** ### 🏗️ [Architecture & Planning](.) - [Testing Guide](./TESTING_GUIDE.md) - How to run all tests in the repository ✅ **NEW** @@ -89,9 +91,11 @@ Welcome to the Fake4Dataverse documentation! This testing framework allows you t - **Understand the architecture**: Read [Middleware Architecture](./concepts/middleware.md) - **Test security with full Dataverse model**: See [Security Model](./usage/security-model.md) ✅ **NEW** - **Test basic security**: See [Security and Permissions](./usage/security-permissions.md) +- **Impersonate another user**: See [Impersonation](./usage/impersonation.md) - **Migrate from FakeXrmEasy**: Check the [Migration Guides](./migration/) - **Find supported messages**: Browse [Message Executors](./messages/) - **Do integration testing**: Use [Fake4DataverseService](./service.md) for network-accessible testing +- **Use the web interface**: See [Model-Driven App Interface](./usage/mda-interface.md) for visual testing ## Quick Example diff --git a/docs/usage/impersonation.md b/docs/usage/impersonation.md new file mode 100644 index 00000000..7609a7a1 --- /dev/null +++ b/docs/usage/impersonation.md @@ -0,0 +1,332 @@ +# Impersonation + +**Implementation Date:** January 2025 +**Issue:** [#116](https://github.com/rnwood/Fake4Dataverse/issues/116) + +## Overview + +Impersonation allows a user with appropriate privileges to perform operations on behalf of another user. This is useful for: +- Service accounts performing actions for end users +- Administrators troubleshooting user-specific issues +- Integration scenarios where one system acts on behalf of users + +**Reference:** [Microsoft Learn - Impersonate another user](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api) + +## How Impersonation Works + +When impersonating: +1. The **calling user** (impersonator) must have the `prvActOnBehalfOfAnotherUser` privilege +2. Operations are performed as if the **impersonated user** made them +3. Security checks use the impersonated user's permissions +4. Audit fields reflect both users: + - `createdby`/`modifiedby` = impersonated user + - `createdonbehalfof`/`modifiedonbehalfof` = calling user (impersonator) + +## Core API Usage + +### Setting Up Impersonation + +```csharp +var context = new XrmFakedContext(); +context.SecurityConfiguration.SecurityEnabled = true; +var service = context.GetOrganizationService(); + +// Create users +var adminUserId = Guid.NewGuid(); +var targetUserId = Guid.NewGuid(); + +var adminUser = new Entity("systemuser") +{ + Id = adminUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Admin User" +}; +context.AddEntity(adminUser); + +var targetUser = new Entity("systemuser") +{ + Id = targetUserId, + ["businessunitid"] = new EntityReference("businessunit", businessUnitId), + ["fullname"] = "Target User" +}; +context.AddEntity(targetUser); + +// Assign System Administrator role to admin +service.Associate("systemuser", adminUserId, + new Relationship("systemuserroles_association"), + new EntityReferenceCollection { new EntityReference("role", context.SecurityManager.SystemAdministratorRoleId) }); + +// Set up impersonation +context.CallerProperties.CallerId = new EntityReference("systemuser", adminUserId); +context.CallerProperties.ImpersonatedUserId = new EntityReference("systemuser", targetUserId); + +// All subsequent operations are performed as targetUser +var account = new Entity("account") { ["name"] = "Contoso Ltd" }; +var accountId = service.Create(account); + +// The account is created as if targetUser created it +var retrieved = service.Retrieve("account", accountId, new ColumnSet(true)); +Assert.Equal(targetUserId, retrieved.GetAttributeValue("createdby").Id); +Assert.Equal(adminUserId, retrieved.GetAttributeValue("createdonbehalfof").Id); +``` + +### Clearing Impersonation + +```csharp +// Stop impersonating +context.CallerProperties.ImpersonatedUserId = null; + +// Operations now performed as the actual calling user +``` + +## Privilege Requirements + +The calling user must have one of the following: +1. **System Administrator role** (has `prvActOnBehalfOfAnotherUser` implicitly) +2. **Custom role** with the `prvActOnBehalfOfAnotherUser` privilege assigned + +### Granting Impersonation Privilege + +```csharp +// Create the impersonation privilege if it doesn't exist +var impersonationPrivilege = new Entity("privilege") +{ + Id = Guid.NewGuid(), + ["name"] = "prvActOnBehalfOfAnotherUser", + ["accessright"] = 0, // Special privilege (not entity-specific) + ["privilegeid"] = Guid.NewGuid() +}; +context.AddEntity(impersonationPrivilege); + +// Assign to a role +var rolePrivilege = new Entity("roleprivileges") +{ + Id = Guid.NewGuid(), + ["roleid"] = new EntityReference("role", customRoleId), + ["privilegeid"] = new EntityReference("privilege", impersonationPrivilege.Id), + ["privilegedepthmask"] = 8 // Global depth +}; +context.AddEntity(rolePrivilege); +``` + +## Service Layer Usage + +### Web API / REST (HTTP Header) + +When using Fake4DataverseService with HTTP/REST endpoints, set the `MSCRMCallerID` header: + +```csharp +using (var client = new HttpClient()) +{ + client.BaseAddress = new Uri("http://localhost:5000/api/data/v9.2/"); + + // Set the impersonation header + client.DefaultRequestHeaders.Add("MSCRMCallerID", targetUserId.ToString()); + + // Create an account - operation performed as targetUserId + var account = new { name = "Contoso Ltd" }; + var response = await client.PostAsJsonAsync("accounts", account); +} +``` + +### SOAP / WCF (SOAP Header) + +When using the SOAP endpoint, set the `CallerObjectId` in the SOAP header: + +```csharp +var binding = new BasicHttpBinding(); +var endpoint = new EndpointAddress("http://localhost:5000/XRMServices/2011/Organization.svc"); +var factory = new ChannelFactory(binding, endpoint); + +using (var scope = new OperationContextScope((IContextChannel)service)) +{ + // Add the impersonation header + var header = MessageHeader.CreateHeader( + "CallerId", + "http://schemas.microsoft.com/xrm/2011/Contracts", + targetUserId); + OperationContext.Current.OutgoingMessageHeaders.Add(header); + + // Create an account - operation performed as targetUserId + var account = new Entity("account") { ["name"] = "Contoso Ltd" }; + var accountId = service.Create(account); +} +``` + +## Security Behavior + +### Permission Checks + +When impersonating, **all security checks use the impersonated user's permissions**: + +```csharp +// Admin impersonates a user with limited permissions +context.CallerProperties.CallerId = new EntityReference("systemuser", adminUserId); +context.CallerProperties.ImpersonatedUserId = new EntityReference("systemuser", limitedUserId); + +// This will FAIL if limitedUser doesn't have create privilege for account +// Even though adminUser is a System Administrator +var account = new Entity("account") { ["name"] = "Test" }; +service.Create(account); // Throws UnauthorizedAccessException +``` + +### Impersonation Validation + +If the calling user lacks the impersonation privilege: + +```csharp +// Regular user without impersonation privilege +context.CallerProperties.CallerId = new EntityReference("systemuser", regularUserId); +context.CallerProperties.ImpersonatedUserId = new EntityReference("systemuser", targetUserId); + +// This will throw UnauthorizedAccessException +service.Create(account); +// Error: "User {regularUserId} does not have the 'prvActOnBehalfOfAnotherUser' privilege" +``` + +## Audit Trail + +Impersonation creates a complete audit trail: + +```csharp +// After creating with impersonation +var account = service.Retrieve("account", accountId, new ColumnSet(true)); + +// Fields show both users +Console.WriteLine($"Created by: {account.GetAttributeValue("createdby").Id}"); +// Output: Created by: {targetUserId} + +Console.WriteLine($"Created on behalf of: {account.GetAttributeValue("createdonbehalfof").Id}"); +// Output: Created on behalf of: {adminUserId} + +// Audit records also reflect the impersonated user +var auditRecords = context.AuditRepository.GetAuditRecordsForEntity("account", accountId); +var auditUserId = auditRecords.First().GetAttributeValue("userid").Id; +Assert.Equal(targetUserId, auditUserId); +``` + +## Common Patterns + +### Bulk Operations + +```csharp +// Impersonate once, perform multiple operations +context.CallerProperties.CallerId = adminRef; +context.CallerProperties.ImpersonatedUserId = userRef; + +foreach (var item in items) +{ + var entity = new Entity("account") { ["name"] = item.Name }; + service.Create(entity); +} + +// Clear impersonation +context.CallerProperties.ImpersonatedUserId = null; +``` + +### Plugin Context + +```csharp +// In a plugin test, set impersonation for the plugin execution +var pluginContext = context.GetDefaultPluginContext(); +pluginContext.InitiatingUserId = adminUserId; +pluginContext.UserId = targetUserId; // The impersonated user + +// Execute plugin +var plugin = new YourPlugin(); +plugin.Execute(pluginContext); +``` + +### Testing Impersonation Logic + +```csharp +[Fact] +public void Should_Respect_Impersonated_User_Permissions() +{ + // Arrange + var context = new XrmFakedContext(); + context.SecurityConfiguration.SecurityEnabled = true; + + var admin = CreateAdminUser(context); + var limitedUser = CreateUserWithLimitedPermissions(context); + + context.CallerProperties.CallerId = admin.ToEntityReference(); + context.CallerProperties.ImpersonatedUserId = limitedUser.ToEntityReference(); + + var service = context.GetOrganizationService(); + + // Act & Assert - Should fail due to limited user's permissions + var account = new Entity("account") { ["name"] = "Test" }; + Assert.Throws(() => service.Create(account)); +} +``` + +## Key Differences from FakeXrmEasy v2 + +**Important**: Fake4Dataverse's impersonation differs from FakeXrmEasy v2+ in the following ways: + +| Feature | FakeXrmEasy v2+ | Fake4Dataverse v4 | +|---------|-----------------|-------------------| +| Property Name | `CallerProperties.ImpersonationUserId` | `CallerProperties.ImpersonatedUserId` | +| Interface Method | None | `ICallerProperties.GetEffectiveUser()` | +| Privilege Check | Automatic | Explicit validation in SecurityMiddleware | +| HTTP Header | Manual setup | Built-in middleware support | +| SOAP Header | Manual setup | Built-in middleware support | + +## Best Practices + +1. **Always enable security** when testing impersonation: + ```csharp + context.SecurityConfiguration.SecurityEnabled = true; + ``` + +2. **Clear impersonation** when no longer needed: + ```csharp + context.CallerProperties.ImpersonatedUserId = null; + ``` + +3. **Test both success and failure** scenarios: + - Verify operations succeed with proper privileges + - Verify operations fail without proper privileges + - Verify audit fields are set correctly + +4. **Use meaningful user names** in tests: + ```csharp + var admin = new Entity("systemuser") { ["fullname"] = "Admin User" }; + var target = new Entity("systemuser") { ["fullname"] = "Target User" }; + ``` + +## Troubleshooting + +### "User does not have the 'prvActOnBehalfOfAnotherUser' privilege" + +**Cause:** The calling user (impersonator) lacks the impersonation privilege. + +**Solution:** Assign System Administrator role or grant the specific privilege: +```csharp +service.Associate("systemuser", callerUserId, + new Relationship("systemuserroles_association"), + new EntityReferenceCollection { new EntityReference("role", sysAdminRoleId) }); +``` + +### Operations Fail with Impersonation + +**Cause:** The impersonated user lacks necessary permissions. + +**Solution:** Grant appropriate privileges to the impersonated user or verify role assignments. + +### createdonbehalfof Not Being Set + +**Cause:** Impersonation is not active (ImpersonatedUserId is null). + +**Solution:** Ensure ImpersonatedUserId is set before the operation: +```csharp +context.CallerProperties.ImpersonatedUserId = targetUserRef; +``` + +## See Also + +- [Security Model](security-model.md) - Overview of Fake4Dataverse security +- [Security Permissions](security-permissions.md) - Managing roles and privileges +- [Audit](auditing.md) - Audit trail functionality +- [Microsoft Learn - Impersonation](https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/impersonate-another-user-web-api) diff --git a/docs/usage/mda-interface.md b/docs/usage/mda-interface.md new file mode 100644 index 00000000..59028d15 --- /dev/null +++ b/docs/usage/mda-interface.md @@ -0,0 +1,341 @@ +# Model-Driven App (MDA) Interface + +**Implementation Date:** January 2025 +**Issue:** [#116](https://github.com/rnwood/Fake4Dataverse/issues/116) + +## Overview + +The Fake4DataverseService includes a web-based Model-Driven App (MDA) interface that provides a visual way to interact with the fake Dataverse instance. This is particularly useful for: +- Manual testing and exploration of test data +- Debugging issues during test development +- Demonstrating test scenarios to stakeholders +- Validating impersonation and security configurations + +## Accessing the MDA + +When Fake4DataverseService is running, the MDA is accessible at: + +``` +http://localhost:{port}/mda/ +``` + +Default: `http://localhost:5000/mda/` + +## Key Features + +### 1. User Management and Impersonation + +The MDA header displays the current user context: + +- **User Avatar**: Shows the currently active user (or impersonated user) +- **User Switcher**: Click the avatar to switch between users +- **Impersonation Indicator**: Visual indicator when impersonating another user +- **Real-time Update**: Operations immediately reflect the selected user context + +#### Using the User Switcher + +1. Click the user avatar in the top-right corner +2. Select a user from the dropdown list +3. All subsequent operations are performed as that user +4. The interface refreshes to show data visible to the selected user + +#### Impersonation Workflow + +When you switch users in the MDA: +1. The MDA sets the `MSCRMCallerID` HTTP header on all requests +2. The service applies impersonation for the selected user +3. Security checks use the impersonated user's permissions +4. Created/modified records show the correct audit trail + +### 2. Entity Data Browsing + +- View all entities in the fake instance +- Browse records with filtering and sorting +- View entity metadata and relationships +- Inspect field values including lookups and option sets + +### 3. Record Operations + +- **Create**: Add new records through forms +- **Update**: Modify existing records +- **Delete**: Remove records +- **Associate**: Link records via relationships + +All operations respect the current user's security permissions. + +### 4. Security Visualization + +- See which records are visible to the current user +- Test security role configurations +- Verify business unit hierarchies +- Validate sharing permissions + +## Setup and Configuration + +### Starting the Service with MDA + +```bash +# Start with default settings +fake4dataverse start --port 5000 + +# Custom configuration +fake4dataverse start --port 8080 --host 0.0.0.0 +``` + +The MDA is automatically available at the `/mda/` endpoint. + +### Creating Initial Users + +When the service starts, it automatically creates a System Administrator user: + +```csharp +// Automatically created on service startup +var systemAdmin = new Entity("systemuser") +{ + Id = Guid.NewGuid(), + ["fullname"] = "System Administrator", + ["businessunitid"] = rootBusinessUnit.ToEntityReference() +}; +``` + +You can create additional users via: +1. The MDA interface itself +2. SOAP/OData API calls +3. Initialization scripts + +### Configuring User List + +The user switcher displays all systemuser records in the instance. To add users: + +```csharp +// Via OData API +POST http://localhost:5000/api/data/v9.2/systemusers +Content-Type: application/json + +{ + "fullname": "Test User", + "businessunitid@odata.bind": "/businessunits({businessunitid})" +} +``` + +Or through the MDA's user management interface. + +## Example Scenarios + +### Scenario 1: Testing Security Roles + +``` +1. Start Fake4DataverseService +2. Open MDA in browser +3. Create two users: "Admin" and "Sales Rep" +4. Assign different security roles to each +5. Create some account records as Admin +6. Switch to Sales Rep user +7. Observe which records are visible +8. Try creating/updating records +9. Verify security enforcement +``` + +### Scenario 2: Impersonation Testing + +``` +1. Create Admin user with System Administrator role +2. Create Target user with limited permissions +3. Switch to Admin user in MDA +4. The backend automatically sets impersonation +5. Create an account record +6. Inspect the record to see: + - createdby = Admin + - createdonbehalfof = (not set, since not impersonating) +7. In your application, set impersonation headers +8. Create another record +9. Inspect to see both users in audit fields +``` + +### Scenario 3: Multi-User Workflows + +``` +1. Create users for different roles (Sales, Manager, Admin) +2. Switch between users to simulate workflow +3. Sales creates an opportunity +4. Manager reviews and updates +5. Admin performs administrative tasks +6. Verify audit trail shows correct user chain +``` + +## Technical Implementation + +### Architecture + +The MDA is a Next.js application embedded in the Fake4DataverseService: + +``` +┌─────────────────────────────────────┐ +│ Browser (MDA UI) │ +│ (Next.js + React) │ +└────────────┬────────────────────────┘ + │ HTTP + MSCRMCallerID header + │ +┌────────────▼────────────────────────┐ +│ Fake4DataverseService │ +│ ┌──────────────────────────────┐ │ +│ │ Impersonation Middleware │ │ +│ │ (Extract MSCRMCallerID) │ │ +│ └──────────┬───────────────────┘ │ +│ │ │ +│ ┌──────────▼───────────────────┐ │ +│ │ OData/SOAP Endpoints │ │ +│ └──────────┬───────────────────┘ │ +└─────────────┼───────────────────────┘ + │ + ┌──────────▼───────────────────┐ + │ Fake4DataverseCore │ + │ (In-Memory Context) │ + └──────────────────────────────┘ +``` + +### HTTP Header Format + +When a user is selected in the MDA, all requests include: + +```http +GET /api/data/v9.2/accounts HTTP/1.1 +Host: localhost:5000 +MSCRMCallerID: {userId} +``` + +The middleware extracts this header and sets `CallerProperties.ImpersonatedUserId`. + +### State Management + +The MDA maintains user selection in: +1. Browser session storage +2. React context +3. HTTP headers on every request + +Switching users: +1. Updates React state +2. Stores selection in session +3. Adds header to all subsequent requests +4. Refreshes data view + +## Development and Testing + +### Running MDA in Development + +```bash +cd Fake4DataverseService/Fake4Dataverse.Service/mda-app + +# Install dependencies +npm ci + +# Run development server +npm run dev + +# Run alongside service +dotnet run --project ../Fake4Dataverse.Service.csproj +``` + +### MDA Tests + +```bash +# Unit tests +npm test + +# E2E tests +npm run test:e2e +``` + +### Building for Production + +```bash +# Build Next.js app +npm run build + +# The build is embedded in the service package +dotnet pack +``` + +## Troubleshooting + +### MDA Not Loading + +**Problem:** Navigating to `/mda/` shows 404 or blank page + +**Solutions:** +- Ensure Fake4DataverseService is running +- Check the correct port +- Verify the MDA app was built: `mda-app/out/` should exist +- Check console for JavaScript errors + +### User Switcher Empty + +**Problem:** No users appear in the switcher dropdown + +**Solutions:** +- Create at least one systemuser record +- Verify systemuser entity is not filtered +- Check browser console for API errors +- Ensure OData endpoint is accessible + +### Impersonation Not Working + +**Problem:** Operations don't reflect the selected user + +**Solutions:** +- Verify the MSCRMCallerID header is being sent (check browser dev tools) +- Ensure impersonation middleware is registered in service +- Check security is enabled: `SecurityConfiguration.SecurityEnabled = true` +- Verify user has necessary privileges + +### Permission Errors + +**Problem:** Operations fail with permission errors + +**Solutions:** +- Verify the selected user has appropriate security roles +- Check business unit hierarchy +- Ensure privileges are assigned correctly +- Use System Administrator for debugging + +## API Reference + +### Getting Available Users + +```http +GET /api/data/v9.2/systemusers?$select=systemuserid,fullname +``` + +### Setting Current User + +```http +GET /api/data/v9.2/accounts +MSCRMCallerID: {userId} +``` + +### Getting Current User Context + +```http +POST /api/data/v9.2/WhoAmI +``` + +Response includes the effective user when impersonating. + +## Best Practices + +1. **Create Meaningful Users**: Use descriptive names like "Admin User", "Sales Rep", "Manager" + +2. **Assign Proper Roles**: Give each test user appropriate security roles for realistic testing + +3. **Test Permission Boundaries**: Switch between users to verify security enforcement + +4. **Clear Data Between Tests**: Use the MDA to inspect data state between test runs + +5. **Document User Scenarios**: Maintain a list of test users and their purposes + +## See Also + +- [Impersonation](./impersonation.md) - Core impersonation concepts and API +- [Security Model](./security-model.md) - Dataverse security implementation +- [Fake4DataverseService](../service.md) - Service architecture and setup +- [REST API](../rest-api.md) - OData endpoint reference