diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs index edca89e56b..0badbc9f30 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/AuditLogBuilderExtensions.cs @@ -11,13 +11,13 @@ internal static class AuditLogBuilderExtensions internal static IUmbracoBuilder AddAuditLogs(this IUmbracoBuilder builder) { builder.Services.AddTransient(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); - builder.AddNotificationHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Security/BackOfficeUserManagerAuditer.cs b/src/Umbraco.Cms.Api.Management/Security/BackOfficeUserManagerAuditer.cs index 561e67bd38..ab06c518ea 100644 --- a/src/Umbraco.Cms.Api.Management/Security/BackOfficeUserManagerAuditer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/BackOfficeUserManagerAuditer.cs @@ -1,4 +1,8 @@ using System.Globalization; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Notifications; @@ -12,24 +16,32 @@ namespace Umbraco.Cms.Api.Management.Security; /// Binds to notifications to write audit logs for the /// internal sealed class BackOfficeUserManagerAuditer : - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler, - INotificationHandler + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler { - private readonly IAuditService _auditService; + private readonly IAuditEntryService _auditEntryService; private readonly IUserService _userService; - public BackOfficeUserManagerAuditer(IAuditService auditService, IUserService userService) + /// + /// Initializes a new instance of the class. + /// + /// The audit entry service. + /// The user service. + public BackOfficeUserManagerAuditer( + IAuditEntryService auditEntryService, + IUserService userService) { - _auditService = auditService; + _auditEntryService = auditEntryService; _userService = userService; } - public void Handle(UserForgotPasswordChangedNotification notification) => + /// + public Task HandleAsync(UserForgotPasswordChangedNotification notification, CancellationToken cancellationToken) => WriteAudit( notification.PerformingUserId, notification.AffectedUserId, @@ -37,7 +49,8 @@ internal sealed class BackOfficeUserManagerAuditer : "umbraco/user/password/forgot/change", "password forgot/change"); - public void Handle(UserForgotPasswordRequestedNotification notification) => + /// + public Task HandleAsync(UserForgotPasswordRequestedNotification notification, CancellationToken cancellationToken) => WriteAudit( notification.PerformingUserId, notification.AffectedUserId, @@ -45,7 +58,8 @@ internal sealed class BackOfficeUserManagerAuditer : "umbraco/user/password/forgot/request", "password forgot/request"); - public void Handle(UserLoginFailedNotification notification) => + /// + public Task HandleAsync(UserLoginFailedNotification notification, CancellationToken cancellationToken) => WriteAudit( notification.PerformingUserId, null, @@ -53,7 +67,8 @@ internal sealed class BackOfficeUserManagerAuditer : "umbraco/user/sign-in/failed", "login failed"); - public void Handle(UserLoginSuccessNotification notification) + /// + public Task HandleAsync(UserLoginSuccessNotification notification, CancellationToken cancellationToken) => WriteAudit( notification.PerformingUserId, notification.AffectedUserId, @@ -61,7 +76,8 @@ internal sealed class BackOfficeUserManagerAuditer : "umbraco/user/sign-in/login", "login success"); - public void Handle(UserLogoutSuccessNotification notification) + /// + public Task HandleAsync(UserLogoutSuccessNotification notification, CancellationToken cancellationToken) => WriteAudit( notification.PerformingUserId, notification.AffectedUserId, @@ -69,7 +85,8 @@ internal sealed class BackOfficeUserManagerAuditer : "umbraco/user/sign-in/logout", "logout success"); - public void Handle(UserPasswordChangedNotification notification) => + /// + public Task HandleAsync(UserPasswordChangedNotification notification, CancellationToken cancellationToken) => WriteAudit( notification.PerformingUserId, notification.AffectedUserId, @@ -77,7 +94,8 @@ internal sealed class BackOfficeUserManagerAuditer : "umbraco/user/password/change", "password change"); - public void Handle(UserPasswordResetNotification notification) => + /// + public Task HandleAsync(UserPasswordResetNotification notification, CancellationToken cancellationToken) => WriteAudit( notification.PerformingUserId, notification.AffectedUserId, @@ -85,58 +103,52 @@ internal sealed class BackOfficeUserManagerAuditer : "umbraco/user/password/reset", "password reset"); - private static string FormatEmail(IMembershipUser? user) => - user is null ? string.Empty : user.Email.IsNullOrWhiteSpace() ? string.Empty : $"<{user.Email}>"; - - private void WriteAudit( + private async Task WriteAudit( string performingId, string? affectedId, string ipAddress, string eventType, string eventDetails) { - int? performingIdAsInt = ParseUserId(performingId); - int? affectedIdAsInt = ParseUserId(affectedId); + var performingIdAsInt = ParseUserId(performingId); + var affectedIdAsInt = ParseUserId(affectedId); - WriteAudit(performingIdAsInt, affectedIdAsInt, ipAddress, eventType, eventDetails); + await WriteAudit(performingIdAsInt, affectedIdAsInt, ipAddress, eventType, eventDetails); } private static int? ParseUserId(string? id) => int.TryParse(id, NumberStyles.Integer, CultureInfo.InvariantCulture, out var isAsInt) ? isAsInt : null; - private void WriteAudit( + private async Task WriteAudit( int? performingId, int? affectedId, string ipAddress, string eventType, string eventDetails) { - var performingDetails = "User UNKNOWN:0"; - if (performingId.HasValue) - { - IUser? performingUser = _userService.GetUserById(performingId.Value); - performingDetails = performingUser is null - ? $"User UNKNOWN:{performingId.Value}" - : $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}"; - } + IUser? performingUser = performingId is not null ? _userService.GetUserById(performingId.Value) : null; + IUser? affectedUser = affectedId is not null ? _userService.GetUserById(affectedId.Value) : null; - var affectedDetails = "User UNKNOWN:0"; - if (affectedId.HasValue) - { - IUser? affectedUser = _userService.GetUserById(affectedId.Value); - affectedDetails = affectedUser is null - ? $"User UNKNOWN:{affectedId.Value}" - : $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}"; - } - - _auditService.Write( - performingId ?? 0, - performingDetails, + await _auditEntryService.WriteAsync( + performingUser?.Key, + FormatDetails(performingId, performingUser), ipAddress, DateTime.UtcNow, - affectedId ?? 0, - affectedDetails, + affectedUser?.Key, + FormatDetails(affectedId, affectedUser), eventType, eventDetails); } + + private static string FormatDetails(int? id, IUser? user) + { + if (user == null) + { + return $"User UNKNOWN:{id ?? 0}"; + } + + return user.Email.IsNullOrWhiteSpace() + ? $"User \"{user.Name}\"" + : $"User \"{user.Name}\" <{user.Email}>"; + } } diff --git a/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs b/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs index 0edd217540..889f95fec4 100644 --- a/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs +++ b/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs @@ -1,6 +1,9 @@ +using System.Runtime.CompilerServices; using System.Text; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; @@ -15,24 +18,51 @@ namespace Umbraco.Cms.Core.Handlers; public sealed class AuditNotificationsHandler : INotificationHandler, + INotificationAsyncHandler, INotificationHandler, + INotificationAsyncHandler, INotificationHandler, + INotificationAsyncHandler, INotificationHandler, + INotificationAsyncHandler, INotificationHandler, + INotificationAsyncHandler, INotificationHandler, + INotificationAsyncHandler, INotificationHandler, + INotificationAsyncHandler, INotificationHandler, - INotificationHandler + INotificationAsyncHandler, + INotificationHandler, + INotificationAsyncHandler { - private readonly IAuditService _auditService; + private readonly IAuditEntryService _auditEntryService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; private readonly IEntityService _entityService; - private readonly GlobalSettings _globalSettings; private readonly IIpResolver _ipResolver; private readonly IMemberService _memberService; private readonly IUserGroupService _userGroupService; private readonly IUserService _userService; + public AuditNotificationsHandler( + IAuditEntryService auditEntryService, + IUserService userService, + IEntityService entityService, + IIpResolver ipResolver, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IMemberService memberService, + IUserGroupService userGroupService) + { + _auditEntryService = auditEntryService; + _userService = userService; + _entityService = entityService; + _ipResolver = ipResolver; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _memberService = memberService; + _userGroupService = userGroupService; + } + + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in V19.")] public AuditNotificationsHandler( IAuditService auditService, IUserService userService, @@ -42,59 +72,75 @@ public sealed class AuditNotificationsHandler : IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IMemberService memberService, IUserGroupService userGroupService) + : this( + StaticServiceProvider.Instance.GetRequiredService(), + userService, + entityService, + ipResolver, + backOfficeSecurityAccessor, + memberService, + userGroupService) { - _auditService = auditService; - _userService = userService; - _entityService = entityService; - _ipResolver = ipResolver; - _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _memberService = memberService; - _userGroupService = userGroupService; - _globalSettings = globalSettings.CurrentValue; } - private IUser CurrentPerformingUser + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in V19.")] + public AuditNotificationsHandler( + IAuditEntryService auditEntryService, + IAuditService auditService, + IUserService userService, + IEntityService entityService, + IIpResolver ipResolver, + IOptionsMonitor globalSettings, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IMemberService memberService, + IUserGroupService userGroupService) + : this( + auditEntryService, + userService, + entityService, + ipResolver, + backOfficeSecurityAccessor, + memberService, + userGroupService) { - get - { - IUser? identity = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; - IUser? user = identity == null ? null : _userService.GetAsync(identity.Key).GetAwaiter().GetResult(); - return user ?? UnknownUser(_globalSettings); - } } + private async Task GetCurrentPerformingUser() => + _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser is { } identity + ? await _userService.GetAsync(identity.Key) + : null; + private string PerformingIp => _ipResolver.GetCurrentRequestIpAddress(); - public static IUser UnknownUser(GlobalSettings globalSettings) => new User(globalSettings) + /// + public async Task HandleAsync(AssignedMemberRolesNotification notification, CancellationToken cancellationToken) { - Id = Constants.Security.UnknownUserId, - Name = Constants.Security.UnknownUserName, - Email = string.Empty, - }; - - public void Handle(AssignedMemberRolesNotification notification) - { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); var roles = string.Join(", ", notification.Roles); var members = _memberService.GetAllMembers(notification.MemberIds).ToDictionary(x => x.Id, x => x); foreach (var id in notification.MemberIds) { members.TryGetValue(id, out IMember? member); - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - -1, - $"Member {id} \"{member?.Name ?? "(unknown)"}\" {FormatEmail(member)}", + + await Audit( + performingUser, + null, + affectedDetails: FormatDetails(id, member, appendType: true), "umbraco/member/roles/assigned", $"roles modified, assigned {roles}"); } } - public void Handle(AssignedUserGroupPermissionsNotification notification) + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(AssignedMemberRolesNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + + /// + public async Task HandleAsync( + AssignedUserGroupPermissionsNotification notification, + CancellationToken cancellationToken) { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); IEnumerable perms = notification.EntityPermissions; foreach (EntityPermission perm in perms) { @@ -102,113 +148,124 @@ public sealed class AuditNotificationsHandler : var assigned = string.Join(", ", perm.AssignedPermissions); IEntitySlim? entity = _entityService.Get(perm.EntityId); - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - -1, + await Audit( + performingUser, + null, $"User Group {group?.Id} \"{group?.Name}\" ({group?.Alias})", "umbraco/user-group/permissions-change", $"assigning {(string.IsNullOrWhiteSpace(assigned) ? "(nothing)" : assigned)} on id:{perm.EntityId} \"{entity?.Name}\""); } } - public void Handle(ExportedMemberNotification notification) + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(AssignedUserGroupPermissionsNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + + /// + public async Task HandleAsync(ExportedMemberNotification notification, CancellationToken cancellationToken) { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); IMember member = notification.Member; - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - -1, - $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", + await Audit( + performingUser, + null, + affectedDetails: FormatDetails(member, appendType: true), "umbraco/member/exported", "exported member data"); } - public void Handle(MemberDeletedNotification notification) + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(ExportedMemberNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + + public async Task HandleAsync(MemberDeletedNotification notification, CancellationToken cancellationToken) { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); IEnumerable members = notification.DeletedEntities; foreach (IMember member in members) { - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - -1, - $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", + await Audit( + performingUser, + null, + affectedDetails: FormatDetails(member, appendType: true), "umbraco/member/delete", - $"delete member id:{member.Id} \"{member.Name}\" {FormatEmail(member)}"); + $"delete member id:{FormatDetails(member)}"); } } - public void Handle(MemberSavedNotification notification) + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(MemberDeletedNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + + /// + public async Task HandleAsync(MemberSavedNotification notification, CancellationToken cancellationToken) { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); IEnumerable members = notification.SavedEntities; foreach (IMember member in members) { var dp = string.Join(", ", ((Member)member).GetWereDirtyProperties()); - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - -1, - $"Member {member.Id} \"{member.Name}\" {FormatEmail(member)}", + await Audit( + performingUser, + null, + affectedDetails: FormatDetails(member, appendType: true), "umbraco/member/save", $"updating {(string.IsNullOrWhiteSpace(dp) ? "(nothing)" : dp)}"); } } - public void Handle(RemovedMemberRolesNotification notification) + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(MemberSavedNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + + /// + public async Task HandleAsync(RemovedMemberRolesNotification notification, CancellationToken cancellationToken) { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); var roles = string.Join(", ", notification.Roles); var members = _memberService.GetAllMembers(notification.MemberIds).ToDictionary(x => x.Id, x => x); foreach (var id in notification.MemberIds) { members.TryGetValue(id, out IMember? member); - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - -1, - $"Member {id} \"{member?.Name ?? "(unknown)"}\" {FormatEmail(member)}", + + await Audit( + performingUser, + null, + affectedDetails: FormatDetails(id, member, appendType: true), "umbraco/member/roles/removed", $"roles modified, removed {roles}"); } } - public void Handle(UserDeletedNotification notification) + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(RemovedMemberRolesNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + + /// + public async Task HandleAsync(UserDeletedNotification notification, CancellationToken cancellationToken) { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); IEnumerable affectedUsers = notification.DeletedEntities; foreach (IUser affectedUser in affectedUsers) { - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - affectedUser.Id, - $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}", + await Audit( + performingUser, + affectedUser, + null, "umbraco/user/delete", "delete user"); } } - public void Handle(UserGroupWithUsersSavedNotification notification) + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(UserDeletedNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + + public async Task HandleAsync(UserGroupWithUsersSavedNotification notification, CancellationToken cancellationToken) { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); foreach (UserGroupWithUsers groupWithUser in notification.SavedEntities) { IUserGroup group = groupWithUser.UserGroup; @@ -238,48 +295,44 @@ public sealed class AuditNotificationsHandler : sb.Append($"default perms: {perms}"); } - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - -1, - $"User Group {group.Id} \"{group.Name}\" ({group.Alias})", + await Audit( + performingUser, + null, + $"User Group {FormatDetails(group)}", "umbraco/user-group/save", $"{sb}"); // now audit the users that have changed foreach (IUser user in groupWithUser.RemovedUsers) { - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - user.Id, - $"User \"{user.Name}\" {FormatEmail(user)}", + await Audit( + performingUser, + user, + null, "umbraco/user-group/save", - $"Removed user \"{user.Name}\" {FormatEmail(user)} from group {group.Id} \"{group.Name}\" ({group.Alias})"); + $"Removed user {FormatDetails(user)} from group {FormatDetails(group)}"); } foreach (IUser user in groupWithUser.AddedUsers) { - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - user.Id, - $"User \"{user.Name}\" {FormatEmail(user)}", + await Audit( + performingUser, + user, + null, "umbraco/user-group/save", - $"Added user \"{user.Name}\" {FormatEmail(user)} to group {group.Id} \"{group.Name}\" ({group.Alias})"); + $"Added user {FormatDetails(user)} to group {FormatDetails(group)}"); } } } - public void Handle(UserSavedNotification notification) + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(UserGroupWithUsersSavedNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); + + /// + public async Task HandleAsync(UserSavedNotification notification, CancellationToken cancellationToken) { - IUser performingUser = CurrentPerformingUser; + IUser? performingUser = await GetCurrentPerformingUser(); IEnumerable affectedUsers = notification.SavedEntities; foreach (IUser affectedUser in affectedUsers) { @@ -289,20 +342,64 @@ public sealed class AuditNotificationsHandler : var dp = string.Join(", ", ((User)affectedUser).GetWereDirtyProperties()); - _auditService.Write( - performingUser.Id, - $"User \"{performingUser.Name}\" {FormatEmail(performingUser)}", - PerformingIp, - DateTime.UtcNow, - affectedUser.Id, - $"User \"{affectedUser.Name}\" {FormatEmail(affectedUser)}", + await Audit( + performingUser, + affectedUser, + null, "umbraco/user/save", $"updating {(string.IsNullOrWhiteSpace(dp) ? "(nothing)" : dp)}{(groups == null ? string.Empty : "; groups assigned: " + groups)}"); } } - private string FormatEmail(IMember? member) => - member == null ? string.Empty : member.Email.IsNullOrWhiteSpace() ? string.Empty : $"<{member.Email}>"; + [Obsolete("Use HandleAsync() instead. Scheduled for removal in V19.")] + public void Handle(UserSavedNotification notification) + => HandleAsync(notification, CancellationToken.None).GetAwaiter().GetResult(); - private string FormatEmail(IUser user) => user == null ? string.Empty : user.Email.IsNullOrWhiteSpace() ? string.Empty : $"<{user.Email}>"; + private async Task Audit( + IUser? performingUser, + IUser? affectedUser, + string? affectedDetails, + string eventType, + string eventDetails) + { + affectedDetails ??= affectedUser is null ? string.Empty : FormatDetails(affectedUser, appendType: true); + await _auditEntryService.WriteAsync( + performingUser?.Key, + FormatDetails(performingUser, appendType: true), + PerformingIp, + DateTime.UtcNow, + affectedUser?.Key, + affectedDetails, + eventType, + eventDetails); + } + + private static string FormatDetails(IUser? user, bool appendType = false) + { + var userName = user?.Name ?? Constants.Security.UnknownUserName; + var details = appendType ? $"User \"{userName}\"" : $"\"{userName}\""; + var email = FormatEmail(user?.Email); + + return email is not null + ? $"{details} {email}" + : details; + } + + private static string FormatDetails(IMember member, bool appendType = false) + => FormatDetails(member.Id, member, appendType); + + private static string FormatDetails(int id, IMember? member, bool appendType = false) + { + var userName = member?.Name ?? "(unknown)"; + var details = appendType ? $"Member {id} \"{userName}\"" : $"{id} \"{userName}\""; + var email = FormatEmail(member?.Email); + + return email is not null + ? $"{details} {email}" + : details; + } + + private static string FormatDetails(IUserGroup group) => $"{group.Id} \"{group.Name}\" ({group.Alias})"; + + private static string? FormatEmail(string? email) => !email.IsNullOrWhiteSpace() ? $"<{email}>" : null; } diff --git a/src/Umbraco.Core/Models/AuditEntry.cs b/src/Umbraco.Core/Models/AuditEntry.cs index 9d1b4dfcef..2d621e93f4 100644 --- a/src/Umbraco.Core/Models/AuditEntry.cs +++ b/src/Umbraco.Core/Models/AuditEntry.cs @@ -12,11 +12,13 @@ public class AuditEntry : EntityBase, IAuditEntry { private string? _affectedDetails; private int _affectedUserId; + private Guid? _affectedUserKey; private string? _eventDetails; private string? _eventType; private string? _performingDetails; private string? _performingIp; private int _performingUserId; + private Guid? _performingUserKey; /// public int PerformingUserId @@ -25,6 +27,13 @@ public class AuditEntry : EntityBase, IAuditEntry set => SetPropertyValueAndDetectChanges(value, ref _performingUserId, nameof(PerformingUserId)); } + /// + public Guid? PerformingUserKey + { + get => _performingUserKey; + set => SetPropertyValueAndDetectChanges(value, ref _performingUserKey, nameof(PerformingUserKey)); + } + /// public string? PerformingDetails { @@ -53,6 +62,13 @@ public class AuditEntry : EntityBase, IAuditEntry set => SetPropertyValueAndDetectChanges(value, ref _affectedUserId, nameof(AffectedUserId)); } + /// + public Guid? AffectedUserKey + { + get => _affectedUserKey; + set => SetPropertyValueAndDetectChanges(value, ref _affectedUserKey, nameof(AffectedUserKey)); + } + /// public string? AffectedDetails { diff --git a/src/Umbraco.Core/Models/IAuditEntry.cs b/src/Umbraco.Core/Models/IAuditEntry.cs index 3a1b412ce0..e01f979fbf 100644 --- a/src/Umbraco.Core/Models/IAuditEntry.cs +++ b/src/Umbraco.Core/Models/IAuditEntry.cs @@ -23,6 +23,16 @@ public interface IAuditEntry : IEntity, IRememberBeingDirty /// int PerformingUserId { get; set; } + /// + /// Gets or sets the key of the user triggering the audited event. + /// + // TODO (V19): Remove the default implementations from this interface. + Guid? PerformingUserKey + { + get => null; + set { } + } + /// /// Gets or sets free-form details about the user triggering the audited event. /// @@ -44,6 +54,17 @@ public interface IAuditEntry : IEntity, IRememberBeingDirty /// Not used when no single user is affected by the event. int AffectedUserId { get; set; } + /// + /// Gets or sets the key of the user affected by the audited event. + /// + /// Not used when no single user is affected by the event. + // TODO (V19): Remove the default implementations from this interface. + Guid? AffectedUserKey + { + get => null; + set { } + } + /// /// Gets or sets free-form details about the entity affected by the audited event. /// diff --git a/src/Umbraco.Core/Persistence/Repositories/IAuditEntryRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IAuditEntryRepository.cs index ade100f0d2..74788c13d6 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IAuditEntryRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IAuditEntryRepository.cs @@ -11,10 +11,4 @@ public interface IAuditEntryRepository : IReadWriteQueryRepository IEnumerable GetPage(long pageIndex, int pageCount, out long records); - - /// - /// Determines whether the repository is available. - /// - /// During an upgrade, the repository may not be available, until the table has been created. - bool IsAvailable(); } diff --git a/src/Umbraco.Core/Services/AuditEntryService.cs b/src/Umbraco.Core/Services/AuditEntryService.cs new file mode 100644 index 0000000000..e1743858b0 --- /dev/null +++ b/src/Umbraco.Core/Services/AuditEntryService.cs @@ -0,0 +1,181 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Core.Services; + +/// +public class AuditEntryService : RepositoryService, IAuditEntryService +{ + private readonly IAuditEntryRepository _auditEntryRepository; + private readonly IUserIdKeyResolver _userIdKeyResolver; + + /// + /// Initializes a new instance of the class. + /// + public AuditEntryService( + IAuditEntryRepository auditEntryRepository, + IUserIdKeyResolver userIdKeyResolver, + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory) + : base(provider, loggerFactory, eventMessagesFactory) + { + _auditEntryRepository = auditEntryRepository; + _userIdKeyResolver = userIdKeyResolver; + } + + /// + public async Task WriteAsync( + Guid? performingUserKey, + string performingDetails, + string performingIp, + DateTime eventDateUtc, + Guid? affectedUserKey, + string? affectedDetails, + string eventType, + string eventDetails) + { + var performingUserId = await GetUserId(performingUserKey); + var affectedUserId = await GetUserId(affectedUserKey); + + return WriteInner( + performingUserId, + performingUserKey, + performingDetails, + performingIp, + eventDateUtc, + affectedUserId, + affectedUserKey, + affectedDetails, + eventType, + eventDetails); + } + + // This method is used by the AuditService while the AuditService.Write() method is not removed. + internal async Task WriteInner( + int? performingUserId, + string performingDetails, + string performingIp, + DateTime eventDateUtc, + int? affectedUserId, + string? affectedDetails, + string eventType, + string eventDetails) + { + Guid? performingUserKey = await GetUserKey(performingUserId); + Guid? affectedUserKey = await GetUserKey(affectedUserId); + + return WriteInner( + performingUserId, + performingUserKey, + performingDetails, + performingIp, + eventDateUtc, + affectedUserId, + affectedUserKey, + affectedDetails, + eventType, + eventDetails); + } + + private IAuditEntry WriteInner( + int? performingUserId, + Guid? performingUserKey, + string performingDetails, + string performingIp, + DateTime eventDateUtc, + int? affectedUserId, + Guid? affectedUserKey, + string? affectedDetails, + string eventType, + string eventDetails) + { + if (performingUserId < Constants.Security.SuperUserId) + { + throw new ArgumentOutOfRangeException(nameof(performingUserId)); + } + + if (string.IsNullOrWhiteSpace(performingDetails)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(performingDetails)); + } + + if (string.IsNullOrWhiteSpace(eventType)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(eventType)); + } + + if (string.IsNullOrWhiteSpace(eventDetails)) + { + throw new ArgumentException("Value cannot be null or whitespace.", nameof(eventDetails)); + } + + // we need to truncate the data else we'll get SQL errors + affectedDetails = + affectedDetails?[..Math.Min(affectedDetails.Length, Constants.Audit.DetailsLength)]; + eventDetails = eventDetails[..Math.Min(eventDetails.Length, Constants.Audit.DetailsLength)]; + + // validate the eventType - must contain a forward slash, no spaces, no special chars + var eventTypeParts = eventType.ToCharArray(); + if (eventTypeParts.Contains('/') == false || + eventTypeParts.All(c => char.IsLetterOrDigit(c) || c == '/' || c == '-') == false) + { + throw new ArgumentException( + nameof(eventType) + " must contain only alphanumeric characters, hyphens and at least one '/' defining a category"); + } + + if (eventType.Length > Constants.Audit.EventTypeLength) + { + throw new ArgumentException($"Must be max {Constants.Audit.EventTypeLength} chars.", nameof(eventType)); + } + + if (performingIp is { Length: > Constants.Audit.IpLength }) + { + throw new ArgumentException($"Must be max {Constants.Audit.EventTypeLength} chars.", nameof(performingIp)); + } + + var entry = new AuditEntry + { + PerformingUserId = performingUserId ?? Constants.Security.UnknownUserId, // Default to UnknownUserId as it is non-nullable + PerformingUserKey = performingUserKey, + PerformingDetails = performingDetails, + PerformingIp = performingIp, + EventDateUtc = eventDateUtc, + AffectedUserId = affectedUserId ?? Constants.Security.UnknownUserId, // Default to UnknownUserId as it is non-nullable + AffectedUserKey = affectedUserKey, + AffectedDetails = affectedDetails, + EventType = eventType, + EventDetails = eventDetails, + }; + + using (ICoreScope scope = ScopeProvider.CreateCoreScope()) + { + _auditEntryRepository.Save(entry); + scope.Complete(); + } + + return entry; + } + + internal async Task GetUserId(Guid? key) => + key is not null && await _userIdKeyResolver.TryGetAsync(key.Value) is { Success: true } attempt + ? attempt.Result + : null; + + internal async Task GetUserKey(int? id) => + id is not null && await _userIdKeyResolver.TryGetAsync(id.Value) is { Success: true } attempt + ? attempt.Result + : null; + + // TODO: Currently used in testing only, not part of the interface, need to add queryable methods to the interface instead + internal IEnumerable GetAll() + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _auditEntryRepository.GetMany(); + } + } +} diff --git a/src/Umbraco.Core/Services/AuditService.cs b/src/Umbraco.Core/Services/AuditService.cs index 6af3d15675..4b71f6fd2a 100644 --- a/src/Umbraco.Core/Services/AuditService.cs +++ b/src/Umbraco.Core/Services/AuditService.cs @@ -1,4 +1,6 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Membership; @@ -10,12 +12,25 @@ namespace Umbraco.Cms.Core.Services.Implement; public sealed class AuditService : RepositoryService, IAuditService { - private readonly IAuditEntryRepository _auditEntryRepository; private readonly IUserService _userService; private readonly IAuditRepository _auditRepository; private readonly IEntityService _entityService; - private readonly Lazy _isAvailable; + public AuditService( + ICoreScopeProvider provider, + ILoggerFactory loggerFactory, + IEventMessagesFactory eventMessagesFactory, + IAuditRepository auditRepository, + IUserService userService, + IEntityService entityService) + : base(provider, loggerFactory, eventMessagesFactory) + { + _auditRepository = auditRepository; + _userService = userService; + _entityService = entityService; + } + + [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 19.")] public AuditService( ICoreScopeProvider provider, ILoggerFactory loggerFactory, @@ -24,13 +39,14 @@ public sealed class AuditService : RepositoryService, IAuditService IAuditEntryRepository auditEntryRepository, IUserService userService, IEntityService entityService) - : base(provider, loggerFactory, eventMessagesFactory) + : this( + provider, + loggerFactory, + eventMessagesFactory, + auditRepository, + userService, + entityService) { - _auditRepository = auditRepository; - _auditEntryRepository = auditEntryRepository; - _userService = userService; - _entityService = entityService; - _isAvailable = new Lazy(DetermineIsAvailable); } public void Add(AuditType type, int userId, int objectId, string? entityType, string comment, string? parameters = null) @@ -264,115 +280,29 @@ public sealed class AuditService : RepositoryService, IAuditService } /// - public IAuditEntry Write(int performingUserId, string perfomingDetails, string performingIp, DateTime eventDateUtc, int affectedUserId, string? affectedDetails, string eventType, string eventDetails) + [Obsolete("Use AuditEntryService.WriteAsync() instead. Scheduled for removal in Umbraco 19.")] + public IAuditEntry Write( + int performingUserId, + string perfomingDetails, + string performingIp, + DateTime eventDateUtc, + int affectedUserId, + string? affectedDetails, + string eventType, + string eventDetails) { - if (performingUserId < 0 && performingUserId != Constants.Security.SuperUserId) - { - throw new ArgumentOutOfRangeException(nameof(performingUserId)); - } + // Use the static service provider to resolve the audit entry service, as this is only needed for this obsolete method. + var auditEntryService = + (AuditEntryService)StaticServiceProvider.Instance.GetRequiredService(); - if (string.IsNullOrWhiteSpace(perfomingDetails)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(perfomingDetails)); - } - - if (string.IsNullOrWhiteSpace(eventType)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(eventType)); - } - - if (string.IsNullOrWhiteSpace(eventDetails)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(eventDetails)); - } - - // we need to truncate the data else we'll get SQL errors - affectedDetails = - affectedDetails?[..Math.Min(affectedDetails.Length, Constants.Audit.DetailsLength)]; - eventDetails = eventDetails[..Math.Min(eventDetails.Length, Constants.Audit.DetailsLength)]; - - // validate the eventType - must contain a forward slash, no spaces, no special chars - var eventTypeParts = eventType.ToCharArray(); - if (eventTypeParts.Contains('/') == false || - eventTypeParts.All(c => char.IsLetterOrDigit(c) || c == '/' || c == '-') == false) - { - throw new ArgumentException(nameof(eventType) + - " must contain only alphanumeric characters, hyphens and at least one '/' defining a category"); - } - - if (eventType.Length > Constants.Audit.EventTypeLength) - { - throw new ArgumentException($"Must be max {Constants.Audit.EventTypeLength} chars.", nameof(eventType)); - } - - if (performingIp != null && performingIp.Length > Constants.Audit.IpLength) - { - throw new ArgumentException($"Must be max {Constants.Audit.EventTypeLength} chars.", nameof(performingIp)); - } - - var entry = new AuditEntry - { - PerformingUserId = performingUserId, - PerformingDetails = perfomingDetails, - PerformingIp = performingIp, - EventDateUtc = eventDateUtc, - AffectedUserId = affectedUserId, - AffectedDetails = affectedDetails, - EventType = eventType, - EventDetails = eventDetails, - }; - - if (_isAvailable.Value == false) - { - return entry; - } - - using (ICoreScope scope = ScopeProvider.CreateCoreScope()) - { - _auditEntryRepository.Save(entry); - scope.Complete(); - } - - return entry; - } - - // TODO: Currently used in testing only, not part of the interface, need to add queryable methods to the interface instead - internal IEnumerable? GetAll() - { - if (_isAvailable.Value == false) - { - return Enumerable.Empty(); - } - - using (ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _auditEntryRepository.GetMany(); - } - } - - // TODO: Currently used in testing only, not part of the interface, need to add queryable methods to the interface instead - internal IEnumerable GetPage(long pageIndex, int pageCount, out long records) - { - if (_isAvailable.Value == false) - { - records = 0; - return Enumerable.Empty(); - } - - using (ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _auditEntryRepository.GetPage(pageIndex, pageCount, out records); - } - } - - /// - /// Determines whether the repository is available. - /// - private bool DetermineIsAvailable() - { - using (ScopeProvider.CreateCoreScope(autoComplete: true)) - { - return _auditEntryRepository.IsAvailable(); - } + return auditEntryService.WriteInner( + performingUserId, + perfomingDetails, + performingIp, + eventDateUtc, + affectedUserId, + affectedDetails, + eventType, + eventDetails).GetAwaiter().GetResult(); } } diff --git a/src/Umbraco.Core/Services/IAuditEntryService.cs b/src/Umbraco.Core/Services/IAuditEntryService.cs new file mode 100644 index 0000000000..fb1d830e0b --- /dev/null +++ b/src/Umbraco.Core/Services/IAuditEntryService.cs @@ -0,0 +1,39 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +/// Represents a service for handling audit entries. +/// +public interface IAuditEntryService : IService +{ + /// + /// Writes an audit entry for an audited event. + /// + /// The key of the user triggering the audited event. + /// Free-form details about the user triggering the audited event. + /// The IP address or the request triggering the audited event. + /// The date and time of the audited event. + /// The identifier of the user affected by the audited event. + /// Free-form details about the entity affected by the audited event. + /// + /// The type of the audited event - must contain only alphanumeric chars and hyphens with forward slashes separating + /// categories. + /// + /// The eventType will generally be formatted like: {application}/{entity-type}/{category}/{sub-category} + /// Example: umbraco/user/sign-in/failed + /// + /// + /// Free-form details about the audited event. + /// The created audit entry. + public Task WriteAsync( + Guid? performingUserKey, + string performingDetails, + string performingIp, + DateTime eventDateUtc, + Guid? affectedUserKey, + string? affectedDetails, + string eventType, + string eventDetails); +} diff --git a/src/Umbraco.Core/Services/IAuditService.cs b/src/Umbraco.Core/Services/IAuditService.cs index 7481a5e613..435b95bb17 100644 --- a/src/Umbraco.Core/Services/IAuditService.cs +++ b/src/Umbraco.Core/Services/IAuditService.cs @@ -144,6 +144,7 @@ public interface IAuditService : IService /// /// /// Free-form details about the audited event. + [Obsolete("Use AuditEntryService.WriteAsync() instead. Scheduled for removal in Umbraco 19.")] IAuditEntry Write( int performingUserId, string perfomingDetails, diff --git a/src/Umbraco.Core/Services/IUserIdKeyResolver.cs b/src/Umbraco.Core/Services/IUserIdKeyResolver.cs index b308f16c01..aec8e38db3 100644 --- a/src/Umbraco.Core/Services/IUserIdKeyResolver.cs +++ b/src/Umbraco.Core/Services/IUserIdKeyResolver.cs @@ -10,6 +10,13 @@ public interface IUserIdKeyResolver /// Thrown when no user was found with the specified key. public Task GetAsync(Guid key); + /// + /// Tries to resolve a user key to a user id without fetching the entire user. + /// + /// The key of the user. + /// An attempt with the id of the user. + public Task> TryGetAsync(Guid key) => throw new NotImplementedException(); + /// /// Tries to resolve a user id to a user key without fetching the entire user. /// @@ -17,4 +24,11 @@ public interface IUserIdKeyResolver /// The key of the user. /// Thrown when no user was found with the specified id. public Task GetAsync(int id); + + /// + /// Tries to resolve a user id to a user key without fetching the entire user. + /// + /// The id of the user. + /// An attempt with the key of the user. + public Task> TryGetAsync(int id) => throw new NotImplementedException(); } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index aee4fdac67..0d7ebc0d50 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -403,15 +403,15 @@ public static partial class UmbracoBuilderExtensions // add notification handlers for auditing builder - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler(); + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler() + .AddNotificationAsyncHandler(); // Handlers for publish warnings builder.AddNotificationHandler(); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs index 4d78be637b..4a87fc2bc8 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Services.cs @@ -44,6 +44,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 21cfe558a6..f4d278e655 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -119,5 +119,8 @@ public class UmbracoPlan : MigrationPlan // To 16.0.0 To("{C6681435-584F-4BC8-BB8D-BC853966AF0B}"); To("{D1568C33-A697-455F-8D16-48060CB954A1}"); + + // To 17.0.0 + To("{17D5F6CA-CEB8-462A-AF86-4B9C3BF91CF1}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/AddGuidsToAuditEntries.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/AddGuidsToAuditEntries.cs new file mode 100644 index 0000000000..e9cf6bdfd4 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_17_0_0/AddGuidsToAuditEntries.cs @@ -0,0 +1,52 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Scoping; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_0_0; + +public class AddGuidsToAuditEntries : UnscopedMigrationBase +{ + private const string NewPerformingUserKeyColumnName = "performingUserKey"; + private const string NewAffectedUserKeyColumnName = "affectedUserKey"; + private readonly IScopeProvider _scopeProvider; + + public AddGuidsToAuditEntries(IMigrationContext context, IScopeProvider scopeProvider) + : base(context) + { + _scopeProvider = scopeProvider; + } + + protected override void Migrate() + { + // If the new column already exists, we'll do nothing. + if (ColumnExists(Constants.DatabaseSchema.Tables.AuditEntry, NewPerformingUserKeyColumnName)) + { + Context.Complete(); + return; + } + + using IScope scope = _scopeProvider.CreateScope(); + using IDisposable notificationSuppression = scope.Notifications.Suppress(); + ScopeDatabase(scope); + + var columns = SqlSyntax.GetColumnsInSchema(Context.Database).ToList(); + AddColumnIfNotExists(columns, NewPerformingUserKeyColumnName); + AddColumnIfNotExists(columns, NewAffectedUserKeyColumnName); + + Database.Execute( + new Sql( + "UPDATE umbracoAudit " + + "SET performingUserKey = (" + + $"SELECT umbracoUser.{Database.SqlContext.SqlSyntax.GetQuotedColumnName("key")} FROM umbracoUser WHERE umbracoUser.id= umbracoAudit.performingUserId);")); + + Database.Execute( + new Sql( + "UPDATE umbracoAudit " + + "SET affectedUserKey = (" + + $"SELECT umbracoUser.{Database.SqlContext.SqlSyntax.GetQuotedColumnName("key")} FROM umbracoUser WHERE umbracoUser.id= umbracoAudit.affectedUserId);")); + + scope.Complete(); + Context.Complete(); + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/AuditEntryDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/AuditEntryDto.cs index c94a680a1f..774bc7b3fc 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/AuditEntryDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/AuditEntryDto.cs @@ -20,6 +20,10 @@ internal class AuditEntryDto [Column("performingUserId")] public int PerformingUserId { get; set; } + [Column("performingUserKey")] + [NullSetting(NullSetting = NullSettings.Null)] + public Guid? PerformingUserKey { get; set; } + [Column("performingDetails")] [NullSetting(NullSetting = NullSettings.Null)] [Length(Constants.Audit.DetailsLength)] @@ -37,6 +41,10 @@ internal class AuditEntryDto [Column("affectedUserId")] public int AffectedUserId { get; set; } + [Column("affectedUserKey")] + [NullSetting(NullSetting = NullSettings.Null)] + public Guid? AffectedUserKey { get; set; } + [Column("affectedDetails")] [NullSetting(NullSetting = NullSettings.Null)] [Length(Constants.Audit.DetailsLength)] diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/AuditEntryFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/AuditEntryFactory.cs index 297cea9025..2d2d15211c 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/AuditEntryFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/AuditEntryFactory.cs @@ -14,10 +14,12 @@ internal static class AuditEntryFactory { Id = dto.Id, PerformingUserId = dto.PerformingUserId, + PerformingUserKey = dto.PerformingUserKey, PerformingDetails = dto.PerformingDetails, PerformingIp = dto.PerformingIp, EventDateUtc = dto.EventDateUtc, AffectedUserId = dto.AffectedUserId, + AffectedUserKey = dto.AffectedUserKey, AffectedDetails = dto.AffectedDetails, EventType = dto.EventType, EventDetails = dto.EventDetails, @@ -34,10 +36,12 @@ internal static class AuditEntryFactory { Id = entity.Id, PerformingUserId = entity.PerformingUserId, + PerformingUserKey = entity.PerformingUserKey, PerformingDetails = entity.PerformingDetails, PerformingIp = entity.PerformingIp, EventDateUtc = entity.EventDateUtc, AffectedUserId = entity.AffectedUserId, + AffectedUserKey = entity.AffectedUserKey, AffectedDetails = entity.AffectedDetails, EventType = entity.EventType, EventDetails = entity.EventDetails, diff --git a/src/Umbraco.Infrastructure/Persistence/Mappers/AuditEntryMapper.cs b/src/Umbraco.Infrastructure/Persistence/Mappers/AuditEntryMapper.cs index 6f27cf830f..04fc3377cd 100644 --- a/src/Umbraco.Infrastructure/Persistence/Mappers/AuditEntryMapper.cs +++ b/src/Umbraco.Infrastructure/Persistence/Mappers/AuditEntryMapper.cs @@ -19,10 +19,12 @@ public sealed class AuditEntryMapper : BaseMapper { DefineMap(nameof(AuditEntry.Id), nameof(AuditEntryDto.Id)); DefineMap(nameof(AuditEntry.PerformingUserId), nameof(AuditEntryDto.PerformingUserId)); + DefineMap(nameof(AuditEntry.PerformingUserKey), nameof(AuditEntryDto.PerformingUserKey)); DefineMap(nameof(AuditEntry.PerformingDetails), nameof(AuditEntryDto.PerformingDetails)); DefineMap(nameof(AuditEntry.PerformingIp), nameof(AuditEntryDto.PerformingIp)); DefineMap(nameof(AuditEntry.EventDateUtc), nameof(AuditEntryDto.EventDateUtc)); DefineMap(nameof(AuditEntry.AffectedUserId), nameof(AuditEntryDto.AffectedUserId)); + DefineMap(nameof(AuditEntry.AffectedUserKey), nameof(AuditEntryDto.AffectedUserKey)); DefineMap(nameof(AuditEntry.AffectedDetails), nameof(AuditEntryDto.AffectedDetails)); DefineMap(nameof(AuditEntry.EventType), nameof(AuditEntryDto.EventType)); DefineMap(nameof(AuditEntry.EventDetails), nameof(AuditEntryDto.EventDetails)); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditEntryRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditEntryRepository.cs index eab408823e..2843432eb2 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditEntryRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/AuditEntryRepository.cs @@ -1,3 +1,5 @@ +using System.Data.Common; +using System.Linq.Expressions; using Microsoft.Extensions.Logging; using NPoco; using Umbraco.Cms.Core; @@ -5,6 +7,7 @@ using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using Umbraco.Cms.Infrastructure.Persistence.Factories; using Umbraco.Cms.Infrastructure.Persistence.Querying; @@ -18,12 +21,19 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; /// internal class AuditEntryRepository : EntityRepositoryBase, IAuditEntryRepository { + private readonly IRuntimeState _runtimeState; + /// /// Initializes a new instance of the class. /// - public AuditEntryRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger logger) + public AuditEntryRepository( + IRuntimeState runtimeState, + IScopeAccessor scopeAccessor, + AppCaches cache, + ILogger logger) : base(scopeAccessor, cache, logger) { + _runtimeState = runtimeState; } /// @@ -39,13 +49,6 @@ internal class AuditEntryRepository : EntityRepositoryBase, IA return page.Items.Select(AuditEntryFactory.BuildEntity); } - /// - public bool IsAvailable() - { - var tables = SqlSyntax.GetTablesInSchema(Database).ToArray(); - return tables.InvariantContains(Constants.DatabaseSchema.Tables.AuditEntry); - } - /// protected override IAuditEntry? PerformGet(int id) { @@ -116,7 +119,55 @@ internal class AuditEntryRepository : EntityRepositoryBase, IA entity.AddingEntity(); AuditEntryDto dto = AuditEntryFactory.BuildDto(entity); - Database.Insert(dto); + try + { + Database.Insert(dto); + } + catch (DbException) when (_runtimeState.Level == RuntimeLevel.Upgrade) + { + // This can happen when in upgrade state, before the migration to add user keys runs. + // In this case, we will try to insert the audit entry without the user keys. + // TODO (V22): Remove this catch clause when 'V_17_0_0.AddGuidsToAuditEntries' is removed. + Expression>[] fields = + [ + x => x.PerformingUserId, + x => x.PerformingDetails, + x => x.PerformingIp, + x => x.EventDateUtc, + x => x.AffectedUserId, + x => x.AffectedDetails, + x => x.EventType, + x => x.EventDetails + ]; + + var cols = Sql().ColumnsForInsert(fields); + IEnumerable values = fields.Select(f => f.Compile().Invoke(dto)); + + Sql sqlValues = Sql(); + foreach (var (value, index) in values.Select((v, i) => (v, i))) + { + switch (value) + { + case null: + sqlValues.Append((index == 0 ? string.Empty : ",") + "NULL"); + break; + case "": + sqlValues.Append((index == 0 ? string.Empty : ",") + "''"); + break; + default: + sqlValues.Append((index == 0 ? string.Empty : ",") + "@0", value); + break; + } + } + + Sql sqlInsert = Sql($"INSERT INTO {Constants.DatabaseSchema.Tables.AuditEntry} ({cols})") + .Append("VALUES (") + .Append(sqlValues) + .Append(")"); + + Database.Execute(sqlInsert); + } + entity.Id = dto.Id; entity.ResetDirtyProperties(); } diff --git a/src/Umbraco.Infrastructure/Services/Implement/UserIdKeyResolver.cs b/src/Umbraco.Infrastructure/Services/Implement/UserIdKeyResolver.cs index e8ebbd6df5..d11c4a09af 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/UserIdKeyResolver.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/UserIdKeyResolver.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using NPoco; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; @@ -22,10 +23,14 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver /// public async Task GetAsync(Guid key) + => await TryGetAsync(key) is { Success: true } attempt ? attempt.Result : throw new InvalidOperationException("No user found with the specified key"); + + /// + public async Task> TryGetAsync(Guid key) { if (_keyToId.TryGetValue(key, out int id)) { - return id; + return Attempt.Succeed(id); } // We don't have it in the cache, so we'll need to look it up in the database @@ -36,7 +41,7 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver if (_keyToId.TryGetValue(key, out int recheckedId)) { // It was added while we were waiting, so we'll just return it - return recheckedId; + return Attempt.Succeed(recheckedId); } // Still not here, so actually fetch it now @@ -48,12 +53,15 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver .From() .Where(x => x.Key == key); - int fetchedId = (await scope.Database.ExecuteScalarAsync(query)) - ?? throw new InvalidOperationException("No user found with the specified key"); + int? fetchedId = await scope.Database.ExecuteScalarAsync(query); + if (fetchedId is null) + { + return Attempt.Fail(); + } - _keyToId[key] = fetchedId; - return fetchedId; + _keyToId[key] = fetchedId.Value; + return Attempt.Succeed(fetchedId.Value); } finally { @@ -63,10 +71,14 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver /// public async Task GetAsync(int id) + => await TryGetAsync(id) is { Success: true } attempt ? attempt.Result : throw new InvalidOperationException("No user found with the specified id"); + + /// + public async Task> TryGetAsync(int id) { if (_idToKey.TryGetValue(id, out Guid key)) { - return key; + return Attempt.Succeed(key); } await _idToKeyLock.WaitAsync(); @@ -74,7 +86,7 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver { if (_idToKey.TryGetValue(id, out Guid recheckedKey)) { - return recheckedKey; + return Attempt.Succeed(recheckedKey); } using IScope scope = _scopeProvider.CreateScope(autoComplete: true); @@ -85,12 +97,15 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver .From() .Where(x => x.Id == id); - Guid fetchedKey = scope.Database.ExecuteScalar(query) - ?? throw new InvalidOperationException("No user found with the specified id"); + Guid? fetchedKey = scope.Database.ExecuteScalar(query); + if (fetchedKey is null) + { + return Attempt.Fail(); + } - _idToKey[id] = fetchedKey; + _idToKey[id] = fetchedKey.Value; - return fetchedKey; + return Attempt.Succeed(fetchedKey.Value); } finally { diff --git a/tests/Umbraco.Tests.Common/Builders/AuditEntryBuilder.cs b/tests/Umbraco.Tests.Common/Builders/AuditEntryBuilder.cs index 15d98ca212..3daca9b518 100644 --- a/tests/Umbraco.Tests.Common/Builders/AuditEntryBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/AuditEntryBuilder.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Tests.Common.Builders.Interfaces; @@ -25,6 +26,7 @@ public class AuditEntryBuilder { private string _affectedDetails; private int? _affectedUserId; + private Guid? _affectedUserKey; private DateTime? _createDate; private DateTime? _deleteDate; private DateTime? _eventDateUtc; @@ -34,6 +36,7 @@ public class AuditEntryBuilder private Guid? _key; private string _performingDetails; private string _performingIp; + private Guid? _performingUserKey; private int? _performingUserId; private DateTime? _updateDate; @@ -84,6 +87,12 @@ public class AuditEntryBuilder return this; } + public AuditEntryBuilder WithAffectedUserKey(Guid? affectedUserKey) + { + _affectedUserKey = affectedUserKey; + return this; + } + public AuditEntryBuilder WithEventDetails(string eventDetails) { _eventDetails = eventDetails; @@ -120,6 +129,12 @@ public class AuditEntryBuilder return this; } + public AuditEntryBuilder WithPerformingUserKey(Guid? performingUserKey) + { + _performingUserKey = performingUserKey; + return this; + } + public override IAuditEntry Build() { var id = _id ?? 0; @@ -129,12 +144,14 @@ public class AuditEntryBuilder var deleteDate = _deleteDate; var affectedDetails = _affectedDetails ?? Guid.NewGuid().ToString(); var affectedUserId = _affectedUserId ?? -1; + var affectedUserKey = _affectedUserKey ?? Constants.Security.SuperUserKey; var eventDetails = _eventDetails ?? Guid.NewGuid().ToString(); var eventType = _eventType ?? "umbraco/user"; var performingDetails = _performingDetails ?? Guid.NewGuid().ToString(); var performingIp = _performingIp ?? "127.0.0.1"; var eventDateUtc = _eventDateUtc ?? DateTime.UtcNow; var performingUserId = _performingUserId ?? -1; + var performingUserKey = _performingUserKey ?? Constants.Security.SuperUserKey; return new AuditEntry { @@ -145,12 +162,14 @@ public class AuditEntryBuilder DeleteDate = deleteDate, AffectedDetails = affectedDetails, AffectedUserId = affectedUserId, + AffectedUserKey = affectedUserKey, EventDetails = eventDetails, EventType = eventType, PerformingDetails = performingDetails, PerformingIp = performingIp, EventDateUtc = eventDateUtc, - PerformingUserId = performingUserId + PerformingUserId = performingUserId, + PerformingUserKey = performingUserKey, }; } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/AuditEntryServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/AuditEntryServiceTests.cs new file mode 100644 index 0000000000..db89c9080b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/AuditEntryServiceTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +internal sealed class AuditEntryServiceTests : UmbracoIntegrationTest +{ + [Test] + public async Task Write_and_GetAll() + { + var sut = (AuditEntryService)Services.GetRequiredService(); + var expected = new AuditEntryBuilder() + .Build(); + + var result = await sut.WriteAsync( + expected.PerformingUserKey.Value, + expected.PerformingDetails, + expected.PerformingIp, + expected.EventDateUtc, + expected.AffectedUserKey.Value, + expected.AffectedDetails, + expected.EventType, + expected.EventDetails); + Assert.NotNull(result); + + var actual = result; + + var entries = sut.GetAll().ToArray(); + + Assert.Multiple(() => + { + Assert.AreEqual(expected.PerformingUserId, actual.PerformingUserId); + Assert.AreEqual(expected.PerformingUserKey, actual.PerformingUserKey); + Assert.AreEqual(expected.PerformingDetails, actual.PerformingDetails); + Assert.AreEqual(expected.EventDateUtc, actual.EventDateUtc); + Assert.AreEqual(expected.AffectedUserId, actual.AffectedUserId); + Assert.AreEqual(expected.AffectedUserKey, actual.AffectedUserKey); + Assert.AreEqual(expected.AffectedDetails, actual.AffectedDetails); + Assert.AreEqual(expected.EventType, actual.EventType); + Assert.AreEqual(expected.EventDetails, actual.EventDetails); + Assert.IsNotNull(entries); + Assert.AreEqual(1, entries.Length); + Assert.AreEqual(expected.PerformingUserKey, entries[0].PerformingUserKey); + }); + } + + [Test] + public async Task Write_and_GetAll_With_Keys() + { + var sut = (AuditEntryService)Services.GetRequiredService(); + var eventDateUtc = DateTime.UtcNow; + var result = await sut.WriteAsync( + Constants.Security.SuperUserKey, + "performingDetails", + "performingIp", + eventDateUtc, + null, + "affectedDetails", + "umbraco/test", + "eventDetails"); + Assert.NotNull(result); + + var actual = result; + + var entries = sut.GetAll().ToArray(); + + Assert.Multiple(() => + { + Assert.AreEqual(Constants.Security.SuperUserId, actual.PerformingUserId); + Assert.AreEqual(Constants.Security.SuperUserKey, actual.PerformingUserKey); + Assert.AreEqual("performingDetails", actual.PerformingDetails); + Assert.AreEqual("performingIp", actual.PerformingIp); + Assert.AreEqual(eventDateUtc, actual.EventDateUtc); + Assert.AreEqual(Constants.Security.UnknownUserId, actual.AffectedUserId); + Assert.AreEqual(null, actual.AffectedUserKey); + Assert.AreEqual("affectedDetails", actual.AffectedDetails); + Assert.AreEqual("umbraco/test", actual.EventType); + Assert.AreEqual("eventDetails", actual.EventDetails); + Assert.IsNotNull(entries); + Assert.AreEqual(1, entries.Length); + Assert.AreEqual(Constants.Security.SuperUserId, entries[0].PerformingUserId); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/AuditServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/AuditServiceTests.cs index 21f663a439..248fa38a05 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/AuditServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/AuditServiceTests.cs @@ -17,35 +17,6 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] internal sealed class AuditServiceTests : UmbracoIntegrationTest { - [Test] - public void GetPage() - { - var sut = (AuditService)GetRequiredService(); - var expected = new AuditEntryBuilder().Build(); - - for (var i = 0; i < 10; i++) - { - sut.Write( - expected.PerformingUserId + i, - expected.PerformingDetails, - expected.PerformingIp, - expected.EventDateUtc.AddMinutes(i), - expected.AffectedUserId + i, - expected.AffectedDetails, - expected.EventType, - expected.EventDetails); - } - - var entries = sut.GetPage(2, 2, out var count).ToArray(); - - Assert.Multiple(() => - { - Assert.AreEqual(2, entries.Length); - Assert.AreEqual(expected.PerformingUserId + 5, entries[0].PerformingUserId); - Assert.AreEqual(expected.PerformingUserId + 4, entries[1].PerformingUserId); - }); - } - [Test] public void GetUserLogs() { @@ -72,37 +43,4 @@ internal sealed class AuditServiceTests : UmbracoIntegrationTest Assert.AreEqual(numberOfEntries, logs.Count(x => x.AuditType == AuditType.Unpublish)); }); } - - [Test] - public void Write_and_GetAll() - { - var sut = (AuditService)Services.GetRequiredService(); - var expected = new AuditEntryBuilder().Build(); - - var actual = sut.Write( - expected.PerformingUserId, - expected.PerformingDetails, - expected.PerformingIp, - expected.EventDateUtc, - expected.AffectedUserId, - expected.AffectedDetails, - expected.EventType, - expected.EventDetails); - - var entries = sut.GetAll().ToArray(); - - Assert.Multiple(() => - { - Assert.AreEqual(expected.PerformingUserId, actual.PerformingUserId); - Assert.AreEqual(expected.PerformingDetails, actual.PerformingDetails); - Assert.AreEqual(expected.EventDateUtc, actual.EventDateUtc); - Assert.AreEqual(expected.AffectedUserId, actual.AffectedUserId); - Assert.AreEqual(expected.AffectedDetails, actual.AffectedDetails); - Assert.AreEqual(expected.EventType, actual.EventType); - Assert.AreEqual(expected.EventDetails, actual.EventDetails); - Assert.IsNotNull(entries); - Assert.AreEqual(1, entries.Length); - Assert.AreEqual(expected.PerformingUserId, entries[0].PerformingUserId); - }); - } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/AuditEntryServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/AuditEntryServiceTests.cs new file mode 100644 index 0000000000..523cd174f1 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Services/AuditEntryServiceTests.cs @@ -0,0 +1,154 @@ +using System.Data; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Services; + +[TestFixture] +public class AuditEntryServiceTests +{ + private static readonly Guid _testUserKey = Guid.NewGuid(); + private IAuditEntryService _auditEntryService; + private Mock _scopeProviderMock; + private Mock _auditEntryRepositoryMock; + private Mock _userIdKeyResolverMock; + + [SetUp] + public void Setup() + { + _scopeProviderMock = new Mock(MockBehavior.Strict); + _auditEntryRepositoryMock = new Mock(MockBehavior.Strict); + _userIdKeyResolverMock = new Mock(MockBehavior.Strict); + + _auditEntryService = new AuditEntryService( + _auditEntryRepositoryMock.Object, + _userIdKeyResolverMock.Object, + _scopeProviderMock.Object, + Mock.Of(MockBehavior.Strict), + Mock.Of(MockBehavior.Strict)); + } + + [Test] + public async Task WriteAsync_Calls_Repository_With_Correct_Values() + { + SetupScopeProviderMock(); + + var date = DateTime.UtcNow; + _auditEntryRepositoryMock.Setup(x => x.Save(It.IsAny())) + .Callback(item => + { + Assert.AreEqual(Constants.Security.SuperUserId, item.PerformingUserId); + Assert.AreEqual(Constants.Security.SuperUserKey, item.PerformingUserKey); + Assert.AreEqual("performingDetails", item.PerformingDetails); + Assert.AreEqual("performingIp", item.PerformingIp); + Assert.AreEqual(date, item.EventDateUtc); + Assert.AreEqual(Constants.Security.UnknownUserId, item.AffectedUserId); + Assert.AreEqual(null, item.AffectedUserKey); + Assert.AreEqual("affectedDetails", item.AffectedDetails); + Assert.AreEqual("umbraco/test", item.EventType); + Assert.AreEqual("eventDetails", item.EventDetails); + }); + _userIdKeyResolverMock.Setup(x => x.TryGetAsync(Constants.Security.SuperUserKey)) + .ReturnsAsync(Attempt.Succeed(Constants.Security.SuperUserId)); + + var result = await _auditEntryService.WriteAsync( + Constants.Security.SuperUserKey, + "performingDetails", + "performingIp", + date, + null, + "affectedDetails", + "umbraco/test", + "eventDetails"); + + _auditEntryRepositoryMock.Verify(x => x.Save(It.IsAny()), Times.Once); + + Assert.NotNull(result); + Assert.Multiple(() => + { + Assert.AreEqual(Constants.Security.SuperUserId, result.PerformingUserId); + Assert.AreEqual("performingDetails", result.PerformingDetails); + Assert.AreEqual("performingIp", result.PerformingIp); + Assert.AreEqual(date, result.EventDateUtc); + Assert.AreEqual(Constants.Security.UnknownUserId, result.AffectedUserId); + Assert.AreEqual("affectedDetails", result.AffectedDetails); + Assert.AreEqual("umbraco/test", result.EventType); + Assert.AreEqual("eventDetails", result.EventDetails); + }); + } + + [Test] + public async Task GetUserId_UsingKey_Returns_Correct_Id() + { + SetupScopeProviderMock(); + + int userId = 12; + _userIdKeyResolverMock.Setup(x => x.TryGetAsync(_testUserKey)) + .ReturnsAsync(Attempt.Succeed(userId)); + + var actualUserId = await ((AuditEntryService)_auditEntryService).GetUserId(_testUserKey); + + Assert.AreEqual(actualUserId, userId); + } + + [Test] + public async Task GetUserId_UsingNonExistingKey_Returns_Null() + { + SetupScopeProviderMock(); + + _userIdKeyResolverMock.Setup(x => x.TryGetAsync(_testUserKey)) + .ReturnsAsync(Attempt.Fail()); + + var actualUserId = await ((AuditEntryService)_auditEntryService).GetUserId(_testUserKey); + + Assert.AreEqual(null, actualUserId); + } + + [Test] + public async Task GetUserKey_UsingKey_Returns_Correct_Id() + { + SetupScopeProviderMock(); + + int userId = 12; + _userIdKeyResolverMock.Setup(x => x.TryGetAsync(userId)) + .ReturnsAsync(Attempt.Succeed(_testUserKey)); + + var actualUserKey = await ((AuditEntryService)_auditEntryService).GetUserKey(userId); + + Assert.AreEqual(actualUserKey, _testUserKey); + } + + [Test] + public async Task GetUserKey_UsingNonExistingId_Returns_Null() + { + SetupScopeProviderMock(); + + int userId = 12; + _userIdKeyResolverMock.Setup(x => x.TryGetAsync(userId)) + .ReturnsAsync(Attempt.Fail()); + + var userKey = await ((AuditEntryService)_auditEntryService).GetUserKey(userId); + + Assert.AreEqual(null, userKey); + } + + private void SetupScopeProviderMock() => + _scopeProviderMock + .Setup(x => x.CreateCoreScope( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Mock.Of()); +}