Implemented culture based authorization for content (#15580)

* Implemented culture based authorization for content

* Implemented culture auth for create/update of documents

* Applied culture authorization to dictionary create/update

* Added an integration test to test an assumption about the ContentTypeEditingService.CreateAsync method

* Fix processing when result is already false;

* Apply suggestions from code review

Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>

* Refactor method to async + clarify and consilidate comments regarding dictionary locks

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
Co-authored-by: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com>
This commit is contained in:
Sven Geusens
2024-01-22 21:08:20 +01:00
committed by GitHub
parent acc71b6d45
commit 26761cc04a
30 changed files with 542 additions and 36 deletions

View File

@@ -1,13 +1,17 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.ViewModels.Dictionary;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Dictionary;
@@ -17,15 +21,18 @@ public class CreateDictionaryController : DictionaryControllerBase
private readonly IDictionaryItemService _dictionaryItemService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IDictionaryPresentationFactory _dictionaryPresentationFactory;
private readonly IAuthorizationService _authorizationService;
public CreateDictionaryController(
IDictionaryItemService dictionaryItemService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IDictionaryPresentationFactory dictionaryPresentationFactory)
IDictionaryPresentationFactory dictionaryPresentationFactory,
IAuthorizationService authorizationService)
{
_dictionaryItemService = dictionaryItemService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_dictionaryPresentationFactory = dictionaryPresentationFactory;
_authorizationService = authorizationService;
}
[HttpPost]
@@ -38,6 +45,16 @@ public class CreateDictionaryController : DictionaryControllerBase
{
IDictionaryItem created = await _dictionaryPresentationFactory.MapCreateModelToDictionaryItemAsync(createDictionaryItemRequestModel);
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
new DictionaryPermissionResource(createDictionaryItemRequestModel.Translations.Select(t => t.IsoCode)),
AuthorizationPolicies.DictionaryPermissionByResource);
if (authorizationResult.Succeeded is false)
{
return Forbidden();
}
Attempt<IDictionaryItem, DictionaryItemOperationStatus> result =
await _dictionaryItemService.CreateAsync(created, CurrentUserKey(_backOfficeSecurityAccessor));

View File

@@ -1,13 +1,17 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.ViewModels.Dictionary;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Dictionary;
@@ -17,15 +21,18 @@ public class UpdateDictionaryController : DictionaryControllerBase
private readonly IDictionaryItemService _dictionaryItemService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IDictionaryPresentationFactory _dictionaryPresentationFactory;
private readonly IAuthorizationService _authorizationService;
public UpdateDictionaryController(
IDictionaryItemService dictionaryItemService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IDictionaryPresentationFactory dictionaryPresentationFactory)
IDictionaryPresentationFactory dictionaryPresentationFactory,
IAuthorizationService authorizationService)
{
_dictionaryItemService = dictionaryItemService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_dictionaryPresentationFactory = dictionaryPresentationFactory;
_authorizationService = authorizationService;
}
[HttpPut($"{{{nameof(id)}:guid}}")]
@@ -41,6 +48,16 @@ public class UpdateDictionaryController : DictionaryControllerBase
return DictionaryNotFound();
}
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
new DictionaryPermissionResource(updateDictionaryItemRequestModel.Translations.Select(t => t.IsoCode)),
AuthorizationPolicies.DictionaryPermissionByResource);
if (authorizationResult.Succeeded is false)
{
return Forbidden();
}
IDictionaryItem updated = await _dictionaryPresentationFactory.MapUpdateModelToDictionaryItemAsync(current, updateDictionaryItemRequestModel);
Attempt<IDictionaryItem, DictionaryItemOperationStatus> result =

View File

@@ -45,7 +45,12 @@ public class CreateDocumentController : DocumentControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionNew.ActionLetter, requestModel.ParentId),
ContentPermissionResource.WithKeys(
ActionNew.ActionLetter,
requestModel.ParentId,
requestModel.Variants
.Where(v => v.Culture is not null)
.Select(v => v.Culture!)),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)

View File

@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization.Content;
using Umbraco.Cms.Api.Management.Security.Authorization.UserGroup;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Actions;
@@ -40,7 +41,7 @@ public class PublishDocumentController : DocumentControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionPublish.ActionLetter, id),
ContentPermissionResource.WithKeys(ActionPublish.ActionLetter, id, requestModel.Cultures),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)

View File

@@ -40,7 +40,7 @@ public class PublishDocumentWithDescendantsController : DocumentControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.Branch(ActionPublish.ActionLetter, id),
ContentPermissionResource.Branch(ActionPublish.ActionLetter, id, requestModel.Cultures),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)

View File

@@ -41,7 +41,10 @@ public class UnpublishDocumentController : DocumentControllerBase
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionUnpublish.ActionLetter, id),
ContentPermissionResource.WithKeys(
ActionUnpublish.ActionLetter,
id,
requestModel.Culture is not null ? requestModel.Culture.Yield() : Enumerable.Empty<string>()),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)

View File

@@ -45,7 +45,12 @@ public class UpdateDocumentController : DocumentControllerBase
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionUpdate.ActionLetter, id),
ContentPermissionResource.WithKeys(
ActionUpdate.ActionLetter,
id,
requestModel.Variants
.Where(v => v.Culture is not null)
.Select(v => v.Culture!)),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)

View File

@@ -4,6 +4,7 @@ using OpenIddict.Validation.AspNetCore;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.Security.Authorization.Content;
using Umbraco.Cms.Api.Management.Security.Authorization.DenyLocalLogin;
using Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
using Umbraco.Cms.Api.Management.Security.Authorization.Feature;
using Umbraco.Cms.Api.Management.Security.Authorization.Media;
using Umbraco.Cms.Api.Management.Security.Authorization.User;
@@ -28,6 +29,7 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
builder.Services.AddSingleton<IAuthorizationHandler, MediaPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, UserGroupPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, UserPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, DictionaryPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHelper, AuthorizationHelper>();
builder.Services.AddSingleton<IContentPermissionAuthorizer, ContentPermissionAuthorizer>();
@@ -35,6 +37,7 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
builder.Services.AddSingleton<IMediaPermissionAuthorizer, MediaPermissionAuthorizer>();
builder.Services.AddSingleton<IUserGroupPermissionAuthorizer, UserGroupPermissionAuthorizer>();
builder.Services.AddSingleton<IUserPermissionAuthorizer, UserPermissionAuthorizer>();
builder.Services.AddSingleton<IDictionaryPermissionAuthorizer, DictionaryPermissionAuthorizer>();
builder.Services.AddAuthorization(CreatePolicies);
return builder;
@@ -131,5 +134,11 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
policy.Requirements.Add(new UserGroupPermissionRequirement());
});
options.AddPolicy($"New{AuthorizationPolicies.DictionaryPermissionByResource}", policy =>
{
policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
policy.Requirements.Add(new DictionaryPermissionRequirement());
});
}
}

View File

@@ -62,4 +62,11 @@ internal sealed class ContentPermissionAuthorizer : IContentPermissionAuthorizer
return result == ContentAuthorizationStatus.Success;
}
public async Task<bool> IsAuthorizedForCultures(IPrincipal currentUser, ISet<string> culturesToCheck)
{
IUser user = _authorizationHelper.GetUmbracoUser(currentUser);
ContentAuthorizationStatus result = await _contentPermissionService.AuthorizeCultureAccessAsync(user, culturesToCheck);
return result is ContentAuthorizationStatus.Success;
}
}

View File

@@ -22,28 +22,36 @@ public class ContentPermissionHandler : MustSatisfyRequirementAuthorizationHandl
ContentPermissionRequirement requirement,
ContentPermissionResource resource)
{
var result = true;
if (resource.CheckRoot)
if (resource.CheckRoot
&& await _contentPermissionAuthorizer.IsAuthorizedAtRootLevelAsync(context.User, resource.PermissionsToCheck) is false)
{
result &= await _contentPermissionAuthorizer.IsAuthorizedAtRootLevelAsync(context.User, resource.PermissionsToCheck);
return false;
}
if (resource.CheckRecycleBin)
if (resource.CheckRecycleBin
&& await _contentPermissionAuthorizer.IsAuthorizedAtRecycleBinLevelAsync(context.User, resource.PermissionsToCheck) is false)
{
result &= await _contentPermissionAuthorizer.IsAuthorizedAtRecycleBinLevelAsync(context.User, resource.PermissionsToCheck);
return false;
}
if (resource.ParentKeyForBranch is not null)
if (resource.ParentKeyForBranch is not null
&& await _contentPermissionAuthorizer.IsAuthorizedWithDescendantsAsync(context.User, resource.ParentKeyForBranch.Value, resource.PermissionsToCheck) is false)
{
result &= await _contentPermissionAuthorizer.IsAuthorizedWithDescendantsAsync(context.User, resource.ParentKeyForBranch.Value, resource.PermissionsToCheck);
return false;
}
if (resource.ContentKeys.Any())
if (resource.ContentKeys.Any()
&& await _contentPermissionAuthorizer.IsAuthorizedAsync(context.User, resource.ContentKeys, resource.PermissionsToCheck) is false)
{
result &= await _contentPermissionAuthorizer.IsAuthorizedAsync(context.User, resource.ContentKeys, resource.PermissionsToCheck);
return false;
}
if (resource.CulturesToCheck is not null
&& await _contentPermissionAuthorizer.IsAuthorizedForCultures(context.User, resource.CulturesToCheck) is false)
{
return false;
}
return result;
return true;
}
}

View File

@@ -18,6 +18,18 @@ public class ContentPermissionResource : IPermissionResource
? Root(permissionToCheck)
: WithKeys(permissionToCheck, contentKey.Value.Yield());
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and content key or root.
/// </summary>
/// <param name="permissionToCheck">The permission to check for.</param>
/// <param name="contentKey">The key of the content or null if root.</param>
/// <param name="cultures">The cultures to validate</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource WithKeys(char permissionToCheck, Guid? contentKey, IEnumerable<string> cultures) =>
contentKey is null
? Root(permissionToCheck, cultures)
: WithKeys(permissionToCheck, contentKey.Value.Yield(), cultures);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and content keys.
/// </summary>
@@ -29,7 +41,7 @@ public class ContentPermissionResource : IPermissionResource
var hasRoot = contentKeys.Any(x => x is null);
IEnumerable<Guid> keys = contentKeys.Where(x => x.HasValue).Select(x => x!.Value);
return new ContentPermissionResource(keys, new HashSet<char> { permissionToCheck }, hasRoot, false, null);
return new ContentPermissionResource(keys, new HashSet<char> { permissionToCheck }, hasRoot, false, null, null);
}
/// <summary>
@@ -40,6 +52,15 @@ public class ContentPermissionResource : IPermissionResource
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource WithKeys(char permissionToCheck, Guid contentKey) => WithKeys(permissionToCheck, contentKey.Yield());
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and content key.
/// </summary>
/// <param name="permissionToCheck">The permission to check for.</param>
/// <param name="contentKey">The key of the content.</param>
/// <param name="cultures">The required culture access</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource WithKeys(char permissionToCheck, Guid contentKey,IEnumerable<string> cultures) => WithKeys(permissionToCheck, contentKey.Yield(),cultures);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and content keys.
/// </summary>
@@ -47,7 +68,23 @@ public class ContentPermissionResource : IPermissionResource
/// <param name="contentKeys">The keys of the contents.</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource WithKeys(char permissionToCheck, IEnumerable<Guid> contentKeys) =>
new ContentPermissionResource(contentKeys, new HashSet<char> { permissionToCheck }, false, false, null);
new ContentPermissionResource(contentKeys, new HashSet<char> { permissionToCheck }, false, false, null, null);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and content keys.
/// </summary>
/// <param name="permissionToCheck">The permission to check for.</param>
/// <param name="contentKeys">The keys of the contents.</param>
/// <param name="cultures">The required culture access</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource WithKeys(char permissionToCheck, IEnumerable<Guid> contentKeys, IEnumerable<string> cultures) =>
new ContentPermissionResource(
contentKeys,
new HashSet<char> { permissionToCheck },
false,
false,
null,
new HashSet<string>(cultures.Distinct()));
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permissions and content keys.
@@ -56,7 +93,7 @@ public class ContentPermissionResource : IPermissionResource
/// <param name="contentKeys">The keys of the contents.</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource WithKeys(ISet<char> permissionsToCheck, IEnumerable<Guid> contentKeys) =>
new ContentPermissionResource(contentKeys, permissionsToCheck, false, false, null);
new ContentPermissionResource(contentKeys, permissionsToCheck, false, false, null, null);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and the root.
@@ -64,7 +101,16 @@ public class ContentPermissionResource : IPermissionResource
/// <param name="permissionToCheck">The permission to check for.</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource Root(char permissionToCheck) =>
new ContentPermissionResource(Enumerable.Empty<Guid>(), new HashSet<char> { permissionToCheck }, true, false, null);
new ContentPermissionResource(Enumerable.Empty<Guid>(), new HashSet<char> { permissionToCheck }, true, false, null, null);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and the root.
/// </summary>
/// <param name="permissionToCheck">The permission to check for.</param>
/// <param name="cultures">The cultures to validate</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource Root(char permissionToCheck, IEnumerable<string> cultures) =>
new ContentPermissionResource(Enumerable.Empty<Guid>(), new HashSet<char> { permissionToCheck }, true, false, null, new HashSet<string>(cultures));
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permissions and the root.
@@ -72,7 +118,18 @@ public class ContentPermissionResource : IPermissionResource
/// <param name="permissionsToCheck">The permissions to check for.</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource Root(ISet<char> permissionsToCheck) =>
new ContentPermissionResource(Enumerable.Empty<Guid>(), permissionsToCheck, true, false, null);
new ContentPermissionResource(Enumerable.Empty<Guid>(), permissionsToCheck, true, false, null, null);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permissions and the root.
/// </summary>
/// <param name="permissionsToCheck">The permissions to check for.</param>
/// <param name="cultures">The cultures to validate</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource Root(ISet<char> permissionsToCheck, IEnumerable<string> cultures) =>
new ContentPermissionResource(Enumerable.Empty<Guid>(), permissionsToCheck, true, false, null, new HashSet<string>(cultures));
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permissions and the recycle bin.
@@ -80,7 +137,7 @@ public class ContentPermissionResource : IPermissionResource
/// <param name="permissionsToCheck">The permissions to check for.</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource RecycleBin(ISet<char> permissionsToCheck) =>
new ContentPermissionResource(Enumerable.Empty<Guid>(), permissionsToCheck, false, true, null);
new ContentPermissionResource(Enumerable.Empty<Guid>(), permissionsToCheck, false, true, null, null);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and the recycle bin.
@@ -88,7 +145,7 @@ public class ContentPermissionResource : IPermissionResource
/// <param name="permissionToCheck">The permission to check for.</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource RecycleBin(char permissionToCheck) =>
new ContentPermissionResource(Enumerable.Empty<Guid>(), new HashSet<char> { permissionToCheck }, false, true, null);
new ContentPermissionResource(Enumerable.Empty<Guid>(), new HashSet<char> { permissionToCheck }, false, true, null, null);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permissions and the branch from the specified parent key.
@@ -97,7 +154,7 @@ public class ContentPermissionResource : IPermissionResource
/// <param name="parentKeyForBranch">The parent key of the branch.</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource Branch(ISet<char> permissionsToCheck, Guid parentKeyForBranch) =>
new ContentPermissionResource(Enumerable.Empty<Guid>(), permissionsToCheck, false, true, parentKeyForBranch);
new ContentPermissionResource(Enumerable.Empty<Guid>(), permissionsToCheck, false, true, parentKeyForBranch, null);
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and the branch from the specified parent key.
@@ -106,15 +163,37 @@ public class ContentPermissionResource : IPermissionResource
/// <param name="parentKeyForBranch">The parent key of the branch.</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource Branch(char permissionToCheck, Guid parentKeyForBranch) =>
new ContentPermissionResource(Enumerable.Empty<Guid>(), new HashSet<char> { permissionToCheck }, false, true, parentKeyForBranch);
new ContentPermissionResource(Enumerable.Empty<Guid>(), new HashSet<char> { permissionToCheck }, false, true, parentKeyForBranch, null);
private ContentPermissionResource(IEnumerable<Guid> contentKeys, ISet<char> permissionsToCheck, bool checkRoot, bool checkRecycleBin, Guid? parentKeyForBranch)
/// <summary>
/// Creates a <see cref="ContentPermissionResource" /> with the specified permission and the branch from the specified parent key.
/// </summary>
/// <param name="permissionToCheck">The permission to check for.</param>
/// <param name="parentKeyForBranch">The parent key of the branch.</param>
/// <param name="culturesToCheck">The required cultures</param>
/// <returns>An instance of <see cref="ContentPermissionResource" />.</returns>
public static ContentPermissionResource Branch(char permissionToCheck, Guid parentKeyForBranch, IEnumerable<string> culturesToCheck) =>
new ContentPermissionResource(
Enumerable.Empty<Guid>(),
new HashSet<char> { permissionToCheck },
false,
true,
parentKeyForBranch,
new HashSet<string>(culturesToCheck.Distinct()));
private ContentPermissionResource(
IEnumerable<Guid> contentKeys,
ISet<char> permissionsToCheck,
bool checkRoot, bool checkRecycleBin,
Guid? parentKeyForBranch,
ISet<string>? culturesToCheck)
{
ContentKeys = contentKeys;
PermissionsToCheck = permissionsToCheck;
CheckRoot = checkRoot;
CheckRecycleBin = checkRecycleBin;
ParentKeyForBranch = parentKeyForBranch;
CulturesToCheck = culturesToCheck;
}
/// <summary>
@@ -144,4 +223,9 @@ public class ContentPermissionResource : IPermissionResource
/// Gets the parent key of a branch.
/// </summary>
public Guid? ParentKeyForBranch { get; }
/// <summary>
/// All the cultures need to be accessible when evaluating
/// </summary>
public ISet<string>? CulturesToCheck { get; }
}

View File

@@ -79,4 +79,6 @@ public interface IContentPermissionAuthorizer
/// <param name="permissionsToCheck">The collection of permissions to authorize.</param>
/// <returns>Returns <c>true</c> if authorization is successful, otherwise <c>false</c>.</returns>
Task<bool> IsAuthorizedAtRecycleBinLevelAsync(IPrincipal currentUser, ISet<char> permissionsToCheck);
Task<bool> IsAuthorizedForCultures(IPrincipal currentUser, ISet<string> culturesToCheck);
}

View File

@@ -0,0 +1,25 @@
using System.Security.Principal;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.AuthorizationStatus;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
public class DictionaryPermissionAuthorizer : IDictionaryPermissionAuthorizer
{
private readonly IAuthorizationHelper _authorizationHelper;
private readonly IDictionaryPermissionService _dictionaryPermissionService;
public DictionaryPermissionAuthorizer(IAuthorizationHelper authorizationHelper, IDictionaryPermissionService dictionaryPermissionService)
{
_authorizationHelper = authorizationHelper;
_dictionaryPermissionService = dictionaryPermissionService;
}
public async Task<bool> IsAuthorizedForCultures(IPrincipal currentUser, ISet<string> culturesToCheck)
{
IUser user = _authorizationHelper.GetUmbracoUser(currentUser);
DictionaryAuthorizationStatus result = await _dictionaryPermissionService.AuthorizeCultureAccessAsync(user, culturesToCheck);
return result is DictionaryAuthorizationStatus.Success;
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Authorization;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
/// <summary>
/// Authorizes that the current user has the correct permission access to the dictionary item(s) specified in the request.
/// </summary>
public class DictionaryPermissionHandler : MustSatisfyRequirementAuthorizationHandler<DictionaryPermissionRequirement, DictionaryPermissionResource>
{
private readonly IDictionaryPermissionAuthorizer _dictionaryPermissionAuthorizer;
/// <summary>
/// Initializes a new instance of the <see cref="DictionaryPermissionHandler" /> class.
/// </summary>
/// <param name="dictionaryPermissionAuthorizer">Authorizer for content access.</param>
public DictionaryPermissionHandler(IDictionaryPermissionAuthorizer dictionaryPermissionAuthorizer)
=> _dictionaryPermissionAuthorizer = dictionaryPermissionAuthorizer;
protected override async Task<bool> IsAuthorized(AuthorizationHandlerContext context, DictionaryPermissionRequirement requirement,
DictionaryPermissionResource resource)
{
if (resource.CulturesToCheck.Any()
&& await _dictionaryPermissionAuthorizer.IsAuthorizedForCultures(context.User, resource.CulturesToCheck) is false)
{
return false;
}
return true;
}
}

View File

@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Authorization;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
/// <summary>
/// Authorization requirement for the <see cref="DictionaryPermissionHandler" />.
/// </summary>
public class DictionaryPermissionRequirement : IAuthorizationRequirement
{
}

View File

@@ -0,0 +1,14 @@
namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
public class DictionaryPermissionResource : IPermissionResource
{
public DictionaryPermissionResource(IEnumerable<string> cultures)
{
CulturesToCheck = new HashSet<string>(cultures);
}
/// <summary>
/// All the cultures need to be accessible when evaluating
/// </summary>
public ISet<string> CulturesToCheck { get; }
}

View File

@@ -0,0 +1,8 @@
using System.Security.Principal;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
public interface IDictionaryPermissionAuthorizer
{
Task<bool> IsAuthorizedForCultures(IPrincipal currentUser, ISet<string> culturesToCheck);
}

View File

@@ -302,6 +302,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IDomainService, DomainService>();
Services.AddUnique<ITagService, TagService>();
Services.AddUnique<IContentPermissionService, ContentPermissionService>();
Services.AddUnique<IDictionaryPermissionService, DictionaryPermissionService>();
Services.AddUnique<IContentService, ContentService>();
Services.AddUnique<IContentEditingService, ContentEditingService>();
Services.AddUnique<IContentPublishingService, ContentPublishingService>();

View File

@@ -37,4 +37,6 @@ public interface ILanguageRepository : IReadWriteQueryRepository<int, ILanguage>
/// <para>This can be optimized and bypass all deep cloning.</para>
/// </remarks>
int? GetDefaultId();
string[] GetIsoCodesByIds(ICollection<int> ids, bool throwOnNotFound = true);
}

View File

@@ -7,5 +7,6 @@ public enum ContentAuthorizationStatus
UnauthorizedMissingBinAccess,
UnauthorizedMissingDescendantAccess,
UnauthorizedMissingPathAccess,
UnauthorizedMissingRootAccess
UnauthorizedMissingRootAccess,
UnauthorizedMissingCulture
}

View File

@@ -0,0 +1,7 @@
namespace Umbraco.Cms.Core.Services.AuthorizationStatus;
public enum DictionaryAuthorizationStatus
{
Success,
UnauthorizedMissingCulture
}

View File

@@ -4,6 +4,7 @@ using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.AuthorizationStatus;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
@@ -14,17 +15,20 @@ internal sealed class ContentPermissionService : IContentPermissionService
private readonly IEntityService _entityService;
private readonly IUserService _userService;
private readonly AppCaches _appCaches;
private readonly ILanguageService _languageService;
public ContentPermissionService(
IContentService contentService,
IEntityService entityService,
IUserService userService,
AppCaches appCaches)
AppCaches appCaches,
ILanguageService languageService)
{
_contentService = contentService;
_entityService = entityService;
_userService = userService;
_appCaches = appCaches;
_languageService = languageService;
}
/// <inheritdoc/>
@@ -130,6 +134,22 @@ internal sealed class ContentPermissionService : IContentPermissionService
: ContentAuthorizationStatus.UnauthorizedMissingPathAccess;
}
/// <inheritdoc/>
public async Task<ContentAuthorizationStatus> AuthorizeCultureAccessAsync(IUser user, ISet<string> culturesToCheck)
{
if (user.Groups.Any(group => group.HasAccessToAllLanguages))
{
return ContentAuthorizationStatus.Success;
}
var allowedLanguages = user.Groups.SelectMany(g => g.AllowedLanguages).Distinct().ToArray();
var allowedLanguageIsoCodes = await _languageService.GetIsoCodesByIdsAsync(allowedLanguages);
return culturesToCheck.All(culture => allowedLanguageIsoCodes.InvariantContains(culture))
? ContentAuthorizationStatus.Success
: ContentAuthorizationStatus.UnauthorizedMissingCulture;
}
/// <summary>
/// Check the implicit/inherited permissions of a user for given content items.
/// </summary>

View File

@@ -0,0 +1,31 @@
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.AuthorizationStatus;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
public class DictionaryPermissionService : IDictionaryPermissionService
{
private readonly ILanguageService _languageService;
public DictionaryPermissionService(ILanguageService languageService)
{
_languageService = languageService;
}
/// <inheritdoc/>
public async Task<DictionaryAuthorizationStatus> AuthorizeCultureAccessAsync(IUser user, ISet<string> culturesToCheck)
{
if (user.Groups.Any(group => group.HasAccessToAllLanguages))
{
return DictionaryAuthorizationStatus.Success;
}
var allowedLanguages = user.Groups.SelectMany(g => g.AllowedLanguages).Distinct().ToArray();
var allowedLanguageIsoCodes = await _languageService.GetIsoCodesByIdsAsync(allowedLanguages);
return culturesToCheck.All(culture => allowedLanguageIsoCodes.InvariantContains(culture))
? DictionaryAuthorizationStatus.Success
: DictionaryAuthorizationStatus.UnauthorizedMissingCulture;
}
}

View File

@@ -80,4 +80,12 @@ public interface IContentPermissionService
/// <param name="permissionsToCheck">The collection of permissions to authorize.</param>
/// <returns>A task resolving into a <see cref="ContentAuthorizationStatus"/>.</returns>
Task<ContentAuthorizationStatus> AuthorizeBinAccessAsync(IUser user, ISet<char> permissionsToCheck);
/// <summary>
/// Authorize that a user has access to specific cultures
/// </summary>
/// <param name="user"><see cref="IUser" /> to authorize.</param>
/// <param name="culturesToCheck">The collection of cultures to authorize.</param>
/// <returns>A task resolving into a <see cref="ContentAuthorizationStatus"/>.</returns>
Task<ContentAuthorizationStatus> AuthorizeCultureAccessAsync(IUser user, ISet<string> culturesToCheck);
}

View File

@@ -0,0 +1,10 @@
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.AuthorizationStatus;
namespace Umbraco.Cms.Core.Services;
public interface IDictionaryPermissionService
{
/// <inheritdoc/>
Task<DictionaryAuthorizationStatus> AuthorizeCultureAccessAsync(IUser user, ISet<string> culturesToCheck);
}

View File

@@ -55,4 +55,12 @@ public interface ILanguageService
/// <param name="isoCode">The ISO code of the <see cref="ILanguage" /> to delete</param>
/// <param name="userKey">Key of the user deleting the language</param>
Task<Attempt<ILanguage?, LanguageOperationStatus>> DeleteAsync(string isoCode, Guid userKey);
/// <summary>
/// Retrieves the isoCodes of configured languages by their Ids
/// </summary>
/// <param name="ids">The ids of the configured <see cref="ILanguage" />s</param>
/// <returns>The ISO codes of the <see cref="ILanguage" />s</returns>
Task<string[]> GetIsoCodesByIdsAsync(ICollection<int> ids);
}

View File

@@ -60,6 +60,13 @@ internal sealed class LanguageService : RepositoryService, ILanguageService
}
}
public async Task<string[]> GetIsoCodesByIdsAsync(ICollection<int> ids)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete:true);
return await Task.FromResult(_languageRepository.GetIsoCodesByIds(ids, throwOnNotFound: true));
}
public async Task<IEnumerable<ILanguage>> GetMultipleAsync(IEnumerable<string> isoCodes) => (await GetAllAsync()).Where(x => isoCodes.Contains(x.IsoCode));
/// <inheritdoc />

View File

@@ -18,6 +18,9 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
/// </summary>
internal class LanguageRepository : EntityRepositoryBase<int, ILanguage>, ILanguageRepository
{
// We need to lock this dictionary every time we do an operation on it as the languageRepository is registered as a unique implementation
// It is used to quickly get isoCodes by Id, or the reverse by avoiding (deep)cloning dtos
// It is rebuild on PerformGetAll
private readonly Dictionary<string, int> _codeIdMap = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<int, string> _idCodeMap = new();
@@ -37,8 +40,6 @@ internal class LanguageRepository : EntityRepositoryBase<int, ILanguage>, ILangu
return id.HasValue ? Get(id.Value) : null;
}
// fast way of getting an id for an isoCode - avoiding cloning
// _codeIdMap is rebuilt whenever PerformGetAll runs
public int? GetIdByIsoCode(string? isoCode, bool throwOnNotFound = true)
{
if (isoCode == null)
@@ -64,8 +65,6 @@ internal class LanguageRepository : EntityRepositoryBase<int, ILanguage>, ILangu
return null;
}
// fast way of getting an isoCode for an id - avoiding cloning
// _idCodeMap is rebuilt whenever PerformGetAll runs
public string? GetIsoCodeById(int? id, bool throwOnNotFound = true)
{
if (id == null)
@@ -75,7 +74,6 @@ internal class LanguageRepository : EntityRepositoryBase<int, ILanguage>, ILangu
EnsureCacheIsPopulated();
// yes, we want to lock _codeIdMap
lock (_codeIdMap)
{
if (_idCodeMap.TryGetValue(id.Value, out var isoCode))
@@ -92,6 +90,38 @@ internal class LanguageRepository : EntityRepositoryBase<int, ILanguage>, ILangu
return null;
}
// multi implementation of GetIsoCodeById
public string[] GetIsoCodesByIds(ICollection<int> ids, bool throwOnNotFound = true)
{
var isoCodes = new string[ids.Count];
if (ids.Any() == false)
{
return isoCodes;
}
EnsureCacheIsPopulated();
lock (_codeIdMap)
{
for (var i = 0; i < ids.Count; i++)
{
var id = ids.ElementAt(i);
if (_idCodeMap.TryGetValue(id, out var isoCode))
{
isoCodes[i] = isoCode;
}
else if (throwOnNotFound)
{
throw new ArgumentException($"Id {id} does not correspond to an existing language.", nameof(id));
}
}
}
return isoCodes;
}
public string GetDefaultIsoCode() => GetDefault().IsoCode;
public int? GetDefaultId() => GetDefault().Id;
@@ -101,7 +131,6 @@ internal class LanguageRepository : EntityRepositoryBase<int, ILanguage>, ILangu
protected ILanguage ConvertFromDto(LanguageDto dto)
{
// yes, we want to lock _codeIdMap
lock (_codeIdMap)
{
string? fallbackIsoCode = null;

View File

@@ -72,6 +72,9 @@ public static class AuthorizationPolicies
public const string TreeAccessAnySchemaTypes = nameof(TreeAccessAnySchemaTypes);
public const string TreeAccessDictionaryOrTemplates = nameof(TreeAccessDictionaryOrTemplates);
// other
public const string DictionaryPermissionByResource = nameof(DictionaryPermissionByResource);
/// <summary>
/// Defines access based on if the user has access to any tree's exposing any types of content (documents, media,
/// members)

View File

@@ -0,0 +1,133 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
using Umbraco.Cms.Tests.Common.Builders;
using Umbraco.Cms.Tests.Common.Builders.Extensions;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
[TestFixture]
[UmbracoTest(
Database = UmbracoTestOptions.Database.NewSchemaPerTest,
PublishedRepositoryEvents = true,
WithApplication = true)]
public class ContentEditingServiceTests : UmbracoIntegrationTestWithContent
{
[SetUp]
public void Setup() => ContentRepositoryBase.ThrowOnWarning = true;
[TearDown]
public void Teardown() => ContentRepositoryBase.ThrowOnWarning = false;
private IContentEditingService ContentTypeEditingService => GetRequiredService<IContentEditingService>();
private ILanguageService LanguageService => GetRequiredService<ILanguageService>();
[Test]
public async Task Only_Supplied_Cultures_Are_Updated()
{
var variantTestData = await SetupVariantTest();
var documentKey = Guid.NewGuid();
var propertyAlias = "title";
var originalPropertyValue = "original";
var updatedPropertyValue = "updated";
var createModel = new ContentCreateModel
{
Key = documentKey,
ContentTypeKey = variantTestData.contentType.Key,
Variants = new[]
{
new VariantModel
{
Name = variantTestData.LangEn.CultureName,
Culture = variantTestData.LangEn.IsoCode,
Properties = new[]
{
new PropertyValueModel
{
Alias = propertyAlias, Value = originalPropertyValue
}
}
},
new VariantModel
{
Name = variantTestData.LangDa.CultureName,
Culture = variantTestData.LangDa.IsoCode,
Properties = new[]
{
new PropertyValueModel
{
Alias = propertyAlias, Value = originalPropertyValue
}
}
}
}
};
await ContentTypeEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey);
var content = ContentService.GetById(documentKey)!;
var updateModel = new ContentUpdateModel
{
Variants = new[]
{
new VariantModel
{
Name = updatedPropertyValue,
Culture = variantTestData.LangEn.IsoCode,
Properties = new[] { new PropertyValueModel { Alias = propertyAlias, Value = updatedPropertyValue } }
}
}
};
await ContentTypeEditingService.UpdateAsync(content, updateModel, Constants.Security.SuperUserKey);
var updatedContent = ContentService.GetById(documentKey)!;
Assert.AreEqual(originalPropertyValue, updatedContent.GetValue(propertyAlias,variantTestData.LangDa.IsoCode));
Assert.AreEqual(updatedPropertyValue, updatedContent.GetValue(propertyAlias,variantTestData.LangEn.IsoCode));
Assert.AreEqual(variantTestData.LangDa.CultureName, updatedContent.GetCultureName(variantTestData.LangDa.IsoCode));
Assert.AreEqual(updatedPropertyValue, updatedContent.GetCultureName(variantTestData.LangEn.IsoCode));
}
private async Task<(ILanguage LangEn, ILanguage LangDa, IContentType contentType)> SetupVariantTest()
{
var langEn = (await LanguageService.GetAsync("en-US"))!;
var langDa = new LanguageBuilder()
.WithCultureInfo("da-DK")
.Build();
await LanguageService.CreateAsync(langDa, Constants.Security.SuperUserKey);
var template = TemplateBuilder.CreateTextPageTemplate();
FileService.SaveTemplate(template);
var contentType = new ContentTypeBuilder()
.WithAlias("variantContent")
.WithName("Variant Content")
.WithContentVariation(ContentVariation.Culture)
.AddPropertyGroup()
.WithAlias("content")
.WithName("Content")
.WithSupportsPublishing(true)
.AddPropertyType()
.WithAlias("title")
.WithName("Title")
.WithVariations(ContentVariation.Culture)
.WithMandatory(true)
.Done()
.Done()
.Build();
contentType.AllowedAsRoot = true;
ContentTypeService.Save(contentType);
return (langEn, langDa, contentType);
}
}