New Backoffice: User Groups Controller (#13811)

* Add key to UserGroupDto

* Fix renaming table in sqlite

The SqliteSyntaxProvider needed an overload to use the correct query

* Start work on user group GUID migration

* Add key index to UserGroupDto

* Copy over data when migrating sqlite

* Make sqlite column migration work

* Remove PostMigrations

These should be replaced with Notification usage

* Remove outer scope from Upgrader

* Remove unececary null check

* Add marker base class for migrations

* Enable scopeless migrations

* Remove unnecessary state check

The final state of the migration is no longer necessarily the final state of the plan.

* Extend ExecutedMigrationPlan

* Ensure that MigrationPlanExecutor.Execute always returns a result.

* Always save final state, regardless of errors

* Remove obsolete Execute

* Add Umbraco specific migration notification

* Publish notification after umbraco migration

* Throw the exception that failed a migration after publishing notification

* Handle notification publishing in DatabaseBuilder

* Fix tests

* Remember to complete scope

* Clean up MigrationPlanExecutor

* Run each package migration in a separate scope

* Add PartialMigrationsTests

* Add unhappy path test

* Fix bug shown by test

* Move PartialMigrationsTests into the correct folder

* Comment out refresh cache in data type migration

Need to add this back again as a notification handler or something.

* Start working on a notification test

* Allow migrations to request a cache rebuild

* Set RebuildCache from MigrateDataTypeConfigurations

* Clean MigrationPlanExecutor

* Add comment explaining the need to partial migration success

* Fix tests

* Allow overriding DefinePlan of UmbracoPlan

This is needed to test the DatabaseBuilder

* Fix notification test

* Don't throw exception to be immediately re-caught

* Assert that scopes notification are always published

* Ensure that scopes are created when requested

* Make test classes internal.

It doesn't really matter, but this way it doesn't show up in intellisense

* Add notification handler for clearing cookies

* Add CompatibilitySuppressions

* Use unscoped migration for adding GUID to user group

* Make sqlite migration work

It's really not pretty, square peg, round hole.

* Don't re-enable foreign keys

This will happen automatically next time a connection is started.

* Scope database when using SQLServer

* Don't call complete transaction

* Tidy up a couple of comment

* Only allow scoping the database from UnscopedMigrationBase

* Fix comment

* Remove remark in UnscopedMigrationBase as it's no longer true

* Add keys when creating default user groups

* Map database value from DTO to entity

* Fix migration

Rename also renamed the foreign keys, making it not work

* Make migration idempotent

* Fix unit test

* Update CompatibilitySuppressions.xml

* Add GetUserGroupByKey to UserService

* Add ByKey endpoint

* Add UniqueId to AppendGroupBy

Otherwise MSSQL grenades

* Ensure that languages are returned by PerformGetByQuery

* add POC displaying model

* Clean up by key controller

* Add GetAllEndpoint

* Add delete endpoint

* Use GetKey to get GUID from id

Instead of pulling up the entire entity.

* Add UserGroup2Permission table

* Fetch the new permissions when getting user groups

* Dont ToString int to parse it to a short

I'm pretty sure this is some way old migration type code that doesn't make any sense anymore

* Add new relation to GetDeleteClauses

* Persist the permissions

* Split UserGroupViewModel into multiple models

This is to make it possible to make endpoints more rest-ish

* Bootstrap create and update endpoints

* Make GetAllUserGroupController paged

* Add method to create IUserGroup from UserGroupSaveModel

* Add sanity check version of endpoint

* Fix persisting permissions

* Map section aliases to the name the frontend expects

This is a temporary fix till we find out how we really want to handle this

* Fix up post merge

* Make naming more consistent

* Implement initial update endpoint

* Fix media start node

* Clean name for XSS when mapping to IUserGroup

* Use a set instead of a list for permission names

We don't want dupes

* Make permission column nvarchar max

* Add UserGroupOperationStatuses

* Add IUserGroupAuthorizationService

* Add specific user group creation method to user service

* Move validating and authorizing into its own methods

* Add operation result to action result mapping

* Update create controller to use the create method

* Fix create end point

* Comment out getting current user untill we have auth

* Add usergroup service

* Obsolete usergroup things from IUserService

* Add update to UserGroupService interface

* User IUserGroupService in controllers

* User async notifications overloads

* Move authorize user group creation into its own service

* Add AuthorizeUserGroupUpdate method

* Make new service implementations internal and sealed

* Add update user

* Add GetAll to usergroup service

* Remove or obsolete usages of GetAllUserGroups

* Add usergroup service to DI

* Remove usage of GetGroupsByAlias

* Remove usages of GetUserGroupByAlias

* Remove usage of GetUserGroupById

* Add new table when creating a new database

* Implement Delete

* Add skip and take to getall

* Move skip take into the service

* Fixup suggestions in user group service

* Fixup unit tests

* Allow admins to change user groups they're not a part of

* Add CompatibilitySuppressions

* Update openapi

* Uppdate OpenApi.json

again

* Add missing compatibility suppression

* Added missing type info in ProducesResponseTypeAttribute

* Added INamedEntityViewModel and added on the relevant view models

* Fixed bug, resulting in serialization not being the same as swagger reported. Now all types objects implementing an interface, is serialized with the $type property

* updated OpenApi.json

* Added missing title in notfound response

* Typo

* .Result to .GetAwaiter().GetResult()

* Update comment to mention it should be implemented on CurrentUserController

* Validate that start nodes actually exists

* Handle not found consistently

* Use iso codes instead of ids

* Update OpenAPI

* Automatically infer statuscode in problemdetails

* Ensure that the language exists

* Fix usergroup 2 permission index

* Validate that group name and alias is not too long

* Only return status from validation

We're just returning the same usergroups, and this is less boilerplate code

* Handle empty and null group names

* Remove group prefix from statuses

* Add some basic validation tests

* Don't allow updating a usergroup to having a duplicate alias

---------

Co-authored-by: Bjarke Berg <mail@bergmania.dk>
This commit is contained in:
Mole
2023-02-16 09:39:17 +01:00
committed by GitHub
parent 808d563aa0
commit 5182f46bdb
64 changed files with 2637 additions and 292 deletions

View File

@@ -7,7 +7,6 @@ public class ProblemDetailsBuilder
{
private string? _title;
private string? _detail;
private int _status = StatusCodes.Status400BadRequest;
private string? _type;
public ProblemDetailsBuilder WithTitle(string title)
@@ -22,12 +21,6 @@ public class ProblemDetailsBuilder
return this;
}
public ProblemDetailsBuilder WithStatus(int status)
{
_status = status;
return this;
}
public ProblemDetailsBuilder WithType(string type)
{
_type = type;
@@ -39,7 +32,6 @@ public class ProblemDetailsBuilder
{
Title = _title,
Detail = _detail,
Status = _status,
Type = _type ?? "Error",
};
}

View File

@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.UserGroups;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.UserGroups;
public class ByKeyUserGroupController : UserGroupsControllerBase
{
private readonly IUserGroupService _userGroupService;
private readonly IUserGroupViewModelFactory _userGroupViewModelFactory;
public ByKeyUserGroupController(
IUserGroupService userGroupService,
IUserGroupViewModelFactory userGroupViewModelFactory)
{
_userGroupService = userGroupService;
_userGroupViewModelFactory = userGroupViewModelFactory;
}
[HttpGet("{key:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(UserGroupViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<UserGroupViewModel>> ByKey(Guid key)
{
IUserGroup? userGroup = await _userGroupService.GetAsync(key);
if (userGroup is null)
{
return NotFound();
}
return await _userGroupViewModelFactory.CreateAsync(userGroup);
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.UserGroups;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.UserGroups;
public class CreateUserGroupController : UserGroupsControllerBase
{
private readonly IUserGroupService _userGroupService;
private readonly IUserGroupViewModelFactory _userGroupViewModelFactory;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public CreateUserGroupController(
IUserGroupService userGroupService,
IUserGroupViewModelFactory userGroupViewModelFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_userGroupService = userGroupService;
_userGroupViewModelFactory = userGroupViewModelFactory;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[HttpPost]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status201Created)]
public async Task<IActionResult> Create(UserGroupSaveModel userGroupSaveModel)
{
// FIXME: Comment this in when auth is in place and we can get a currently logged in user.
// IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser;
// if (currentUser is null)
// {
// return UserGroupOperationStatusResult(UserGroupOperationStatus.MissingUser);
// }
Attempt<IUserGroup, UserGroupOperationStatus> userGroupCreationAttempt = await _userGroupViewModelFactory.CreateAsync(userGroupSaveModel);
if (userGroupCreationAttempt.Success is false)
{
return UserGroupOperationStatusResult(userGroupCreationAttempt.Status);
}
IUserGroup group = userGroupCreationAttempt.Result;
Attempt<IUserGroup, UserGroupOperationStatus> result = await _userGroupService.CreateAsync(group, /*currentUser.Id*/ -1);
return result.Success
? CreatedAtAction<ByKeyUserGroupController>(controller => nameof(controller.ByKey), group.Key)
: UserGroupOperationStatusResult(result.Status);
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.UserGroups;
public class DeleteUserGroupController : UserGroupsControllerBase
{
private readonly IUserGroupService _userGroupService;
public DeleteUserGroupController(IUserGroupService userGroupService)
{
_userGroupService = userGroupService;
}
[HttpDelete("{key:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid key)
{
Attempt<UserGroupOperationStatus> result = await _userGroupService.DeleteAsync(key);
return result.Success
? Ok()
: UserGroupOperationStatusResult(result.Result);
}
}

View File

@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.UserGroups;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.New.Cms.Core.Models;
namespace Umbraco.Cms.Api.Management.Controllers.UserGroups;
public class GetAllUserGroupController : UserGroupsControllerBase
{
private readonly IUserGroupService _userGroupService;
private readonly IUserGroupViewModelFactory _userViewModelFactory;
public GetAllUserGroupController(
IUserGroupService userGroupService,
IUserGroupViewModelFactory userViewModelFactory)
{
_userGroupService = userGroupService;
_userViewModelFactory = userViewModelFactory;
}
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<UserGroupViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<PagedViewModel<UserGroupViewModel>>> GetAll(int skip = 0, int take = 100)
{
// FIXME: In the old controller this endpoint had a switch "onlyCurrentUserGroup"
// If this was enabled we'd only return the groups the current user was in
// and even if it was set to false we'd still remove the admin group.
// We still need to have this functionality, however, it does not belong here.
// Instead we should implement this functionality on the CurrentUserController
PagedModel<IUserGroup> userGroups = await _userGroupService.GetAllAsync(skip, take);
var viewModels = (await _userViewModelFactory.CreateMultipleAsync(userGroups.Items)).ToList();
return new PagedViewModel<UserGroupViewModel> { Total = userGroups.Total, Items = viewModels };
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.UserGroups;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.UserGroups;
public class UpdateUserGroupController : UserGroupsControllerBase
{
private readonly IUserGroupService _userGroupService;
private readonly IUserGroupViewModelFactory _userGroupViewModelFactory;
public UpdateUserGroupController(
IUserGroupService userGroupService,
IUserGroupViewModelFactory userGroupViewModelFactory)
{
_userGroupService = userGroupService;
_userGroupViewModelFactory = userGroupViewModelFactory;
}
[HttpPut("{key:guid}")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(Guid key, UserGroupUpdateModel dataTypeUpdateModel)
{
IUserGroup? existingUserGroup = await _userGroupService.GetAsync(key);
if (existingUserGroup is null)
{
return UserGroupOperationStatusResult(UserGroupOperationStatus.NotFound);
}
Attempt<IUserGroup, UserGroupOperationStatus> userGroupUpdateAttempt = await _userGroupViewModelFactory.UpdateAsync(existingUserGroup, dataTypeUpdateModel);
if (userGroupUpdateAttempt.Success is false)
{
return UserGroupOperationStatusResult(userGroupUpdateAttempt.Status);
}
IUserGroup userGroup = userGroupUpdateAttempt.Result;
Attempt<IUserGroup, UserGroupOperationStatus> result = await _userGroupService.UpdateAsync(userGroup, -1);
return result.Success
? Ok()
: UserGroupOperationStatusResult(result.Status);
}
}

View File

@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.UserGroups;
// TODO: This needs to be an authorized controller.
[ApiController]
[VersionedApiBackOfficeRoute("user-groups")]
[ApiExplorerSettings(GroupName = "User Groups")]
[ApiVersion("1.0")]
public class UserGroupsControllerBase : ManagementApiControllerBase
{
protected IActionResult UserGroupOperationStatusResult(UserGroupOperationStatus status) =>
status switch
{
UserGroupOperationStatus.NotFound => NotFound("The user group could not be found"),
UserGroupOperationStatus.AlreadyExists => Conflict(new ProblemDetailsBuilder()
.WithTitle("User group already exists")
.WithDetail("The user group exists already.")
.Build()),
UserGroupOperationStatus.DuplicateAlias => Conflict(new ProblemDetailsBuilder()
.WithTitle("Duplicate alias")
.WithDetail("A user group already exists with the attempted alias.")
.Build()),
UserGroupOperationStatus.MissingUser => Unauthorized(new ProblemDetailsBuilder()
.WithTitle("Missing user")
.WithDetail("A performing user was not found when attempting to create the user group.")
.Build()),
UserGroupOperationStatus.IsSystemUserGroup => BadRequest(new ProblemDetailsBuilder()
.WithTitle("System user group")
.WithDetail("The operation is not allowed on a system user group.")
.Build()),
UserGroupOperationStatus.UnauthorizedMissingUserSection => Unauthorized(new ProblemDetailsBuilder()
.WithTitle("Unauthorized")
.WithDetail("The performing user does not have access to the required section")
.Build()),
UserGroupOperationStatus.UnauthorizedMissingSections => Unauthorized(new ProblemDetailsBuilder()
.WithTitle("Unauthorized section")
.WithDetail("The specified allowed section contained a section the performing user doesn't have access to.")
.Build()),
UserGroupOperationStatus.UnauthorizedStartNodes => Unauthorized(new ProblemDetailsBuilder()
.WithTitle("Unauthorized start node")
.WithDetail("The specified start nodes contained a start node the performing user doesn't have access to.")
.Build()),
UserGroupOperationStatus.UnauthorizedMissingUserGroup => Unauthorized(new ProblemDetailsBuilder()
.WithTitle("User not in user group")
.WithDetail("The current user is not in the user group")
.Build()),
UserGroupOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Cancelled by notification")
.WithDetail("A notification handler prevented the language operation.")
.Build()),
UserGroupOperationStatus.DocumentStartNodeKeyNotFound => NotFound(new ProblemDetailsBuilder()
.WithTitle("Document start node key not found")
.WithDetail("The assigned document start node does not exists.")
.Build()),
UserGroupOperationStatus.MediaStartNodeKeyNotFound => NotFound(new ProblemDetailsBuilder()
.WithTitle("Media start node key not found")
.WithDetail("The assigned media start node does not exists.")
.Build()),
UserGroupOperationStatus.LanguageNotFound => NotFound(new ProblemDetailsBuilder()
.WithTitle("Language not found")
.WithDetail("The specified language cannot be found.")
.Build()),
UserGroupOperationStatus.NameTooLong => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Name too long")
.WithDetail("User Group name is too long.")
.Build()),
UserGroupOperationStatus.AliasTooLong => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Alias too long")
.WithDetail("The user group alias is too long.")
.Build()),
UserGroupOperationStatus.MissingName => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Missing user group name.")
.WithDetail("The user group name is required, and cannot be an empty string.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown user group operation status."),
};
}

View File

@@ -0,0 +1,14 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Management.DependencyInjection;
internal static class UserGroupsBuilderExtensions
{
internal static IUmbracoBuilder AddUserGroups(this IUmbracoBuilder builder)
{
builder.Services.AddTransient<IUserGroupViewModelFactory, UserGroupViewModelFactory>();
return builder;
}
}

View File

@@ -0,0 +1,41 @@
using Umbraco.Cms.Api.Management.ViewModels.UserGroups;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Factories;
/// <summary>
/// A factory for creating <see cref="UserGroupViewModel"/>
/// </summary>
public interface IUserGroupViewModelFactory
{
/// <summary>
/// Creates a <see cref="UserGroupViewModel"/> based on a <see cref="UserGroup"/>
/// </summary>
/// <param name="userGroup"></param>
/// <returns></returns>
Task<UserGroupViewModel> CreateAsync(IUserGroup userGroup);
/// <summary>
/// Creates multiple <see cref="UserGroupViewModel"/> base on multiple <see cref="UserGroup"/>
/// </summary>
/// <param name="userGroups"></param>
/// <returns></returns>
Task<IEnumerable<UserGroupViewModel>> CreateMultipleAsync(IEnumerable<IUserGroup> userGroups);
/// <summary>
/// Creates an <see cref="IUserGroup"/> based on a <see cref="UserGroupSaveModel"/>
/// </summary>
/// <param name="saveModel"></param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
Task<Attempt<IUserGroup, UserGroupOperationStatus>> CreateAsync(UserGroupSaveModel saveModel);
/// <summary>
/// Converts the values of an update model to fit with the existing backoffice implementations, and maps it to an existing user group.
/// </summary>
/// <param name="current">Existing user group to map to.</param>
/// <param name="update">Update model containing the new values.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
Task<Attempt<IUserGroup, UserGroupOperationStatus>> UpdateAsync(IUserGroup current, UserGroupUpdateModel update);
}

View File

@@ -0,0 +1,226 @@
using Umbraco.Cms.Api.Management.Mapping;
using Umbraco.Cms.Api.Management.ViewModels.UserGroups;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Factories;
/// <inheritdoc />
public class UserGroupViewModelFactory : IUserGroupViewModelFactory
{
private readonly IEntityService _entityService;
private readonly IShortStringHelper _shortStringHelper;
private readonly ILanguageService _languageService;
public UserGroupViewModelFactory(
IEntityService entityService,
IShortStringHelper shortStringHelper,
ILanguageService languageService)
{
_entityService = entityService;
_shortStringHelper = shortStringHelper;
_languageService = languageService;
}
/// <inheritdoc />
public async Task<UserGroupViewModel> CreateAsync(IUserGroup userGroup)
{
Guid? contentStartNodeKey = GetKeyFromId(userGroup.StartContentId, UmbracoObjectTypes.Document);
Guid? mediaStartNodeKey = GetKeyFromId(userGroup.StartMediaId, UmbracoObjectTypes.Media);
Attempt<IEnumerable<string>, UserGroupOperationStatus> languageIsoCodesMappingAttempt = await MapLanguageIdsToIsoCodeAsync(userGroup.AllowedLanguages);
// We've gotten this data from the database, so the mapping should not fail
if (languageIsoCodesMappingAttempt.Success is false)
{
throw new InvalidOperationException($"Unknown language ID in User Group: {userGroup.Name}");
}
return new UserGroupViewModel
{
Name = userGroup.Name ?? string.Empty,
Key = userGroup.Key,
DocumentStartNodeKey = contentStartNodeKey,
MediaStartNodeKey = mediaStartNodeKey,
Icon = userGroup.Icon,
Languages = languageIsoCodesMappingAttempt.Result,
HasAccessToAllLanguages = userGroup.HasAccessToAllLanguages,
Permissions = userGroup.PermissionNames,
Sections = userGroup.AllowedSections.Select(SectionMapper.GetName),
};
}
/// <inheritdoc />
public async Task<IEnumerable<UserGroupViewModel>> CreateMultipleAsync(IEnumerable<IUserGroup> userGroups)
{
var userGroupViewModels = new List<UserGroupViewModel>();
foreach (IUserGroup userGroup in userGroups)
{
userGroupViewModels.Add(await CreateAsync(userGroup));
}
return userGroupViewModels;
}
/// <inheritdoc />
public async Task<Attempt<IUserGroup, UserGroupOperationStatus>> CreateAsync(UserGroupSaveModel saveModel)
{
var cleanedName = saveModel.Name.CleanForXss('[', ']', '(', ')', ':');
var group = new UserGroup(_shortStringHelper)
{
Name = cleanedName,
Alias = cleanedName,
Icon = saveModel.Icon,
HasAccessToAllLanguages = saveModel.HasAccessToAllLanguages,
PermissionNames = saveModel.Permissions,
};
Attempt<UserGroupOperationStatus> assignmentAttempt = AssignStartNodesToUserGroup(saveModel, group);
if (assignmentAttempt.Success is false)
{
return Attempt.FailWithStatus<IUserGroup, UserGroupOperationStatus>(assignmentAttempt.Result, group);
}
foreach (var section in saveModel.Sections)
{
group.AddAllowedSection(SectionMapper.GetAlias(section));
}
Attempt<IEnumerable<int>, UserGroupOperationStatus> languageIsoCodeMappingAttempt = await MapLanguageIsoCodesToIdsAsync(saveModel.Languages);
if (languageIsoCodeMappingAttempt.Success is false)
{
return Attempt.FailWithStatus<IUserGroup, UserGroupOperationStatus>(languageIsoCodeMappingAttempt.Status, group);
}
foreach (var languageId in languageIsoCodeMappingAttempt.Result)
{
group.AddAllowedLanguage(languageId);
}
return Attempt.SucceedWithStatus<IUserGroup, UserGroupOperationStatus>(UserGroupOperationStatus.Success, group);
}
/// <inheritdoc />
public async Task<Attempt<IUserGroup, UserGroupOperationStatus>> UpdateAsync(IUserGroup current, UserGroupUpdateModel update)
{
Attempt<UserGroupOperationStatus> assignmentAttempt = AssignStartNodesToUserGroup(update, current);
if (assignmentAttempt.Success is false)
{
return Attempt.FailWithStatus(assignmentAttempt.Result, current);
}
current.ClearAllowedLanguages();
Attempt<IEnumerable<int>, UserGroupOperationStatus> languageIdsMappingAttempt = await MapLanguageIsoCodesToIdsAsync(update.Languages);
if (languageIdsMappingAttempt.Success is false)
{
return Attempt.FailWithStatus(languageIdsMappingAttempt.Status, current);
}
foreach (var languageId in languageIdsMappingAttempt.Result)
{
current.AddAllowedLanguage(languageId);
}
current.ClearAllowedSections();
foreach (var sectionName in update.Sections)
{
current.AddAllowedSection(SectionMapper.GetAlias(sectionName));
}
current.Name = update.Name.CleanForXss('[', ']', '(', ')', ':');
current.Icon = update.Icon;
current.HasAccessToAllLanguages = update.HasAccessToAllLanguages;
current.PermissionNames = update.Permissions;
return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, current);
}
private async Task<Attempt<IEnumerable<string>, UserGroupOperationStatus>> MapLanguageIdsToIsoCodeAsync(IEnumerable<int> ids)
{
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
string[] isoCodes = languages
.Where(x => ids.Contains(x.Id))
.Select(x => x.IsoCode)
.ToArray();
return isoCodes.Length == ids.Count()
? Attempt.SucceedWithStatus<IEnumerable<string>, UserGroupOperationStatus>(UserGroupOperationStatus.Success, isoCodes)
: Attempt.FailWithStatus<IEnumerable<string>, UserGroupOperationStatus>(UserGroupOperationStatus.LanguageNotFound, isoCodes);
}
private async Task<Attempt<IEnumerable<int>, UserGroupOperationStatus>> MapLanguageIsoCodesToIdsAsync(IEnumerable<string> isoCodes)
{
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
int[] languageIds = languages
.Where(x => isoCodes.Contains(x.IsoCode))
.Select(x => x.Id)
.ToArray();
return languageIds.Length == isoCodes.Count()
? Attempt.SucceedWithStatus<IEnumerable<int>, UserGroupOperationStatus>(UserGroupOperationStatus.Success, languageIds)
: Attempt.FailWithStatus<IEnumerable<int>, UserGroupOperationStatus>(UserGroupOperationStatus.LanguageNotFound, languageIds);
}
private Attempt<UserGroupOperationStatus> AssignStartNodesToUserGroup(UserGroupBase source, IUserGroup target)
{
if (source.DocumentStartNodeKey is not null)
{
var contentId = GetIdFromKey(source.DocumentStartNodeKey.Value, UmbracoObjectTypes.Document);
if (contentId is null)
{
return Attempt.Fail(UserGroupOperationStatus.DocumentStartNodeKeyNotFound);
}
target.StartContentId = contentId;
}
if (source.MediaStartNodeKey is not null)
{
var mediaId = GetIdFromKey(source.MediaStartNodeKey.Value, UmbracoObjectTypes.Media);
if (mediaId is null)
{
return Attempt.Fail(UserGroupOperationStatus.MediaStartNodeKeyNotFound);
}
target.StartMediaId = mediaId;
}
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
private Guid? GetKeyFromId(int? id, UmbracoObjectTypes objectType)
{
if (id is null)
{
return null;
}
Attempt<Guid> attempt = _entityService.GetKey(id.Value, objectType);
if (attempt.Success is false)
{
return null;
}
return attempt.Result;
}
private int? GetIdFromKey(Guid key, UmbracoObjectTypes objectType)
{
Attempt<int> attempt = _entityService.GetId(key, objectType);
if (attempt.Success is false)
{
return null;
}
return attempt.Result;
}
}

View File

@@ -35,6 +35,7 @@ public class ManagementApiComposer : IComposer
.AddDataTypes()
.AddTemplates()
.AddLogViewer()
.AddUserGroups()
.AddBackOfficeAuthentication()
.AddApiVersioning()
.AddSwaggerGen();

View File

@@ -0,0 +1,54 @@
namespace Umbraco.Cms.Api.Management.Mapping;
/// <summary>
/// Maps from the old section aliases to the new section names.
/// This is static since it's expected to be removed, so might as well make the clean up work as easy as possible.
/// FIXME: This is a temporary thing until permissions is fleshed out and section is either migrated to some form of permission
/// </summary>
public static class SectionMapper
{
private static readonly List<SectionMapping> _sectionMappings = new()
{
new SectionMapping { Alias = "content", Name = "Umb.Section.Content" },
new SectionMapping { Alias = "media", Name = "Umb.Section.Media" },
new SectionMapping { Alias = "member", Name = "Umb.Section.Members" },
new SectionMapping { Alias = "settings", Name = "Umb.Section.Settings" },
new SectionMapping { Alias = "packages", Name = "Umb.Section.Packages" },
new SectionMapping { Alias = "translation", Name = "Umb.Section.Translation" },
new SectionMapping { Alias = "users", Name = "Umb.Section.Users" },
new SectionMapping { Alias = "forms", Name = "Umb.Section.Forms" },
};
public static string GetName(string alias)
{
SectionMapping? mapping = _sectionMappings.FirstOrDefault(x => x.Alias == alias);
if (mapping is not null)
{
return mapping.Name;
}
// If we can't find it we just fall back to the alias
return alias;
}
public static string GetAlias(string name)
{
SectionMapping? mapping = _sectionMappings.FirstOrDefault(x => x.Name == name);
if (mapping is not null)
{
return mapping.Alias;
}
// If we can't find it we just fall back to the name
return name;
}
private class SectionMapping
{
public required string Alias { get; init; }
public required string Name { get; init; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.DataType;
public class DataTypeViewModel : DataTypeModelBase
public class DataTypeViewModel : DataTypeModelBase, INamedEntityViewModel
{
public Guid Key { get; set; }

View File

@@ -1,6 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary;
public class DictionaryItemViewModel : DictionaryItemModelBase
public class DictionaryItemViewModel : DictionaryItemModelBase, INamedEntityViewModel
{
public Guid Key { get; set; }
}

View File

@@ -1,6 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Folder;
public class FolderViewModel : FolderModelBase
public class FolderViewModel : FolderModelBase, INamedEntityViewModel
{
public Guid Key { get; set; }

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.ViewModels;
public interface INamedEntityViewModel
{
Guid Key { get; }
string Name { get;}
}

View File

@@ -1,6 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.RecycleBin;
public class RecycleBinItemViewModel
public class RecycleBinItemViewModel : INamedEntityViewModel
{
public Guid Key { get; set; }

View File

@@ -1,6 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Template;
public class TemplateViewModel : TemplateModelBase
public class TemplateViewModel : TemplateModelBase, INamedEntityViewModel
{
public Guid Key { get; set; }
}

View File

@@ -1,6 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Tree;
public class EntityTreeItemViewModel : TreeItemViewModel
public class EntityTreeItemViewModel : TreeItemViewModel, INamedEntityViewModel
{
public Guid Key { get; set; }

View File

@@ -0,0 +1,58 @@
namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups;
/// <summary>
/// <para>
/// Base class for front-end representation of a User Group.
/// </para>
/// <para>
/// Contains all the properties shared between Save, Update, Representation, etc...
/// </para>
/// </summary>
public class UserGroupBase
{
/// <summary>
/// The name of the user groups
/// </summary>
public required string Name { get; init; }
/// <summary>
/// The Icon for the user group
/// </summary>
public string? Icon { get; init; }
/// <summary>
/// The sections that the user group has access to
/// </summary>
public required IEnumerable<string> Sections { get; init; }
/// <summary>
/// The languages that the user group has access to
/// </summary>
public required IEnumerable<string> Languages { get; init; }
/// <summary>
/// Flag indicating if the user group gives access to all languages, regardless of <see cref="UserGroupBase.Languages"/>.
/// </summary>
public required bool HasAccessToAllLanguages { get; init; }
/// <summary>
/// The key of the document that should act as root node for the user group
/// <remarks>
/// This can be overwritten by a different user group if a user is a member of multiple groups
/// </remarks>
/// </summary>
public Guid? DocumentStartNodeKey { get; init; }
/// <summary>
/// The Key of the media that should act as root node for the user group
/// <remarks>
/// This can be overwritten by a different user group if a user is a member of multiple groups
/// </remarks>
/// </summary>
public Guid? MediaStartNodeKey { get; init; }
/// <summary>
/// Ad-hoc list of permissions provided, and maintained by the front-end. The server has no concept of what these mean.
/// </summary>
public required ISet<string> Permissions { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups;
public class UserGroupSaveModel : UserGroupBase
{
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups;
public class UserGroupUpdateModel : UserGroupBase
{
}

View File

@@ -0,0 +1,10 @@
namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups;
public class UserGroupViewModel : UserGroupBase, INamedEntityViewModel
{
/// <summary>
/// The key identifier for the user group.
/// </summary>
public required Guid Key { get; init; }
}

View File

@@ -203,6 +203,13 @@
<Right>lib/net7.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Umbraco.Cms.Core.Models.Mapping.UserMapDefinition.#ctor(Umbraco.Cms.Core.Services.ILocalizedTextService,Umbraco.Cms.Core.Services.IUserService,Umbraco.Cms.Core.Services.IEntityService,Umbraco.Cms.Core.Services.ISectionService,Umbraco.Cms.Core.Cache.AppCaches,Umbraco.Cms.Core.Actions.ActionCollection,Microsoft.Extensions.Options.IOptions{Umbraco.Cms.Core.Configuration.Models.GlobalSettings},Umbraco.Cms.Core.IO.MediaFileManager,Umbraco.Cms.Core.Strings.IShortStringHelper,Umbraco.Cms.Core.Media.IImageUrlGenerator)</Target>
<Left>lib/net7.0/Umbraco.Core.dll</Left>
<Right>lib/net7.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Umbraco.Cms.Core.Models.Membership.ReadOnlyUserGroup.#ctor(System.Int32,System.String,System.String,System.Nullable{System.Int32},System.Nullable{System.Int32},System.String,System.Collections.Generic.IEnumerable{System.Int32},System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String},System.Boolean)</Target>
@@ -602,4 +609,11 @@
<Right>lib/net7.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>P:Umbraco.Cms.Core.Models.Membership.IUserGroup.PermissionNames</Target>
<Left>lib/net7.0/Umbraco.Core.dll</Left>
<Right>lib/net7.0/Umbraco.Core.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>

View File

@@ -279,6 +279,8 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddUnique<IKeyValueService, KeyValueService>();
Services.AddUnique<IPublicAccessService, PublicAccessService>();
Services.AddUnique<IContentVersionService, ContentVersionService>();
Services.AddTransient<IUserGroupAuthorizationService, UserGroupAuthorizationService>();
Services.AddUnique<IUserGroupService, UserGroupService>();
Services.AddUnique<IUserService, UserService>();
Services.AddUnique<ILocalizationService, LocalizationService>();
Services.AddUnique<IDictionaryItemService, DictionaryItemService>();

View File

@@ -1,6 +1,8 @@
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
@@ -30,6 +32,7 @@ public sealed class AuditNotificationsHandler :
private readonly GlobalSettings _globalSettings;
private readonly IIpResolver _ipResolver;
private readonly IMemberService _memberService;
private readonly IUserGroupService _userGroupService;
private readonly IUserService _userService;
public AuditNotificationsHandler(
@@ -39,7 +42,8 @@ public sealed class AuditNotificationsHandler :
IIpResolver ipResolver,
IOptionsMonitor<GlobalSettings> globalSettings,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IMemberService memberService)
IMemberService memberService,
IUserGroupService userGroupService)
{
_auditService = auditService;
_userService = userService;
@@ -47,9 +51,32 @@ public sealed class AuditNotificationsHandler :
_ipResolver = ipResolver;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_memberService = memberService;
_userGroupService = userGroupService;
_globalSettings = globalSettings.CurrentValue;
}
[Obsolete("Use constructor that takes IUserGroupService, scheduled for removal in V15.")]
public AuditNotificationsHandler(
IAuditService auditService,
IUserService userService,
IEntityService entityService,
IIpResolver ipResolver,
IOptionsMonitor<GlobalSettings> globalSettings,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IMemberService memberService)
: this(
auditService,
userService,
entityService,
ipResolver,
globalSettings,
backOfficeSecurityAccessor,
memberService,
StaticServiceProvider.Instance.GetRequiredService<IUserGroupService>()
)
{
}
private IUser CurrentPerformingUser
{
get
@@ -95,7 +122,7 @@ public sealed class AuditNotificationsHandler :
IEnumerable<EntityPermission> perms = notification.EntityPermissions;
foreach (EntityPermission perm in perms)
{
IUserGroup? group = _userService.GetUserGroupById(perm.UserGroupId);
IUserGroup? group = _userGroupService.GetAsync(perm.UserGroupId).Result;
var assigned = string.Join(", ", perm.AssignedPermissions ?? Array.Empty<string>());
IEntitySlim? entity = _entityService.Get(perm.EntityId);

View File

@@ -37,6 +37,16 @@ public class UserGroupSave : EntityBasic, IValidatableObject
[DataMember(Name = "hasAccessToAllLanguages")]
public bool HasAccessToAllLanguages { get; set; }
/// <summary>
/// A set of ad-hoc permissions provided by the frontend.
/// </summary>
/// <remarks>
/// By default the server has no concept of what these strings mean, we simple store them and return them to the UI.
/// FIXME: Permissions already exists in the form of "DefaultPermissions", but is subject to change in the future
/// when we know more about how we want to handle permissions, potentially those will be migrated in the these "soft" permissions.
/// </remarks>
public ISet<string>? Permissions { get; set; }
/// <summary>
/// The list of letters (permission codes) to assign as the default for the user group
/// </summary>

View File

@@ -1,5 +1,4 @@
using System.Globalization;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Actions;
@@ -33,6 +32,7 @@ public class UserMapDefinition : IMapDefinition
private readonly ILocalizedTextService _textService;
private readonly IUserService _userService;
private readonly ILocalizationService _localizationService;
private readonly IUserGroupService _userGroupService;
public UserMapDefinition(
ILocalizedTextService textService,
@@ -45,7 +45,8 @@ public class UserMapDefinition : IMapDefinition
MediaFileManager mediaFileManager,
IShortStringHelper shortStringHelper,
IImageUrlGenerator imageUrlGenerator,
ILocalizationService localizationService)
ILocalizationService localizationService,
IUserGroupService userGroupService)
{
_sectionService = sectionService;
_entityService = entityService;
@@ -58,9 +59,10 @@ public class UserMapDefinition : IMapDefinition
_shortStringHelper = shortStringHelper;
_imageUrlGenerator = imageUrlGenerator;
_localizationService = localizationService;
_userGroupService = userGroupService;
}
[Obsolete("Please use constructor that takes an ILocalizationService instead")]
[Obsolete("Use constructor that takes IUserGroupService, scheduled for removal in V15.")]
public UserMapDefinition(
ILocalizedTextService textService,
IUserService userService,
@@ -71,19 +73,21 @@ public class UserMapDefinition : IMapDefinition
IOptions<GlobalSettings> globalSettings,
MediaFileManager mediaFileManager,
IShortStringHelper shortStringHelper,
IImageUrlGenerator imageUrlGenerator)
: this(
textService,
userService,
entityService,
sectionService,
appCaches,
actions,
globalSettings,
mediaFileManager,
shortStringHelper,
imageUrlGenerator,
StaticServiceProvider.Instance.GetRequiredService<ILocalizationService>())
IImageUrlGenerator imageUrlGenerator,
ILocalizationService localizationService)
: this(
textService,
userService,
entityService,
sectionService,
appCaches,
actions,
globalSettings,
mediaFileManager,
shortStringHelper,
imageUrlGenerator,
localizationService,
StaticServiceProvider.Instance.GetRequiredService<IUserGroupService>())
{
}
@@ -135,6 +139,7 @@ public class UserMapDefinition : IMapDefinition
target.Permissions = source.DefaultPermissions;
target.Key = source.Key;
target.HasAccessToAllLanguages = source.HasAccessToAllLanguages;
target.PermissionNames = source.Permissions ?? new HashSet<string>();
var id = GetIntId(source.Id);
if (id > 0)
@@ -222,7 +227,7 @@ public class UserMapDefinition : IMapDefinition
target.IsApproved = false;
target.ClearGroups();
IEnumerable<IUserGroup> groups = _userService.GetUserGroupsByAlias(source.UserGroups.ToArray());
IEnumerable<IUserGroup> groups = _userGroupService.GetAsync(source.UserGroups.ToArray()).GetAwaiter().GetResult();
foreach (IUserGroup group in groups)
{
target.AddGroup(group.ToReadOnlyGroup());
@@ -247,7 +252,7 @@ public class UserMapDefinition : IMapDefinition
target.Id = source.Id;
target.ClearGroups();
IEnumerable<IUserGroup> groups = _userService.GetUserGroupsByAlias(source.UserGroups.ToArray());
IEnumerable<IUserGroup> groups = _userGroupService.GetAsync(source.UserGroups.ToArray()).GetAwaiter().GetResult();
foreach (IUserGroup group in groups)
{
target.AddGroup(group.ToReadOnlyGroup());

View File

@@ -1,3 +1,4 @@
using System.Collections;
using Umbraco.Cms.Core.Models.Entities;
namespace Umbraco.Cms.Core.Models.Membership;
@@ -24,7 +25,11 @@ public interface IUserGroup : IEntity, IRememberBeingDirty
/// If this property is true it will give the group access to all languages
/// </summary>
/// This is set to return true as default to avoid breaking changes
public bool HasAccessToAllLanguages => true;
public bool HasAccessToAllLanguages
{
get => true;
set { /* This is NoOp to avoid breaking changes */ }
}
/// <summary>
/// The set of default permissions
@@ -35,6 +40,16 @@ public interface IUserGroup : IEntity, IRememberBeingDirty
/// </remarks>
IEnumerable<string>? Permissions { get; set; }
/// <summary>
/// The set of permissions provided by the frontend.
/// </summary>
/// <remarks>
/// By default the server has no concept of what these strings mean, we simple store them and return them to the UI.
/// FIXME: For now this is named PermissionNames since Permissions already exists, but is subject to change in the future
/// when we know more about how we want to handle permissions, potentially those will be migrated in the these "soft" permissions.
/// </remarks>
ISet<string> PermissionNames { get; set; }
IEnumerable<string> AllowedSections { get; }
void RemoveAllowedSection(string sectionAlias);

View File

@@ -1,3 +1,4 @@
using System.Collections;
using System.Runtime.Serialization;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Strings;
@@ -24,6 +25,7 @@ public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup
private string _name;
private bool _hasAccessToAllLanguages;
private IEnumerable<string>? _permissions;
private ISet<string> _permissionNames = new HashSet<string>();
private List<string> _sectionCollection;
private List<int> _languageCollection;
private int? _startContentId;
@@ -124,6 +126,13 @@ public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup
set => SetPropertyValueAndDetectChanges(value, ref _permissions, nameof(Permissions), _stringEnumerableComparer);
}
/// <inheritdoc />
public ISet<string> PermissionNames
{
get => _permissionNames;
set => SetPropertyValueAndDetectChanges(value, ref _permissionNames!, nameof(PermissionNames), _stringEnumerableComparer);
}
public IEnumerable<string> AllowedSections => _sectionCollection;
public int UserCount { get; }

View File

@@ -54,6 +54,7 @@ public static partial class Constants
public const string User2NodeNotify = TableNamePrefix + "User2NodeNotify";
public const string UserGroup2App = TableNamePrefix + "UserGroup2App";
public const string UserGroup2Node = TableNamePrefix + "UserGroup2Node";
public const string UserGroup2Permission = TableNamePrefix + "UserGroup2Permission";
public const string UserGroup2NodePermission = TableNamePrefix + "UserGroup2NodePermission";
public const string UserGroup2Language = TableNamePrefix + "UserGroup2Language";
public const string ExternalLogin = TableNamePrefix + "ExternalLogin";

View File

@@ -48,6 +48,22 @@ public interface IEntityRepository : IRepository
bool Exists(Guid key);
/// <summary>
/// Asserts if an entity with the given object type exists.
/// </summary>
/// <param name="key">The Key of the entity to find.</param>
/// <param name="objectType">The object type key of the entity.</param>
/// <returns>True if an entity with the given key and object type exists.</returns>
bool Exists(Guid key, Guid objectType) => throw new NotImplementedException();
/// <summary>
/// Asserts if an entity with the given object type exists.
/// </summary>
/// <param name="id">The id of the entity to find.</param>
/// <param name="objectType">The object type key of the entity.</param>
/// <returns>True if an entity with the given id and object type exists.</returns>
bool Exists(int id, Guid objectType) => throw new NotImplementedException();
/// <summary>
/// Gets paged entities for a query and a specific object type
/// </summary>

View File

@@ -124,6 +124,24 @@ public class EntityService : RepositoryService, IEntityService
}
}
/// <inheritdoc />
public bool Exists(Guid key, UmbracoObjectTypes objectType)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _entityRepository.Exists(key, objectType.GetGuid());
}
}
/// <inheritdoc />
public bool Exists(int id, UmbracoObjectTypes objectType)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _entityRepository.Exists(id, objectType.GetGuid());
}
}
/// <inheritdoc />
public virtual IEnumerable<IEntitySlim> GetAll<T>()
where T : IUmbracoEntity

View File

@@ -60,6 +60,22 @@ public interface IEntityService
/// <param name="key">The unique key of the entity.</param>
bool Exists(Guid key);
/// <summary>
/// Determines whether and entity of a certain object type exists.
/// </summary>
/// <param name="key">The unique key of the entity.</param>
/// <param name="objectType">The object type to look for.</param>
/// <returns>True if the entity exists, false if it does not.</returns>
bool Exists(Guid key, UmbracoObjectTypes objectType) => throw new NotImplementedException();
/// <summary>
/// Determines whether and entity of a certain object type exists.
/// </summary>
/// <param name="id">The id of the entity.</param>
/// <param name="objectType">The object type to look for.</param>
/// <returns>True if the entity exists, false if it does not.</returns>
bool Exists(int id, UmbracoObjectTypes objectType) => throw new NotImplementedException();
/// <summary>
/// Gets entities of a given object type.
/// </summary>

View File

@@ -0,0 +1,24 @@
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
public interface IUserGroupAuthorizationService
{
/// <summary>
/// Authorizes a user to create a new user group.
/// </summary>
/// <param name="performingUser">The user performing the create operation.</param>
/// <param name="userGroup">The user group to be created.</param>
/// <returns>An attempt with an operation status.</returns>
Attempt<UserGroupOperationStatus> AuthorizeUserGroupCreation(IUser performingUser, IUserGroup userGroup);
/// <summary>
/// Authorizes a user to update an existing user group.
/// </summary>
/// <param name="performingUser">The user performing the update operation.</param>
/// <param name="userGroup">The user group to be created.</param>
/// <returns>An attempt with an operation.</returns>
Attempt<UserGroupOperationStatus> AuthorizeUserGroupUpdate(IUser performingUser, IUserGroup userGroup);
}

View File

@@ -0,0 +1,86 @@
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.New.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
/// <summary>
/// Manages user groups.
/// </summary>
public interface IUserGroupService
{
/// <summary>
/// Gets all user groups.
/// </summary>
/// <param name="skip">The amount of user groups to skip.</param>
/// <param name="take">The amount of user groups to take.</param>
/// <returns>All user groups as an enumerable list of <see cref="IUserGroup"/>.</returns>
Task<PagedModel<IUserGroup>> GetAllAsync(int skip, int take);
/// <summary>
/// Gets all UserGroups matching an ID in the parameter list.
/// </summary>
/// <param name="ids">Optional Ids of UserGroups to retrieve.</param>
/// <returns>An enumerable list of <see cref="IUserGroup"/>.</returns>
Task<IEnumerable<IUserGroup>> GetAsync(params int[] ids);
/// <summary>
/// Gets all UserGroups matching an alias in the parameter list.
/// </summary>
/// <param name="aliases">Alias of the UserGroup to retrieve.</param>
/// <returns>
/// <returns>An enumerable list of <see cref="IUserGroup"/>.</returns>
/// </returns>
Task<IEnumerable<IUserGroup>> GetAsync(params string[] aliases);
/// <summary>
/// Gets a UserGroup by its Alias
/// </summary>
/// <param name="alias">Name of the UserGroup to retrieve.</param>
/// <returns>
/// <see cref="IUserGroup" />
/// </returns>
Task<IUserGroup?> GetAsync(string alias);
/// <summary>
/// Gets a UserGroup by its Id
/// </summary>
/// <param name="id">Id of the UserGroup to retrieve.</param>
/// <returns>
/// <see cref="IUserGroup" />
/// </returns>
Task<IUserGroup?> GetAsync(int id);
/// <summary>
/// Gets a UserGroup by its key
/// </summary>
/// <param name="key">Key of the UserGroup to retrieve.</param>
/// <returns>
/// <see cref="IUserGroup" />
/// </returns>
Task<IUserGroup?> GetAsync(Guid key);
/// <summary>
/// Persists a new user group.
/// </summary>
/// <param name="userGroup">The user group to create.</param>
/// <param name="performingUserId">The ID of the user responsible for creating the group.</param>
/// <param name="groupMembersUserIds">The IDs of the users that should be part of the group when created.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
Task<Attempt<IUserGroup, UserGroupOperationStatus>> CreateAsync(IUserGroup userGroup, int performingUserId, int[]? groupMembersUserIds = null);
/// <summary>
/// Updates an existing user group.
/// </summary>
/// <param name="userGroup">The user group to update.</param>
/// <param name="performingUserId">The ID of the user responsible for updating the group.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
Task<Attempt<IUserGroup, UserGroupOperationStatus>> UpdateAsync(IUserGroup userGroup, int performingUserId);
/// <summary>
/// Deletes a UserGroup
/// </summary>
/// <param name="key">The key of the user group to delete.</param>
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
Task<Attempt<UserGroupOperationStatus>> DeleteAsync(Guid key);
}

View File

@@ -240,6 +240,7 @@ public interface IUserService : IMembershipUserService
/// </summary>
/// <param name="ids">Optional Ids of UserGroups to retrieve</param>
/// <returns>An enumerable list of <see cref="IUserGroup" /></returns>
[Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")]
IEnumerable<IUserGroup> GetAllUserGroups(params int[] ids);
/// <summary>
@@ -249,6 +250,7 @@ public interface IUserService : IMembershipUserService
/// <returns>
/// <see cref="IUserGroup" />
/// </returns>
[Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")]
IEnumerable<IUserGroup> GetUserGroupsByAlias(params string[] alias);
/// <summary>
@@ -258,6 +260,7 @@ public interface IUserService : IMembershipUserService
/// <returns>
/// <see cref="IUserGroup" />
/// </returns>
[Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")]
IUserGroup? GetUserGroupByAlias(string name);
/// <summary>
@@ -267,6 +270,7 @@ public interface IUserService : IMembershipUserService
/// <returns>
/// <see cref="IUserGroup" />
/// </returns>
[Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")]
IUserGroup? GetUserGroupById(int id);
/// <summary>
@@ -277,12 +281,14 @@ public interface IUserService : IMembershipUserService
/// If null than no changes are made to the users who are assigned to this group, however if a value is passed in
/// than all users will be removed from this group and only these users will be added
/// </param>
[Obsolete("Use IUserGroupService.CreateAsync and IUserGroupService.UpdateAsync instead, scheduled for removal in V15.")]
void Save(IUserGroup userGroup, int[]? userIds = null);
/// <summary>
/// Deletes a UserGroup
/// </summary>
/// <param name="userGroup">UserGroup to delete</param>
[Obsolete("Use IUserGroupService.DeleteAsync instead, scheduled for removal in V15.")]
void DeleteUserGroup(IUserGroup userGroup);
#endregion

View File

@@ -0,0 +1,22 @@
namespace Umbraco.Cms.Core.Services.OperationStatus;
public enum UserGroupOperationStatus
{
Success,
NotFound,
AlreadyExists,
DuplicateAlias,
MissingUser,
IsSystemUserGroup,
UnauthorizedMissingUserSection,
UnauthorizedMissingSections,
UnauthorizedStartNodes,
UnauthorizedMissingUserGroup,
CancelledByNotification,
MediaStartNodeKeyNotFound,
DocumentStartNodeKeyNotFound,
LanguageNotFound,
NameTooLong,
AliasTooLong,
MissingName,
}

View File

@@ -0,0 +1,177 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
internal sealed class UserGroupAuthorizationService : IUserGroupAuthorizationService
{
private readonly IContentService _contentService;
private readonly IMediaService _mediaService;
private readonly IEntityService _entityService;
private readonly AppCaches _appCaches;
public UserGroupAuthorizationService(
IContentService contentService,
IMediaService mediaService,
IEntityService entityService,
AppCaches appCaches)
{
_contentService = contentService;
_mediaService = mediaService;
_entityService = entityService;
_appCaches = appCaches;
}
/// <inheritdoc />
public Attempt<UserGroupOperationStatus> AuthorizeUserGroupCreation(IUser performingUser, IUserGroup userGroup)
{
Attempt<UserGroupOperationStatus> hasSectionAccess = AuthorizeHasAccessToUserSection(performingUser);
if (hasSectionAccess.Success is false)
{
return Attempt.Fail(hasSectionAccess.Result);
}
Attempt<UserGroupOperationStatus> authorizeSectionChanges = AuthorizeSectionAccess(performingUser, userGroup);
if (authorizeSectionChanges.Success is false)
{
return Attempt.Fail(authorizeSectionChanges.Result);
}
Attempt<UserGroupOperationStatus> authorizeContentNodeChanges = AuthorizeStartNodeChanges(performingUser, userGroup);
return authorizeSectionChanges.Success is false
? Attempt.Fail(authorizeContentNodeChanges.Result)
: Attempt.Succeed(UserGroupOperationStatus.Success);
}
/// <inheritdoc />
public Attempt<UserGroupOperationStatus> AuthorizeUserGroupUpdate(IUser performingUser, IUserGroup userGroup)
{
Attempt<UserGroupOperationStatus> hasAccessToUserSection = AuthorizeHasAccessToUserSection(performingUser);
if (hasAccessToUserSection.Success is false)
{
return Attempt.Fail(hasAccessToUserSection.Result);
}
Attempt<UserGroupOperationStatus> authorizeSectionAccess = AuthorizeSectionAccess(performingUser, userGroup);
if (authorizeSectionAccess.Success is false)
{
return Attempt.Fail(authorizeSectionAccess.Result);
}
Attempt<UserGroupOperationStatus> authorizeGroupAccess = AuthorizeGroupAccess(performingUser, userGroup);
if (authorizeGroupAccess.Success is false)
{
return Attempt.Fail(authorizeGroupAccess.Result);
}
Attempt<UserGroupOperationStatus> authorizeStartNodeChanges = AuthorizeStartNodeChanges(performingUser, userGroup);
if (authorizeSectionAccess.Success is false)
{
return Attempt.Fail(authorizeStartNodeChanges.Result);
}
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
/// <summary>
/// Authorize that a user is not adding a section to the group that they don't have access to.
/// </summary>
/// <param name="performingUser">The user performing the action.</param>
/// <param name="userGroup">The UserGroup being created or updated.</param>
/// <returns>An attempt with an operation status.</returns>
private Attempt<UserGroupOperationStatus> AuthorizeSectionAccess(IUser performingUser, IUserGroup userGroup)
{
if (performingUser.IsAdmin())
{
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
IEnumerable<string> sectionsMissingAccess = userGroup.AllowedSections.Except(performingUser.AllowedSections).ToArray();
return sectionsMissingAccess.Any()
? Attempt.Fail(UserGroupOperationStatus.UnauthorizedMissingSections)
: Attempt.Succeed(UserGroupOperationStatus.Success);
}
/// <summary>
/// Authorize that the user is not changing to a start node that they don't have access to.
/// </summary>
/// <param name="user">The user performing the action.</param>
/// <param name="userGroup">The UserGroup being created or updated.</param>
/// <returns>An attempt with an operation status.</returns>
private Attempt<UserGroupOperationStatus> AuthorizeStartNodeChanges(IUser user, IUserGroup userGroup)
{
Attempt<UserGroupOperationStatus> authorizeContent = AuthorizeContentStartNode(user, userGroup);
return authorizeContent.Success is false
? authorizeContent
: AuthorizeMediaStartNode(user, userGroup);
}
/// <summary>
/// Ensures that a user has access to the user section.
/// </summary>
/// <param name="user">The user performing the action.</param>
/// <returns>An attempt with an operation status.</returns>
private Attempt<UserGroupOperationStatus> AuthorizeHasAccessToUserSection(IUser user)
{
if (user.AllowedSections.Contains(Constants.Applications.Users) is false)
{
return Attempt.Fail(UserGroupOperationStatus.UnauthorizedMissingUserSection);
}
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
/// <summary>
/// Ensures that the performing user is part of the user group.
/// </summary>
private Attempt<UserGroupOperationStatus> AuthorizeGroupAccess(IUser performingUser, IUserGroup userGroup) =>
performingUser.Groups.Any(x => x.Key == userGroup.Key) || performingUser.IsAdmin()
? Attempt.Succeed(UserGroupOperationStatus.Success)
: Attempt.Fail(UserGroupOperationStatus.UnauthorizedMissingUserGroup);
// We explicitly take an IUser here which is non-nullable, since nullability should be handled in caller.
private Attempt<UserGroupOperationStatus> AuthorizeContentStartNode(IUser user, IUserGroup userGroup)
{
if (userGroup.StartContentId is null)
{
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
IContent? content = _contentService.GetById(userGroup.StartContentId.Value);
if (content is null)
{
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
return user.HasPathAccess(content, _entityService, _appCaches) is false
? Attempt.Fail(UserGroupOperationStatus.UnauthorizedStartNodes)
: Attempt.Succeed(UserGroupOperationStatus.Success);
}
// We explicitly take an IUser here which is non-nullable, since nullability should be handled in caller.
private Attempt<UserGroupOperationStatus> AuthorizeMediaStartNode(IUser user, IUserGroup userGroup)
{
if (userGroup.StartMediaId is null)
{
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
IMedia? media = _mediaService.GetById(userGroup.StartMediaId.Value);
if (media is null)
{
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
return user.HasPathAccess(media, _entityService, _appCaches) is false
? Attempt.Fail(UserGroupOperationStatus.UnauthorizedStartNodes)
: Attempt.Succeed(UserGroupOperationStatus.Success);
}
}

View File

@@ -0,0 +1,376 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
using Umbraco.New.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
/// <inheritdoc cref="Umbraco.Cms.Core.Services.IUserGroupService" />
internal sealed class UserGroupService : RepositoryService, IUserGroupService
{
public const int MaxUserGroupNameLength = 200;
public const int MaxUserGroupAliasLength = 200;
private readonly IUserGroupRepository _userGroupRepository;
private readonly IUserGroupAuthorizationService _userGroupAuthorizationService;
private readonly IUserService _userService;
private readonly IEntityService _entityService;
public UserGroupService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IUserGroupRepository userGroupRepository,
IUserGroupAuthorizationService userGroupAuthorizationService,
IUserService userService,
IEntityService entityService)
: base(provider, loggerFactory, eventMessagesFactory)
{
_userGroupRepository = userGroupRepository;
_userGroupAuthorizationService = userGroupAuthorizationService;
_userService = userService;
_entityService = entityService;
}
/// <inheritdoc/>
public Task<PagedModel<IUserGroup>> GetAllAsync(int skip, int take)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
IUserGroup[] groups = _userGroupRepository.GetMany()
.OrderBy(x => x.Name).ToArray();
var total = groups.Length;
return Task.FromResult(new PagedModel<IUserGroup>
{
Items = groups.Skip(skip).Take(take),
Total = total,
});
}
/// <inheritdoc />
public Task<IEnumerable<IUserGroup>> GetAsync(params int[] ids)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
IEnumerable<IUserGroup> groups = _userGroupRepository
.GetMany(ids)
.OrderBy(x => x.Name);
return Task.FromResult(groups);
}
/// <inheritdoc />
public Task<IEnumerable<IUserGroup>> GetAsync(params string[] aliases)
{
if (aliases.Length == 0)
{
return Task.FromResult(Enumerable.Empty<IUserGroup>());
}
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
IQuery<IUserGroup> query = Query<IUserGroup>().Where(x => aliases.SqlIn(x.Alias));
IEnumerable<IUserGroup> contents = _userGroupRepository
.Get(query)
.WhereNotNull()
.OrderBy(x => x.Name)
.ToArray();
return Task.FromResult(contents);
}
/// <inheritdoc />
public Task<IUserGroup?> GetAsync(string alias)
{
if (string.IsNullOrWhiteSpace(alias))
{
throw new ArgumentException("Value cannot be null or whitespace.", nameof(alias));
}
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
IQuery<IUserGroup> query = Query<IUserGroup>().Where(x => x.Alias == alias);
IUserGroup? contents = _userGroupRepository.Get(query).FirstOrDefault();
return Task.FromResult(contents);
}
/// <inheritdoc />
public Task<IUserGroup?> GetAsync(int id)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
return Task.FromResult(_userGroupRepository.Get(id));
}
/// <inheritdoc />
public Task<IUserGroup?> GetAsync(Guid key)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
IQuery<IUserGroup> query = Query<IUserGroup>().Where(x => x.Key == key);
IUserGroup? groups = _userGroupRepository.Get(query).FirstOrDefault();
return Task.FromResult(groups);
}
/// <inheritdoc/>
public async Task<Attempt<UserGroupOperationStatus>> DeleteAsync(Guid key)
{
IUserGroup? userGroup = await GetAsync(key);
Attempt<UserGroupOperationStatus> validationResult = ValidateUserGroupDeletion(userGroup);
if (validationResult.Success is false)
{
return validationResult;
}
EventMessages eventMessages = EventMessagesFactory.Get();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
var deletingNotification = new UserGroupDeletingNotification(userGroup!, eventMessages);
if (await scope.Notifications.PublishCancelableAsync(deletingNotification))
{
scope.Complete();
return Attempt.Fail(UserGroupOperationStatus.CancelledByNotification);
}
_userGroupRepository.Delete(userGroup!);
scope.Notifications.Publish(new UserGroupDeletedNotification(userGroup!, eventMessages).WithStateFrom(deletingNotification));
scope.Complete();
}
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
private Attempt<UserGroupOperationStatus> ValidateUserGroupDeletion(IUserGroup? userGroup)
{
if (userGroup is null)
{
return Attempt.Fail(UserGroupOperationStatus.NotFound);
}
if (userGroup.IsSystemUserGroup())
{
return Attempt.Fail(UserGroupOperationStatus.IsSystemUserGroup);
}
return Attempt.Succeed(UserGroupOperationStatus.Success);
}
/// <inheritdoc />
public async Task<Attempt<IUserGroup, UserGroupOperationStatus>> CreateAsync(
IUserGroup userGroup,
int performingUserId,
int[]? groupMembersUserIds = null)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IUser? performingUser = _userService.GetUserById(performingUserId);
if (performingUser is null)
{
return Attempt.FailWithStatus(UserGroupOperationStatus.MissingUser, userGroup);
}
Attempt<IUserGroup, UserGroupOperationStatus> validationAttempt = await ValidateUserGroupCreationAsync(userGroup);
if (validationAttempt.Success is false)
{
return validationAttempt;
}
Attempt<UserGroupOperationStatus> authorizationAttempt = _userGroupAuthorizationService.AuthorizeUserGroupCreation(performingUser, userGroup);
if (authorizationAttempt.Success is false)
{
return Attempt.FailWithStatus(authorizationAttempt.Result, userGroup);
}
EventMessages eventMessages = EventMessagesFactory.Get();
var savingNotification = new UserGroupSavingNotification(userGroup, eventMessages);
if (await scope.Notifications.PublishCancelableAsync(savingNotification))
{
scope.Complete();
return Attempt.FailWithStatus(UserGroupOperationStatus.CancelledByNotification, userGroup);
}
var checkedGroupMembers = EnsureNonAdminUserIsInSavedUserGroup(performingUser, groupMembersUserIds ?? Enumerable.Empty<int>()).ToArray();
IEnumerable<IUser> usersToAdd = _userService.GetUsersById(checkedGroupMembers);
// Since this is a brand new creation we don't have to be worried about what users were added and removed
// simply put all members that are requested to be in the group will be "added"
var userGroupWithUsers = new UserGroupWithUsers(userGroup, usersToAdd.ToArray(), Array.Empty<IUser>());
var savingUserGroupWithUsersNotification = new UserGroupWithUsersSavingNotification(userGroupWithUsers, eventMessages);
if (await scope.Notifications.PublishCancelableAsync(savingUserGroupWithUsersNotification))
{
scope.Complete();
return Attempt.FailWithStatus(UserGroupOperationStatus.CancelledByNotification, userGroup);
}
_userGroupRepository.AddOrUpdateGroupWithUsers(userGroup, checkedGroupMembers);
scope.Complete();
return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, userGroup);
}
private async Task<Attempt<IUserGroup, UserGroupOperationStatus>> ValidateUserGroupCreationAsync(IUserGroup userGroup)
{
if (await IsNewUserGroup(userGroup) is false)
{
return Attempt.FailWithStatus(UserGroupOperationStatus.AlreadyExists, userGroup);
}
UserGroupOperationStatus commonValidationStatus = ValidateCommon(userGroup);
if (commonValidationStatus != UserGroupOperationStatus.Success)
{
return Attempt.FailWithStatus(commonValidationStatus, userGroup);
}
return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, userGroup);
}
/// <inheritdoc />
public async Task<Attempt<IUserGroup, UserGroupOperationStatus>> UpdateAsync(
IUserGroup userGroup,
int performingUserId)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IUser? performingUser = _userService.GetUserById(performingUserId);
if (performingUser is null)
{
return Attempt.FailWithStatus(UserGroupOperationStatus.MissingUser, userGroup);
}
UserGroupOperationStatus validationStatus = await ValidateUserGroupUpdateAsync(userGroup);
if (validationStatus is not UserGroupOperationStatus.Success)
{
return Attempt.FailWithStatus(validationStatus, userGroup);
}
Attempt<UserGroupOperationStatus> authorizationAttempt = _userGroupAuthorizationService.AuthorizeUserGroupUpdate(performingUser, userGroup);
if (authorizationAttempt.Success is false)
{
return Attempt.FailWithStatus(authorizationAttempt.Result, userGroup);
}
EventMessages eventMessages = EventMessagesFactory.Get();
var savingNotification = new UserGroupSavingNotification(userGroup, eventMessages);
if (await scope.Notifications.PublishCancelableAsync(savingNotification))
{
scope.Complete();
return Attempt.FailWithStatus(UserGroupOperationStatus.CancelledByNotification, userGroup);
}
_userGroupRepository.Save(userGroup);
scope.Notifications.Publish(new UserGroupSavedNotification(userGroup, eventMessages).WithStateFrom(savingNotification));
scope.Complete();
return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, userGroup);
}
private async Task<UserGroupOperationStatus> ValidateUserGroupUpdateAsync(IUserGroup userGroup)
{
UserGroupOperationStatus commonValidationStatus = ValidateCommon(userGroup);
if (commonValidationStatus != UserGroupOperationStatus.Success)
{
return commonValidationStatus;
}
if (await IsNewUserGroup(userGroup))
{
return UserGroupOperationStatus.NotFound;
}
return UserGroupOperationStatus.Success;
}
/// <summary>
/// Validate common user group properties, that are shared between update, create, etc.
/// </summary>
private UserGroupOperationStatus ValidateCommon(IUserGroup userGroup)
{
if (string.IsNullOrEmpty(userGroup.Name))
{
return UserGroupOperationStatus.MissingName;
}
if (userGroup.Name.Length > MaxUserGroupNameLength)
{
return UserGroupOperationStatus.NameTooLong;
}
if (userGroup.Alias.Length > MaxUserGroupAliasLength)
{
return UserGroupOperationStatus.AliasTooLong;
}
if (UserGroupHasUniqueAlias(userGroup) is false)
{
return UserGroupOperationStatus.DuplicateAlias;
}
UserGroupOperationStatus startNodesValidationStatus = ValidateStartNodesExists(userGroup);
if (startNodesValidationStatus is not UserGroupOperationStatus.Success)
{
return startNodesValidationStatus;
}
return UserGroupOperationStatus.Success;
}
private async Task<bool> IsNewUserGroup(IUserGroup userGroup)
{
if (userGroup.Id != 0 && userGroup.HasIdentity is false)
{
return false;
}
return await GetAsync(userGroup.Key) is null;
}
private UserGroupOperationStatus ValidateStartNodesExists(IUserGroup userGroup)
{
if (userGroup.StartContentId is not null
&& _entityService.Exists(userGroup.StartContentId.Value, UmbracoObjectTypes.Document) is false)
{
return UserGroupOperationStatus.DocumentStartNodeKeyNotFound;
}
if (userGroup.StartMediaId is not null
&& _entityService.Exists(userGroup.StartMediaId.Value, UmbracoObjectTypes.Media) is false)
{
return UserGroupOperationStatus.MediaStartNodeKeyNotFound;
}
return UserGroupOperationStatus.Success;
}
private bool UserGroupHasUniqueAlias(IUserGroup userGroup) => _userGroupRepository.Get(userGroup.Alias) is null;
/// <summary>
/// Ensures that the user creating the user group is either an admin, or in the group itself.
/// </summary>
/// <remarks>
/// This is to ensure that the user can access the group they themselves created at a later point and modify it.
/// </remarks>
private IEnumerable<int> EnsureNonAdminUserIsInSavedUserGroup(IUser performingUser, IEnumerable<int> groupMembersUserIds)
{
var userIds = groupMembersUserIds.ToList();
// If the performing user is and admin we don't care, they can access the group later regardless
if (performingUser.IsAdmin() is false && userIds.Contains(performingUser.Id) is false)
{
userIds.Add(performingUser.Id);
}
return userIds;
}
}

View File

@@ -11,7 +11,9 @@ using Umbraco.Cms.Core.Persistence;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
using UserProfile = Umbraco.Cms.Core.Models.Membership.UserProfile;
namespace Umbraco.Cms.Core.Services;
@@ -25,6 +27,7 @@ internal class UserService : RepositoryService, IUserService
private readonly ILogger<UserService> _logger;
private readonly IRuntimeState _runtimeState;
private readonly IUserGroupRepository _userGroupRepository;
private readonly IUserGroupAuthorizationService _userGroupAuthorizationService;
private readonly IUserRepository _userRepository;
public UserService(
@@ -34,12 +37,14 @@ internal class UserService : RepositoryService, IUserService
IRuntimeState runtimeState,
IUserRepository userRepository,
IUserGroupRepository userGroupRepository,
IOptions<GlobalSettings> globalSettings)
IOptions<GlobalSettings> globalSettings,
IUserGroupAuthorizationService userGroupAuthorizationService)
: base(provider, loggerFactory, eventMessagesFactory)
{
_runtimeState = runtimeState;
_userRepository = userRepository;
_userGroupRepository = userGroupRepository;
_userGroupAuthorizationService = userGroupAuthorizationService;
_globalSettings = globalSettings.Value;
_logger = loggerFactory.CreateLogger<UserService>();
}
@@ -884,6 +889,7 @@ internal class UserService : RepositoryService, IUserService
/// </summary>
/// <param name="ids">Optional Ids of UserGroups to retrieve</param>
/// <returns>An enumerable list of <see cref="IUserGroup" /></returns>
[Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")]
public IEnumerable<IUserGroup> GetAllUserGroups(params int[] ids)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
@@ -892,6 +898,7 @@ internal class UserService : RepositoryService, IUserService
}
}
[Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")]
public IEnumerable<IUserGroup> GetUserGroupsByAlias(params string[] aliases)
{
if (aliases.Length == 0)
@@ -914,6 +921,7 @@ internal class UserService : RepositoryService, IUserService
/// <returns>
/// <see cref="IUserGroup" />
/// </returns>
[Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")]
public IUserGroup? GetUserGroupByAlias(string alias)
{
if (string.IsNullOrWhiteSpace(alias))
@@ -936,6 +944,7 @@ internal class UserService : RepositoryService, IUserService
/// <returns>
/// <see cref="IUserGroup" />
/// </returns>
[Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")]
public IUserGroup? GetUserGroupById(int id)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
@@ -958,6 +967,7 @@ internal class UserService : RepositoryService, IUserService
/// <c>False</c>
/// to not raise events
/// </param>
[Obsolete("Use IUserGroupService.CreateAsync and IUserGroupService.UpdateAsync instead, scheduled for removal in V15.")]
public void Save(IUserGroup userGroup, int[]? userIds = null)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
@@ -965,7 +975,7 @@ internal class UserService : RepositoryService, IUserService
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
// we need to figure out which users have been added / removed, for audit purposes
var empty = new IUser[0];
IUser[] empty = Array.Empty<IUser>();
IUser[] addedUsers = empty;
IUser[] removedUsers = empty;
@@ -1018,6 +1028,7 @@ internal class UserService : RepositoryService, IUserService
/// Deletes a UserGroup
/// </summary>
/// <param name="userGroup">UserGroup to delete</param>
[Obsolete("Use IUserGroupService.DeleteAsync instead, scheduled for removal in V15.")]
public void DeleteUserGroup(IUserGroup userGroup)
{
EventMessages evtMsgs = EventMessagesFactory.Get();

View File

@@ -77,6 +77,13 @@
<Right>lib/net7.0/Umbraco.Infrastructure.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Umbraco.Cms.Core.Security.BackOfficeUserStore.#ctor(Umbraco.Cms.Core.Scoping.ICoreScopeProvider,Umbraco.Cms.Core.Services.IUserService,Umbraco.Cms.Core.Services.IEntityService,Umbraco.Cms.Core.Services.IExternalLoginWithKeyService,Microsoft.Extensions.Options.IOptions{Umbraco.Cms.Core.Configuration.Models.GlobalSettings},Umbraco.Cms.Core.Mapping.IUmbracoMapper,Umbraco.Cms.Core.Security.BackOfficeErrorDescriber,Umbraco.Cms.Core.Cache.AppCaches)</Target>
<Left>lib/net7.0/Umbraco.Infrastructure.dll</Left>
<Right>lib/net7.0/Umbraco.Infrastructure.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Umbraco.Cms.Infrastructure.Install.PackageMigrationRunner.#ctor(Umbraco.Cms.Core.Logging.IProfilingLogger,Umbraco.Cms.Core.Scoping.ICoreScopeProvider,Umbraco.Cms.Core.Packaging.PendingPackageMigrations,Umbraco.Cms.Core.Packaging.PackageMigrationPlanCollection,Umbraco.Cms.Core.Migrations.IMigrationPlanExecutor,Umbraco.Cms.Core.Services.IKeyValueService,Umbraco.Cms.Core.Events.IEventAggregator)</Target>

View File

@@ -68,6 +68,7 @@ public class DatabaseSchemaCreator
typeof(User2UserGroupDto),
typeof(UserGroup2NodePermissionDto),
typeof(UserGroup2AppDto),
typeof(UserGroup2PermissionDto),
typeof(UserStartNodeDto),
typeof(ContentNuDto),
typeof(DocumentVersionDto),

View File

@@ -10,6 +10,7 @@ using Umbraco.Cms.Infrastructure.Migrations.Expressions.Rename;
using Umbraco.Cms.Infrastructure.Migrations.Expressions.Update;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax;
using Umbraco.Cms.Infrastructure.Scoping;
namespace Umbraco.Cms.Infrastructure.Migrations;

View File

@@ -1,4 +1,4 @@
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Cms.Infrastructure.Scoping;
namespace Umbraco.Cms.Infrastructure.Migrations;

View File

@@ -83,5 +83,6 @@ public class UmbracoPlan : MigrationPlan
To<AddPropertyEditorUiAliasColumn>("{419827A0-4FCE-464B-A8F3-247C6092AF55}");
To<MigrateDataTypeConfigurations>("{5F15A1CC-353D-4889-8C7E-F303B4766196}");
To<AddGuidsToUserGroups>("{69E12556-D9B3-493A-8E8A-65EC89FB658D}");
To<AddUserGroup2PermisionTable>("{F2B16CD4-F181-4BEE-81C9-11CF384E6025}");
}
}

View File

@@ -1,4 +1,4 @@
using NPoco;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations;
using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions;

View File

@@ -0,0 +1,21 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0;
public class AddUserGroup2PermisionTable : MigrationBase
{
public AddUserGroup2PermisionTable(IMigrationContext context) : base(context)
{
}
protected override void Migrate()
{
if (TableExists(Constants.DatabaseSchema.Tables.UserGroup2Permission))
{
return;
}
Create.Table<UserGroup2PermissionDto>().Do();
}
}

View File

@@ -0,0 +1,22 @@
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations;
namespace Umbraco.Cms.Infrastructure.Persistence.Dtos;
[TableName(Constants.DatabaseSchema.Tables.UserGroup2Permission)]
[ExplicitColumns]
public class UserGroup2PermissionDto
{
[PrimaryKeyColumn(Name = "PK_userGroup2Permission", AutoIncrement = true)]
public int Id { get; set; }
[Column("userGroupId")]
[Index(IndexTypes.NonClustered, IncludeColumns = "permission")]
[ForeignKey(typeof(UserGroupDto))]
public int UserGroupId { get; set; }
[Column("permission")]
[SpecialDbType(SpecialDbTypes.NVARCHARMAX)]
public required string Permission { get; set; }
}

View File

@@ -14,6 +14,7 @@ public class UserGroupDto
{
UserGroup2AppDtos = new List<UserGroup2AppDto>();
UserGroup2LanguageDtos = new List<UserGroup2LanguageDto>();
UserGroup2PermissionDtos = new List<UserGroup2PermissionDto>();
}
[Column("id")]
@@ -77,6 +78,10 @@ public class UserGroupDto
[Reference(ReferenceType.Many, ReferenceMemberName = "UserGroupId")]
public List<UserGroup2LanguageDto> UserGroup2LanguageDtos { get; set; }
[ResultColumn]
[Reference(ReferenceType.Many, ReferenceMemberName = "UserGroupId")]
public List<UserGroup2PermissionDto> UserGroup2PermissionDtos { get; set; }
/// <summary>
/// This is only relevant when this column is included in the results (i.e. GetUserGroupsWithUserCounts)
/// </summary>

View File

@@ -27,6 +27,7 @@ internal static class UserGroupFactory
userGroup.UpdateDate = dto.UpdateDate;
userGroup.StartContentId = dto.StartContentId;
userGroup.StartMediaId = dto.StartMediaId;
userGroup.PermissionNames = dto.UserGroup2PermissionDtos.Select(x => x.Permission).ToHashSet();
userGroup.HasAccessToAllLanguages = dto.HasAccessToAllLanguages;
if (dto.UserGroup2AppDtos != null)
{
@@ -80,7 +81,7 @@ internal static class UserGroupFactory
if (entity.HasIdentity)
{
dto.Id = short.Parse(entity.Id.ToString());
dto.Id = entity.Id;
}
return dto;

View File

@@ -289,6 +289,27 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended
return Database.ExecuteScalar<int>(sql) > 0;
}
/// <inheritdoc />
public bool Exists(Guid key, Guid objectType)
{
Sql<ISqlContext> sql = Sql()
.SelectCount()
.From<NodeDto>()
.Where<NodeDto>(x => x.UniqueId == key && x.NodeObjectType == objectType);
return Database.ExecuteScalar<int>(sql) > 0;
}
public bool Exists(int id, Guid objectType)
{
Sql<ISqlContext> sql = Sql()
.SelectCount()
.From<NodeDto>()
.Where<NodeDto>(x => x.NodeId == id && x.NodeObjectType == objectType);
return Database.ExecuteScalar<int>(sql) > 0;
}
public bool Exists(int id)
{
Sql<ISqlContext> sql = Sql().SelectCount().From<NodeDto>().Where<NodeDto>(x => x.NodeId == id);

View File

@@ -1,3 +1,4 @@
using System.Collections;
using Microsoft.Extensions.Logging;
using NPoco;
using Umbraco.Cms.Core;
@@ -25,7 +26,12 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
private readonly IShortStringHelper _shortStringHelper;
private readonly UserGroupWithUsersRepository _userGroupWithUsersRepository;
public UserGroupRepository(IScopeAccessor scopeAccessor, AppCaches appCaches, ILogger<UserGroupRepository> logger, ILoggerFactory loggerFactory, IShortStringHelper shortStringHelper)
public UserGroupRepository(
IScopeAccessor scopeAccessor,
AppCaches appCaches,
ILogger<UserGroupRepository> logger,
ILoggerFactory loggerFactory,
IShortStringHelper shortStringHelper)
: base(scopeAccessor, appCaches, logger)
{
_shortStringHelper = shortStringHelper;
@@ -299,6 +305,7 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
}
dto.UserGroup2LanguageDtos = GetUserGroupLanguages(id);
dto.UserGroup2PermissionDtos = GetUserGroupPermissions(id);
IUserGroup userGroup = UserGroupFactory.BuildEntity(_shortStringHelper, dto);
return userGroup;
@@ -322,13 +329,7 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
List<UserGroupDto> dtos = Database.FetchOneToMany<UserGroupDto>(x => x.UserGroup2AppDtos, sql);
IDictionary<int, List<UserGroup2LanguageDto>> dic = GetAllUserGroupLanguageGrouped();
foreach (UserGroupDto dto in dtos)
{
dic.TryGetValue(dto.Id, out var userGroup2LanguageDtos);
dto.UserGroup2LanguageDtos = userGroup2LanguageDtos ?? new();
}
AssignUserGroupOneToManyTables(ref dtos);
return dtos.Select(x => UserGroupFactory.BuildEntity(_shortStringHelper, x));
}
@@ -342,10 +343,28 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
AppendGroupBy(sql);
sql.OrderBy<UserGroupDto>(x => x.Id); // required for references
List<UserGroupDto>? dtos = Database.FetchOneToMany<UserGroupDto>(x => x.UserGroup2AppDtos, sql);
List<UserGroupDto> dtos = Database.FetchOneToMany<UserGroupDto>(x => x.UserGroup2AppDtos, sql);
AssignUserGroupOneToManyTables(ref dtos);
return dtos.Select(x => UserGroupFactory.BuildEntity(_shortStringHelper, x));
}
private void AssignUserGroupOneToManyTables(ref List<UserGroupDto> userGroupDtos)
{
IDictionary<int, List<UserGroup2LanguageDto>> userGroups2Languages = GetAllUserGroupLanguageGrouped();
IDictionary<int, List<UserGroup2PermissionDto>> userGroups2Permissions = GetAllUserGroupPermissionsGrouped();
foreach (UserGroupDto dto in userGroupDtos)
{
userGroups2Languages.TryGetValue(dto.Id, out List<UserGroup2LanguageDto>? userGroup2LanguageDtos);
dto.UserGroup2LanguageDtos = userGroup2LanguageDtos ?? new List<UserGroup2LanguageDto>();
userGroups2Permissions.TryGetValue(dto.Id, out List<UserGroup2PermissionDto>? userGroup2PermissionDtos);
dto.UserGroup2PermissionDtos = userGroup2PermissionDtos ?? new List<UserGroup2PermissionDto>();
}
}
#endregion
#region Overrides of EntityRepositoryBase<int,IUserGroup>
@@ -417,6 +436,7 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
"DELETE FROM umbracoUserGroup2App WHERE userGroupId = @id",
"DELETE FROM umbracoUserGroup2Node WHERE userGroupId = @id",
"DELETE FROM umbracoUserGroup2NodePermission WHERE userGroupId = @id",
"DELETE FROM umbracoUserGroup2Permission WHERE userGroupId = @id",
"DELETE FROM umbracoUserGroup WHERE id = @id",
};
return list;
@@ -433,6 +453,7 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
PersistAllowedSections(entity);
PersistAllowedLanguages(entity);
PersistPermissions(entity);
entity.ResetDirtyProperties();
}
@@ -446,7 +467,8 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
Database.Update(userGroupDto);
PersistAllowedSections(entity);
PersistAllowedLanguages(entity);
PersistAllowedLanguages(entity);
PersistPermissions(entity);
entity.ResetDirtyProperties();
}
@@ -466,43 +488,74 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
}
}
private void PersistAllowedLanguages(IUserGroup entity)
private void PersistAllowedLanguages(IUserGroup entity)
{
var userGroup = entity;
// First delete all
Database.Delete<UserGroup2LanguageDto>("WHERE UserGroupId = @UserGroupId", new { UserGroupId = userGroup.Id });
// Then re-add any associated with the group
foreach (var language in userGroup.AllowedLanguages)
{
var userGroup = entity;
// First delete all
Database.Delete<UserGroup2LanguageDto>("WHERE UserGroupId = @UserGroupId", new { UserGroupId = userGroup.Id });
// Then re-add any associated with the group
foreach (var language in userGroup.AllowedLanguages)
var dto = new UserGroup2LanguageDto
{
var dto = new UserGroup2LanguageDto
{
UserGroupId = userGroup.Id,
LanguageId = language,
};
UserGroupId = userGroup.Id,
LanguageId = language,
};
Database.Insert(dto);
}
Database.Insert(dto);
}
}
private List<UserGroup2LanguageDto> GetUserGroupLanguages(int userGroupId)
private void PersistPermissions(IUserGroup userGroup)
{
Database.Delete<UserGroup2PermissionDto>("WHERE UserGroupId = @UserGroupId", new { UserGroupId = userGroup.Id });
foreach (var permission in userGroup.PermissionNames)
{
Sql<ISqlContext> query = Sql()
.Select<UserGroup2LanguageDto>()
.From<UserGroup2LanguageDto>()
.Where<UserGroup2LanguageDto>(x => x.UserGroupId == userGroupId);
return Database.Fetch<UserGroup2LanguageDto>(query);
var permissionDto = new UserGroup2PermissionDto { UserGroupId = userGroup.Id, Permission = permission, };
Database.Insert(permissionDto);
}
}
private IDictionary<int, List<UserGroup2LanguageDto>> GetAllUserGroupLanguageGrouped()
{
Sql<ISqlContext> query = Sql()
.Select<UserGroup2LanguageDto>()
.From<UserGroup2LanguageDto>();
List<UserGroup2LanguageDto> userGroupLanguages = Database.Fetch<UserGroup2LanguageDto>(query);
return userGroupLanguages.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList());
}
private List<UserGroup2LanguageDto> GetUserGroupLanguages(int userGroupId)
{
Sql<ISqlContext> query = Sql()
.Select<UserGroup2LanguageDto>()
.From<UserGroup2LanguageDto>()
.Where<UserGroup2LanguageDto>(x => x.UserGroupId == userGroupId);
return Database.Fetch<UserGroup2LanguageDto>(query);
}
private IDictionary<int, List<UserGroup2LanguageDto>> GetAllUserGroupLanguageGrouped()
{
Sql<ISqlContext> query = Sql()
.Select<UserGroup2LanguageDto>()
.From<UserGroup2LanguageDto>();
List<UserGroup2LanguageDto> userGroupLanguages = Database.Fetch<UserGroup2LanguageDto>(query);
return userGroupLanguages.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList());
}
private List<UserGroup2PermissionDto> GetUserGroupPermissions(int userGroupId)
{
Sql<ISqlContext> query = Sql()
.Select<UserGroup2PermissionDto>()
.From<UserGroup2PermissionDto>()
.Where<UserGroup2PermissionDto>(x => x.UserGroupId == userGroupId);
return Database.Fetch<UserGroup2PermissionDto>(query);
}
private Dictionary<int, List<UserGroup2PermissionDto>> GetAllUserGroupPermissionsGrouped()
{
Sql<ISqlContext> query = Sql()
.Select<UserGroup2PermissionDto>()
.From<UserGroup2PermissionDto>();
List<UserGroup2PermissionDto> userGroupPermissions = Database.Fetch<UserGroup2PermissionDto>(query);
return userGroupPermissions.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList());
}
#endregion
}

View File

@@ -28,6 +28,7 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
private readonly IUmbracoMapper _mapper;
private readonly ICoreScopeProvider _scopeProvider;
private readonly ITwoFactorLoginService _twoFactorLoginService;
private readonly IUserGroupService _userGroupService;
private readonly IUserService _userService;
/// <summary>
@@ -43,7 +44,8 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
IUmbracoMapper mapper,
BackOfficeErrorDescriber describer,
AppCaches appCaches,
ITwoFactorLoginService twoFactorLoginService)
ITwoFactorLoginService twoFactorLoginService,
IUserGroupService userGroupService)
: base(describer)
{
_scopeProvider = scopeProvider;
@@ -54,30 +56,33 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
_mapper = mapper;
_appCaches = appCaches;
_twoFactorLoginService = twoFactorLoginService;
_userGroupService = userGroupService;
_userService = userService;
_externalLoginService = externalLoginService;
}
[Obsolete("Use non obsolete ctor")]
[Obsolete("Use constructor that takes IUserGroupService, scheduled for removal in V15.")]
public BackOfficeUserStore(
ICoreScopeProvider scopeProvider,
IUserService userService,
IEntityService entityService,
IExternalLoginWithKeyService externalLoginService,
IOptions<GlobalSettings> globalSettings,
IOptionsSnapshot<GlobalSettings> globalSettings,
IUmbracoMapper mapper,
BackOfficeErrorDescriber describer,
AppCaches appCaches)
AppCaches appCaches,
ITwoFactorLoginService twoFactorLoginService)
: this(
scopeProvider,
userService,
entityService,
externalLoginService,
StaticServiceProvider.Instance.GetRequiredService<IOptionsSnapshot<GlobalSettings>>(),
globalSettings,
mapper,
describer,
appCaches,
StaticServiceProvider.Instance.GetRequiredService<ITwoFactorLoginService>())
twoFactorLoginService,
StaticServiceProvider.Instance.GetRequiredService<IUserGroupService>())
{
}
@@ -390,7 +395,7 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
throw new ArgumentNullException(nameof(normalizedRoleName));
}
IUserGroup? userGroup = _userService.GetUserGroupByAlias(normalizedRoleName);
IUserGroup? userGroup = _userGroupService.GetAsync(normalizedRoleName).GetAwaiter().GetResult();
IEnumerable<IUser> users = _userService.GetAllInGroup(userGroup?.Id);
IList<BackOfficeIdentityUser> backOfficeIdentityUsers =
@@ -495,7 +500,7 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
string normalizedRoleName,
CancellationToken cancellationToken)
{
IUserGroup? group = _userService.GetUserGroupByAlias(normalizedRoleName);
IUserGroup? group = _userGroupService.GetAsync(normalizedRoleName).GetAwaiter().GetResult();
if (group?.Name is null)
{
return Task.FromResult<IdentityRole<string>?>(null);
@@ -670,7 +675,7 @@ public class BackOfficeUserStore : UmbracoUserStore<BackOfficeIdentityUser, Iden
user.ClearGroups();
// go lookup all these groups
IReadOnlyUserGroup[] groups = _userService.GetUserGroupsByAlias(identityUserRoles)
IReadOnlyUserGroup[] groups = _userGroupService.GetAsync(identityUserRoles).GetAwaiter().GetResult()
.Select(x => x.ToReadOnlyGroup()).ToArray();
// use all of the ones assigned and add them

View File

@@ -31,12 +31,36 @@ public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, I
{
JsonTypeInfo result = base.GetTypeInfo(type, options);
if (!type.IsInterface)
if (type.IsInterface)
{
return GetTypeInfoForInterface(result, type, options);
}
else
{
return GetTypeInfoForClass(result, type, options);
}
}
private JsonTypeInfo GetTypeInfoForClass(JsonTypeInfo result, Type type, JsonSerializerOptions options)
{
if (result.Kind != JsonTypeInfoKind.Object || !type.GetInterfaces().Any())
{
return result;
}
Type[] subTypes = FindSubTypes(type).ToArray();
JsonPolymorphismOptions jsonPolymorphismOptions = result.PolymorphismOptions ?? new JsonPolymorphismOptions();
jsonPolymorphismOptions.DerivedTypes.Add(new JsonDerivedType(type, type.Name));
result.PolymorphismOptions = jsonPolymorphismOptions;
return result;
}
private JsonTypeInfo GetTypeInfoForInterface(JsonTypeInfo result, Type type, JsonSerializerOptions options)
{
IEnumerable<Type> subTypes = FindSubTypes(type);
if (!subTypes.Any())
{
@@ -56,6 +80,7 @@ public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, I
result.PolymorphismOptions = jsonPolymorphismOptions;
return result;
}
}

View File

@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Telemetry.Interfaces;
@@ -7,14 +9,26 @@ namespace Umbraco.Cms.Infrastructure.Telemetry.Providers;
public class UserTelemetryProvider : IDetailedTelemetryProvider
{
private readonly IUserGroupService _userGroupService;
private readonly IUserService _userService;
public UserTelemetryProvider(IUserService userService) => _userService = userService;
[Obsolete("Use constructor that takes IUserGroupService, scheduled for removal in V15")]
public UserTelemetryProvider(IUserService userService)
: this(userService, StaticServiceProvider.Instance.GetRequiredService<IUserGroupService>())
{
}
[Obsolete("Use constructor that only takes IUserGroupService, scheduled for removal in V15")]
public UserTelemetryProvider(IUserService userService, IUserGroupService userGroupService)
{
_userService = userService;
_userGroupService = userGroupService;
}
public IEnumerable<UsageInformation> GetInformation()
{
_userService.GetAll(1, 1, out var total);
var userGroups = _userService.GetAllUserGroups().Count();
var userGroups = _userGroupService.GetAllAsync(0, int.MaxValue).GetAwaiter().GetResult().Items.Count();
yield return new UsageInformation(Constants.Telemetry.UserCount, total);
yield return new UsageInformation(Constants.Telemetry.UserGroupCount, userGroups);

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Umbraco.Cms.Web.BackOffice.Controllers.ContentController.#ctor(Umbraco.Cms.Core.Dictionary.ICultureDictionary,Microsoft.Extensions.Logging.ILoggerFactory,Umbraco.Cms.Core.Strings.IShortStringHelper,Umbraco.Cms.Core.Events.IEventMessagesFactory,Umbraco.Cms.Core.Services.ILocalizedTextService,Umbraco.Cms.Core.PropertyEditors.PropertyEditorCollection,Umbraco.Cms.Core.Services.IContentService,Umbraco.Cms.Core.Services.IUserService,Umbraco.Cms.Core.Security.IBackOfficeSecurityAccessor,Umbraco.Cms.Core.Services.IContentTypeService,Umbraco.Cms.Core.Mapping.IUmbracoMapper,Umbraco.Cms.Core.Routing.IPublishedUrlProvider,Umbraco.Cms.Core.Services.IDomainService,Umbraco.Cms.Core.Services.IDataTypeService,Umbraco.Cms.Core.Services.ILocalizationService,Umbraco.Cms.Core.Services.IFileService,Umbraco.Cms.Core.Services.INotificationService,Umbraco.Cms.Core.Actions.ActionCollection,Umbraco.Cms.Infrastructure.Persistence.ISqlContext,Umbraco.Cms.Core.Serialization.IJsonSerializer,Umbraco.Cms.Core.Scoping.ICoreScopeProvider,Microsoft.AspNetCore.Authorization.IAuthorizationService,Umbraco.Cms.Core.Services.IContentVersionService)</Target>
<Left>lib/net7.0/Umbraco.Web.BackOffice.dll</Left>
<Right>lib/net7.0/Umbraco.Web.BackOffice.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>

View File

@@ -58,6 +58,7 @@ public class ContentController : ContentControllerBase
private readonly ILocalizedTextService _localizedTextService;
private readonly INotificationService _notificationService;
private readonly ICultureImpactFactory _cultureImpactFactory;
private readonly IUserGroupService _userGroupService;
private readonly ILogger<ContentController> _logger;
private readonly PropertyEditorCollection _propertyEditors;
private readonly IPublishedUrlProvider _publishedUrlProvider;
@@ -91,7 +92,8 @@ public class ContentController : ContentControllerBase
ICoreScopeProvider scopeProvider,
IAuthorizationService authorizationService,
IContentVersionService contentVersionService,
ICultureImpactFactory cultureImpactFactory)
ICultureImpactFactory cultureImpactFactory,
IUserGroupService userGroupService)
: base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, serializer)
{
_propertyEditors = propertyEditors;
@@ -112,13 +114,14 @@ public class ContentController : ContentControllerBase
_authorizationService = authorizationService;
_contentVersionService = contentVersionService;
_cultureImpactFactory = cultureImpactFactory;
_userGroupService = userGroupService;
_logger = loggerFactory.CreateLogger<ContentController>();
_scopeProvider = scopeProvider;
_allLangs = new Lazy<IDictionary<string, ILanguage>>(() =>
_localizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase));
_localizationService.GetAllLanguages().ToDictionary(x => x.IsoCode, x => x, StringComparer.InvariantCultureIgnoreCase));
}
[Obsolete("Use constructor that accepts ICultureImpactService as a parameter, scheduled for removal in V12")]
[Obsolete("User constructor that takes a IUserGroupService, scheduled for removal in V15.")]
public ContentController(
ICultureDictionary cultureDictionary,
ILoggerFactory loggerFactory,
@@ -142,7 +145,8 @@ public class ContentController : ContentControllerBase
IJsonSerializer serializer,
ICoreScopeProvider scopeProvider,
IAuthorizationService authorizationService,
IContentVersionService contentVersionService)
IContentVersionService contentVersionService,
ICultureImpactFactory cultureImpactFactory)
: this(
cultureDictionary,
loggerFactory,
@@ -167,9 +171,11 @@ public class ContentController : ContentControllerBase
scopeProvider,
authorizationService,
contentVersionService,
StaticServiceProvider.Instance.GetRequiredService<ICultureImpactFactory>())
{
}
cultureImpactFactory,
StaticServiceProvider.Instance.GetRequiredService<IUserGroupService>()
)
{
}
public object? Domains { get; private set; }
@@ -224,7 +230,7 @@ public class ContentController : ContentControllerBase
var contentPermissions = _contentService.GetPermissions(content)
.ToDictionary(x => x.UserGroupId, x => x);
IUserGroup[] allUserGroups = _userService.GetAllUserGroups().ToArray();
IUserGroup[] allUserGroups = _userGroupService.GetAllAsync(0, int.MaxValue).GetAwaiter().GetResult().Items.ToArray();
//loop through each user group
foreach (IUserGroup userGroup in allUserGroups)
@@ -277,7 +283,7 @@ public class ContentController : ContentControllerBase
// TODO: Should non-admins be able to see detailed permissions?
IEnumerable<IUserGroup> allUserGroups = _userService.GetAllUserGroups();
IEnumerable<IUserGroup> allUserGroups = _userGroupService.GetAllAsync(0, int.MaxValue).GetAwaiter().GetResult().Items;
return GetDetailedPermissions(content, allUserGroups);
}

View File

@@ -7,6 +7,7 @@ using Umbraco.Extensions;
namespace Umbraco.Cms.Web.BackOffice.Controllers;
[Obsolete("Use IUserGroupAuthorizationService instead, should be removed alongside UserGroup controller in V13.")]
internal class UserGroupEditorAuthorizationHelper
{
private readonly AppCaches _appCaches;

View File

@@ -18,6 +18,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers;
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
[Authorize(Policy = AuthorizationPolicies.SectionAccessUsers)]
[PrefixlessBodyModelValidator]
[Obsolete("Use the new user group controllers instead.")]
public class UserGroupsController : BackOfficeNotificationsController
{
private readonly AppCaches _appCaches;

View File

@@ -20,13 +20,14 @@ internal sealed class UserGroupValidateAttribute : TypeFilterAttribute
private class UserGroupValidateFilter : IActionFilter
{
private readonly IShortStringHelper _shortStringHelper;
private readonly IUserService _userService;
private readonly IUserGroupService _userGroupService;
public UserGroupValidateFilter(
IUserService userService,
IUserGroupService userGroupService,
IShortStringHelper shortStringHelper)
{
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
ArgumentNullException.ThrowIfNull(userGroupService);
_userGroupService = userGroupService;
_shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper));
}
@@ -45,7 +46,7 @@ internal sealed class UserGroupValidateAttribute : TypeFilterAttribute
switch (userGroupSave?.Action)
{
case ContentSaveAction.Save:
persisted = _userService.GetUserGroupById(Convert.ToInt32(userGroupSave.Id));
persisted = _userGroupService.GetAsync(Convert.ToInt32(userGroupSave.Id)).GetAwaiter().GetResult();
if (persisted == null)
{
var message = $"User group with id: {userGroupSave.Id} was not found";
@@ -73,7 +74,7 @@ internal sealed class UserGroupValidateAttribute : TypeFilterAttribute
//now assign the persisted entity to the model so we can use it in the action
userGroupSave.PersistedUserGroup = persisted;
IUserGroup? existing = _userService.GetUserGroupByAlias(userGroupSave.Alias);
IUserGroup? existing = _userGroupService.GetAsync(userGroupSave.Alias).GetAwaiter().GetResult();
if (existing != null && existing.Id != userGroupSave.PersistedUserGroup.Id)
{
context.ModelState.AddModelError("Alias", "A user group with this alias already exists");

View File

@@ -0,0 +1,151 @@
using NUnit.Framework;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Strings;
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)]
public class UserGroupServiceValidationTests : UmbracoIntegrationTest
{
private IUserGroupService UserGroupService => GetRequiredService<IUserGroupService>();
private IShortStringHelper ShortStringHelper => GetRequiredService<IShortStringHelper>();
[Test]
public async Task Cannot_create_user_group_with_name_equals_null()
{
var userGroup = new UserGroup(ShortStringHelper)
{
Name = null
};
var result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId);
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.MissingName, result.Status);
}
[Test]
public async Task Cannot_create_user_group_with_name_longer_than_max_length()
{
var userGroup = new UserGroup(ShortStringHelper)
{
Name = "Sed porttitor lectus nibh. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Vivamus suscipit tortor eget felis porttitor volutpat. Quisque velit nisi, pretium ut lacinia in, elementum id enim."
};
var result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId);
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.NameTooLong, result.Status);
}
[Test]
public async Task Cannot_create_user_group_with_alias_longer_than_max_length()
{
var userGroup = new UserGroup(ShortStringHelper)
{
Name = "Some Name",
Alias = "Sed porttitor lectus nibh. Vivamus magna justo, lacinia eget consectetur sed, convallis at tellus. Vivamus suscipit tortor eget felis porttitor volutpat. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Vivamus suscipit tortor eget felis porttitor volutpat. Quisque velit nisi, pretium ut lacinia in, elementum id enim."
};
var result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId);
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.AliasTooLong, result.Status);
}
[Test]
public async Task Cannot_update_non_existing_user_group()
{
var userGroup = new UserGroup(ShortStringHelper)
{
Name = "Some Name",
Alias = "someAlias"
};
var result = await UserGroupService.UpdateAsync(userGroup, Constants.Security.SuperUserId);
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.NotFound, result.Status);
}
[Test]
public async Task Cannot_create_existing_user_group()
{
var userGroup = new UserGroup(ShortStringHelper)
{
Name = "Some Name",
Alias = "someAlias"
};
var result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId);
Assert.IsTrue(result.Success);
result = await UserGroupService.CreateAsync(userGroup, Constants.Security.SuperUserId);
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.AlreadyExists, result.Status);
}
[Test]
public async Task Cannot_create_user_group_with_duplicate_alias()
{
var alias = "duplicateAlias";
var existingUserGroup = new UserGroup(ShortStringHelper)
{
Name = "I already exist",
Alias = alias
};
var setupResult = await UserGroupService.CreateAsync(existingUserGroup, Constants.Security.SuperUserId);
Assert.IsTrue(setupResult.Success);
var newUserGroup = new UserGroup(ShortStringHelper)
{
Name = "I have a duplicate alias",
Alias = alias,
};
var result = await UserGroupService.CreateAsync(newUserGroup, Constants.Security.SuperUserId);
Assert.IsFalse(result.Success);
Assert.AreEqual(UserGroupOperationStatus.DuplicateAlias, result.Status);
}
[Test]
public async Task Cannot_update_user_group_with_duplicate_alias()
{
var alias = "duplicateAlias";
var existingUserGroup = new UserGroup(ShortStringHelper)
{
Name = "I already exist",
Alias = alias
};
var setupResult = await UserGroupService.CreateAsync(existingUserGroup, Constants.Security.SuperUserId);
Assert.IsTrue(setupResult.Success);
IUserGroup userGroupToUpdate = new UserGroup(ShortStringHelper)
{
Name = "I don't have a duplicate alias",
Alias = "somAlias",
};
var creationResult = await UserGroupService.CreateAsync(userGroupToUpdate, Constants.Security.SuperUserId);
Assert.IsTrue(creationResult.Success);
userGroupToUpdate = creationResult.Result;
userGroupToUpdate.Name = "Now I have a duplicate alias";
userGroupToUpdate.Alias = alias;
var updateResult = await UserGroupService.UpdateAsync(userGroupToUpdate, Constants.Security.SuperUserId);
Assert.IsFalse(updateResult.Success);
Assert.AreEqual(UserGroupOperationStatus.DuplicateAlias, updateResult.Status);
}
}

View File

@@ -266,7 +266,8 @@ public class ContentControllerTests
Mock.Of<ICoreScopeProvider>(),
Mock.Of<IAuthorizationService>(),
Mock.Of<IContentVersionService>(),
Mock.Of<ICultureImpactFactory>());
Mock.Of<ICultureImpactFactory>(),
Mock.Of<IUserGroupService>());
return controller;
}