From 5c276c2d108448076d33b7231508d920f319ec09 Mon Sep 17 00:00:00 2001 From: Andreas Zerbst Date: Thu, 16 Feb 2023 09:18:14 +0100 Subject: [PATCH 1/8] Removed path so we can generate templates --- build/azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index ff52d3c2aa..56e6dccc1b 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -110,7 +110,7 @@ stages: } } - foreach($csproj in Get-ChildItem –Path "src/" -Recurse -Filter *.csproj) + foreach($csproj in Get-ChildItem -Recurse -Filter *.csproj) { dotnet pack $csproj --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory)/nupkg } From 5182f46bdb926ece52ab48cca5aa3f8b22d988a4 Mon Sep 17 00:00:00 2001 From: Mole Date: Thu, 16 Feb 2023 09:39:17 +0100 Subject: [PATCH 2/8] 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 --- .../Builders/ProblemDetailsBuilder.cs | 8 - .../UserGroups/ByKeyUserGroupController.cs | 39 + .../UserGroups/CreateUserGroupController.cs | 55 ++ .../UserGroups/DeleteUserGroupController.cs | 30 + .../UserGroups/GetAllUserGroupController.cs | 40 + .../UserGroups/UpdateUserGroupController.cs | 51 ++ .../UserGroups/UserGroupsControllerBase.cs | 83 ++ .../UserGroupsBuilderExtensions.cs | 14 + .../Factories/IUserGroupViewModelFactory.cs | 41 + .../Factories/UserGroupViewModelFactory.cs | 226 +++++ .../ManagementApiComposer.cs | 1 + .../Mapping/SectionMapper.cs | 54 ++ src/Umbraco.Cms.Api.Management/OpenApi.json | 818 ++++++++++++++---- .../ViewModels/DataType/DataTypeViewModel.cs | 2 +- .../Dictionary/DictionaryItemViewModel.cs | 2 +- .../ViewModels/Folder/FolderViewModel.cs | 2 +- .../ViewModels/INamedEntityViewModel.cs | 8 + .../RecycleBin/RecycleBinItemViewModel.cs | 2 +- .../ViewModels/Template/TemplateViewModel.cs | 2 +- .../Tree/EntityTreeItemViewModel.cs | 2 +- .../ViewModels/UserGroups/UserGroupBase.cs | 58 ++ .../UserGroups/UserGroupSaveModel.cs | 6 + .../UserGroups/UserGroupUpdateModel.cs | 6 + .../UserGroups/UserGroupViewModel.cs | 10 + .../CompatibilitySuppressions.xml | 14 + .../DependencyInjection/UmbracoBuilder.cs | 2 + .../Handlers/AuditNotificationsHandler.cs | 31 +- .../Models/ContentEditing/UserGroupSave.cs | 10 + .../Models/Mapping/UserMapDefinition.cs | 41 +- .../Models/Membership/IUserGroup.cs | 17 +- .../Models/Membership/UserGroup.cs | 9 + .../Persistence/Constants-DatabaseSchema.cs | 1 + .../Repositories/IEntityRepository.cs | 16 + src/Umbraco.Core/Services/EntityService.cs | 18 + src/Umbraco.Core/Services/IEntityService.cs | 16 + .../IUserGroupAuthorizationService.cs | 24 + .../Services/IUserGroupService.cs | 86 ++ src/Umbraco.Core/Services/IUserService.cs | 6 + .../UserGroupOperationStatus.cs | 22 + .../Services/UserGroupAuthorizationService.cs | 177 ++++ src/Umbraco.Core/Services/UserGroupService.cs | 376 ++++++++ src/Umbraco.Core/Services/UserService.cs | 15 +- .../CompatibilitySuppressions.xml | 7 + .../Install/DatabaseSchemaCreator.cs | 1 + .../Migrations/MigrationBase.cs | 1 + .../Migrations/UnscopedMigrationBase.cs | 2 +- .../Migrations/Upgrade/UmbracoPlan.cs | 1 + .../Upgrade/V_13_0_0/AddGuidsToUserGroups.cs | 2 +- .../V_13_0_0/AddUserGroupPermissionTable.cs | 21 + .../Dtos/UserGroup2PermissionDto.cs | 22 + .../Persistence/Dtos/UserGroupDto.cs | 5 + .../Persistence/Factories/UserGroupFactory.cs | 3 +- .../Implement/EntityRepository.cs | 21 + .../Implement/UserGroupRepository.cs | 131 ++- .../Security/BackOfficeUserStore.cs | 23 +- .../UmbracoJsonTypeInfoResolver.cs | 29 +- .../Providers/UserTelemetryProvider.cs | 18 +- .../CompatibilitySuppressions.xml | 10 + .../Controllers/ContentController.cs | 24 +- .../UserGroupEditorAuthorizationHelper.cs | 1 + .../Controllers/UserGroupsController.cs | 1 + .../Filters/UserGroupValidateAttribute.cs | 11 +- .../UserGroupServiceValidationTests.cs | 151 ++++ .../Controllers/ContentControllerTests.cs | 3 +- 64 files changed, 2637 insertions(+), 292 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/ByKeyUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/CreateUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/DeleteUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/GetAllUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UpdateUserGroupController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UserGroupsControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/IUserGroupViewModelFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/Factories/UserGroupViewModelFactory.cs create mode 100644 src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/INamedEntityViewModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupSaveModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupUpdateModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupViewModel.cs create mode 100644 src/Umbraco.Core/Services/IUserGroupAuthorizationService.cs create mode 100644 src/Umbraco.Core/Services/IUserGroupService.cs create mode 100644 src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs create mode 100644 src/Umbraco.Core/Services/UserGroupAuthorizationService.cs create mode 100644 src/Umbraco.Core/Services/UserGroupService.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddUserGroupPermissionTable.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Dtos/UserGroup2PermissionDto.cs create mode 100644 src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserGroupServiceValidationTests.cs diff --git a/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs b/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs index bc49851911..d3897d5377 100644 --- a/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs +++ b/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs @@ -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", }; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/ByKeyUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/ByKeyUserGroupController.cs new file mode 100644 index 0000000000..c335926e74 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/ByKeyUserGroupController.cs @@ -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> ByKey(Guid key) + { + IUserGroup? userGroup = await _userGroupService.GetAsync(key); + + if (userGroup is null) + { + return NotFound(); + } + + return await _userGroupViewModelFactory.CreateAsync(userGroup); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/CreateUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/CreateUserGroupController.cs new file mode 100644 index 0000000000..755cb1dd4b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/CreateUserGroupController.cs @@ -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 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 userGroupCreationAttempt = await _userGroupViewModelFactory.CreateAsync(userGroupSaveModel); + if (userGroupCreationAttempt.Success is false) + { + return UserGroupOperationStatusResult(userGroupCreationAttempt.Status); + } + + IUserGroup group = userGroupCreationAttempt.Result; + + Attempt result = await _userGroupService.CreateAsync(group, /*currentUser.Id*/ -1); + return result.Success + ? CreatedAtAction(controller => nameof(controller.ByKey), group.Key) + : UserGroupOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/DeleteUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/DeleteUserGroupController.cs new file mode 100644 index 0000000000..ad26e28cc2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/DeleteUserGroupController.cs @@ -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 Delete(Guid key) + { + Attempt result = await _userGroupService.DeleteAsync(key); + + return result.Success + ? Ok() + : UserGroupOperationStatusResult(result.Result); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/GetAllUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/GetAllUserGroupController.cs new file mode 100644 index 0000000000..86b61019fc --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/GetAllUserGroupController.cs @@ -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), StatusCodes.Status200OK)] + public async Task>> 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 userGroups = await _userGroupService.GetAllAsync(skip, take); + + var viewModels = (await _userViewModelFactory.CreateMultipleAsync(userGroups.Items)).ToList(); + return new PagedViewModel { Total = userGroups.Total, Items = viewModels }; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UpdateUserGroupController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UpdateUserGroupController.cs new file mode 100644 index 0000000000..dc3f5bac70 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UpdateUserGroupController.cs @@ -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 Update(Guid key, UserGroupUpdateModel dataTypeUpdateModel) + { + IUserGroup? existingUserGroup = await _userGroupService.GetAsync(key); + + if (existingUserGroup is null) + { + return UserGroupOperationStatusResult(UserGroupOperationStatus.NotFound); + } + + Attempt userGroupUpdateAttempt = await _userGroupViewModelFactory.UpdateAsync(existingUserGroup, dataTypeUpdateModel); + if (userGroupUpdateAttempt.Success is false) + { + return UserGroupOperationStatusResult(userGroupUpdateAttempt.Status); + } + + IUserGroup userGroup = userGroupUpdateAttempt.Result; + Attempt result = await _userGroupService.UpdateAsync(userGroup, -1); + + return result.Success + ? Ok() + : UserGroupOperationStatusResult(result.Status); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UserGroupsControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UserGroupsControllerBase.cs new file mode 100644 index 0000000000..a24edfd973 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserGroups/UserGroupsControllerBase.cs @@ -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."), + }; +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs new file mode 100644 index 0000000000..0cb4cf7595 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UserGroupsBuilderExtensions.cs @@ -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(); + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/IUserGroupViewModelFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IUserGroupViewModelFactory.cs new file mode 100644 index 0000000000..b79fd7b9b3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/IUserGroupViewModelFactory.cs @@ -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; + +/// +/// A factory for creating +/// +public interface IUserGroupViewModelFactory +{ + /// + /// Creates a based on a + /// + /// + /// + Task CreateAsync(IUserGroup userGroup); + + /// + /// Creates multiple base on multiple + /// + /// + /// + Task> CreateMultipleAsync(IEnumerable userGroups); + + /// + /// Creates an based on a + /// + /// + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> CreateAsync(UserGroupSaveModel saveModel); + + /// + /// Converts the values of an update model to fit with the existing backoffice implementations, and maps it to an existing user group. + /// + /// Existing user group to map to. + /// Update model containing the new values. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> UpdateAsync(IUserGroup current, UserGroupUpdateModel update); +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/UserGroupViewModelFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/UserGroupViewModelFactory.cs new file mode 100644 index 0000000000..e732497f3a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Factories/UserGroupViewModelFactory.cs @@ -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; + +/// +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; + } + + /// + public async Task CreateAsync(IUserGroup userGroup) + { + Guid? contentStartNodeKey = GetKeyFromId(userGroup.StartContentId, UmbracoObjectTypes.Document); + Guid? mediaStartNodeKey = GetKeyFromId(userGroup.StartMediaId, UmbracoObjectTypes.Media); + Attempt, 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), + }; + } + + /// + public async Task> CreateMultipleAsync(IEnumerable userGroups) + { + var userGroupViewModels = new List(); + foreach (IUserGroup userGroup in userGroups) + { + userGroupViewModels.Add(await CreateAsync(userGroup)); + } + + return userGroupViewModels; + } + + /// + public async Task> 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 assignmentAttempt = AssignStartNodesToUserGroup(saveModel, group); + if (assignmentAttempt.Success is false) + { + return Attempt.FailWithStatus(assignmentAttempt.Result, group); + } + + foreach (var section in saveModel.Sections) + { + group.AddAllowedSection(SectionMapper.GetAlias(section)); + } + + Attempt, UserGroupOperationStatus> languageIsoCodeMappingAttempt = await MapLanguageIsoCodesToIdsAsync(saveModel.Languages); + if (languageIsoCodeMappingAttempt.Success is false) + { + return Attempt.FailWithStatus(languageIsoCodeMappingAttempt.Status, group); + } + + foreach (var languageId in languageIsoCodeMappingAttempt.Result) + { + group.AddAllowedLanguage(languageId); + } + + return Attempt.SucceedWithStatus(UserGroupOperationStatus.Success, group); + } + + /// + public async Task> UpdateAsync(IUserGroup current, UserGroupUpdateModel update) + { + Attempt assignmentAttempt = AssignStartNodesToUserGroup(update, current); + if (assignmentAttempt.Success is false) + { + return Attempt.FailWithStatus(assignmentAttempt.Result, current); + } + + current.ClearAllowedLanguages(); + Attempt, 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, UserGroupOperationStatus>> MapLanguageIdsToIsoCodeAsync(IEnumerable ids) + { + IEnumerable 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, UserGroupOperationStatus>(UserGroupOperationStatus.Success, isoCodes) + : Attempt.FailWithStatus, UserGroupOperationStatus>(UserGroupOperationStatus.LanguageNotFound, isoCodes); + } + + private async Task, UserGroupOperationStatus>> MapLanguageIsoCodesToIdsAsync(IEnumerable isoCodes) + { + IEnumerable 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, UserGroupOperationStatus>(UserGroupOperationStatus.Success, languageIds) + : Attempt.FailWithStatus, UserGroupOperationStatus>(UserGroupOperationStatus.LanguageNotFound, languageIds); + } + + private Attempt 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 attempt = _entityService.GetKey(id.Value, objectType); + if (attempt.Success is false) + { + return null; + } + + return attempt.Result; + } + + private int? GetIdFromKey(Guid key, UmbracoObjectTypes objectType) + { + Attempt attempt = _entityService.GetId(key, objectType); + + if (attempt.Success is false) + { + return null; + } + + return attempt.Result; + } +} diff --git a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs index 16eeef87b3..9b539cc742 100644 --- a/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.Api.Management/ManagementApiComposer.cs @@ -35,6 +35,7 @@ public class ManagementApiComposer : IComposer .AddDataTypes() .AddTemplates() .AddLogViewer() + .AddUserGroups() .AddBackOfficeAuthentication() .AddApiVersioning() .AddSwaggerGen(); diff --git a/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs b/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs new file mode 100644 index 0000000000..41db2123e3 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Mapping/SectionMapper.cs @@ -0,0 +1,54 @@ +namespace Umbraco.Cms.Api.Management.Mapping; + +/// +/// 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 +/// +public static class SectionMapper +{ + private static readonly List _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; } + } +} diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 328914ed1f..4844299fa3 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -702,9 +702,6 @@ } } }, - "404": { - "description": "Not Found" - }, "400": { "description": "Bad Request", "content": { @@ -715,6 +712,9 @@ } } }, + "404": { + "description": "Not Found" + }, "409": { "description": "Conflict", "content": { @@ -998,7 +998,9 @@ ], "operationId": "PostDictionaryUpload", "requestBody": { - "content": { } + "content": { + + } }, "responses": { "200": { @@ -1418,9 +1420,6 @@ } ], "responses": { - "401": { - "description": "Unauthorized" - }, "200": { "description": "Success", "content": { @@ -1430,6 +1429,9 @@ } } } + }, + "401": { + "description": "Unauthorized" } } } @@ -1461,9 +1463,6 @@ } ], "responses": { - "401": { - "description": "Unauthorized" - }, "200": { "description": "Success", "content": { @@ -1473,6 +1472,9 @@ } } } + }, + "401": { + "description": "Unauthorized" } } } @@ -1707,9 +1709,6 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success", "content": { @@ -1723,6 +1722,9 @@ } } } + }, + "404": { + "description": "Not Found" } } } @@ -1744,9 +1746,6 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success", "content": { @@ -1760,6 +1759,9 @@ } } } + }, + "404": { + "description": "Not Found" } } } @@ -1784,16 +1786,6 @@ } }, "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -1807,6 +1799,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -1858,16 +1860,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -1877,6 +1869,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -1936,16 +1938,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -1959,6 +1951,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -1980,16 +1982,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -1999,6 +1991,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -2010,6 +2012,20 @@ ], "operationId": "GetInstallSettings", "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/InstallSettingsModel" + } + ] + } + } + } + }, "400": { "description": "Bad Request", "content": { @@ -2029,20 +2045,6 @@ } } } - }, - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/InstallSettingsModel" - } - ] - } - } - } } } } @@ -2067,6 +2069,9 @@ } }, "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2086,9 +2091,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -2113,6 +2115,9 @@ } }, "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2122,9 +2127,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -2187,19 +2189,6 @@ } }, "responses": { - "404": { - "description": "Not Found" - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "201": { "description": "Created", "headers": { @@ -2212,6 +2201,19 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + }, + "404": { + "description": "Not Found" } } } @@ -2233,9 +2235,6 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success", "content": { @@ -2249,6 +2248,9 @@ } } } + }, + "404": { + "description": "Not Found" } } }, @@ -2268,6 +2270,9 @@ } ], "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2287,9 +2292,6 @@ } } } - }, - "200": { - "description": "Success" } } }, @@ -2322,8 +2324,8 @@ } }, "responses": { - "404": { - "description": "Not Found" + "200": { + "description": "Success" }, "400": { "description": "Bad Request", @@ -2335,8 +2337,8 @@ } } }, - "200": { - "description": "Success" + "404": { + "description": "Not Found" } } } @@ -2406,6 +2408,9 @@ } ], "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2415,9 +2420,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -2545,16 +2547,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -2564,6 +2556,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -2626,16 +2628,6 @@ } }, "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "201": { "description": "Created", "headers": { @@ -2648,6 +2640,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -2669,9 +2671,6 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success", "content": { @@ -2685,6 +2684,9 @@ } } } + }, + "404": { + "description": "Not Found" } } }, @@ -2704,11 +2706,11 @@ } ], "responses": { - "404": { - "description": "Not Found" - }, "200": { "description": "Success" + }, + "404": { + "description": "Not Found" } } } @@ -2738,6 +2740,9 @@ } ], "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -2747,9 +2752,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -2936,9 +2938,6 @@ } ], "responses": { - "401": { - "description": "Unauthorized" - }, "200": { "description": "Success", "content": { @@ -2948,6 +2947,9 @@ } } } + }, + "401": { + "description": "Unauthorized" } } } @@ -2979,9 +2981,6 @@ } ], "responses": { - "401": { - "description": "Unauthorized" - }, "200": { "description": "Success", "content": { @@ -2991,6 +2990,9 @@ } } } + }, + "401": { + "description": "Unauthorized" } } } @@ -3652,16 +3654,6 @@ } ], "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -3671,6 +3663,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -4222,16 +4224,6 @@ ], "operationId": "GetServerStatus", "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -4245,6 +4237,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -4256,16 +4258,6 @@ ], "operationId": "GetServerVersion", "responses": { - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsModel" - } - } - } - }, "200": { "description": "Success", "content": { @@ -4279,6 +4271,16 @@ } } } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } } } } @@ -4615,6 +4617,9 @@ } }, "responses": { + "200": { + "description": "Success" + }, "400": { "description": "Bad Request", "content": { @@ -4624,9 +4629,6 @@ } } } - }, - "200": { - "description": "Success" } } } @@ -5262,6 +5264,191 @@ } } } + }, + "/umbraco/management/api/v1/user-groups": { + "post": { + "tags": [ + "User Groups" + ], + "operationId": "PostUserGroups", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupSaveModel" + } + ] + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "description": "Location of the newly created resource", + "schema": { + "type": "string", + "description": "Location of the newly created resource", + "format": "uri" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsModel" + } + } + } + } + } + }, + "get": { + "tags": [ + "User Groups" + ], + "operationId": "GetUserGroups", + "parameters": [ + { + "name": "skip", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } + }, + { + "name": "take", + "in": "query", + "schema": { + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PagedUserGroupModel" + } + } + } + } + } + } + }, + "/umbraco/management/api/v1/user-groups/{key}": { + "get": { + "tags": [ + "User Groups" + ], + "operationId": "GetUserGroupsByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupModel" + } + ] + } + } + } + }, + "404": { + "description": "Not Found" + } + } + }, + "delete": { + "tags": [ + "User Groups" + ], + "operationId": "DeleteUserGroupsByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found" + } + } + }, + "put": { + "tags": [ + "User Groups" + ], + "operationId": "PutUserGroupsByKey", + "parameters": [ + { + "name": "key", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupUpdateModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + }, + "404": { + "description": "Not Found" + } + } + } } }, "components": { @@ -5279,6 +5466,9 @@ "additionalProperties": false }, "ContentTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5286,6 +5476,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "noAccess": { "type": "boolean" }, @@ -5293,7 +5486,14 @@ "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "ContentTreeItemViewModel": "#/components/schemas/ContentTreeItemModel", + "DocumentTreeItemViewModel": "#/components/schemas/DocumentTreeItemModel" + } + } }, "CultureModel": { "type": "object", @@ -5335,6 +5535,9 @@ "additionalProperties": false }, "DataTypeModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5342,6 +5545,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" @@ -5352,7 +5558,13 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DataTypeViewModel": "#/components/schemas/DataTypeModel" + } + } }, "DataTypeModelBaseModel": { "type": "object", @@ -5560,6 +5772,9 @@ "additionalProperties": false }, "DictionaryItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5567,12 +5782,21 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DictionaryItemViewModel": "#/components/schemas/DictionaryItemModel" + } + } }, "DictionaryItemModelBaseModel": { "type": "object", @@ -5698,6 +5922,9 @@ "format": "int32" }, "DocumentBlueprintTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5705,6 +5932,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "documentTypeKey": { "type": "string", "format": "uuid" @@ -5717,9 +5947,18 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentBlueprintTreeItemViewModel": "#/components/schemas/DocumentBlueprintTreeItemModel" + } + } }, "DocumentTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5727,6 +5966,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "isProtected": { "type": "boolean" }, @@ -5737,9 +5979,18 @@ "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTreeItemViewModel": "#/components/schemas/DocumentTreeItemModel" + } + } }, "DocumentTypeTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5747,13 +5998,25 @@ } ], "properties": { + "$type": { + "type": "string" + }, "isElement": { "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypeTreeItemViewModel": "#/components/schemas/DocumentTypeTreeItemModel" + } + } }, "EntityTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5761,6 +6024,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" @@ -5774,7 +6040,18 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "EntityTreeItemViewModel": "#/components/schemas/EntityTreeItemModel", + "ContentTreeItemViewModel": "#/components/schemas/ContentTreeItemModel", + "DocumentBlueprintTreeItemViewModel": "#/components/schemas/DocumentBlueprintTreeItemModel", + "DocumentTreeItemViewModel": "#/components/schemas/DocumentTreeItemModel", + "DocumentTypeTreeItemViewModel": "#/components/schemas/DocumentTypeTreeItemModel", + "FolderTreeItemViewModel": "#/components/schemas/FolderTreeItemModel" + } + } }, "FieldModel": { "type": "object", @@ -5825,6 +6102,9 @@ "additionalProperties": false }, "FolderModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5832,6 +6112,9 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" @@ -5842,7 +6125,13 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "FolderViewModel": "#/components/schemas/FolderModel" + } + } }, "FolderModelBaseModel": { "type": "object", @@ -5854,6 +6143,9 @@ "additionalProperties": false }, "FolderTreeItemModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -5861,11 +6153,21 @@ } ], "properties": { + "$type": { + "type": "string" + }, "isFolder": { "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "FolderTreeItemViewModel": "#/components/schemas/FolderTreeItemModel", + "DocumentTypeTreeItemViewModel": "#/components/schemas/DocumentTypeTreeItemModel" + } + } }, "FolderUpdateModel": { "type": "object", @@ -6099,7 +6401,9 @@ }, "providerProperties": { "type": "object", - "additionalProperties": { }, + "additionalProperties": { + + }, "nullable": true } }, @@ -6979,6 +7283,30 @@ }, "additionalProperties": false }, + "PagedUserGroupModel": { + "required": [ + "items", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/UserGroupModel" + } + ] + } + } + }, + "additionalProperties": false + }, "ProblemDetailsModel": { "type": "object", "properties": { @@ -7004,7 +7332,9 @@ "nullable": true } }, - "additionalProperties": { } + "additionalProperties": { + + } }, "ProfilingStatusModel": { "type": "object", @@ -7016,8 +7346,14 @@ "additionalProperties": false }, "RecycleBinItemModel": { + "required": [ + "$type" + ], "type": "object", "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" @@ -7043,7 +7379,13 @@ "nullable": true } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "RecycleBinItemViewModel": "#/components/schemas/RecycleBinItemModel" + } + } }, "RedirectStatusModel": { "enum": [ @@ -7271,6 +7613,9 @@ "additionalProperties": false }, "TemplateModel": { + "required": [ + "$type" + ], "type": "object", "allOf": [ { @@ -7278,12 +7623,21 @@ } ], "properties": { + "$type": { + "type": "string" + }, "key": { "type": "string", "format": "uuid" } }, - "additionalProperties": false + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "TemplateViewModel": "#/components/schemas/TemplateModel" + } + } }, "TemplateModelBaseModel": { "type": "object", @@ -7532,6 +7886,96 @@ }, "additionalProperties": false }, + "UserGroupBaseModel": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "icon": { + "type": "string", + "nullable": true + }, + "sections": { + "type": "array", + "items": { + "type": "string" + } + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "hasAccessToAllLanguages": { + "type": "boolean" + }, + "documentStartNodeKey": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "mediaStartNodeKey": { + "type": "string", + "format": "uuid", + "nullable": true + }, + "permissions": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "UserGroupModel": { + "required": [ + "$type" + ], + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserGroupBaseModel" + } + ], + "properties": { + "$type": { + "type": "string" + }, + "key": { + "type": "string", + "format": "uuid" + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "UserGroupViewModel": "#/components/schemas/UserGroupModel" + } + } + }, + "UserGroupSaveModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserGroupBaseModel" + } + ], + "additionalProperties": false + }, + "UserGroupUpdateModel": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserGroupBaseModel" + } + ], + "additionalProperties": false + }, "UserInstallModel": { "required": [ "email", @@ -7603,7 +8047,9 @@ "authorizationCode": { "authorizationUrl": "/umbraco/management/api/v1.0/security/back-office/authorize", "tokenUrl": "/umbraco/management/api/v1.0/security/back-office/token", - "scopes": { } + "scopes": { + + } } } } @@ -7611,7 +8057,9 @@ }, "security": [ { - "OAuth": [ ] + "OAuth": [ + + ] } ] } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeViewModel.cs index 77a0a8df25..25d094df36 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/DataType/DataTypeViewModel.cs @@ -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; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemViewModel.cs index a7ee7e5b32..0b795c1fc2 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryItemViewModel.cs @@ -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; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderViewModel.cs index f76bf1493f..777ca988fd 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Folder/FolderViewModel.cs @@ -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; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/INamedEntityViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/INamedEntityViewModel.cs new file mode 100644 index 0000000000..1da1a24c0e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/INamedEntityViewModel.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Api.Management.ViewModels; + +public interface INamedEntityViewModel +{ + Guid Key { get; } + + string Name { get;} +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/RecycleBin/RecycleBinItemViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/RecycleBin/RecycleBinItemViewModel.cs index d3d25622f0..aa7ab3cd46 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/RecycleBin/RecycleBinItemViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/RecycleBin/RecycleBinItemViewModel.cs @@ -1,6 +1,6 @@ namespace Umbraco.Cms.Api.Management.ViewModels.RecycleBin; -public class RecycleBinItemViewModel +public class RecycleBinItemViewModel : INamedEntityViewModel { public Guid Key { get; set; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Template/TemplateViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Template/TemplateViewModel.cs index 8fe8fc7ddd..e8958df583 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Template/TemplateViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Template/TemplateViewModel.cs @@ -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; } } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/EntityTreeItemViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/EntityTreeItemViewModel.cs index a163a4f9a7..008bb81c5c 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Tree/EntityTreeItemViewModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Tree/EntityTreeItemViewModel.cs @@ -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; } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupBase.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupBase.cs new file mode 100644 index 0000000000..994ef00abf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupBase.cs @@ -0,0 +1,58 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups; + +/// +/// +/// Base class for front-end representation of a User Group. +/// +/// +/// Contains all the properties shared between Save, Update, Representation, etc... +/// +/// +public class UserGroupBase +{ + /// + /// The name of the user groups + /// + public required string Name { get; init; } + + /// + /// The Icon for the user group + /// + public string? Icon { get; init; } + + /// + /// The sections that the user group has access to + /// + public required IEnumerable Sections { get; init; } + + /// + /// The languages that the user group has access to + /// + public required IEnumerable Languages { get; init; } + + /// + /// Flag indicating if the user group gives access to all languages, regardless of . + /// + public required bool HasAccessToAllLanguages { get; init; } + + /// + /// The key of the document that should act as root node for the user group + /// + /// This can be overwritten by a different user group if a user is a member of multiple groups + /// + /// + public Guid? DocumentStartNodeKey { get; init; } + + /// + /// The Key of the media that should act as root node for the user group + /// + /// This can be overwritten by a different user group if a user is a member of multiple groups + /// + /// + public Guid? MediaStartNodeKey { get; init; } + + /// + /// Ad-hoc list of permissions provided, and maintained by the front-end. The server has no concept of what these mean. + /// + public required ISet Permissions { get; init; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupSaveModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupSaveModel.cs new file mode 100644 index 0000000000..d42a2c2faf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupSaveModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups; + +public class UserGroupSaveModel : UserGroupBase +{ + +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupUpdateModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupUpdateModel.cs new file mode 100644 index 0000000000..fa08b60ec9 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupUpdateModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups; + +public class UserGroupUpdateModel : UserGroupBase +{ + +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupViewModel.cs new file mode 100644 index 0000000000..9b6212f7ef --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/UserGroups/UserGroupViewModel.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.UserGroups; + +public class UserGroupViewModel : UserGroupBase, INamedEntityViewModel +{ + /// + /// The key identifier for the user group. + /// + public required Guid Key { get; init; } + +} diff --git a/src/Umbraco.Core/CompatibilitySuppressions.xml b/src/Umbraco.Core/CompatibilitySuppressions.xml index ad50b77190..7234ff16c8 100644 --- a/src/Umbraco.Core/CompatibilitySuppressions.xml +++ b/src/Umbraco.Core/CompatibilitySuppressions.xml @@ -203,6 +203,13 @@ lib/net7.0/Umbraco.Core.dll true + + CP0002 + 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) + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + CP0002 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) @@ -602,4 +609,11 @@ lib/net7.0/Umbraco.Core.dll true + + CP0006 + P:Umbraco.Cms.Core.Models.Membership.IUserGroup.PermissionNames + lib/net7.0/Umbraco.Core.dll + lib/net7.0/Umbraco.Core.dll + true + \ No newline at end of file diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index d6d0a25c7b..5c4a2b6477 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -279,6 +279,8 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddTransient(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs b/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs index 28fbea027b..142be715f7 100644 --- a/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs +++ b/src/Umbraco.Core/Handlers/AuditNotificationsHandler.cs @@ -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, 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, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IMemberService memberService) + : this( + auditService, + userService, + entityService, + ipResolver, + globalSettings, + backOfficeSecurityAccessor, + memberService, + StaticServiceProvider.Instance.GetRequiredService() + ) + { + } + private IUser CurrentPerformingUser { get @@ -95,7 +122,7 @@ public sealed class AuditNotificationsHandler : IEnumerable 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()); IEntitySlim? entity = _entityService.Get(perm.EntityId); diff --git a/src/Umbraco.Core/Models/ContentEditing/UserGroupSave.cs b/src/Umbraco.Core/Models/ContentEditing/UserGroupSave.cs index a646c10337..d32f2fa9fe 100644 --- a/src/Umbraco.Core/Models/ContentEditing/UserGroupSave.cs +++ b/src/Umbraco.Core/Models/ContentEditing/UserGroupSave.cs @@ -37,6 +37,16 @@ public class UserGroupSave : EntityBasic, IValidatableObject [DataMember(Name = "hasAccessToAllLanguages")] public bool HasAccessToAllLanguages { get; set; } + /// + /// A set of ad-hoc permissions provided by the frontend. + /// + /// + /// 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. + /// + public ISet? Permissions { get; set; } + /// /// The list of letters (permission codes) to assign as the default for the user group /// diff --git a/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs b/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs index 34552d0f0b..c82b166eed 100644 --- a/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs +++ b/src/Umbraco.Core/Models/Mapping/UserMapDefinition.cs @@ -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, MediaFileManager mediaFileManager, IShortStringHelper shortStringHelper, - IImageUrlGenerator imageUrlGenerator) - : this( - textService, - userService, - entityService, - sectionService, - appCaches, - actions, - globalSettings, - mediaFileManager, - shortStringHelper, - imageUrlGenerator, - StaticServiceProvider.Instance.GetRequiredService()) + IImageUrlGenerator imageUrlGenerator, + ILocalizationService localizationService) + : this( + textService, + userService, + entityService, + sectionService, + appCaches, + actions, + globalSettings, + mediaFileManager, + shortStringHelper, + imageUrlGenerator, + localizationService, + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -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(); var id = GetIntId(source.Id); if (id > 0) @@ -222,7 +227,7 @@ public class UserMapDefinition : IMapDefinition target.IsApproved = false; target.ClearGroups(); - IEnumerable groups = _userService.GetUserGroupsByAlias(source.UserGroups.ToArray()); + IEnumerable 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 groups = _userService.GetUserGroupsByAlias(source.UserGroups.ToArray()); + IEnumerable groups = _userGroupService.GetAsync(source.UserGroups.ToArray()).GetAwaiter().GetResult(); foreach (IUserGroup group in groups) { target.AddGroup(group.ToReadOnlyGroup()); diff --git a/src/Umbraco.Core/Models/Membership/IUserGroup.cs b/src/Umbraco.Core/Models/Membership/IUserGroup.cs index 11b97a9996..c78ee7cbd5 100644 --- a/src/Umbraco.Core/Models/Membership/IUserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/IUserGroup.cs @@ -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 /// /// 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 */ } + } /// /// The set of default permissions @@ -35,6 +40,16 @@ public interface IUserGroup : IEntity, IRememberBeingDirty /// IEnumerable? Permissions { get; set; } + /// + /// The set of permissions provided by the frontend. + /// + /// + /// 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. + /// + ISet PermissionNames { get; set; } + IEnumerable AllowedSections { get; } void RemoveAllowedSection(string sectionAlias); diff --git a/src/Umbraco.Core/Models/Membership/UserGroup.cs b/src/Umbraco.Core/Models/Membership/UserGroup.cs index 7369771cbd..119bc6bf69 100644 --- a/src/Umbraco.Core/Models/Membership/UserGroup.cs +++ b/src/Umbraco.Core/Models/Membership/UserGroup.cs @@ -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? _permissions; + private ISet _permissionNames = new HashSet(); private List _sectionCollection; private List _languageCollection; private int? _startContentId; @@ -124,6 +126,13 @@ public class UserGroup : EntityBase, IUserGroup, IReadOnlyUserGroup set => SetPropertyValueAndDetectChanges(value, ref _permissions, nameof(Permissions), _stringEnumerableComparer); } + /// + public ISet PermissionNames + { + get => _permissionNames; + set => SetPropertyValueAndDetectChanges(value, ref _permissionNames!, nameof(PermissionNames), _stringEnumerableComparer); + } + public IEnumerable AllowedSections => _sectionCollection; public int UserCount { get; } diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 420f36c759..a7c094db2a 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -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"; diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index ff7c8f12d9..025d291a72 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -48,6 +48,22 @@ public interface IEntityRepository : IRepository bool Exists(Guid key); + /// + /// Asserts if an entity with the given object type exists. + /// + /// The Key of the entity to find. + /// The object type key of the entity. + /// True if an entity with the given key and object type exists. + bool Exists(Guid key, Guid objectType) => throw new NotImplementedException(); + + /// + /// Asserts if an entity with the given object type exists. + /// + /// The id of the entity to find. + /// The object type key of the entity. + /// True if an entity with the given id and object type exists. + bool Exists(int id, Guid objectType) => throw new NotImplementedException(); + /// /// Gets paged entities for a query and a specific object type /// diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index b6fc244bd0..ed35e78879 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -124,6 +124,24 @@ public class EntityService : RepositoryService, IEntityService } } + /// + public bool Exists(Guid key, UmbracoObjectTypes objectType) + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _entityRepository.Exists(key, objectType.GetGuid()); + } + } + + /// + public bool Exists(int id, UmbracoObjectTypes objectType) + { + using (ScopeProvider.CreateCoreScope(autoComplete: true)) + { + return _entityRepository.Exists(id, objectType.GetGuid()); + } + } + /// public virtual IEnumerable GetAll() where T : IUmbracoEntity diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 5151d9ed1f..a2204b97ce 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -60,6 +60,22 @@ public interface IEntityService /// The unique key of the entity. bool Exists(Guid key); + /// + /// Determines whether and entity of a certain object type exists. + /// + /// The unique key of the entity. + /// The object type to look for. + /// True if the entity exists, false if it does not. + bool Exists(Guid key, UmbracoObjectTypes objectType) => throw new NotImplementedException(); + + /// + /// Determines whether and entity of a certain object type exists. + /// + /// The id of the entity. + /// The object type to look for. + /// True if the entity exists, false if it does not. + bool Exists(int id, UmbracoObjectTypes objectType) => throw new NotImplementedException(); + /// /// Gets entities of a given object type. /// diff --git a/src/Umbraco.Core/Services/IUserGroupAuthorizationService.cs b/src/Umbraco.Core/Services/IUserGroupAuthorizationService.cs new file mode 100644 index 0000000000..1a223fbc01 --- /dev/null +++ b/src/Umbraco.Core/Services/IUserGroupAuthorizationService.cs @@ -0,0 +1,24 @@ +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +public interface IUserGroupAuthorizationService +{ + + /// + /// Authorizes a user to create a new user group. + /// + /// The user performing the create operation. + /// The user group to be created. + /// An attempt with an operation status. + Attempt AuthorizeUserGroupCreation(IUser performingUser, IUserGroup userGroup); + + /// + /// Authorizes a user to update an existing user group. + /// + /// The user performing the update operation. + /// The user group to be created. + /// An attempt with an operation. + Attempt AuthorizeUserGroupUpdate(IUser performingUser, IUserGroup userGroup); +} diff --git a/src/Umbraco.Core/Services/IUserGroupService.cs b/src/Umbraco.Core/Services/IUserGroupService.cs new file mode 100644 index 0000000000..ee49334d12 --- /dev/null +++ b/src/Umbraco.Core/Services/IUserGroupService.cs @@ -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; + +/// +/// Manages user groups. +/// +public interface IUserGroupService +{ + /// + /// Gets all user groups. + /// + /// The amount of user groups to skip. + /// The amount of user groups to take. + /// All user groups as an enumerable list of . + Task> GetAllAsync(int skip, int take); + + /// + /// Gets all UserGroups matching an ID in the parameter list. + /// + /// Optional Ids of UserGroups to retrieve. + /// An enumerable list of . + Task> GetAsync(params int[] ids); + + /// + /// Gets all UserGroups matching an alias in the parameter list. + /// + /// Alias of the UserGroup to retrieve. + /// + /// An enumerable list of . + /// + Task> GetAsync(params string[] aliases); + + /// + /// Gets a UserGroup by its Alias + /// + /// Name of the UserGroup to retrieve. + /// + /// + /// + Task GetAsync(string alias); + + /// + /// Gets a UserGroup by its Id + /// + /// Id of the UserGroup to retrieve. + /// + /// + /// + Task GetAsync(int id); + + /// + /// Gets a UserGroup by its key + /// + /// Key of the UserGroup to retrieve. + /// + /// + /// + Task GetAsync(Guid key); + + /// + /// Persists a new user group. + /// + /// The user group to create. + /// The ID of the user responsible for creating the group. + /// The IDs of the users that should be part of the group when created. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> CreateAsync(IUserGroup userGroup, int performingUserId, int[]? groupMembersUserIds = null); + + /// + /// Updates an existing user group. + /// + /// The user group to update. + /// The ID of the user responsible for updating the group. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> UpdateAsync(IUserGroup userGroup, int performingUserId); + + /// + /// Deletes a UserGroup + /// + /// The key of the user group to delete. + /// An attempt indicating if the operation was a success as well as a more detailed . + Task> DeleteAsync(Guid key); +} diff --git a/src/Umbraco.Core/Services/IUserService.cs b/src/Umbraco.Core/Services/IUserService.cs index 40a3fbd899..b04e9d8850 100644 --- a/src/Umbraco.Core/Services/IUserService.cs +++ b/src/Umbraco.Core/Services/IUserService.cs @@ -240,6 +240,7 @@ public interface IUserService : IMembershipUserService /// /// Optional Ids of UserGroups to retrieve /// An enumerable list of + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] IEnumerable GetAllUserGroups(params int[] ids); /// @@ -249,6 +250,7 @@ public interface IUserService : IMembershipUserService /// /// /// + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] IEnumerable GetUserGroupsByAlias(params string[] alias); /// @@ -258,6 +260,7 @@ public interface IUserService : IMembershipUserService /// /// /// + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] IUserGroup? GetUserGroupByAlias(string name); /// @@ -267,6 +270,7 @@ public interface IUserService : IMembershipUserService /// /// /// + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] IUserGroup? GetUserGroupById(int id); /// @@ -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 /// + [Obsolete("Use IUserGroupService.CreateAsync and IUserGroupService.UpdateAsync instead, scheduled for removal in V15.")] void Save(IUserGroup userGroup, int[]? userIds = null); /// /// Deletes a UserGroup /// /// UserGroup to delete + [Obsolete("Use IUserGroupService.DeleteAsync instead, scheduled for removal in V15.")] void DeleteUserGroup(IUserGroup userGroup); #endregion diff --git a/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs new file mode 100644 index 0000000000..f25f0de64e --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/UserGroupOperationStatus.cs @@ -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, +} diff --git a/src/Umbraco.Core/Services/UserGroupAuthorizationService.cs b/src/Umbraco.Core/Services/UserGroupAuthorizationService.cs new file mode 100644 index 0000000000..57a250752d --- /dev/null +++ b/src/Umbraco.Core/Services/UserGroupAuthorizationService.cs @@ -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; + } + + /// + public Attempt AuthorizeUserGroupCreation(IUser performingUser, IUserGroup userGroup) + { + Attempt hasSectionAccess = AuthorizeHasAccessToUserSection(performingUser); + if (hasSectionAccess.Success is false) + { + return Attempt.Fail(hasSectionAccess.Result); + } + + Attempt authorizeSectionChanges = AuthorizeSectionAccess(performingUser, userGroup); + if (authorizeSectionChanges.Success is false) + { + return Attempt.Fail(authorizeSectionChanges.Result); + } + + Attempt authorizeContentNodeChanges = AuthorizeStartNodeChanges(performingUser, userGroup); + return authorizeSectionChanges.Success is false + ? Attempt.Fail(authorizeContentNodeChanges.Result) + : Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + public Attempt AuthorizeUserGroupUpdate(IUser performingUser, IUserGroup userGroup) + { + Attempt hasAccessToUserSection = AuthorizeHasAccessToUserSection(performingUser); + if (hasAccessToUserSection.Success is false) + { + return Attempt.Fail(hasAccessToUserSection.Result); + } + + Attempt authorizeSectionAccess = AuthorizeSectionAccess(performingUser, userGroup); + if (authorizeSectionAccess.Success is false) + { + return Attempt.Fail(authorizeSectionAccess.Result); + } + + Attempt authorizeGroupAccess = AuthorizeGroupAccess(performingUser, userGroup); + if (authorizeGroupAccess.Success is false) + { + return Attempt.Fail(authorizeGroupAccess.Result); + } + + Attempt authorizeStartNodeChanges = AuthorizeStartNodeChanges(performingUser, userGroup); + if (authorizeSectionAccess.Success is false) + { + return Attempt.Fail(authorizeStartNodeChanges.Result); + } + + + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + /// Authorize that a user is not adding a section to the group that they don't have access to. + /// + /// The user performing the action. + /// The UserGroup being created or updated. + /// An attempt with an operation status. + private Attempt AuthorizeSectionAccess(IUser performingUser, IUserGroup userGroup) + { + if (performingUser.IsAdmin()) + { + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + IEnumerable sectionsMissingAccess = userGroup.AllowedSections.Except(performingUser.AllowedSections).ToArray(); + return sectionsMissingAccess.Any() + ? Attempt.Fail(UserGroupOperationStatus.UnauthorizedMissingSections) + : Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + /// Authorize that the user is not changing to a start node that they don't have access to. + /// + /// The user performing the action. + /// The UserGroup being created or updated. + /// An attempt with an operation status. + private Attempt AuthorizeStartNodeChanges(IUser user, IUserGroup userGroup) + { + Attempt authorizeContent = AuthorizeContentStartNode(user, userGroup); + + return authorizeContent.Success is false + ? authorizeContent + : AuthorizeMediaStartNode(user, userGroup); + } + + /// + /// Ensures that a user has access to the user section. + /// + /// The user performing the action. + /// An attempt with an operation status. + private Attempt AuthorizeHasAccessToUserSection(IUser user) + { + if (user.AllowedSections.Contains(Constants.Applications.Users) is false) + { + return Attempt.Fail(UserGroupOperationStatus.UnauthorizedMissingUserSection); + } + + return Attempt.Succeed(UserGroupOperationStatus.Success); + } + + /// + /// Ensures that the performing user is part of the user group. + /// + private Attempt 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 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 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); + } +} diff --git a/src/Umbraco.Core/Services/UserGroupService.cs b/src/Umbraco.Core/Services/UserGroupService.cs new file mode 100644 index 0000000000..42bf0070e7 --- /dev/null +++ b/src/Umbraco.Core/Services/UserGroupService.cs @@ -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; + +/// +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; + } + + /// + public Task> 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 + { + Items = groups.Skip(skip).Take(take), + Total = total, + }); + } + + /// + public Task> GetAsync(params int[] ids) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + IEnumerable groups = _userGroupRepository + .GetMany(ids) + .OrderBy(x => x.Name); + + return Task.FromResult(groups); + } + + /// + public Task> GetAsync(params string[] aliases) + { + if (aliases.Length == 0) + { + return Task.FromResult(Enumerable.Empty()); + } + + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IQuery query = Query().Where(x => aliases.SqlIn(x.Alias)); + IEnumerable contents = _userGroupRepository + .Get(query) + .WhereNotNull() + .OrderBy(x => x.Name) + .ToArray(); + + return Task.FromResult(contents); + } + + /// + public Task 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 query = Query().Where(x => x.Alias == alias); + IUserGroup? contents = _userGroupRepository.Get(query).FirstOrDefault(); + return Task.FromResult(contents); + } + + /// + public Task GetAsync(int id) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + return Task.FromResult(_userGroupRepository.Get(id)); + } + + /// + public Task GetAsync(Guid key) + { + using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); + + IQuery query = Query().Where(x => x.Key == key); + IUserGroup? groups = _userGroupRepository.Get(query).FirstOrDefault(); + return Task.FromResult(groups); + } + + /// + public async Task> DeleteAsync(Guid key) + { + IUserGroup? userGroup = await GetAsync(key); + + Attempt 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 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); + } + + /// + public async Task> 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 validationAttempt = await ValidateUserGroupCreationAsync(userGroup); + if (validationAttempt.Success is false) + { + return validationAttempt; + } + + Attempt 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()).ToArray(); + IEnumerable 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()); + 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> 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); + } + + /// + public async Task> 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 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 ValidateUserGroupUpdateAsync(IUserGroup userGroup) + { + UserGroupOperationStatus commonValidationStatus = ValidateCommon(userGroup); + if (commonValidationStatus != UserGroupOperationStatus.Success) + { + return commonValidationStatus; + } + + if (await IsNewUserGroup(userGroup)) + { + return UserGroupOperationStatus.NotFound; + } + + return UserGroupOperationStatus.Success; + } + + /// + /// Validate common user group properties, that are shared between update, create, etc. + /// + 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 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; + + /// + /// Ensures that the user creating the user group is either an admin, or in the group itself. + /// + /// + /// This is to ensure that the user can access the group they themselves created at a later point and modify it. + /// + private IEnumerable EnsureNonAdminUserIsInSavedUserGroup(IUser performingUser, IEnumerable 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; + } +} diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index 69e6351fbd..0782431e91 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -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 _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) + IOptions globalSettings, + IUserGroupAuthorizationService userGroupAuthorizationService) : base(provider, loggerFactory, eventMessagesFactory) { _runtimeState = runtimeState; _userRepository = userRepository; _userGroupRepository = userGroupRepository; + _userGroupAuthorizationService = userGroupAuthorizationService; _globalSettings = globalSettings.Value; _logger = loggerFactory.CreateLogger(); } @@ -884,6 +889,7 @@ internal class UserService : RepositoryService, IUserService /// /// Optional Ids of UserGroups to retrieve /// An enumerable list of + [Obsolete("Use IUserGroupService.GetAsync instead, scheduled for removal in V15.")] public IEnumerable 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 GetUserGroupsByAlias(params string[] aliases) { if (aliases.Length == 0) @@ -914,6 +921,7 @@ internal class UserService : RepositoryService, IUserService /// /// /// + [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 /// /// /// + [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 /// False /// to not raise events /// + [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[] addedUsers = empty; IUser[] removedUsers = empty; @@ -1018,6 +1028,7 @@ internal class UserService : RepositoryService, IUserService /// Deletes a UserGroup /// /// UserGroup to delete + [Obsolete("Use IUserGroupService.DeleteAsync instead, scheduled for removal in V15.")] public void DeleteUserGroup(IUserGroup userGroup) { EventMessages evtMsgs = EventMessagesFactory.Get(); diff --git a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml index 7eb0063a32..fc303216ba 100644 --- a/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml +++ b/src/Umbraco.Infrastructure/CompatibilitySuppressions.xml @@ -77,6 +77,13 @@ lib/net7.0/Umbraco.Infrastructure.dll true + + CP0002 + 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) + lib/net7.0/Umbraco.Infrastructure.dll + lib/net7.0/Umbraco.Infrastructure.dll + true + CP0002 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) diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index 8c1e0e2a54..ed4903f531 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -68,6 +68,7 @@ public class DatabaseSchemaCreator typeof(User2UserGroupDto), typeof(UserGroup2NodePermissionDto), typeof(UserGroup2AppDto), + typeof(UserGroup2PermissionDto), typeof(UserStartNodeDto), typeof(ContentNuDto), typeof(DocumentVersionDto), diff --git a/src/Umbraco.Infrastructure/Migrations/MigrationBase.cs b/src/Umbraco.Infrastructure/Migrations/MigrationBase.cs index 7b9bb1551c..ab6d079a96 100644 --- a/src/Umbraco.Infrastructure/Migrations/MigrationBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/MigrationBase.cs @@ -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; diff --git a/src/Umbraco.Infrastructure/Migrations/UnscopedMigrationBase.cs b/src/Umbraco.Infrastructure/Migrations/UnscopedMigrationBase.cs index ca52be6d9d..910b9753de 100644 --- a/src/Umbraco.Infrastructure/Migrations/UnscopedMigrationBase.cs +++ b/src/Umbraco.Infrastructure/Migrations/UnscopedMigrationBase.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Infrastructure.Scoping; namespace Umbraco.Cms.Infrastructure.Migrations; diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index a8631b671a..5e9ef82a13 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -83,5 +83,6 @@ public class UmbracoPlan : MigrationPlan To("{419827A0-4FCE-464B-A8F3-247C6092AF55}"); To("{5F15A1CC-353D-4889-8C7E-F303B4766196}"); To("{69E12556-D9B3-493A-8E8A-65EC89FB658D}"); + To("{F2B16CD4-F181-4BEE-81C9-11CF384E6025}"); } } diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs index 3d06a07ce4..3f75caabee 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddGuidsToUserGroups.cs @@ -1,4 +1,4 @@ -using NPoco; +using NPoco; using Umbraco.Cms.Core; using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; using Umbraco.Cms.Infrastructure.Persistence.DatabaseModelDefinitions; diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddUserGroupPermissionTable.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddUserGroupPermissionTable.cs new file mode 100644 index 0000000000..2510f32d65 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddUserGroupPermissionTable.cs @@ -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().Do(); + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroup2PermissionDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroup2PermissionDto.cs new file mode 100644 index 0000000000..c5cf3b12b7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroup2PermissionDto.cs @@ -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; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs index e8699221d7..bd9803dfa0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/UserGroupDto.cs @@ -14,6 +14,7 @@ public class UserGroupDto { UserGroup2AppDtos = new List(); UserGroup2LanguageDtos = new List(); + UserGroup2PermissionDtos = new List(); } [Column("id")] @@ -77,6 +78,10 @@ public class UserGroupDto [Reference(ReferenceType.Many, ReferenceMemberName = "UserGroupId")] public List UserGroup2LanguageDtos { get; set; } + [ResultColumn] + [Reference(ReferenceType.Many, ReferenceMemberName = "UserGroupId")] + public List UserGroup2PermissionDtos { get; set; } + /// /// This is only relevant when this column is included in the results (i.e. GetUserGroupsWithUserCounts) /// diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs index e48d1cd17f..a272f84fa6 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/UserGroupFactory.cs @@ -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; diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index 9841ae9d0c..0df84fa20f 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -289,6 +289,27 @@ internal class EntityRepository : RepositoryBase, IEntityRepositoryExtended return Database.ExecuteScalar(sql) > 0; } + /// + public bool Exists(Guid key, Guid objectType) + { + Sql sql = Sql() + .SelectCount() + .From() + .Where(x => x.UniqueId == key && x.NodeObjectType == objectType); + + return Database.ExecuteScalar(sql) > 0; + } + + public bool Exists(int id, Guid objectType) + { + Sql sql = Sql() + .SelectCount() + .From() + .Where(x => x.NodeId == id && x.NodeObjectType == objectType); + + return Database.ExecuteScalar(sql) > 0; + } + public bool Exists(int id) { Sql sql = Sql().SelectCount().From().Where(x => x.NodeId == id); diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs index 33d666dfe4..b00c1875c5 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserGroupRepository.cs @@ -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, IUserG private readonly IShortStringHelper _shortStringHelper; private readonly UserGroupWithUsersRepository _userGroupWithUsersRepository; - public UserGroupRepository(IScopeAccessor scopeAccessor, AppCaches appCaches, ILogger logger, ILoggerFactory loggerFactory, IShortStringHelper shortStringHelper) + public UserGroupRepository( + IScopeAccessor scopeAccessor, + AppCaches appCaches, + ILogger logger, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper) : base(scopeAccessor, appCaches, logger) { _shortStringHelper = shortStringHelper; @@ -299,6 +305,7 @@ public class UserGroupRepository : EntityRepositoryBase, 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, IUserG List dtos = Database.FetchOneToMany(x => x.UserGroup2AppDtos, sql); - IDictionary> 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, IUserG AppendGroupBy(sql); sql.OrderBy(x => x.Id); // required for references - List? dtos = Database.FetchOneToMany(x => x.UserGroup2AppDtos, sql); + List dtos = Database.FetchOneToMany(x => x.UserGroup2AppDtos, sql); + + AssignUserGroupOneToManyTables(ref dtos); + return dtos.Select(x => UserGroupFactory.BuildEntity(_shortStringHelper, x)); } + private void AssignUserGroupOneToManyTables(ref List userGroupDtos) + { + IDictionary> userGroups2Languages = GetAllUserGroupLanguageGrouped(); + IDictionary> userGroups2Permissions = GetAllUserGroupPermissionsGrouped(); + + foreach (UserGroupDto dto in userGroupDtos) + { + userGroups2Languages.TryGetValue(dto.Id, out List? userGroup2LanguageDtos); + dto.UserGroup2LanguageDtos = userGroup2LanguageDtos ?? new List(); + + userGroups2Permissions.TryGetValue(dto.Id, out List? userGroup2PermissionDtos); + dto.UserGroup2PermissionDtos = userGroup2PermissionDtos ?? new List(); + } + } + #endregion #region Overrides of EntityRepositoryBase @@ -417,6 +436,7 @@ public class UserGroupRepository : EntityRepositoryBase, 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, IUserG PersistAllowedSections(entity); PersistAllowedLanguages(entity); + PersistPermissions(entity); entity.ResetDirtyProperties(); } @@ -446,7 +467,8 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG Database.Update(userGroupDto); PersistAllowedSections(entity); - PersistAllowedLanguages(entity); + PersistAllowedLanguages(entity); + PersistPermissions(entity); entity.ResetDirtyProperties(); } @@ -466,43 +488,74 @@ public class UserGroupRepository : EntityRepositoryBase, IUserG } } - private void PersistAllowedLanguages(IUserGroup entity) + private void PersistAllowedLanguages(IUserGroup entity) + { + var userGroup = entity; + + // First delete all + Database.Delete("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("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 GetUserGroupLanguages(int userGroupId) + private void PersistPermissions(IUserGroup userGroup) + { + Database.Delete("WHERE UserGroupId = @UserGroupId", new { UserGroupId = userGroup.Id }); + + foreach (var permission in userGroup.PermissionNames) { - Sql query = Sql() - .Select() - .From() - .Where(x => x.UserGroupId == userGroupId); - return Database.Fetch(query); + var permissionDto = new UserGroup2PermissionDto { UserGroupId = userGroup.Id, Permission = permission, }; + Database.Insert(permissionDto); } + } - private IDictionary> GetAllUserGroupLanguageGrouped() - { - Sql query = Sql() - .Select() - .From(); - List userGroupLanguages = Database.Fetch(query); - return userGroupLanguages.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList()); - } + private List GetUserGroupLanguages(int userGroupId) + { + Sql query = Sql() + .Select() + .From() + .Where(x => x.UserGroupId == userGroupId); + return Database.Fetch(query); + } + + private IDictionary> GetAllUserGroupLanguageGrouped() + { + Sql query = Sql() + .Select() + .From(); + List userGroupLanguages = Database.Fetch(query); + return userGroupLanguages.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList()); + } + + private List GetUserGroupPermissions(int userGroupId) + { + Sql query = Sql() + .Select() + .From() + .Where(x => x.UserGroupId == userGroupId); + + return Database.Fetch(query); + } + + private Dictionary> GetAllUserGroupPermissionsGrouped() + { + Sql query = Sql() + .Select() + .From(); + + List userGroupPermissions = Database.Fetch(query); + return userGroupPermissions.GroupBy(x => x.UserGroupId).ToDictionary(x => x.Key, x => x.ToList()); + } #endregion } diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 0d2767dd25..a4ac523681 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -28,6 +28,7 @@ public class BackOfficeUserStore : UmbracoUserStore @@ -43,7 +44,8 @@ public class BackOfficeUserStore : UmbracoUserStore globalSettings, + IOptionsSnapshot globalSettings, IUmbracoMapper mapper, BackOfficeErrorDescriber describer, - AppCaches appCaches) + AppCaches appCaches, + ITwoFactorLoginService twoFactorLoginService) : this( scopeProvider, userService, entityService, externalLoginService, - StaticServiceProvider.Instance.GetRequiredService>(), + globalSettings, mapper, describer, appCaches, - StaticServiceProvider.Instance.GetRequiredService()) + twoFactorLoginService, + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -390,7 +395,7 @@ public class BackOfficeUserStore : UmbracoUserStore users = _userService.GetAllInGroup(userGroup?.Id); IList backOfficeIdentityUsers = @@ -495,7 +500,7 @@ public class BackOfficeUserStore : UmbracoUserStore?>(null); @@ -670,7 +675,7 @@ public class BackOfficeUserStore : UmbracoUserStore x.ToReadOnlyGroup()).ToArray(); // use all of the ones assigned and add them diff --git a/src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs b/src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs index db4b538f6a..02180322ee 100644 --- a/src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs +++ b/src/Umbraco.Infrastructure/Serialization/UmbracoJsonTypeInfoResolver.cs @@ -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 subTypes = FindSubTypes(type); if (!subTypes.Any()) { @@ -56,6 +80,7 @@ public sealed class UmbracoJsonTypeInfoResolver : DefaultJsonTypeInfoResolver, I result.PolymorphismOptions = jsonPolymorphismOptions; + return result; } } diff --git a/src/Umbraco.Infrastructure/Telemetry/Providers/UserTelemetryProvider.cs b/src/Umbraco.Infrastructure/Telemetry/Providers/UserTelemetryProvider.cs index 5b9023da9a..f9ddfb8cb3 100644 --- a/src/Umbraco.Infrastructure/Telemetry/Providers/UserTelemetryProvider.cs +++ b/src/Umbraco.Infrastructure/Telemetry/Providers/UserTelemetryProvider.cs @@ -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()) + { + } + + [Obsolete("Use constructor that only takes IUserGroupService, scheduled for removal in V15")] + public UserTelemetryProvider(IUserService userService, IUserGroupService userGroupService) + { + _userService = userService; + _userGroupService = userGroupService; + } public IEnumerable 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); diff --git a/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml b/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml new file mode 100644 index 0000000000..a4d1773597 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/CompatibilitySuppressions.xml @@ -0,0 +1,10 @@ + + + + CP0002 + 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) + lib/net7.0/Umbraco.Web.BackOffice.dll + lib/net7.0/Umbraco.Web.BackOffice.dll + true + + \ No newline at end of file diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index 642db289a0..a2d34a8f96 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -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 _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(); _scopeProvider = scopeProvider; _allLangs = new Lazy>(() => - _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()) - { - } + cultureImpactFactory, + StaticServiceProvider.Instance.GetRequiredService() + ) + { + } 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 allUserGroups = _userService.GetAllUserGroups(); + IEnumerable allUserGroups = _userGroupService.GetAllAsync(0, int.MaxValue).GetAwaiter().GetResult().Items; return GetDetailedPermissions(content, allUserGroups); } diff --git a/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs b/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs index ff9160b1c6..8a78afa2c6 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UserGroupEditorAuthorizationHelper.cs @@ -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; diff --git a/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs b/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs index 280b8cf30f..b259fbc897 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UserGroupsController.cs @@ -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; diff --git a/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs index 73cf10d983..77f87c449c 100644 --- a/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/UserGroupValidateAttribute.cs @@ -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"); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserGroupServiceValidationTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserGroupServiceValidationTests.cs new file mode 100644 index 0000000000..6bf09e2a00 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserGroupServiceValidationTests.cs @@ -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(); + + private IShortStringHelper ShortStringHelper => GetRequiredService(); + + [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); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs index 7046972d83..a4f5d7436f 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/ContentControllerTests.cs @@ -266,7 +266,8 @@ public class ContentControllerTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); return controller; } From d7ef924c32f44a6dd9a506202f06d8fb4bd14de0 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Thu, 16 Feb 2023 13:08:57 +0100 Subject: [PATCH 3/8] Miniprofiler for v13 (#13841) * Updated miniprofiler and added a few configurations * added fixme * Remove file that should not have been committed * added ignore list. We check the entire path and ignore client requests anyway --- .../UmbracoBuilderExtensions.cs | 17 +++----- .../Profiler/ConfigureMiniProfilerOptions.cs | 43 +++++++++++++++++++ .../Profiler/WebProfiler.cs | 24 +++++++++-- 3 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 src/Umbraco.Web.Common/Profiler/ConfigureMiniProfilerOptions.cs diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index dc94fa3dc8..e44a7449aa 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -109,7 +109,7 @@ public static partial class UmbracoBuilderExtensions services.ConfigureOptions(); services.ConfigureOptions(); - IProfiler profiler = GetWebProfiler(config); + IProfiler profiler = GetWebProfiler(config, httpContextAccessor); services.AddLogger(webHostEnvironment, config); @@ -200,15 +200,8 @@ public static partial class UmbracoBuilderExtensions { builder.Services.AddSingleton(); - builder.Services.AddMiniProfiler(options => - { - // WebProfiler determine and start profiling. We should not use the MiniProfilerMiddleware to also profile - options.ShouldProfile = request => false; - - // this is a default path and by default it performs a 'contains' check which will match our content controller - // (and probably other requests) and ignore them. - options.IgnoredPaths.Remove("/content/"); - }); + builder.Services.AddMiniProfiler(); + builder.Services.ConfigureOptions(); builder.AddNotificationHandler(); return builder; @@ -385,7 +378,7 @@ public static partial class UmbracoBuilderExtensions return builder; } - private static IProfiler GetWebProfiler(IConfiguration config) + private static IProfiler GetWebProfiler(IConfiguration config, IHttpContextAccessor httpContextAccessor) { var isDebug = config.GetValue($"{Constants.Configuration.ConfigHosting}:Debug"); @@ -397,7 +390,7 @@ public static partial class UmbracoBuilderExtensions return new NoopProfiler(); } - var webProfiler = new WebProfiler(); + var webProfiler = new WebProfiler(httpContextAccessor); webProfiler.StartBoot(); return webProfiler; diff --git a/src/Umbraco.Web.Common/Profiler/ConfigureMiniProfilerOptions.cs b/src/Umbraco.Web.Common/Profiler/ConfigureMiniProfilerOptions.cs new file mode 100644 index 0000000000..4239ba1737 --- /dev/null +++ b/src/Umbraco.Web.Common/Profiler/ConfigureMiniProfilerOptions.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using StackExchange.Profiling; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.Profiler; + +internal sealed class ConfigureMiniProfilerOptions : IConfigureOptions +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly string _backOfficePath; + + public ConfigureMiniProfilerOptions( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IOptions globalSettings, + IHostingEnvironment hostingEnvironment) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _backOfficePath = globalSettings.Value.GetBackOfficePath(hostingEnvironment); + } + + public void Configure(MiniProfilerOptions options) + { + options.RouteBasePath = WebPath.Combine(_backOfficePath, "profiler"); + // WebProfiler determine and start profiling. We should not use the MiniProfilerMiddleware to also profile + options.ShouldProfile = request => false; + + options.IgnoredPaths.Clear(); + options.IgnoredPaths.Add(WebPath.Combine(_backOfficePath, "swagger")); + options.IgnoredPaths.Add(WebPath.Combine(options.RouteBasePath, "results-list")); + options.IgnoredPaths.Add(WebPath.Combine(options.RouteBasePath, "results-index")); + options.IgnoredPaths.Add(WebPath.Combine(options.RouteBasePath, "results")); + + options.ResultsAuthorize = IsBackofficeUserAuthorized; + options.ResultsListAuthorize = IsBackofficeUserAuthorized; + } + + private bool IsBackofficeUserAuthorized(HttpRequest request) => true;// FIXME when we can get current backoffice user, _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser is not null; +} diff --git a/src/Umbraco.Web.Common/Profiler/WebProfiler.cs b/src/Umbraco.Web.Common/Profiler/WebProfiler.cs index 9608bad715..50a0de19a9 100644 --- a/src/Umbraco.Web.Common/Profiler/WebProfiler.cs +++ b/src/Umbraco.Web.Common/Profiler/WebProfiler.cs @@ -1,6 +1,7 @@ using System.Net; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using StackExchange.Profiling; @@ -14,6 +15,14 @@ namespace Umbraco.Cms.Web.Common.Profiler; public class WebProfiler : IProfiler { + private readonly IHttpContextAccessor _httpContextAccessor; + + public WebProfiler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public static readonly AsyncLocal MiniProfilerContext = new(x => { _ = x; @@ -24,11 +33,12 @@ public class WebProfiler : IProfiler private int _first; private MiniProfiler? _startupProfiler; - public IDisposable? Step(string name) => MiniProfiler.Current?.Step(name); + public IDisposable? Step(string name) => + MiniProfiler.Current?.Step(name); public void Start() { - MiniProfiler.StartNew(); + MiniProfiler.StartNew(_httpContextAccessor.HttpContext?.Request.Path); MiniProfilerContext.Value = MiniProfiler.Current; } @@ -75,7 +85,6 @@ public class WebProfiler : IProfiler { AddSubProfiler(_startupProfiler); } - _startupProfiler = null; } @@ -102,13 +111,20 @@ public class WebProfiler : IProfiler private static ICookieManager GetCookieManager(HttpContext context) => context.RequestServices.GetRequiredService(); - private static bool ShouldProfile(HttpRequest request) + private bool ShouldProfile(HttpRequest request) { if (request.IsClientSideRequest()) { return false; } + var miniprofilerOptions = _httpContextAccessor.HttpContext?.RequestServices?.GetService>(); + if (miniprofilerOptions is not null && miniprofilerOptions.Value.IgnoredPaths.Contains(request.Path)) + { + return false; + } + + if (bool.TryParse(request.Query["umbDebug"], out var umbDebug)) { return umbDebug; From 8f9c67ffee9431bfdeda08c681165a384fb0dedb Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Thu, 16 Feb 2023 15:15:59 +0100 Subject: [PATCH 4/8] Handle static assets for the website directly in static assets project. These should not be build by the backoffice scripts as they are not owned by backoffice --- .../Umbraco.Cms.StaticAssets.csproj | 32 +++++++++++++++++-- .../umbraco/UmbracoWebsite/Maintenance.cshtml | 2 +- .../umbraco/UmbracoWebsite/NoNodes.cshtml | 2 +- .../umbraco/UmbracoWebsite/NotFound.cshtml | 2 +- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj b/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj index d082dff945..5a8dd128a5 100644 --- a/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj +++ b/src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj @@ -16,7 +16,9 @@ - $(ProjectDir)wwwroot\umbraco + $(ProjectDir)wwwroot\umbraco + $(BasePath)\lib + $(BasePath)\backoffice @@ -24,9 +26,19 @@ + + + + + + + + + + @@ -36,12 +48,26 @@ + + + + + + + - + - + + + + + + + + diff --git a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml index 46739cdef7..94de5f3c52 100644 --- a/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml +++ b/src/Umbraco.Cms.StaticAssets/umbraco/UmbracoWebsite/Maintenance.cshtml @@ -17,7 +17,7 @@ Website is Under Maintainance - +