diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/CreateDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/CreateDictionaryController.cs index 7bd27b6dfa..60613efa29 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/CreateDictionaryController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/CreateDictionaryController.cs @@ -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 result = await _dictionaryItemService.CreateAsync(created, CurrentUserKey(_backOfficeSecurityAccessor)); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UpdateDictionaryController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UpdateDictionaryController.cs index b508b709f9..7dd423e7da 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UpdateDictionaryController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Dictionary/UpdateDictionaryController.cs @@ -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 result = diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentController.cs index 4ce888b74c..c2302fba2c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentController.cs @@ -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) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs index 746251e544..5b40449be5 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs @@ -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) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs index 98cd287efe..75a0b8dc80 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs @@ -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) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UnpublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UnpublishDocumentController.cs index 7f3aa24641..f0538402ae 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/UnpublishDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UnpublishDocumentController.cs @@ -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()), AuthorizationPolicies.ContentPermissionByResource); if (!authorizationResult.Succeeded) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentController.cs index 3d68d3e6d2..059040ce89 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentController.cs @@ -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) diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthPolicyBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthPolicyBuilderExtensions.cs index 8907c37933..3f678c1bc4 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthPolicyBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/BackOfficeAuthPolicyBuilderExtensions.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -35,6 +37,7 @@ internal static class BackOfficeAuthPolicyBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); 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()); + }); } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionAuthorizer.cs index ca4aff3a10..f6c49b8963 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionAuthorizer.cs @@ -62,4 +62,11 @@ internal sealed class ContentPermissionAuthorizer : IContentPermissionAuthorizer return result == ContentAuthorizationStatus.Success; } + + public async Task IsAuthorizedForCultures(IPrincipal currentUser, ISet culturesToCheck) + { + IUser user = _authorizationHelper.GetUmbracoUser(currentUser); + ContentAuthorizationStatus result = await _contentPermissionService.AuthorizeCultureAccessAsync(user, culturesToCheck); + return result is ContentAuthorizationStatus.Success; + } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionHandler.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionHandler.cs index 63d098b7f6..7a46fa2557 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionHandler.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionHandler.cs @@ -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; } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionResource.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionResource.cs index 16e9cc0dad..921aba05ff 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionResource.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/ContentPermissionResource.cs @@ -18,6 +18,18 @@ public class ContentPermissionResource : IPermissionResource ? Root(permissionToCheck) : WithKeys(permissionToCheck, contentKey.Value.Yield()); + /// + /// Creates a with the specified permission and content key or root. + /// + /// The permission to check for. + /// The key of the content or null if root. + /// The cultures to validate + /// An instance of . + public static ContentPermissionResource WithKeys(char permissionToCheck, Guid? contentKey, IEnumerable cultures) => + contentKey is null + ? Root(permissionToCheck, cultures) + : WithKeys(permissionToCheck, contentKey.Value.Yield(), cultures); + /// /// Creates a with the specified permission and content keys. /// @@ -29,7 +41,7 @@ public class ContentPermissionResource : IPermissionResource var hasRoot = contentKeys.Any(x => x is null); IEnumerable keys = contentKeys.Where(x => x.HasValue).Select(x => x!.Value); - return new ContentPermissionResource(keys, new HashSet { permissionToCheck }, hasRoot, false, null); + return new ContentPermissionResource(keys, new HashSet { permissionToCheck }, hasRoot, false, null, null); } /// @@ -40,6 +52,15 @@ public class ContentPermissionResource : IPermissionResource /// An instance of . public static ContentPermissionResource WithKeys(char permissionToCheck, Guid contentKey) => WithKeys(permissionToCheck, contentKey.Yield()); + /// + /// Creates a with the specified permission and content key. + /// + /// The permission to check for. + /// The key of the content. + /// The required culture access + /// An instance of . + public static ContentPermissionResource WithKeys(char permissionToCheck, Guid contentKey,IEnumerable cultures) => WithKeys(permissionToCheck, contentKey.Yield(),cultures); + /// /// Creates a with the specified permission and content keys. /// @@ -47,7 +68,23 @@ public class ContentPermissionResource : IPermissionResource /// The keys of the contents. /// An instance of . public static ContentPermissionResource WithKeys(char permissionToCheck, IEnumerable contentKeys) => - new ContentPermissionResource(contentKeys, new HashSet { permissionToCheck }, false, false, null); + new ContentPermissionResource(contentKeys, new HashSet { permissionToCheck }, false, false, null, null); + + /// + /// Creates a with the specified permission and content keys. + /// + /// The permission to check for. + /// The keys of the contents. + /// The required culture access + /// An instance of . + public static ContentPermissionResource WithKeys(char permissionToCheck, IEnumerable contentKeys, IEnumerable cultures) => + new ContentPermissionResource( + contentKeys, + new HashSet { permissionToCheck }, + false, + false, + null, + new HashSet(cultures.Distinct())); /// /// Creates a with the specified permissions and content keys. @@ -56,7 +93,7 @@ public class ContentPermissionResource : IPermissionResource /// The keys of the contents. /// An instance of . public static ContentPermissionResource WithKeys(ISet permissionsToCheck, IEnumerable contentKeys) => - new ContentPermissionResource(contentKeys, permissionsToCheck, false, false, null); + new ContentPermissionResource(contentKeys, permissionsToCheck, false, false, null, null); /// /// Creates a with the specified permission and the root. @@ -64,7 +101,16 @@ public class ContentPermissionResource : IPermissionResource /// The permission to check for. /// An instance of . public static ContentPermissionResource Root(char permissionToCheck) => - new ContentPermissionResource(Enumerable.Empty(), new HashSet { permissionToCheck }, true, false, null); + new ContentPermissionResource(Enumerable.Empty(), new HashSet { permissionToCheck }, true, false, null, null); + + /// + /// Creates a with the specified permission and the root. + /// + /// The permission to check for. + /// The cultures to validate + /// An instance of . + public static ContentPermissionResource Root(char permissionToCheck, IEnumerable cultures) => + new ContentPermissionResource(Enumerable.Empty(), new HashSet { permissionToCheck }, true, false, null, new HashSet(cultures)); /// /// Creates a with the specified permissions and the root. @@ -72,7 +118,18 @@ public class ContentPermissionResource : IPermissionResource /// The permissions to check for. /// An instance of . public static ContentPermissionResource Root(ISet permissionsToCheck) => - new ContentPermissionResource(Enumerable.Empty(), permissionsToCheck, true, false, null); + new ContentPermissionResource(Enumerable.Empty(), permissionsToCheck, true, false, null, null); + + /// + /// Creates a with the specified permissions and the root. + /// + /// The permissions to check for. + /// The cultures to validate + /// An instance of . + public static ContentPermissionResource Root(ISet permissionsToCheck, IEnumerable cultures) => + new ContentPermissionResource(Enumerable.Empty(), permissionsToCheck, true, false, null, new HashSet(cultures)); + + /// /// Creates a with the specified permissions and the recycle bin. @@ -80,7 +137,7 @@ public class ContentPermissionResource : IPermissionResource /// The permissions to check for. /// An instance of . public static ContentPermissionResource RecycleBin(ISet permissionsToCheck) => - new ContentPermissionResource(Enumerable.Empty(), permissionsToCheck, false, true, null); + new ContentPermissionResource(Enumerable.Empty(), permissionsToCheck, false, true, null, null); /// /// Creates a with the specified permission and the recycle bin. @@ -88,7 +145,7 @@ public class ContentPermissionResource : IPermissionResource /// The permission to check for. /// An instance of . public static ContentPermissionResource RecycleBin(char permissionToCheck) => - new ContentPermissionResource(Enumerable.Empty(), new HashSet { permissionToCheck }, false, true, null); + new ContentPermissionResource(Enumerable.Empty(), new HashSet { permissionToCheck }, false, true, null, null); /// /// Creates a with the specified permissions and the branch from the specified parent key. @@ -97,7 +154,7 @@ public class ContentPermissionResource : IPermissionResource /// The parent key of the branch. /// An instance of . public static ContentPermissionResource Branch(ISet permissionsToCheck, Guid parentKeyForBranch) => - new ContentPermissionResource(Enumerable.Empty(), permissionsToCheck, false, true, parentKeyForBranch); + new ContentPermissionResource(Enumerable.Empty(), permissionsToCheck, false, true, parentKeyForBranch, null); /// /// Creates a with the specified permission and the branch from the specified parent key. @@ -106,15 +163,37 @@ public class ContentPermissionResource : IPermissionResource /// The parent key of the branch. /// An instance of . public static ContentPermissionResource Branch(char permissionToCheck, Guid parentKeyForBranch) => - new ContentPermissionResource(Enumerable.Empty(), new HashSet { permissionToCheck }, false, true, parentKeyForBranch); + new ContentPermissionResource(Enumerable.Empty(), new HashSet { permissionToCheck }, false, true, parentKeyForBranch, null); - private ContentPermissionResource(IEnumerable contentKeys, ISet permissionsToCheck, bool checkRoot, bool checkRecycleBin, Guid? parentKeyForBranch) + /// + /// Creates a with the specified permission and the branch from the specified parent key. + /// + /// The permission to check for. + /// The parent key of the branch. + /// The required cultures + /// An instance of . + public static ContentPermissionResource Branch(char permissionToCheck, Guid parentKeyForBranch, IEnumerable culturesToCheck) => + new ContentPermissionResource( + Enumerable.Empty(), + new HashSet { permissionToCheck }, + false, + true, + parentKeyForBranch, + new HashSet(culturesToCheck.Distinct())); + + private ContentPermissionResource( + IEnumerable contentKeys, + ISet permissionsToCheck, + bool checkRoot, bool checkRecycleBin, + Guid? parentKeyForBranch, + ISet? culturesToCheck) { ContentKeys = contentKeys; PermissionsToCheck = permissionsToCheck; CheckRoot = checkRoot; CheckRecycleBin = checkRecycleBin; ParentKeyForBranch = parentKeyForBranch; + CulturesToCheck = culturesToCheck; } /// @@ -144,4 +223,9 @@ public class ContentPermissionResource : IPermissionResource /// Gets the parent key of a branch. /// public Guid? ParentKeyForBranch { get; } + + /// + /// All the cultures need to be accessible when evaluating + /// + public ISet? CulturesToCheck { get; } } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/IContentPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/IContentPermissionAuthorizer.cs index e25a509c80..5257b25e3e 100644 --- a/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/IContentPermissionAuthorizer.cs +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Content/IContentPermissionAuthorizer.cs @@ -79,4 +79,6 @@ public interface IContentPermissionAuthorizer /// The collection of permissions to authorize. /// Returns true if authorization is successful, otherwise false. Task IsAuthorizedAtRecycleBinLevelAsync(IPrincipal currentUser, ISet permissionsToCheck); + + Task IsAuthorizedForCultures(IPrincipal currentUser, ISet culturesToCheck); } diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionAuthorizer.cs new file mode 100644 index 0000000000..5ce7d738a5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionAuthorizer.cs @@ -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 IsAuthorizedForCultures(IPrincipal currentUser, ISet culturesToCheck) + { + IUser user = _authorizationHelper.GetUmbracoUser(currentUser); + DictionaryAuthorizationStatus result = await _dictionaryPermissionService.AuthorizeCultureAccessAsync(user, culturesToCheck); + return result is DictionaryAuthorizationStatus.Success; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionHandler.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionHandler.cs new file mode 100644 index 0000000000..bc376635e6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionHandler.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary; + +/// +/// Authorizes that the current user has the correct permission access to the dictionary item(s) specified in the request. +/// +public class DictionaryPermissionHandler : MustSatisfyRequirementAuthorizationHandler +{ + private readonly IDictionaryPermissionAuthorizer _dictionaryPermissionAuthorizer; + + /// + /// Initializes a new instance of the class. + /// + /// Authorizer for content access. + public DictionaryPermissionHandler(IDictionaryPermissionAuthorizer dictionaryPermissionAuthorizer) + => _dictionaryPermissionAuthorizer = dictionaryPermissionAuthorizer; + + protected override async Task IsAuthorized(AuthorizationHandlerContext context, DictionaryPermissionRequirement requirement, + DictionaryPermissionResource resource) + { + if (resource.CulturesToCheck.Any() + && await _dictionaryPermissionAuthorizer.IsAuthorizedForCultures(context.User, resource.CulturesToCheck) is false) + { + return false; + } + + return true; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionRequirement.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionRequirement.cs new file mode 100644 index 0000000000..e50fc0f9df --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionRequirement.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary; + +/// +/// Authorization requirement for the . +/// +public class DictionaryPermissionRequirement : IAuthorizationRequirement +{ + +} diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionResource.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionResource.cs new file mode 100644 index 0000000000..a173fcd35b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/DictionaryPermissionResource.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary; + +public class DictionaryPermissionResource : IPermissionResource +{ + public DictionaryPermissionResource(IEnumerable cultures) + { + CulturesToCheck = new HashSet(cultures); + } + + /// + /// All the cultures need to be accessible when evaluating + /// + public ISet CulturesToCheck { get; } +} diff --git a/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/IDictionaryPermissionAuthorizer.cs b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/IDictionaryPermissionAuthorizer.cs new file mode 100644 index 0000000000..94b814bce7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Security/Authorization/Dictionary/IDictionaryPermissionAuthorizer.cs @@ -0,0 +1,8 @@ +using System.Security.Principal; + +namespace Umbraco.Cms.Api.Management.Security.Authorization.Dictionary; + +public interface IDictionaryPermissionAuthorizer +{ + Task IsAuthorizedForCultures(IPrincipal currentUser, ISet culturesToCheck); +} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 414d5035d2..5137ab6471 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -302,6 +302,7 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Persistence/Repositories/ILanguageRepository.cs b/src/Umbraco.Core/Persistence/Repositories/ILanguageRepository.cs index e7fff03bd7..849d789f3a 100644 --- a/src/Umbraco.Core/Persistence/Repositories/ILanguageRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/ILanguageRepository.cs @@ -37,4 +37,6 @@ public interface ILanguageRepository : IReadWriteQueryRepository /// This can be optimized and bypass all deep cloning. /// int? GetDefaultId(); + + string[] GetIsoCodesByIds(ICollection ids, bool throwOnNotFound = true); } diff --git a/src/Umbraco.Core/Services/AuthorizationStatus/ContentAuthorizationStatus.cs b/src/Umbraco.Core/Services/AuthorizationStatus/ContentAuthorizationStatus.cs index e0408d4ba4..ea267650e3 100644 --- a/src/Umbraco.Core/Services/AuthorizationStatus/ContentAuthorizationStatus.cs +++ b/src/Umbraco.Core/Services/AuthorizationStatus/ContentAuthorizationStatus.cs @@ -7,5 +7,6 @@ public enum ContentAuthorizationStatus UnauthorizedMissingBinAccess, UnauthorizedMissingDescendantAccess, UnauthorizedMissingPathAccess, - UnauthorizedMissingRootAccess + UnauthorizedMissingRootAccess, + UnauthorizedMissingCulture } diff --git a/src/Umbraco.Core/Services/AuthorizationStatus/DictionaryAuthorizationStatus.cs b/src/Umbraco.Core/Services/AuthorizationStatus/DictionaryAuthorizationStatus.cs new file mode 100644 index 0000000000..5cb420e17b --- /dev/null +++ b/src/Umbraco.Core/Services/AuthorizationStatus/DictionaryAuthorizationStatus.cs @@ -0,0 +1,7 @@ +namespace Umbraco.Cms.Core.Services.AuthorizationStatus; + +public enum DictionaryAuthorizationStatus +{ + Success, + UnauthorizedMissingCulture +} diff --git a/src/Umbraco.Core/Services/ContentPermissionService.cs b/src/Umbraco.Core/Services/ContentPermissionService.cs index aafcddf8b8..bef5c1a099 100644 --- a/src/Umbraco.Core/Services/ContentPermissionService.cs +++ b/src/Umbraco.Core/Services/ContentPermissionService.cs @@ -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; } /// @@ -130,6 +134,22 @@ internal sealed class ContentPermissionService : IContentPermissionService : ContentAuthorizationStatus.UnauthorizedMissingPathAccess; } + /// + public async Task AuthorizeCultureAccessAsync(IUser user, ISet 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; + } + /// /// Check the implicit/inherited permissions of a user for given content items. /// diff --git a/src/Umbraco.Core/Services/DictionaryPermissionService.cs b/src/Umbraco.Core/Services/DictionaryPermissionService.cs new file mode 100644 index 0000000000..0cf73b6300 --- /dev/null +++ b/src/Umbraco.Core/Services/DictionaryPermissionService.cs @@ -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; + } + + /// + public async Task AuthorizeCultureAccessAsync(IUser user, ISet 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; + } +} diff --git a/src/Umbraco.Core/Services/IContentPermissionService.cs b/src/Umbraco.Core/Services/IContentPermissionService.cs index 7ea0b52863..bb1d2e5bff 100644 --- a/src/Umbraco.Core/Services/IContentPermissionService.cs +++ b/src/Umbraco.Core/Services/IContentPermissionService.cs @@ -80,4 +80,12 @@ public interface IContentPermissionService /// The collection of permissions to authorize. /// A task resolving into a . Task AuthorizeBinAccessAsync(IUser user, ISet permissionsToCheck); + + /// + /// Authorize that a user has access to specific cultures + /// + /// to authorize. + /// The collection of cultures to authorize. + /// A task resolving into a . + Task AuthorizeCultureAccessAsync(IUser user, ISet culturesToCheck); } diff --git a/src/Umbraco.Core/Services/IDictionaryPermissionService.cs b/src/Umbraco.Core/Services/IDictionaryPermissionService.cs new file mode 100644 index 0000000000..925c83a7b5 --- /dev/null +++ b/src/Umbraco.Core/Services/IDictionaryPermissionService.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.AuthorizationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IDictionaryPermissionService +{ + /// + Task AuthorizeCultureAccessAsync(IUser user, ISet culturesToCheck); +} diff --git a/src/Umbraco.Core/Services/ILanguageService.cs b/src/Umbraco.Core/Services/ILanguageService.cs index cc7e8023ff..e3f5c38f67 100644 --- a/src/Umbraco.Core/Services/ILanguageService.cs +++ b/src/Umbraco.Core/Services/ILanguageService.cs @@ -55,4 +55,12 @@ public interface ILanguageService /// The ISO code of the to delete /// Key of the user deleting the language Task> DeleteAsync(string isoCode, Guid userKey); + + + /// + /// Retrieves the isoCodes of configured languages by their Ids + /// + /// The ids of the configured s + /// The ISO codes of the s + Task GetIsoCodesByIdsAsync(ICollection ids); } diff --git a/src/Umbraco.Core/Services/LanguageService.cs b/src/Umbraco.Core/Services/LanguageService.cs index fbd3c84ff8..e97f5df314 100644 --- a/src/Umbraco.Core/Services/LanguageService.cs +++ b/src/Umbraco.Core/Services/LanguageService.cs @@ -60,6 +60,13 @@ internal sealed class LanguageService : RepositoryService, ILanguageService } } + public async Task GetIsoCodesByIdsAsync(ICollection ids) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete:true); + + return await Task.FromResult(_languageRepository.GetIsoCodesByIds(ids, throwOnNotFound: true)); + } + public async Task> GetMultipleAsync(IEnumerable isoCodes) => (await GetAllAsync()).Where(x => isoCodes.Contains(x.IsoCode)); /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs index e6500b49b7..9c7416602b 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/LanguageRepository.cs @@ -18,6 +18,9 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; /// internal class LanguageRepository : EntityRepositoryBase, 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 _codeIdMap = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _idCodeMap = new(); @@ -37,8 +40,6 @@ internal class LanguageRepository : EntityRepositoryBase, 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, 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, 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, ILangu return null; } + // multi implementation of GetIsoCodeById + public string[] GetIsoCodesByIds(ICollection 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, ILangu protected ILanguage ConvertFromDto(LanguageDto dto) { - // yes, we want to lock _codeIdMap lock (_codeIdMap) { string? fallbackIsoCode = null; diff --git a/src/Umbraco.Web.Common/Authorization/AuthorizationPolicies.cs b/src/Umbraco.Web.Common/Authorization/AuthorizationPolicies.cs index 1b15409813..3659b45b12 100644 --- a/src/Umbraco.Web.Common/Authorization/AuthorizationPolicies.cs +++ b/src/Umbraco.Web.Common/Authorization/AuthorizationPolicies.cs @@ -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); + /// /// Defines access based on if the user has access to any tree's exposing any types of content (documents, media, /// members) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentEditingServiceTests.cs new file mode 100644 index 0000000000..4e77b58613 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentEditingServiceTests.cs @@ -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(); + private ILanguageService LanguageService => GetRequiredService(); + + [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); + } +}