Audit entries rework (#19345)

* Introduce new AuditEntryService

- Moved logic related to the IAuditEntryRepository from the AuditService to the new service
- Introduced new Async methods
  - Using ids (for easier transition from the previous Write method)
  - Using keys
- Moved and updated integration tests related to the audit entries to a new test class `AuditEntryServiceTests`
- Added unit tests class `AuditEntryServiceTests` and added a few unit tests
- Added migration to add columns for `performingUserKey` and `affectedUserKey` and convert existing user ids
- Adjusted usages of the old AuditService.Write method to use the new one (mostly notification handlers)

* Apply suggestions from code review

* Small improvement

* Some adjustments following code review. Removed UnknownUserKey and used null instead.

* Small adjustments

* Better handle audits performed during the migration state

* Update TODO comment
This commit is contained in:
Laura Neto
2025-06-06 13:12:35 +02:00
committed by GitHub
parent 321087c96e
commit 7fc2bc84de
24 changed files with 1040 additions and 394 deletions

View File

@@ -11,13 +11,13 @@ internal static class AuditLogBuilderExtensions
internal static IUmbracoBuilder AddAuditLogs(this IUmbracoBuilder builder)
{
builder.Services.AddTransient<IAuditLogPresentationFactory, AuditLogPresentationFactory>();
builder.AddNotificationHandler<UserLoginSuccessNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserLogoutSuccessNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserLoginFailedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserForgotPasswordRequestedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserForgotPasswordChangedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserPasswordChangedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationHandler<UserPasswordResetNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationAsyncHandler<UserLoginSuccessNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationAsyncHandler<UserLogoutSuccessNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationAsyncHandler<UserLoginFailedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationAsyncHandler<UserForgotPasswordRequestedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationAsyncHandler<UserForgotPasswordChangedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationAsyncHandler<UserPasswordChangedNotification, BackOfficeUserManagerAuditer>();
builder.AddNotificationAsyncHandler<UserPasswordResetNotification, BackOfficeUserManagerAuditer>();
return builder;
}

View File

@@ -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 <see cref="BackOfficeUserManager" />
/// </summary>
internal sealed class BackOfficeUserManagerAuditer :
INotificationHandler<UserLoginSuccessNotification>,
INotificationHandler<UserLogoutSuccessNotification>,
INotificationHandler<UserLoginFailedNotification>,
INotificationHandler<UserForgotPasswordRequestedNotification>,
INotificationHandler<UserForgotPasswordChangedNotification>,
INotificationHandler<UserPasswordChangedNotification>,
INotificationHandler<UserPasswordResetNotification>
INotificationAsyncHandler<UserLoginSuccessNotification>,
INotificationAsyncHandler<UserLogoutSuccessNotification>,
INotificationAsyncHandler<UserLoginFailedNotification>,
INotificationAsyncHandler<UserForgotPasswordRequestedNotification>,
INotificationAsyncHandler<UserForgotPasswordChangedNotification>,
INotificationAsyncHandler<UserPasswordChangedNotification>,
INotificationAsyncHandler<UserPasswordResetNotification>
{
private readonly IAuditService _auditService;
private readonly IAuditEntryService _auditEntryService;
private readonly IUserService _userService;
public BackOfficeUserManagerAuditer(IAuditService auditService, IUserService userService)
/// <summary>
/// Initializes a new instance of the <see cref="BackOfficeUserManagerAuditer"/> class.
/// </summary>
/// <param name="auditEntryService">The audit entry service.</param>
/// <param name="userService">The user service.</param>
public BackOfficeUserManagerAuditer(
IAuditEntryService auditEntryService,
IUserService userService)
{
_auditService = auditService;
_auditEntryService = auditEntryService;
_userService = userService;
}
public void Handle(UserForgotPasswordChangedNotification notification) =>
/// <inheritdoc />
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) =>
/// <inheritdoc />
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) =>
/// <inheritdoc />
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)
/// <inheritdoc />
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)
/// <inheritdoc />
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) =>
/// <inheritdoc />
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) =>
/// <inheritdoc />
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}>";
}
}

View File

@@ -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<MemberSavedNotification>,
INotificationAsyncHandler<MemberSavedNotification>,
INotificationHandler<MemberDeletedNotification>,
INotificationAsyncHandler<MemberDeletedNotification>,
INotificationHandler<AssignedMemberRolesNotification>,
INotificationAsyncHandler<AssignedMemberRolesNotification>,
INotificationHandler<RemovedMemberRolesNotification>,
INotificationAsyncHandler<RemovedMemberRolesNotification>,
INotificationHandler<ExportedMemberNotification>,
INotificationAsyncHandler<ExportedMemberNotification>,
INotificationHandler<UserSavedNotification>,
INotificationAsyncHandler<UserSavedNotification>,
INotificationHandler<UserDeletedNotification>,
INotificationAsyncHandler<UserDeletedNotification>,
INotificationHandler<UserGroupWithUsersSavedNotification>,
INotificationHandler<AssignedUserGroupPermissionsNotification>
INotificationAsyncHandler<UserGroupWithUsersSavedNotification>,
INotificationHandler<AssignedUserGroupPermissionsNotification>,
INotificationAsyncHandler<AssignedUserGroupPermissionsNotification>
{
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<IAuditEntryService>(),
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> 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<IUser?> 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)
/// <inheritdoc />
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();
/// <inheritdoc />
public async Task HandleAsync(
AssignedUserGroupPermissionsNotification notification,
CancellationToken cancellationToken)
{
IUser performingUser = CurrentPerformingUser;
IUser? performingUser = await GetCurrentPerformingUser();
IEnumerable<EntityPermission> 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();
/// <inheritdoc />
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<IMember> 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();
/// <inheritdoc />
public async Task HandleAsync(MemberSavedNotification notification, CancellationToken cancellationToken)
{
IUser performingUser = CurrentPerformingUser;
IUser? performingUser = await GetCurrentPerformingUser();
IEnumerable<IMember> 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();
/// <inheritdoc />
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();
/// <inheritdoc />
public async Task HandleAsync(UserDeletedNotification notification, CancellationToken cancellationToken)
{
IUser performingUser = CurrentPerformingUser;
IUser? performingUser = await GetCurrentPerformingUser();
IEnumerable<IUser> 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();
/// <inheritdoc />
public async Task HandleAsync(UserSavedNotification notification, CancellationToken cancellationToken)
{
IUser performingUser = CurrentPerformingUser;
IUser? performingUser = await GetCurrentPerformingUser();
IEnumerable<IUser> 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;
}

View File

@@ -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;
/// <inheritdoc />
public int PerformingUserId
@@ -25,6 +27,13 @@ public class AuditEntry : EntityBase, IAuditEntry
set => SetPropertyValueAndDetectChanges(value, ref _performingUserId, nameof(PerformingUserId));
}
/// <inheritdoc />
public Guid? PerformingUserKey
{
get => _performingUserKey;
set => SetPropertyValueAndDetectChanges(value, ref _performingUserKey, nameof(PerformingUserKey));
}
/// <inheritdoc />
public string? PerformingDetails
{
@@ -53,6 +62,13 @@ public class AuditEntry : EntityBase, IAuditEntry
set => SetPropertyValueAndDetectChanges(value, ref _affectedUserId, nameof(AffectedUserId));
}
/// <inheritdoc />
public Guid? AffectedUserKey
{
get => _affectedUserKey;
set => SetPropertyValueAndDetectChanges(value, ref _affectedUserKey, nameof(AffectedUserKey));
}
/// <inheritdoc />
public string? AffectedDetails
{

View File

@@ -23,6 +23,16 @@ public interface IAuditEntry : IEntity, IRememberBeingDirty
/// </summary>
int PerformingUserId { get; set; }
/// <summary>
/// Gets or sets the key of the user triggering the audited event.
/// </summary>
// TODO (V19): Remove the default implementations from this interface.
Guid? PerformingUserKey
{
get => null;
set { }
}
/// <summary>
/// Gets or sets free-form details about the user triggering the audited event.
/// </summary>
@@ -44,6 +54,17 @@ public interface IAuditEntry : IEntity, IRememberBeingDirty
/// <remarks>Not used when no single user is affected by the event.</remarks>
int AffectedUserId { get; set; }
/// <summary>
/// Gets or sets the key of the user affected by the audited event.
/// </summary>
/// <remarks>Not used when no single user is affected by the event.</remarks>
// TODO (V19): Remove the default implementations from this interface.
Guid? AffectedUserKey
{
get => null;
set { }
}
/// <summary>
/// Gets or sets free-form details about the entity affected by the audited event.
/// </summary>

View File

@@ -11,10 +11,4 @@ public interface IAuditEntryRepository : IReadWriteQueryRepository<int, IAuditEn
/// Gets a page of entries.
/// </summary>
IEnumerable<IAuditEntry> GetPage(long pageIndex, int pageCount, out long records);
/// <summary>
/// Determines whether the repository is available.
/// </summary>
/// <remarks>During an upgrade, the repository may not be available, until the table has been created.</remarks>
bool IsAvailable();
}

View File

@@ -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;
/// <inheritdoc cref="IAuditEntryService"/>
public class AuditEntryService : RepositoryService, IAuditEntryService
{
private readonly IAuditEntryRepository _auditEntryRepository;
private readonly IUserIdKeyResolver _userIdKeyResolver;
/// <summary>
/// Initializes a new instance of the <see cref="AuditEntryService" /> class.
/// </summary>
public AuditEntryService(
IAuditEntryRepository auditEntryRepository,
IUserIdKeyResolver userIdKeyResolver,
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory)
: base(provider, loggerFactory, eventMessagesFactory)
{
_auditEntryRepository = auditEntryRepository;
_userIdKeyResolver = userIdKeyResolver;
}
/// <inheritdoc />
public async Task<IAuditEntry> 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<IAuditEntry> 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<int?> GetUserId(Guid? key) =>
key is not null && await _userIdKeyResolver.TryGetAsync(key.Value) is { Success: true } attempt
? attempt.Result
: null;
internal async Task<Guid?> 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<IAuditEntry> GetAll()
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _auditEntryRepository.GetMany();
}
}
}

View File

@@ -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<bool> _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<bool>(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
}
/// <inheritdoc />
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<IAuditEntryService>();
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<IAuditEntry>? GetAll()
{
if (_isAvailable.Value == false)
{
return Enumerable.Empty<IAuditEntry>();
}
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<IAuditEntry> GetPage(long pageIndex, int pageCount, out long records)
{
if (_isAvailable.Value == false)
{
records = 0;
return Enumerable.Empty<IAuditEntry>();
}
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _auditEntryRepository.GetPage(pageIndex, pageCount, out records);
}
}
/// <summary>
/// Determines whether the repository is available.
/// </summary>
private bool DetermineIsAvailable()
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _auditEntryRepository.IsAvailable();
}
return auditEntryService.WriteInner(
performingUserId,
perfomingDetails,
performingIp,
eventDateUtc,
affectedUserId,
affectedDetails,
eventType,
eventDetails).GetAwaiter().GetResult();
}
}

View File

@@ -0,0 +1,39 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Represents a service for handling audit entries.
/// </summary>
public interface IAuditEntryService : IService
{
/// <summary>
/// Writes an audit entry for an audited event.
/// </summary>
/// <param name="performingUserKey">The key of the user triggering the audited event.</param>
/// <param name="performingDetails">Free-form details about the user triggering the audited event.</param>
/// <param name="performingIp">The IP address or the request triggering the audited event.</param>
/// <param name="eventDateUtc">The date and time of the audited event.</param>
/// <param name="affectedUserKey">The identifier of the user affected by the audited event.</param>
/// <param name="affectedDetails">Free-form details about the entity affected by the audited event.</param>
/// <param name="eventType">
/// The type of the audited event - must contain only alphanumeric chars and hyphens with forward slashes separating
/// categories.
/// <example>
/// The eventType will generally be formatted like: {application}/{entity-type}/{category}/{sub-category}
/// Example: umbraco/user/sign-in/failed
/// </example>
/// </param>
/// <param name="eventDetails">Free-form details about the audited event.</param>
/// <returns>The created audit entry.</returns>
public Task<IAuditEntry> WriteAsync(
Guid? performingUserKey,
string performingDetails,
string performingIp,
DateTime eventDateUtc,
Guid? affectedUserKey,
string? affectedDetails,
string eventType,
string eventDetails);
}

View File

@@ -144,6 +144,7 @@ public interface IAuditService : IService
/// </example>
/// </param>
/// <param name="eventDetails">Free-form details about the audited event.</param>
[Obsolete("Use AuditEntryService.WriteAsync() instead. Scheduled for removal in Umbraco 19.")]
IAuditEntry Write(
int performingUserId,
string perfomingDetails,

View File

@@ -10,6 +10,13 @@ public interface IUserIdKeyResolver
/// <exception cref="InvalidOperationException">Thrown when no user was found with the specified key.</exception>
public Task<int> GetAsync(Guid key);
/// <summary>
/// Tries to resolve a user key to a user id without fetching the entire user.
/// </summary>
/// <param name="key">The key of the user.</param>
/// <returns>An attempt with the id of the user.</returns>
public Task<Attempt<int>> TryGetAsync(Guid key) => throw new NotImplementedException();
/// <summary>
/// Tries to resolve a user id to a user key without fetching the entire user.
/// </summary>
@@ -17,4 +24,11 @@ public interface IUserIdKeyResolver
/// <returns>The key of the user.</returns>
/// <exception cref="InvalidOperationException">Thrown when no user was found with the specified id.</exception>
public Task<Guid> GetAsync(int id);
/// <summary>
/// Tries to resolve a user id to a user key without fetching the entire user.
/// </summary>
/// <param name="id">The id of the user.</param>
/// <returns>An attempt with the key of the user.</returns>
public Task<Attempt<Guid>> TryGetAsync(int id) => throw new NotImplementedException();
}

View File

@@ -403,15 +403,15 @@ public static partial class UmbracoBuilderExtensions
// add notification handlers for auditing
builder
.AddNotificationHandler<MemberSavedNotification, AuditNotificationsHandler>()
.AddNotificationHandler<MemberDeletedNotification, AuditNotificationsHandler>()
.AddNotificationHandler<AssignedMemberRolesNotification, AuditNotificationsHandler>()
.AddNotificationHandler<RemovedMemberRolesNotification, AuditNotificationsHandler>()
.AddNotificationHandler<ExportedMemberNotification, AuditNotificationsHandler>()
.AddNotificationHandler<UserSavedNotification, AuditNotificationsHandler>()
.AddNotificationHandler<UserDeletedNotification, AuditNotificationsHandler>()
.AddNotificationHandler<UserGroupWithUsersSavedNotification, AuditNotificationsHandler>()
.AddNotificationHandler<AssignedUserGroupPermissionsNotification, AuditNotificationsHandler>();
.AddNotificationAsyncHandler<MemberSavedNotification, AuditNotificationsHandler>()
.AddNotificationAsyncHandler<MemberDeletedNotification, AuditNotificationsHandler>()
.AddNotificationAsyncHandler<AssignedMemberRolesNotification, AuditNotificationsHandler>()
.AddNotificationAsyncHandler<RemovedMemberRolesNotification, AuditNotificationsHandler>()
.AddNotificationAsyncHandler<ExportedMemberNotification, AuditNotificationsHandler>()
.AddNotificationAsyncHandler<UserSavedNotification, AuditNotificationsHandler>()
.AddNotificationAsyncHandler<UserDeletedNotification, AuditNotificationsHandler>()
.AddNotificationAsyncHandler<UserGroupWithUsersSavedNotification, AuditNotificationsHandler>()
.AddNotificationAsyncHandler<AssignedUserGroupPermissionsNotification, AuditNotificationsHandler>();
// Handlers for publish warnings
builder.AddNotificationHandler<ContentPublishedNotification, AddDomainWarningsWhenPublishingNotificationHandler>();

View File

@@ -44,6 +44,7 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddUnique<IUserIdKeyResolver, UserIdKeyResolver>();
builder.Services.AddUnique<IAuditService, AuditService>();
builder.Services.AddUnique<IAuditEntryService, AuditEntryService>();
builder.Services.AddUnique<ICacheInstructionService, CacheInstructionService>();
builder.Services.AddUnique<IBasicAuthService, BasicAuthService>();
builder.Services.AddUnique<IDataTypeService, DataTypeService>();

View File

@@ -119,5 +119,8 @@ public class UmbracoPlan : MigrationPlan
// To 16.0.0
To<V_16_0_0.MigrateTinyMceToTiptap>("{C6681435-584F-4BC8-BB8D-BC853966AF0B}");
To<V_16_0_0.AddDocumentPropertyPermissions>("{D1568C33-A697-455F-8D16-48060CB954A1}");
// To 17.0.0
To<V_17_0_0.AddGuidsToAuditEntries>("{17D5F6CA-CEB8-462A-AF86-4B9C3BF91CF1}");
}
}

View File

@@ -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<AuditEntryDto>(columns, NewPerformingUserKeyColumnName);
AddColumnIfNotExists<AuditEntryDto>(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();
}
}

View File

@@ -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)]

View File

@@ -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,

View File

@@ -19,10 +19,12 @@ public sealed class AuditEntryMapper : BaseMapper
{
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.Id), nameof(AuditEntryDto.Id));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.PerformingUserId), nameof(AuditEntryDto.PerformingUserId));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.PerformingUserKey), nameof(AuditEntryDto.PerformingUserKey));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.PerformingDetails), nameof(AuditEntryDto.PerformingDetails));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.PerformingIp), nameof(AuditEntryDto.PerformingIp));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.EventDateUtc), nameof(AuditEntryDto.EventDateUtc));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.AffectedUserId), nameof(AuditEntryDto.AffectedUserId));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.AffectedUserKey), nameof(AuditEntryDto.AffectedUserKey));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.AffectedDetails), nameof(AuditEntryDto.AffectedDetails));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.EventType), nameof(AuditEntryDto.EventType));
DefineMap<AuditEntry, AuditEntryDto>(nameof(AuditEntry.EventDetails), nameof(AuditEntryDto.EventDetails));

View File

@@ -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;
/// </summary>
internal class AuditEntryRepository : EntityRepositoryBase<int, IAuditEntry>, IAuditEntryRepository
{
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="AuditEntryRepository" /> class.
/// </summary>
public AuditEntryRepository(IScopeAccessor scopeAccessor, AppCaches cache, ILogger<AuditEntryRepository> logger)
public AuditEntryRepository(
IRuntimeState runtimeState,
IScopeAccessor scopeAccessor,
AppCaches cache,
ILogger<AuditEntryRepository> logger)
: base(scopeAccessor, cache, logger)
{
_runtimeState = runtimeState;
}
/// <inheritdoc />
@@ -39,13 +49,6 @@ internal class AuditEntryRepository : EntityRepositoryBase<int, IAuditEntry>, IA
return page.Items.Select(AuditEntryFactory.BuildEntity);
}
/// <inheritdoc />
public bool IsAvailable()
{
var tables = SqlSyntax.GetTablesInSchema(Database).ToArray();
return tables.InvariantContains(Constants.DatabaseSchema.Tables.AuditEntry);
}
/// <inheritdoc />
protected override IAuditEntry? PerformGet(int id)
{
@@ -116,7 +119,55 @@ internal class AuditEntryRepository : EntityRepositoryBase<int, IAuditEntry>, 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<Func<AuditEntryDto, object?>>[] 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<object?> values = fields.Select(f => f.Compile().Invoke(dto));
Sql<ISqlContext> 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<ISqlContext> sqlInsert = Sql($"INSERT INTO {Constants.DatabaseSchema.Tables.AuditEntry} ({cols})")
.Append("VALUES (")
.Append(sqlValues)
.Append(")");
Database.Execute(sqlInsert);
}
entity.Id = dto.Id;
entity.ResetDirtyProperties();
}

View File

@@ -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
/// <inheritdoc/>
public async Task<int> GetAsync(Guid key)
=> await TryGetAsync(key) is { Success: true } attempt ? attempt.Result : throw new InvalidOperationException("No user found with the specified key");
/// <inheritdoc/>
public async Task<Attempt<int>> 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<UserDto>()
.Where<UserDto>(x => x.Key == key);
int fetchedId = (await scope.Database.ExecuteScalarAsync<int?>(query))
?? throw new InvalidOperationException("No user found with the specified key");
int? fetchedId = await scope.Database.ExecuteScalarAsync<int?>(query);
if (fetchedId is null)
{
return Attempt.Fail<int>();
}
_keyToId[key] = fetchedId;
return fetchedId;
_keyToId[key] = fetchedId.Value;
return Attempt.Succeed(fetchedId.Value);
}
finally
{
@@ -63,10 +71,14 @@ internal sealed class UserIdKeyResolver : IUserIdKeyResolver
/// <inheritdoc/>
public async Task<Guid> GetAsync(int id)
=> await TryGetAsync(id) is { Success: true } attempt ? attempt.Result : throw new InvalidOperationException("No user found with the specified id");
/// <inheritdoc/>
public async Task<Attempt<Guid>> 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<UserDto>()
.Where<UserDto>(x => x.Id == id);
Guid fetchedKey = scope.Database.ExecuteScalar<Guid?>(query)
?? throw new InvalidOperationException("No user found with the specified id");
Guid? fetchedKey = scope.Database.ExecuteScalar<Guid?>(query);
if (fetchedKey is null)
{
return Attempt<Guid>.Fail();
}
_idToKey[id] = fetchedKey;
_idToKey[id] = fetchedKey.Value;
return fetchedKey;
return Attempt.Succeed(fetchedKey.Value);
}
finally
{

View File

@@ -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<TParent>
{
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<TParent>
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<TParent>
return this;
}
public AuditEntryBuilder<TParent> WithAffectedUserKey(Guid? affectedUserKey)
{
_affectedUserKey = affectedUserKey;
return this;
}
public AuditEntryBuilder<TParent> WithEventDetails(string eventDetails)
{
_eventDetails = eventDetails;
@@ -120,6 +129,12 @@ public class AuditEntryBuilder<TParent>
return this;
}
public AuditEntryBuilder<TParent> WithPerformingUserKey(Guid? performingUserKey)
{
_performingUserKey = performingUserKey;
return this;
}
public override IAuditEntry Build()
{
var id = _id ?? 0;
@@ -129,12 +144,14 @@ public class AuditEntryBuilder<TParent>
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<TParent>
DeleteDate = deleteDate,
AffectedDetails = affectedDetails,
AffectedUserId = affectedUserId,
AffectedUserKey = affectedUserKey,
EventDetails = eventDetails,
EventType = eventType,
PerformingDetails = performingDetails,
PerformingIp = performingIp,
EventDateUtc = eventDateUtc,
PerformingUserId = performingUserId
PerformingUserId = performingUserId,
PerformingUserKey = performingUserKey,
};
}
}

View File

@@ -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<IAuditEntryService>();
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<IAuditEntryService>();
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);
});
}
}

View File

@@ -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<IAuditService>();
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<IAuditService>();
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);
});
}
}

View File

@@ -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<ICoreScopeProvider> _scopeProviderMock;
private Mock<IAuditEntryRepository> _auditEntryRepositoryMock;
private Mock<IUserIdKeyResolver> _userIdKeyResolverMock;
[SetUp]
public void Setup()
{
_scopeProviderMock = new Mock<ICoreScopeProvider>(MockBehavior.Strict);
_auditEntryRepositoryMock = new Mock<IAuditEntryRepository>(MockBehavior.Strict);
_userIdKeyResolverMock = new Mock<IUserIdKeyResolver>(MockBehavior.Strict);
_auditEntryService = new AuditEntryService(
_auditEntryRepositoryMock.Object,
_userIdKeyResolverMock.Object,
_scopeProviderMock.Object,
Mock.Of<ILoggerFactory>(MockBehavior.Strict),
Mock.Of<IEventMessagesFactory>(MockBehavior.Strict));
}
[Test]
public async Task WriteAsync_Calls_Repository_With_Correct_Values()
{
SetupScopeProviderMock();
var date = DateTime.UtcNow;
_auditEntryRepositoryMock.Setup(x => x.Save(It.IsAny<IAuditEntry>()))
.Callback<IAuditEntry>(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<IAuditEntry>()), 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<int>());
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<Guid>());
var userKey = await ((AuditEntryService)_auditEntryService).GetUserKey(userId);
Assert.AreEqual(null, userKey);
}
private void SetupScopeProviderMock() =>
_scopeProviderMock
.Setup(x => x.CreateCoreScope(
It.IsAny<IsolationLevel>(),
It.IsAny<RepositoryCacheMode>(),
It.IsAny<IEventDispatcher>(),
It.IsAny<IScopedNotificationPublisher>(),
It.IsAny<bool?>(),
It.IsAny<bool>(),
It.IsAny<bool>()))
.Returns(Mock.Of<IScope>());
}