Handle sensitive properties in the Management API (#15936)

* Handle sensitive properties in the Management API

* Use Assert.Multiple to catch all failing tests in one run

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
This commit is contained in:
Kenn Jacobsen
2024-03-25 16:56:13 +01:00
committed by GitHub
parent f6f868e463
commit 4dca7495f8
27 changed files with 788 additions and 53 deletions

View File

@@ -73,6 +73,10 @@ public abstract class DocumentTypeControllerBase : ManagementApiControllerBase
.WithTitle("Duplicate property type alias")
.WithDetail("One or more property type aliases are already in use, all property type aliases must be unique.")
.Build()),
ContentTypeOperationStatus.NotAllowed => new BadRequestObjectResult(problemDetailsBuilder
.WithTitle("Operation not permitted")
.WithDetail("The attempted operation was not permitted, likely due to a permission/configuration mismatch with the operation.")
.Build()),
_ => new ObjectResult("Unknown content type operation status") { StatusCode = StatusCodes.Status500InternalServerError },
});

View File

@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.Member;
@@ -13,11 +14,16 @@ public class ByKeyMemberController : MemberControllerBase
{
private readonly IMemberEditingService _memberEditingService;
private readonly IMemberPresentationFactory _memberPresentationFactory;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public ByKeyMemberController(IMemberEditingService memberEditingService, IMemberPresentationFactory memberPresentationFactory)
public ByKeyMemberController(
IMemberEditingService memberEditingService,
IMemberPresentationFactory memberPresentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_memberEditingService = memberEditingService;
_memberPresentationFactory = memberPresentationFactory;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[HttpGet("{id:guid}")]
@@ -32,7 +38,7 @@ public class ByKeyMemberController : MemberControllerBase
return MemberNotFound();
}
MemberResponseModel model = await _memberPresentationFactory.CreateResponseModelAsync(member);
MemberResponseModel model = await _memberPresentationFactory.CreateResponseModelAsync(member, CurrentUser(_backOfficeSecurityAccessor));
return Ok(model);
}
}

View File

@@ -7,6 +7,7 @@ using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.Member.Filter;
@@ -16,13 +17,16 @@ public class FilterMemberFilterController : MemberFilterControllerBase
{
private readonly IMemberService _memberService;
private readonly IMemberPresentationFactory _memberPresentationFactory;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public FilterMemberFilterController(
IMemberService memberService,
IMemberPresentationFactory memberPresentationFactory)
IMemberPresentationFactory memberPresentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_memberService = memberService;
_memberPresentationFactory = memberPresentationFactory;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[HttpGet]
@@ -53,7 +57,7 @@ public class FilterMemberFilterController : MemberFilterControllerBase
var pageViewModel = new PagedViewModel<MemberResponseModel>
{
Items = await _memberPresentationFactory.CreateMultipleAsync(members.Items),
Items = await _memberPresentationFactory.CreateMultipleAsync(members.Items, CurrentUser(_backOfficeSecurityAccessor)),
Total = members.Total,
};

View File

@@ -1,8 +1,8 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.MemberType;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -13,12 +13,12 @@ namespace Umbraco.Cms.Api.Management.Controllers.MemberType;
public class ByKeyMemberTypeController : MemberTypeControllerBase
{
private readonly IMemberTypeService _memberTypeService;
private readonly IUmbracoMapper _umbracoMapper;
private readonly IMemberTypePresentationFactory _memberTypePresentationFactory;
public ByKeyMemberTypeController(IMemberTypeService memberTypeService, IUmbracoMapper umbracoMapper)
public ByKeyMemberTypeController(IMemberTypeService memberTypeService, IMemberTypePresentationFactory memberTypePresentationFactory)
{
_memberTypeService = memberTypeService;
_umbracoMapper = umbracoMapper;
_memberTypePresentationFactory = memberTypePresentationFactory;
}
[HttpGet("{id:guid}")]
@@ -27,13 +27,13 @@ public class ByKeyMemberTypeController : MemberTypeControllerBase
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ByKey(Guid id)
{
IMemberType? MemberType = await _memberTypeService.GetAsync(id);
if (MemberType == null)
IMemberType? memberType = await _memberTypeService.GetAsync(id);
if (memberType is null)
{
return OperationStatusResult(ContentTypeOperationStatus.NotFound);
}
MemberTypeResponseModel model = _umbracoMapper.Map<MemberTypeResponseModel>(MemberType)!;
return await Task.FromResult(Ok(model));
MemberTypeResponseModel model = await _memberTypePresentationFactory.CreateResponseModelAsync(memberType);
return Ok(model);
}
}

View File

@@ -10,6 +10,7 @@ internal static class MemberTypeBuilderExtensions
{
internal static IUmbracoBuilder AddMemberTypes(this IUmbracoBuilder builder)
{
builder.Services.AddTransient<IMemberTypePresentationFactory, MemberTypePresentationFactory>();
builder.Services.AddTransient<IMemberTypeEditingPresentationFactory, MemberTypeEditingPresentationFactory>();
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>().Add<MemberTypeMapDefinition>();

View File

@@ -4,14 +4,15 @@ using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
using Umbraco.Cms.Api.Management.ViewModels.MemberType;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
namespace Umbraco.Cms.Api.Management.Factories;
public interface IMemberPresentationFactory
{
Task<MemberResponseModel> CreateResponseModelAsync(IMember member);
Task<MemberResponseModel> CreateResponseModelAsync(IMember member, IUser currentUser);
Task<IEnumerable<MemberResponseModel>> CreateMultipleAsync(IEnumerable<IMember> members);
Task<IEnumerable<MemberResponseModel>> CreateMultipleAsync(IEnumerable<IMember> members, IUser currentUser);
MemberItemResponseModel CreateItemResponseModel(IMemberEntitySlim entity);

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Api.Management.ViewModels.MemberType;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Api.Management.Factories;
public interface IMemberTypePresentationFactory
{
Task<MemberTypeResponseModel> CreateResponseModelAsync(IMemberType memberType);
}

View File

@@ -2,10 +2,13 @@
using Umbraco.Cms.Api.Management.ViewModels.Member;
using Umbraco.Cms.Api.Management.ViewModels.Member.Item;
using Umbraco.Cms.Api.Management.ViewModels.MemberType;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Factories;
@@ -13,34 +16,39 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
{
private readonly IUmbracoMapper _umbracoMapper;
private readonly IMemberService _memberService;
private readonly IMemberTypeService _memberTypeService;
private readonly ITwoFactorLoginService _twoFactorLoginService;
public MemberPresentationFactory(
IUmbracoMapper umbracoMapper,
IMemberService memberService,
IMemberTypeService memberTypeService,
ITwoFactorLoginService twoFactorLoginService)
{
_umbracoMapper = umbracoMapper;
_memberService = memberService;
_memberTypeService = memberTypeService;
_twoFactorLoginService = twoFactorLoginService;
}
public async Task<MemberResponseModel> CreateResponseModelAsync(IMember member)
public async Task<MemberResponseModel> CreateResponseModelAsync(IMember member, IUser currentUser)
{
MemberResponseModel responseModel = _umbracoMapper.Map<MemberResponseModel>(member)!;
responseModel.IsTwoFactorEnabled = await _twoFactorLoginService.IsTwoFactorEnabledAsync(member.Key);
responseModel.Groups = _memberService.GetAllRoles(member.Username);
return responseModel;
return currentUser.HasAccessToSensitiveData()
? responseModel
: await RemoveSensitiveDataAsync(member, responseModel);
}
public async Task<IEnumerable<MemberResponseModel>> CreateMultipleAsync(IEnumerable<IMember> members)
public async Task<IEnumerable<MemberResponseModel>> CreateMultipleAsync(IEnumerable<IMember> members, IUser currentUser)
{
var memberResponseModels = new List<MemberResponseModel>();
foreach (IMember member in members)
{
memberResponseModels.Add(await CreateResponseModelAsync(member));
memberResponseModels.Add(await CreateResponseModelAsync(member, currentUser));
}
return memberResponseModels;
@@ -72,4 +80,29 @@ internal sealed class MemberPresentationFactory : IMemberPresentationFactory
public MemberTypeReferenceResponseModel CreateMemberTypeReferenceResponseModel(IMemberEntitySlim entity)
=> _umbracoMapper.Map<MemberTypeReferenceResponseModel>(entity)!;
private async Task<MemberResponseModel> RemoveSensitiveDataAsync(IMember member, MemberResponseModel responseModel)
{
// these properties are considered sensitive; some of them are not nullable, so for
// those we can't do much more than force revert them to their default values.
responseModel.IsApproved = false;
responseModel.IsLockedOut = false;
responseModel.IsTwoFactorEnabled = false;
responseModel.FailedPasswordAttempts = 0;
responseModel.LastLoginDate = null;
responseModel.LastLockoutDate = null;
responseModel.LastPasswordChangeDate = null;
IMemberType memberType = await _memberTypeService.GetAsync(member.ContentType.Key)
?? throw new InvalidOperationException($"The member type {member.ContentType.Alias} could not be found");
var sensitivePropertyAliases = memberType.GetSensitivePropertyTypeAliases().ToArray();
// remove all properties whose property types are flagged as sensitive
responseModel.Values = responseModel.Values
.Where(valueModel => sensitivePropertyAliases.InvariantContains(valueModel.Alias) is false)
.ToArray();
return responseModel;
}
}

View File

@@ -25,6 +25,8 @@ internal sealed class MemberTypeEditingPresentationFactory : ContentTypeEditingP
createModel.Key = requestModel.Id;
createModel.Compositions = MapCompositions(requestModel.Compositions);
MapPropertyTypeSensitivityAndVisibility(createModel.Properties, requestModel.Properties);
return createModel;
}
@@ -40,6 +42,8 @@ internal sealed class MemberTypeEditingPresentationFactory : ContentTypeEditingP
updateModel.Compositions = MapCompositions(requestModel.Compositions);
MapPropertyTypeSensitivityAndVisibility(updateModel.Properties, requestModel.Properties);
return updateModel;
}
@@ -50,4 +54,23 @@ internal sealed class MemberTypeEditingPresentationFactory : ContentTypeEditingP
=> MapCompositions(documentTypeCompositions
.DistinctBy(c => c.MemberType.Id)
.ToDictionary(c => c.MemberType.Id, c => c.CompositionType));
private void MapPropertyTypeSensitivityAndVisibility<TRequestPropertyTypeModel>(
IEnumerable<MemberTypePropertyTypeModel> propertyTypes,
IEnumerable<TRequestPropertyTypeModel> requestPropertyTypes)
where TRequestPropertyTypeModel : MemberTypePropertyTypeModelBase
{
var requestModelPropertiesByAlias = requestPropertyTypes.ToDictionary(p => p.Alias);
foreach (MemberTypePropertyTypeModel propertyType in propertyTypes)
{
if (requestModelPropertiesByAlias.TryGetValue(propertyType.Alias, out TRequestPropertyTypeModel? requestPropertyType) is false)
{
throw new InvalidOperationException($"Could not find the property type model {propertyType.Alias} in the request");
}
propertyType.IsSensitive = requestPropertyType.IsSensitive;
propertyType.MemberCanView = requestPropertyType.Visibility.MemberCanView;
propertyType.MemberCanEdit = requestPropertyType.Visibility.MemberCanEdit;
}
}
}

View File

@@ -0,0 +1,28 @@
using Umbraco.Cms.Api.Management.ViewModels.MemberType;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Api.Management.Factories;
internal sealed class MemberTypePresentationFactory : IMemberTypePresentationFactory
{
private readonly IUmbracoMapper _umbracoMapper;
public MemberTypePresentationFactory(IUmbracoMapper umbracoMapper)
=> _umbracoMapper = umbracoMapper;
public Task<MemberTypeResponseModel> CreateResponseModelAsync(IMemberType memberType)
{
MemberTypeResponseModel model = _umbracoMapper.Map<MemberTypeResponseModel>(memberType)!;
foreach (MemberTypePropertyTypeResponseModel propertyType in model.Properties)
{
propertyType.IsSensitive = memberType.IsSensitiveProperty(propertyType.Alias);
propertyType.Visibility.MemberCanEdit = memberType.MemberCanEditProperty(propertyType.Alias);
propertyType.Visibility.MemberCanView = memberType.MemberCanViewProperty(propertyType.Alias);
}
return Task.FromResult(model);
}
}

View File

@@ -164,6 +164,7 @@ public class UserPresentationFactory : IUserPresentationFactory
var hasAccessToAllLanguages = presentationGroups.Any(x => x.HasAccessToAllLanguages);
var allowedSections = presentationGroups.SelectMany(x => x.Sections).ToHashSet();
return await Task.FromResult(new CurrentUserResponseModel()
{
Id = presentationUser.Id,
@@ -178,7 +179,8 @@ public class UserPresentationFactory : IUserPresentationFactory
Permissions = permissions,
FallbackPermissions = fallbackPermissions,
HasAccessToAllLanguages = hasAccessToAllLanguages,
AllowedSections = allowedSections
HasAccessToSensitiveData = user.HasAccessToSensitiveData(),
AllowedSections = allowedSections,
});
}

View File

@@ -34497,7 +34497,7 @@
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PropertyTypeModelBaseModel"
"$ref": "#/components/schemas/MemberTypePropertyTypeModelBaseModel"
}
],
"additionalProperties": false
@@ -34739,6 +34739,7 @@
"email",
"fallbackPermissions",
"hasAccessToAllLanguages",
"hasAccessToSensitiveData",
"id",
"languages",
"mediaStartNodeIds",
@@ -34796,6 +34797,9 @@
"hasAccessToAllLanguages": {
"type": "boolean"
},
"hasAccessToSensitiveData": {
"type": "boolean"
},
"fallbackPermissions": {
"uniqueItems": true,
"type": "array",
@@ -37652,15 +37656,115 @@
],
"additionalProperties": false
},
"MemberTypePropertyTypeModelBaseModel": {
"required": [
"alias",
"appearance",
"dataType",
"id",
"isSensitive",
"name",
"sortOrder",
"validation",
"variesByCulture",
"variesBySegment",
"visibility"
],
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"container": {
"oneOf": [
{
"$ref": "#/components/schemas/ReferenceByIdModel"
}
],
"nullable": true
},
"sortOrder": {
"type": "integer",
"format": "int32"
},
"alias": {
"minLength": 1,
"type": "string"
},
"name": {
"minLength": 1,
"type": "string"
},
"description": {
"type": "string",
"nullable": true
},
"dataType": {
"oneOf": [
{
"$ref": "#/components/schemas/ReferenceByIdModel"
}
]
},
"variesByCulture": {
"type": "boolean"
},
"variesBySegment": {
"type": "boolean"
},
"validation": {
"oneOf": [
{
"$ref": "#/components/schemas/PropertyTypeValidationModel"
}
]
},
"appearance": {
"oneOf": [
{
"$ref": "#/components/schemas/PropertyTypeAppearanceModel"
}
]
},
"isSensitive": {
"type": "boolean"
},
"visibility": {
"oneOf": [
{
"$ref": "#/components/schemas/MemberTypePropertyTypeVisibilityModel"
}
]
}
},
"additionalProperties": false
},
"MemberTypePropertyTypeResponseModel": {
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PropertyTypeModelBaseModel"
"$ref": "#/components/schemas/MemberTypePropertyTypeModelBaseModel"
}
],
"additionalProperties": false
},
"MemberTypePropertyTypeVisibilityModel": {
"required": [
"memberCanEdit",
"memberCanView"
],
"type": "object",
"properties": {
"memberCanView": {
"type": "boolean"
},
"memberCanEdit": {
"type": "boolean"
}
},
"additionalProperties": false
},
"MemberTypeReferenceResponseModel": {
"type": "object",
"allOf": [
@@ -41472,7 +41576,7 @@
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PropertyTypeModelBaseModel"
"$ref": "#/components/schemas/MemberTypePropertyTypeModelBaseModel"
}
],
"additionalProperties": false

View File

@@ -1,7 +1,5 @@
using Umbraco.Cms.Api.Management.ViewModels.ContentType;
namespace Umbraco.Cms.Api.Management.ViewModels.MemberType;
namespace Umbraco.Cms.Api.Management.ViewModels.MemberType;
public class CreateMemberTypePropertyTypeRequestModel : PropertyTypeModelBase
public class CreateMemberTypePropertyTypeRequestModel : MemberTypePropertyTypeModelBase
{
}

View File

@@ -0,0 +1,10 @@
using Umbraco.Cms.Api.Management.ViewModels.ContentType;
namespace Umbraco.Cms.Api.Management.ViewModels.MemberType;
public abstract class MemberTypePropertyTypeModelBase : PropertyTypeModelBase
{
public bool IsSensitive { get; set; }
public MemberTypePropertyTypeVisibility Visibility { get; set; } = new();
}

View File

@@ -1,7 +1,5 @@
using Umbraco.Cms.Api.Management.ViewModels.ContentType;
namespace Umbraco.Cms.Api.Management.ViewModels.MemberType;
namespace Umbraco.Cms.Api.Management.ViewModels.MemberType;
public class MemberTypePropertyTypeResponseModel : PropertyTypeModelBase
public class MemberTypePropertyTypeResponseModel : MemberTypePropertyTypeModelBase
{
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.ViewModels.MemberType;
public class MemberTypePropertyTypeVisibility
{
public bool MemberCanView { get; set; }
public bool MemberCanEdit { get; set; }
}

View File

@@ -1,7 +1,5 @@
using Umbraco.Cms.Api.Management.ViewModels.ContentType;
namespace Umbraco.Cms.Api.Management.ViewModels.MemberType;
namespace Umbraco.Cms.Api.Management.ViewModels.MemberType;
public class UpdateMemberTypePropertyTypeRequestModel : PropertyTypeModelBase
public class UpdateMemberTypePropertyTypeRequestModel : MemberTypePropertyTypeModelBase
{
}

View File

@@ -26,7 +26,11 @@ public class CurrentUserResponseModel
public required bool HasAccessToAllLanguages { get; init; }
public required bool HasAccessToSensitiveData { get; set; }
public required ISet<string> FallbackPermissions { get; init; }
public required ISet<IPermissionPresentationModel> Permissions { get; init; }
public required ISet<string> AllowedSections { get; init; }
}

View File

@@ -0,0 +1,12 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Extensions;
public static class MemberTypeExtensions
{
public static IEnumerable<IPropertyType> GetSensitivePropertyTypes(this IMemberType memberType)
=> memberType.CompositionPropertyTypes.Where(p => memberType.IsSensitiveProperty(p.Alias));
public static IEnumerable<string> GetSensitivePropertyTypeAliases(this IMemberType memberType)
=> memberType.GetSensitivePropertyTypes().Select(p => p.Alias);
}

View File

@@ -2,4 +2,9 @@
public class MemberTypePropertyTypeModel : PropertyTypeModelBase
{
public bool IsSensitive { get; set; }
public bool MemberCanView { get; set; }
public bool MemberCanEdit { get; set; }
}

View File

@@ -1,5 +1,6 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentTypeEditing;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Strings;
@@ -8,37 +9,58 @@ namespace Umbraco.Cms.Core.Services.ContentTypeEditing;
internal sealed class MemberTypeEditingService : ContentTypeEditingServiceBase<IMemberType, IMemberTypeService, MemberTypePropertyTypeModel, MemberTypePropertyContainerModel>, IMemberTypeEditingService
{
private readonly IMemberTypeService _memberTypeService;
private readonly IUserService _userService;
public MemberTypeEditingService(
IContentTypeService contentTypeService,
IMemberTypeService memberTypeService,
IDataTypeService dataTypeService,
IEntityService entityService,
IShortStringHelper shortStringHelper)
IShortStringHelper shortStringHelper,
IUserService userService)
: base(contentTypeService, memberTypeService, dataTypeService, entityService, shortStringHelper)
=> _memberTypeService = memberTypeService;
{
_memberTypeService = memberTypeService;
_userService = userService;
}
public async Task<Attempt<IMemberType?, ContentTypeOperationStatus>> CreateAsync(MemberTypeCreateModel model, Guid userKey)
{
Attempt<IMemberType?, ContentTypeOperationStatus> result = await ValidateAndMapForCreationAsync(model, model.Key, containerKey: null);
if (result.Success)
if (result.Success is false)
{
IMemberType memberType = result.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForCreationAsync)} succeeded but did not yield any result");
await _memberTypeService.SaveAsync(memberType, userKey);
return result;
}
IMemberType memberType = result.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForCreationAsync)} succeeded but did not yield any result");
if (await UpdatePropertyTypeSensitivity(memberType, model, userKey) is false)
{
return Attempt.FailWithStatus<IMemberType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.NotAllowed, memberType);
}
UpdatePropertyTypeVisibility(memberType, model);
await _memberTypeService.SaveAsync(memberType, userKey);
return result;
}
public async Task<Attempt<IMemberType?, ContentTypeOperationStatus>> UpdateAsync(IMemberType memberType, MemberTypeUpdateModel model, Guid userKey)
{
Attempt<IMemberType?, ContentTypeOperationStatus> result = await ValidateAndMapForUpdateAsync(memberType, model);
if (result.Success)
if (result.Success is false)
{
memberType = result.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForUpdateAsync)} succeeded but did not yield any result");
await _memberTypeService.SaveAsync(memberType, userKey);
return result;
}
memberType = result.Result ?? throw new InvalidOperationException($"{nameof(ValidateAndMapForUpdateAsync)} succeeded but did not yield any result");
if (await UpdatePropertyTypeSensitivity(memberType, model, userKey) is false)
{
return Attempt.FailWithStatus<IMemberType?, ContentTypeOperationStatus>(ContentTypeOperationStatus.NotAllowed, memberType);
}
UpdatePropertyTypeVisibility(memberType, model);
await _memberTypeService.SaveAsync(memberType, userKey);
return result;
}
@@ -56,4 +78,34 @@ internal sealed class MemberTypeEditingService : ContentTypeEditingServiceBase<I
protected override UmbracoObjectTypes ContentTypeObjectType => UmbracoObjectTypes.MemberType;
protected override UmbracoObjectTypes ContainerObjectType => throw new NotSupportedException("Member type tree does not support containers");
private void UpdatePropertyTypeVisibility(IMemberType memberType, MemberTypeModelBase model)
{
foreach (MemberTypePropertyTypeModel propertyType in model.Properties)
{
memberType.SetMemberCanViewProperty(propertyType.Alias, propertyType.MemberCanView);
memberType.SetMemberCanEditProperty(propertyType.Alias, propertyType.MemberCanEdit);
}
}
private async Task<bool> UpdatePropertyTypeSensitivity(IMemberType memberType, MemberTypeModelBase model, Guid userKey)
{
IUser user = await _userService.GetAsync(userKey)
?? throw new ArgumentException($"Could not find a user with the specified user key ({userKey})", nameof(userKey));
var canChangeSensitivity = user.HasAccessToSensitiveData();
foreach (MemberTypePropertyTypeModel propertyType in model.Properties)
{
var changed = memberType.IsSensitiveProperty(propertyType.Alias) != propertyType.IsSensitive;
if (changed && canChangeSensitivity is false)
{
return false;
}
memberType.SetIsSensitiveProperty(propertyType.Alias, propertyType.IsSensitive);
}
return true;
}
}

View File

@@ -1,6 +1,8 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -11,6 +13,7 @@ internal sealed class MemberContentEditingService
: ContentEditingServiceBase<IMember, IMemberType, IMemberService, IMemberTypeService>, IMemberContentEditingService
{
private readonly ILogger<ContentEditingServiceBase<IMember, IMemberType, IMemberService, IMemberTypeService>> _logger;
private readonly IUserService _userService;
public MemberContentEditingService(
IMemberService contentService,
@@ -20,22 +23,42 @@ internal sealed class MemberContentEditingService
ILogger<ContentEditingServiceBase<IMember, IMemberType, IMemberService, IMemberTypeService>> logger,
ICoreScopeProvider scopeProvider,
IUserIdKeyResolver userIdKeyResolver,
IMemberValidationService memberValidationService)
IMemberValidationService memberValidationService,
IUserService userService)
: base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider, userIdKeyResolver, memberValidationService)
=> _logger = logger;
{
_logger = logger;
_userService = userService;
}
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateAsync(MemberEditingModelBase editingModel, Guid memberTypeKey)
=> await ValidatePropertiesAsync(editingModel, memberTypeKey);
public async Task<Attempt<MemberUpdateResult, ContentEditingOperationStatus>> UpdateAsync(IMember member, MemberEditingModelBase updateModel, Guid userKey)
{
// FIXME: handle sensitive property data
IMemberType memberType = await ContentTypeService.GetAsync(member.ContentType.Key)
?? throw new InvalidOperationException($"The member type {member.ContentType.Alias} could not be found.");
IUser user = await _userService.GetAsync(userKey)
?? throw new InvalidOperationException("The supplied user key did not match any existing user");
if (ValidateAccessToSensitiveProperties(member, memberType, updateModel, user) is false)
{
return Attempt.FailWithStatus(ContentEditingOperationStatus.NotAllowed, new MemberUpdateResult());
}
// store any sensitive properties that must be explicitly carried over between updates (due to missing user access to sensitive data)
Dictionary<string, object?> sensitivePropertiesToRetain = FindSensitivePropertiesToRetain(member, memberType, user);
Attempt<MemberUpdateResult, ContentEditingOperationStatus> result = await MapUpdate<MemberUpdateResult>(member, updateModel);
if (result.Success == false)
{
return Attempt.FailWithStatus(result.Status, result.Result);
}
// restore the retained sensitive properties after the base update (they have been removed from the member at this point)
RetainSensitiveProperties(member, sensitivePropertiesToRetain);
// the create mapping might succeed, but this doesn't mean the model is valid at property level.
// we'll return the actual property validation status if the entire operation succeeds.
ContentEditingOperationStatus validationStatus = result.Status;
@@ -51,7 +74,6 @@ internal sealed class MemberContentEditingService
public async Task<Attempt<IMember?, ContentEditingOperationStatus>> DeleteAsync(Guid key, Guid userKey)
=> await HandleDeleteAsync(key, userKey, false);
protected override IMember New(string? name, int parentId, IMemberType memberType)
=> throw new NotSupportedException("Member creation is not supported by this service. This should never be called.");
@@ -88,4 +110,41 @@ internal sealed class MemberContentEditingService
return ContentEditingOperationStatus.Unknown;
}
}
private bool ValidateAccessToSensitiveProperties(IMember member, IMemberType memberType, MemberEditingModelBase updateModel, IUser user)
{
if (user.HasAccessToSensitiveData())
{
return true;
}
var sensitivePropertyAliases = memberType.GetSensitivePropertyTypeAliases().ToArray();
return updateModel
.InvariantProperties
.Union(updateModel.Variants.SelectMany(variant => variant.Properties))
.Select(property => property.Alias)
.Intersect(sensitivePropertyAliases, StringComparer.OrdinalIgnoreCase)
.Any() is false;
}
private Dictionary<string, object?> FindSensitivePropertiesToRetain(IMember member, IMemberType memberType, IUser user)
{
if (user.HasAccessToSensitiveData())
{
return new Dictionary<string, object?>();
}
var sensitivePropertyAliases = memberType.GetSensitivePropertyTypeAliases().ToArray();
// NOTE: this is explicitly NOT handling variance. if variance becomes a thing for members, this needs to be amended.
return sensitivePropertyAliases.ToDictionary(alias => alias, alias => member.GetValue(alias));
}
private void RetainSensitiveProperties(IMember member, Dictionary<string, object?> sensitivePropertyValues)
{
foreach (KeyValuePair<string, object?> sensitiveProperty in sensitivePropertyValues)
{
// NOTE: this is explicitly NOT handling variance. if variance becomes a thing for members, this needs to be amended.
member.SetValue(sensitiveProperty.Key, sensitiveProperty.Value);
}
}
}

View File

@@ -15,4 +15,5 @@ public enum ContentTypeOperationStatus
MissingContainer,
DuplicateContainer,
NotFound,
NotAllowed
}

View File

@@ -111,7 +111,17 @@ internal sealed class MemberEditingService : IMemberEditingService
if (member is null)
{
status.ContentEditingOperationStatus = ContentEditingOperationStatus.NotFound;
return Attempt.FailWithStatus(new MemberEditingStatus(), new MemberUpdateResult());
return Attempt.FailWithStatus(status, new MemberUpdateResult());
}
if (user.HasAccessToSensitiveData() is false)
{
// handle sensitive data. certain member properties (IsApproved, IsLockedOut) are subject to "sensitive data" rules.
if (member.IsLockedOut != updateModel.IsLockedOut || member.IsApproved != updateModel.IsApproved)
{
status.ContentEditingOperationStatus = ContentEditingOperationStatus.NotAllowed;
return Attempt.FailWithStatus(status, new MemberUpdateResult());
}
}
MemberIdentityUser? identityMember = await _memberManager.FindByIdAsync(member.Id.ToString());
@@ -165,8 +175,6 @@ internal sealed class MemberEditingService : IMemberEditingService
return Attempt.FailWithStatus(status, new MemberUpdateResult { Content = member });
}
// FIXME: handle sensitive data. certain properties (IsApproved, IsLockedOut, ...) are subject to "sensitive data" rules.
// reverse engineer what's happening in the old backoffice MemberController and replicate here
member.IsLockedOut = updateModel.IsLockedOut;
member.IsApproved = updateModel.IsApproved;
member.Email = updateModel.Email;