diff --git a/Directory.Build.props b/Directory.Build.props index d2c4e87700..a3b7db5b37 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -25,8 +25,8 @@ TODO: Fix and remove overrides: [NU5104] Warning As Error: A stable release of a package should not have a prerelease dependency. Either modify the version spec of dependency --> - $(NoWarn),NU5104 - $(WarningsNotAsErrors),NU5104 + $(NoWarn),NU5104,SA1309 + $(WarningsNotAsErrors),NU5104,SA1600 diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/ServerEventExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/ServerEventExtensions.cs new file mode 100644 index 0000000000..6089a1dc88 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/ServerEventExtensions.cs @@ -0,0 +1,106 @@ +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.ServerEvents; +using Umbraco.Cms.Api.Management.ServerEvents.Authorizers; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +internal static class ServerEventExtensions +{ + internal static IUmbracoBuilder AddServerEvents(this IUmbracoBuilder builder) + { + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.AddNotificationAsyncHandler(); + + builder + .AddEvents() + .AddAuthorizers(); + + return builder; + } + + private static IUmbracoBuilder AddEvents(this IUmbracoBuilder builder) + { + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + + builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); + + return builder; + } + + private static IUmbracoBuilder AddAuthorizers(this IUmbracoBuilder builder) + { + builder.EventSourceAuthorizers() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append() + .Append(); + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs index 90993aa382..4ef42524c4 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs @@ -66,6 +66,7 @@ public static partial class UmbracoBuilderExtensions .AddCorsPolicy() .AddWebhooks() .AddPreview() + .AddServerEvents() .AddPasswordConfiguration() .AddSupplemenataryLocalizedTextFileSources() .AddUserData() diff --git a/src/Umbraco.Cms.Api.Management/Factories/DataTypePresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DataTypePresentationFactory.cs index 32b8478d53..944cc7c5cd 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DataTypePresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DataTypePresentationFactory.cs @@ -1,6 +1,8 @@  +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.ViewModels.DataType; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Serialization; @@ -16,17 +18,35 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory private readonly PropertyEditorCollection _propertyEditorCollection; private readonly IDataValueEditorFactory _dataValueEditorFactory; private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer; + private readonly TimeProvider _timeProvider; public DataTypePresentationFactory( IDataTypeContainerService dataTypeContainerService, PropertyEditorCollection propertyEditorCollection, IDataValueEditorFactory dataValueEditorFactory, - IConfigurationEditorJsonSerializer configurationEditorJsonSerializer) + IConfigurationEditorJsonSerializer configurationEditorJsonSerializer, + TimeProvider timeProvider) { _dataTypeContainerService = dataTypeContainerService; _propertyEditorCollection = propertyEditorCollection; _dataValueEditorFactory = dataValueEditorFactory; _configurationEditorJsonSerializer = configurationEditorJsonSerializer; + _timeProvider = timeProvider; + } + + [Obsolete("Use constructor that takes a TimeProvider")] + public DataTypePresentationFactory( + IDataTypeContainerService dataTypeContainerService, + PropertyEditorCollection propertyEditorCollection, + IDataValueEditorFactory dataValueEditorFactory, + IConfigurationEditorJsonSerializer configurationEditorJsonSerializer) + : this( + dataTypeContainerService, + propertyEditorCollection, + dataValueEditorFactory, + configurationEditorJsonSerializer, + StaticServiceProvider.Instance.GetRequiredService()) + { } /// @@ -44,6 +64,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory return Attempt.FailWithStatus(parentAttempt.Status, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer)); } + DateTime createDate = _timeProvider.GetLocalNow().DateTime; var dataType = new DataType(editor, _configurationEditorJsonSerializer) { Name = requestModel.Name, @@ -51,7 +72,8 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory DatabaseType = GetEditorValueStorageType(editor), ConfigurationData = MapConfigurationData(requestModel, editor), ParentId = parentAttempt.Result, - CreateDate = DateTime.Now, + CreateDate = createDate, + UpdateDate = createDate, }; if (requestModel.Id.HasValue) diff --git a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs index a12e1acb2e..e7f0a597a6 100644 --- a/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs +++ b/src/Umbraco.Cms.Api.Management/Routing/BackOfficeAreaRoutes.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Options; using Umbraco.Cms.Api.Management.Controllers.Security; +using Umbraco.Cms.Api.Management.ServerEvents; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Hosting; @@ -54,6 +55,7 @@ public sealed class BackOfficeAreaRoutes : IAreaRoutes case RuntimeLevel.Run: MapMinimalBackOffice(endpoints); endpoints.MapHub(_umbracoPathSegment + Constants.Web.BackofficeSignalRHub); + endpoints.MapHub(_umbracoPathSegment + Constants.Web.ServerEventSignalRHub); break; case RuntimeLevel.BootFailed: case RuntimeLevel.Unknown: diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DataTypeEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DataTypeEventAuthorizer.cs new file mode 100644 index 0000000000..724e197ae2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DataTypeEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class DataTypeEventAuthorizer : EventSourcePolicyAuthorizer +{ + public DataTypeEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.DataType]; + + protected override string Policy => AuthorizationPolicies.TreeAccessDataTypes; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DictionaryItemEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DictionaryItemEventAuthorizer.cs new file mode 100644 index 0000000000..cc0382eaaf --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DictionaryItemEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class DictionaryItemEventAuthorizer : EventSourcePolicyAuthorizer +{ + public DictionaryItemEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.DictionaryItem]; + + protected override string Policy => AuthorizationPolicies.TreeAccessDictionary; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DocumentEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DocumentEventAuthorizer.cs new file mode 100644 index 0000000000..dfe13c0427 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DocumentEventAuthorizer.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class DocumentEventAuthorizer : EventSourcePolicyAuthorizer +{ + public DocumentEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Document]; + + protected override string Policy => AuthorizationPolicies.TreeAccessDocuments; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DocumentTypeEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DocumentTypeEventAuthorizer.cs new file mode 100644 index 0000000000..257a38a43a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DocumentTypeEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class DocumentTypeEventAuthorizer : EventSourcePolicyAuthorizer +{ + public DocumentTypeEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.DocumentType]; + + protected override string Policy => AuthorizationPolicies.TreeAccessDocumentTypes; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DomainEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DomainEventAuthorizer.cs new file mode 100644 index 0000000000..e6db3475c4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/DomainEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class DomainEventAuthorizer : EventSourcePolicyAuthorizer +{ + public DomainEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Domain]; + + protected override string Policy => AuthorizationPolicies.TreeAccessDocuments; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/LanguageEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/LanguageEventAuthorizer.cs new file mode 100644 index 0000000000..8a7640ccac --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/LanguageEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class LanguageEventAuthorizer : EventSourcePolicyAuthorizer +{ + public LanguageEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Language]; + + protected override string Policy => AuthorizationPolicies.TreeAccessLanguages; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MediaEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MediaEventAuthorizer.cs new file mode 100644 index 0000000000..2d72bb592d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MediaEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class MediaEventAuthorizer : EventSourcePolicyAuthorizer +{ + public MediaEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Media]; + + protected override string Policy => AuthorizationPolicies.TreeAccessMediaOrMediaTypes; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MediaTypeEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MediaTypeEventAuthorizer.cs new file mode 100644 index 0000000000..b8504bdb4c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MediaTypeEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class MediaTypeEventAuthorizer : EventSourcePolicyAuthorizer +{ + public MediaTypeEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.MediaType]; + + protected override string Policy => AuthorizationPolicies.TreeAccessMediaTypes; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberEventAuthorizer.cs new file mode 100644 index 0000000000..71d9e9f1d8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class MemberEventAuthorizer : EventSourcePolicyAuthorizer +{ + public MemberEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Member]; + + protected override string Policy => AuthorizationPolicies.TreeAccessMembersOrMemberTypes; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberGroupEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberGroupEventAuthorizer.cs new file mode 100644 index 0000000000..297d0dfe40 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberGroupEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class MemberGroupEventAuthorizer : EventSourcePolicyAuthorizer +{ + public MemberGroupEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.MemberGroup]; + + protected override string Policy => AuthorizationPolicies.TreeAccessMemberGroups; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberTypeEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberTypeEventAuthorizer.cs new file mode 100644 index 0000000000..25fc28d9a2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/MemberTypeEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class MemberTypeEventAuthorizer : EventSourcePolicyAuthorizer +{ + public MemberTypeEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.MemberType]; + + protected override string Policy => AuthorizationPolicies.TreeAccessMemberTypes; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/PartialViewEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/PartialViewEventAuthorizer.cs new file mode 100644 index 0000000000..bae2635d8e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/PartialViewEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class PartialViewEventAuthorizer : EventSourcePolicyAuthorizer +{ + public PartialViewEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.PartialView]; + + protected override string Policy => AuthorizationPolicies.TreeAccessPartialViews; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/PublicAccessEntryEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/PublicAccessEntryEventAuthorizer.cs new file mode 100644 index 0000000000..81dcb840bd --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/PublicAccessEntryEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class PublicAccessEntryEventAuthorizer : EventSourcePolicyAuthorizer +{ + public PublicAccessEntryEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.PublicAccessEntry]; + + protected override string Policy => AuthorizationPolicies.TreeAccessDocuments; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationEventAuthorizer.cs new file mode 100644 index 0000000000..c7f5755821 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class RelationEventAuthorizer : EventSourcePolicyAuthorizer +{ + public RelationEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Relation]; + + protected override string Policy => AuthorizationPolicies.TreeAccessDocumentsOrMediaOrMembersOrContentTypes; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationTypeEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationTypeEventAuthorizer.cs new file mode 100644 index 0000000000..895d026573 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/RelationTypeEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class RelationTypeEventAuthorizer : EventSourcePolicyAuthorizer +{ + public RelationTypeEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.RelationType]; + + protected override string Policy => AuthorizationPolicies.TreeAccessRelationTypes; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/ScriptEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/ScriptEventAuthorizer.cs new file mode 100644 index 0000000000..84ee7384dd --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/ScriptEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class ScriptEventAuthorizer : EventSourcePolicyAuthorizer +{ + public ScriptEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Script]; + + protected override string Policy => AuthorizationPolicies.TreeAccessScripts; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/StylesheetEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/StylesheetEventAuthorizer.cs new file mode 100644 index 0000000000..68220f965f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/StylesheetEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class StylesheetEventAuthorizer : EventSourcePolicyAuthorizer +{ + public StylesheetEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Stylesheet]; + + protected override string Policy => AuthorizationPolicies.TreeAccessStylesheets; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/TemplateEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/TemplateEventAuthorizer.cs new file mode 100644 index 0000000000..c080f989fe --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/TemplateEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class TemplateEventAuthorizer : EventSourcePolicyAuthorizer +{ + public TemplateEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Template]; + + protected override string Policy => AuthorizationPolicies.TreeAccessTemplates; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/UserEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/UserEventAuthorizer.cs new file mode 100644 index 0000000000..19278f10ae --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/UserEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class UserEventAuthorizer : EventSourcePolicyAuthorizer +{ + public UserEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.User]; + + protected override string Policy => AuthorizationPolicies.SectionAccessUsers; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/UserGroupEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/UserGroupEventAuthorizer.cs new file mode 100644 index 0000000000..485d03fcec --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/UserGroupEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class UserGroupEventAuthorizer : EventSourcePolicyAuthorizer +{ + public UserGroupEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.UserGroup]; + + protected override string Policy => AuthorizationPolicies.SectionAccessUsers; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/WebhookEventAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/WebhookEventAuthorizer.cs new file mode 100644 index 0000000000..a768ad2050 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/Authorizers/WebhookEventAuthorizer.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core; +using Umbraco.Cms.Web.Common.Authorization; + +namespace Umbraco.Cms.Api.Management.ServerEvents.Authorizers; + +public class WebhookEventAuthorizer : EventSourcePolicyAuthorizer +{ + public WebhookEventAuthorizer(IAuthorizationService authorizationService) : base(authorizationService) + { + } + + public override IEnumerable AuthorizableEventSources => [Constants.ServerEvents.EventSource.Webhook]; + + protected override string Policy => AuthorizationPolicies.TreeAccessWebhooks; +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/EventSourcePolicyAuthorizer.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/EventSourcePolicyAuthorizer.cs new file mode 100644 index 0000000000..d2cdb3ded7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/EventSourcePolicyAuthorizer.cs @@ -0,0 +1,25 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Api.Management.ServerEvents; + +public abstract class EventSourcePolicyAuthorizer : IEventSourceAuthorizer +{ + private readonly IAuthorizationService _authorizationService; + + public EventSourcePolicyAuthorizer(IAuthorizationService authorizationService) + { + _authorizationService = authorizationService; + } + + public abstract IEnumerable AuthorizableEventSources { get; } + + protected abstract string Policy { get; } + + public async Task AuthorizeAsync(ClaimsPrincipal principal, string eventSource) + { + AuthorizationResult result = await _authorizationService.AuthorizeAsync(principal, Policy); + return result.Succeeded; + } +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventAuthorizationService.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventAuthorizationService.cs new file mode 100644 index 0000000000..04eb5f3c99 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventAuthorizationService.cs @@ -0,0 +1,93 @@ +using System.Security.Claims; +using Umbraco.Cms.Core.Models.ServerEvents; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Api.Management.ServerEvents; + +internal sealed class ServerEventAuthorizationService : IServerEventAuthorizationService +{ + private readonly EventSourceAuthorizerCollection _eventSourceAuthorizers; + private Dictionary>? _groupedAuthorizersByEventSource; + + public ServerEventAuthorizationService(EventSourceAuthorizerCollection eventSourceAuthorizers) + { + _eventSourceAuthorizers = eventSourceAuthorizers; + } + + private Dictionary> GroupedAuthorizersByEventSource + { + get + { + if (_groupedAuthorizersByEventSource is not null) + { + return _groupedAuthorizersByEventSource; + } + + _groupedAuthorizersByEventSource = GetGroupedAuthorizersByEventSource(); + return _groupedAuthorizersByEventSource; + } + } + + private IEnumerable AuthorizableEventSources => GroupedAuthorizersByEventSource.Keys; + + private Dictionary> GetGroupedAuthorizersByEventSource() + { + var groupedAuthorizers = new Dictionary>(); + + foreach (IEventSourceAuthorizer eventSourceAuthorizer in _eventSourceAuthorizers) + { + foreach (var eventSource in eventSourceAuthorizer.AuthorizableEventSources) + { + if (groupedAuthorizers.TryGetValue(eventSource, out List? authorizers) is false) + { + authorizers = [eventSourceAuthorizer]; + groupedAuthorizers[eventSource] = authorizers; + } + + authorizers.Add(eventSourceAuthorizer); + } + } + + return groupedAuthorizers; + } + + public async Task AuthorizeAsync(ClaimsPrincipal user) + { + var authorizedEventSources = new List(); + var unauthorizedEventSources = new List(); + + foreach (var eventSource in AuthorizableEventSources) + { + if (GroupedAuthorizersByEventSource.TryGetValue(eventSource, out List? authorizers) is false || authorizers.Count == 0) + { + throw new InvalidOperationException($"No authorizers found for event source {eventSource}"); + } + + // There may have been registered multiple authorizers for the same event source, in that case if any authorizer fails, the user is unauthorized. + var isAuthorized = false; + foreach (IEventSourceAuthorizer authorizer in authorizers) + { + isAuthorized = await authorizer.AuthorizeAsync(user, eventSource); + if (isAuthorized is false) + { + break; + } + } + + if (isAuthorized) + { + authorizedEventSources.Add(eventSource); + } + else + { + unauthorizedEventSources.Add(eventSource); + } + } + + return new SeverEventAuthorizationResult + { + AuthorizedEventSources = authorizedEventSources, + UnauthorizedEventSources = unauthorizedEventSources, + }; + } +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventHub.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventHub.cs new file mode 100644 index 0000000000..b857649c6c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventHub.cs @@ -0,0 +1,52 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Umbraco.Cms.Core.ServerEvents; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.ServerEvents; + +[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] +public class ServerEventHub : Hub +{ + private readonly IUserConnectionManager _userConnectionManager; + private readonly IServerEventUserManager _serverEventUserManager; + + public ServerEventHub( + IUserConnectionManager userConnectionManager, + IServerEventUserManager serverEventUserManager) + { + _userConnectionManager = userConnectionManager; + _serverEventUserManager = serverEventUserManager; + } + + public override async Task OnConnectedAsync() + { + ClaimsPrincipal? principal = Context.User; + Guid? userKey = principal?.Identity?.GetUserKey(); + + if (userKey is null) + { + Context.Abort(); + return; + } + + _userConnectionManager.AddConnection(userKey.Value, Context.ConnectionId); + await _serverEventUserManager.AssignToGroupsAsync(principal!, Context.ConnectionId); + } + + public override Task OnDisconnectedAsync(Exception? exception) + { + ClaimsPrincipal? principal = Context.User; + Guid? userKey = principal?.Identity?.GetUserKey(); + + if (userKey is null) + { + return Task.CompletedTask; + } + + _userConnectionManager.RemoveConnection(userKey.Value, Context.ConnectionId); + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventRouter.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventRouter.cs new file mode 100644 index 0000000000..c9f66bc8ca --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventRouter.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.SignalR; +using Umbraco.Cms.Core.Models.ServerEvents; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Api.Management.ServerEvents; + +/// +internal sealed class ServerEventRouter : IServerEventRouter +{ + private readonly IHubContext _eventHub; + private readonly IUserConnectionManager _connectionManager; + + public ServerEventRouter( + IHubContext eventHub, + IUserConnectionManager connectionManager) + { + _eventHub = eventHub; + _connectionManager = connectionManager; + } + + /// + public Task RouteEventAsync(ServerEvent serverEvent) + => _eventHub.Clients.Group(serverEvent.EventSource).notify(serverEvent); + + /// + public async Task NotifyUserAsync(ServerEvent serverEvent, Guid userKey) + { + ISet userConnections = _connectionManager.GetConnections(userKey); + + if (userConnections.Any() is false) + { + return; + } + + await _eventHub.Clients.Clients(userConnections).notify(serverEvent); + } + + + /// + public async Task BroadcastEventAsync(ServerEvent serverEvent) => await _eventHub.Clients.All.notify(serverEvent); +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventSender.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventSender.cs new file mode 100644 index 0000000000..4a53fb702b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventSender.cs @@ -0,0 +1,256 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Models.ServerEvents; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Api.Management.ServerEvents; + +internal sealed class ServerEventSender : + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler, + INotificationAsyncHandler +{ + private readonly IServerEventRouter _serverEventRouter; + + public ServerEventSender(IServerEventRouter serverEventRouter) + { + _serverEventRouter = serverEventRouter; + } + + private async Task NotifySavedAsync(SavedNotification notification, string source) + where T : IEntity + { + foreach (T entity in notification.SavedEntities) + { + var eventModel = new ServerEvent + { + EventType = entity.CreateDate == entity.UpdateDate + ? Constants.ServerEvents.EventType.Created : Constants.ServerEvents.EventType.Updated, + Key = entity.Key, + EventSource = source, + }; + + await _serverEventRouter.RouteEventAsync(eventModel); + } + } + + private async Task NotifyDeletedAsync(DeletedNotification notification, string source) + where T : IEntity + { + foreach (T entity in notification.DeletedEntities) + { + await _serverEventRouter.RouteEventAsync(new ServerEvent + { + EventType = Constants.ServerEvents.EventType.Deleted, + EventSource = source, + Key = entity.Key, + }); + } + } + + private async Task NotifyTrashedAsync(MovedToRecycleBinNotification notification, string source) + where T : IEntity + { + foreach (MoveToRecycleBinEventInfo movedEvent in notification.MoveInfoCollection) + { + await _serverEventRouter.RouteEventAsync(new ServerEvent + { + EventType = Constants.ServerEvents.EventType.Trashed, + EventSource = source, + Key = movedEvent.Entity.Key, + }); + } + } + + public async Task HandleAsync(ContentSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Document); + + public async Task HandleAsync(ContentTypeSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.DocumentType); + + public async Task HandleAsync(MediaSavedNotification notification, CancellationToken cancellationToken) + => await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Media); + + public async Task HandleAsync(MediaTypeSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.MediaType); + + public async Task HandleAsync(MemberSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Member); + + public async Task HandleAsync(MemberTypeSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.MemberType); + + public async Task HandleAsync(MemberGroupSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.MemberGroup); + + public async Task HandleAsync(DataTypeSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.DataType); + + public async Task HandleAsync(LanguageSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Language); + + public async Task HandleAsync(ScriptSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Script); + + public async Task HandleAsync(StylesheetSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Stylesheet); + + public async Task HandleAsync(TemplateSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Template); + + public async Task HandleAsync(DictionaryItemSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.DictionaryItem); + + public async Task HandleAsync(DomainSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Domain); + + public async Task HandleAsync(PartialViewSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.PartialView); + + public async Task HandleAsync(PublicAccessEntrySavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.PublicAccessEntry); + + public async Task HandleAsync(RelationSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Relation); + + public async Task HandleAsync(RelationTypeSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.RelationType); + + public async Task HandleAsync(UserGroupSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.UserGroup); + + public async Task HandleAsync(UserSavedNotification notification, CancellationToken cancellationToken) + { + // We still need to notify of saved entities like any other event source. + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.User); + + // But for users we also want to notify each updated user that they have been updated separately. + foreach (IUser user in notification.SavedEntities) + { + var eventModel = new ServerEvent + { + EventType = Constants.ServerEvents.EventType.Updated, + Key = user.Key, + EventSource = Constants.ServerEvents.EventSource.CurrentUser, + }; + + await _serverEventRouter.NotifyUserAsync(eventModel, user.Key); + } + } + + public async Task HandleAsync(WebhookSavedNotification notification, CancellationToken cancellationToken) => + await NotifySavedAsync(notification, Constants.ServerEvents.EventSource.Webhook); + + public async Task HandleAsync(ContentDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Document); + + public async Task HandleAsync(ContentTypeDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.DocumentType); + + public async Task HandleAsync(MediaDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Media); + + public async Task HandleAsync(MediaTypeDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.MediaType); + + public async Task HandleAsync(MemberDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Member); + + public async Task HandleAsync(MemberTypeDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.MemberType); + + public async Task HandleAsync(MemberGroupDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.MemberGroup); + + public async Task HandleAsync(DataTypeDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.DataType); + + public async Task HandleAsync(LanguageDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Language); + + public async Task HandleAsync(ScriptDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Script); + + public async Task HandleAsync(StylesheetDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Stylesheet); + + public async Task HandleAsync(TemplateDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Template); + + public async Task HandleAsync(DictionaryItemDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.DictionaryItem); + + public async Task HandleAsync(DomainDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Domain); + + public async Task HandleAsync(PartialViewDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.PartialView); + + public async Task HandleAsync(PublicAccessEntryDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.PublicAccessEntry); + + public async Task HandleAsync(RelationDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Relation); + + public async Task HandleAsync(RelationTypeDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.RelationType); + + public async Task HandleAsync(UserGroupDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.UserGroup); + + public async Task HandleAsync(UserDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.User); + + public async Task HandleAsync(WebhookDeletedNotification notification, CancellationToken cancellationToken) => + await NotifyDeletedAsync(notification, Constants.ServerEvents.EventSource.Webhook); + + public async Task HandleAsync(ContentMovedToRecycleBinNotification notification, CancellationToken cancellationToken) => + await NotifyTrashedAsync(notification, Constants.ServerEvents.EventSource.Document); + + public async Task HandleAsync(MediaMovedToRecycleBinNotification notification, CancellationToken cancellationToken) => + await NotifyTrashedAsync(notification, Constants.ServerEvents.EventSource.Media); +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventUserManager.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventUserManager.cs new file mode 100644 index 0000000000..3e7cd55118 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventUserManager.cs @@ -0,0 +1,77 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.SignalR; +using Umbraco.Cms.Core.Models.ServerEvents; +using Umbraco.Cms.Core.ServerEvents; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.ServerEvents; + +/// +internal sealed class ServerEventUserManager : IServerEventUserManager +{ + private readonly IUserConnectionManager _userConnectionManager; + private readonly IServerEventAuthorizationService _serverEventAuthorizationService; + private readonly IHubContext _eventHub; + + public ServerEventUserManager( + IUserConnectionManager userConnectionManager, + IServerEventAuthorizationService serverEventAuthorizationService, + IHubContext eventHub) + { + _userConnectionManager = userConnectionManager; + _serverEventAuthorizationService = serverEventAuthorizationService; + _eventHub = eventHub; + } + + /// + public async Task AssignToGroupsAsync(ClaimsPrincipal user, string connectionId) + { + SeverEventAuthorizationResult authorizationResult = await _serverEventAuthorizationService.AuthorizeAsync(user); + + foreach (var authorizedEventSource in authorizationResult.AuthorizedEventSources) + { + await _eventHub.Groups.AddToGroupAsync(connectionId, authorizedEventSource); + } + } + + /// + public async Task RefreshGroupsAsync(ClaimsPrincipal user) + { + Guid? userKey = user.Identity?.GetUserKey(); + + // If we can't resolve the user key from the principal something is quite wrong, and we shouldn't continue. + if (userKey is null) + { + throw new InvalidOperationException("Unable to resolve user key."); + } + + // Ensure that all the users connections are removed from all groups. + ISet connections = _userConnectionManager.GetConnections(userKey.Value); + + // If there's no connection there's nothing to do. + if (connections.Count == 0) + { + return; + } + + SeverEventAuthorizationResult authorizationResult = await _serverEventAuthorizationService.AuthorizeAsync(user); + + // Add the user to the authorized groups, and remove them from the unauthorized groups. + // Note that it's safe to add a user to a group multiple times, so we don't have ot worry about that. + foreach (var authorizedEventSource in authorizationResult.AuthorizedEventSources) + { + foreach (var connection in connections) + { + await _eventHub.Groups.AddToGroupAsync(connection, authorizedEventSource); + } + } + + foreach (var unauthorizedEventSource in authorizationResult.UnauthorizedEventSources) + { + foreach (var connection in connections) + { + await _eventHub.Groups.RemoveFromGroupAsync(connection, unauthorizedEventSource); + } + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionManager.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionManager.cs new file mode 100644 index 0000000000..69bcd284e8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionManager.cs @@ -0,0 +1,53 @@ +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Api.Management.ServerEvents; + +/// +internal sealed class UserConnectionManager : IUserConnectionManager +{ + // We use a normal dictionary instead of ConcurrentDictionary, since we need to lock the set anyways. + private readonly Dictionary> _connections = new(); + private readonly object _lock = new(); + + /// + public ISet GetConnections(Guid userKey) + { + lock (_lock) + { + return _connections.TryGetValue(userKey, out HashSet? connections) ? connections : []; + } + } + + /// + public void AddConnection(Guid userKey, string connectionId) + { + lock (_lock) + { + if (_connections.TryGetValue(userKey, out HashSet? connections) is false) + { + connections = []; + _connections[userKey] = connections; + } + + connections.Add(connectionId); + } + } + + /// + public void RemoveConnection(Guid userKey, string connectionId) + { + lock (_lock) + { + if (_connections.TryGetValue(userKey, out HashSet? connections) is false) + { + return; + } + + connections.Remove(connectionId); + if (connections.Count == 0) + { + _connections.Remove(userKey); + } + } + } +} diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionRefresher.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionRefresher.cs new file mode 100644 index 0000000000..b9dd9c6c8b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionRefresher.cs @@ -0,0 +1,41 @@ +using System.Security.Claims; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.ServerEvents; +using Umbraco.Cms.Web.Common.Security; + +namespace Umbraco.Cms.Api.Management.ServerEvents; + +/// +/// updates the user's connections if any, when a user is saved +/// +internal sealed class UserConnectionRefresher : INotificationAsyncHandler +{ + private readonly IBackOfficeSignInManager _backOfficeSignInManager; + private readonly IServerEventUserManager _serverEventUserManager; + + public UserConnectionRefresher( + IBackOfficeSignInManager backOfficeSignInManager, + IServerEventUserManager serverEventUserManager) + { + _backOfficeSignInManager = backOfficeSignInManager; + _serverEventUserManager = serverEventUserManager; + } + + public async Task HandleAsync(UserSavedNotification notification, CancellationToken cancellationToken) + { + foreach (IUser user in notification.SavedEntities) + { + // This might look strange, but we need a claims principal to authorize, this doesn't log the user in, but just creates a principal. + ClaimsPrincipal? claimsIdentity = await _backOfficeSignInManager.CreateUserPrincipalAsync(user.Key); + if (claimsIdentity is null) + { + return; + } + + await _serverEventUserManager.RefreshGroupsAsync(claimsIdentity); + } + + } +} diff --git a/src/Umbraco.Core/Constants-ServerEvents.cs b/src/Umbraco.Core/Constants-ServerEvents.cs new file mode 100644 index 0000000000..e84e62d350 --- /dev/null +++ b/src/Umbraco.Core/Constants-ServerEvents.cs @@ -0,0 +1,65 @@ +namespace Umbraco.Cms.Core; + +public static partial class Constants +{ + public static class ServerEvents + { + public static class EventSource + { + public const string Document = "Umbraco:CMS:Document"; + + public const string DocumentType = "Umbraco:CMS:DocumentType"; + + public const string Media = "Umbraco:CMS:Media"; + + public const string MediaType = "Umbraco:CMS:MediaType"; + + public const string Member = "Umbraco:CMS:Member"; + + public const string MemberType = "Umbraco:CMS:MemberType"; + + public const string MemberGroup = "Umbraco:CMS:MemberGroup"; + + public const string DataType = "Umbraco:CMS:DataType"; + + public const string Language = "Umbraco:CMS:Language"; + + public const string Script = "Umbraco:CMS:Script"; + + public const string Stylesheet = "Umbraco:CMS:Stylesheet"; + + public const string Template = "Umbraco:CMS:Template"; + + public const string DictionaryItem = "Umbraco:CMS:DictionaryItem"; + + public const string Domain = "Umbraco:CMS:Domain"; + + public const string PartialView = "Umbraco:CMS:PartialView"; + + public const string PublicAccessEntry = "Umbraco:CMS:PublicAccessEntry"; + + public const string Relation = "Umbraco:CMS:Relation"; + + public const string RelationType = "Umbraco:CMS:RelationType"; + + public const string UserGroup = "Umbraco:CMS:UserGroup"; + + public const string User = "Umbraco:CMS:User"; + + public const string CurrentUser = "Umbraco:CMS:CurrentUser"; + + public const string Webhook = "Umbraco:CMS:Webhook"; + } + + public static class EventType + { + public static string Created = "Created"; + + public static string Updated = "Updated"; + + public static string Deleted = "Deleted"; + + public static string Trashed = "Trashed"; + } + } +} diff --git a/src/Umbraco.Core/Constants-Web.cs b/src/Umbraco.Core/Constants-Web.cs index 65b460ba69..de5e5d1f7a 100644 --- a/src/Umbraco.Core/Constants-Web.cs +++ b/src/Umbraco.Core/Constants-Web.cs @@ -57,6 +57,7 @@ public static partial class Constants /// public const string ManagementApiPath = "/management/api/"; public const string BackofficeSignalRHub = "/backofficeHub"; + public const string ServerEventSignalRHub = "/serverEventHub"; public static class Routing { diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs index d6f7b480aa..2ec4a72480 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Media.EmbedProviders; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.ServerEvents; using Umbraco.Cms.Core.Snippets; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Webhooks; @@ -107,6 +108,9 @@ public static partial class UmbracoBuilderExtensions public static ContentFinderCollectionBuilder ContentFinders(this IUmbracoBuilder builder) => builder.WithCollectionBuilder(); + public static EventSourceAuthorizerCollectionBuilder EventSourceAuthorizers(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); + /// /// Gets the editor validators collection builder. /// diff --git a/src/Umbraco.Core/Models/ServerEvents/ServerEvent.cs b/src/Umbraco.Core/Models/ServerEvents/ServerEvent.cs new file mode 100644 index 0000000000..299fd66392 --- /dev/null +++ b/src/Umbraco.Core/Models/ServerEvents/ServerEvent.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Models.ServerEvents; + +public class ServerEvent +{ + public required string EventType { get; set; } + + public required string EventSource { get; set; } + + public Guid Key { get; set; } +} diff --git a/src/Umbraco.Core/Models/ServerEvents/SeverEventAuthorizationResult.cs b/src/Umbraco.Core/Models/ServerEvents/SeverEventAuthorizationResult.cs new file mode 100644 index 0000000000..71fdd78b7e --- /dev/null +++ b/src/Umbraco.Core/Models/ServerEvents/SeverEventAuthorizationResult.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.ServerEvents; + +public class SeverEventAuthorizationResult +{ + /// + /// The list of events the user should be authorized to listen to + /// + public required IEnumerable AuthorizedEventSources { get; set; } + + /// + /// The list of events the user should not be authorized to listen to + /// + public required IEnumerable UnauthorizedEventSources { get; set; } +} diff --git a/src/Umbraco.Core/ServerEvents/EventSourceAuthorizerCollection.cs b/src/Umbraco.Core/ServerEvents/EventSourceAuthorizerCollection.cs new file mode 100644 index 0000000000..b62db65df8 --- /dev/null +++ b/src/Umbraco.Core/ServerEvents/EventSourceAuthorizerCollection.cs @@ -0,0 +1,11 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.ServerEvents; + +public class EventSourceAuthorizerCollection : BuilderCollectionBase +{ + public EventSourceAuthorizerCollection(Func> items) + : base(items) + { + } +} diff --git a/src/Umbraco.Core/ServerEvents/EventSourceAuthorizerCollectionBuilder.cs b/src/Umbraco.Core/ServerEvents/EventSourceAuthorizerCollectionBuilder.cs new file mode 100644 index 0000000000..36fdd432c8 --- /dev/null +++ b/src/Umbraco.Core/ServerEvents/EventSourceAuthorizerCollectionBuilder.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.ServerEvents; + +public class EventSourceAuthorizerCollectionBuilder : OrderedCollectionBuilderBase +{ + protected override EventSourceAuthorizerCollectionBuilder This => this; +} diff --git a/src/Umbraco.Core/ServerEvents/IEventSourceAuthorizer.cs b/src/Umbraco.Core/ServerEvents/IEventSourceAuthorizer.cs new file mode 100644 index 0000000000..eacd51d51a --- /dev/null +++ b/src/Umbraco.Core/ServerEvents/IEventSourceAuthorizer.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; + +namespace Umbraco.Cms.Core.ServerEvents; + +/// +/// Authorizes a Claims principal to access an event source. +/// +public interface IEventSourceAuthorizer +{ + /// + /// The event sources this authorizer authorizes for. + /// + public IEnumerable AuthorizableEventSources { get; } + + /// + /// Authorizes a Claims principal to access an event source. + /// + /// The principal that being authorized. + /// The event source to authorize the principal for. + /// True is authorized, false if unauthorized. + Task AuthorizeAsync(ClaimsPrincipal principal, string eventSource); +} diff --git a/src/Umbraco.Core/ServerEvents/IServerEventAuthorizationService.cs b/src/Umbraco.Core/ServerEvents/IServerEventAuthorizationService.cs new file mode 100644 index 0000000000..3dd19cef4b --- /dev/null +++ b/src/Umbraco.Core/ServerEvents/IServerEventAuthorizationService.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; +using Umbraco.Cms.Core.Models.ServerEvents; + +namespace Umbraco.Cms.Core.ServerEvents; + +public interface IServerEventAuthorizationService +{ + /// + /// Authorizes a user to listen to server events. + /// + /// The user to authorize. + /// The authorization result, containing all authorized event sources, and unauthorized event sources. + Task AuthorizeAsync(ClaimsPrincipal user); +} diff --git a/src/Umbraco.Core/ServerEvents/IServerEventHub.cs b/src/Umbraco.Core/ServerEvents/IServerEventHub.cs new file mode 100644 index 0000000000..77892c5418 --- /dev/null +++ b/src/Umbraco.Core/ServerEvents/IServerEventHub.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models.ServerEvents; + +namespace Umbraco.Cms.Core.ServerEvents; + +public interface IServerEventHub +{ +#pragma warning disable SA1300 + Task notify(ServerEvent payload); +#pragma warning restore SA1300 +} diff --git a/src/Umbraco.Core/ServerEvents/IServerEventRouter.cs b/src/Umbraco.Core/ServerEvents/IServerEventRouter.cs new file mode 100644 index 0000000000..a968b74ba4 --- /dev/null +++ b/src/Umbraco.Core/ServerEvents/IServerEventRouter.cs @@ -0,0 +1,32 @@ +using Umbraco.Cms.Core.Models.ServerEvents; + +namespace Umbraco.Cms.Core.ServerEvents; + +/// +/// Routes server events to the correct users. +/// +public interface IServerEventRouter +{ + /// + /// Route a server event the users that has permissions to see it. + /// + /// The server event to route. + /// + Task RouteEventAsync(ServerEvent serverEvent); + + /// + /// Notify a specific user about a server event. + /// Does not consider authorization. + /// + /// The server event to send to the user. + /// Key of the user. + /// + Task NotifyUserAsync(ServerEvent serverEvent, Guid userKey); + + /// + /// Broadcast a server event to all users, regardless of authorization. + /// + /// The event to broadcast. + /// + Task BroadcastEventAsync(ServerEvent serverEvent); +} diff --git a/src/Umbraco.Core/ServerEvents/IServerEventUserManager.cs b/src/Umbraco.Core/ServerEvents/IServerEventUserManager.cs new file mode 100644 index 0000000000..d13befe530 --- /dev/null +++ b/src/Umbraco.Core/ServerEvents/IServerEventUserManager.cs @@ -0,0 +1,24 @@ +using System.Security.Claims; + +namespace Umbraco.Cms.Core.ServerEvents; + +/// +/// Manages group access for a user. +/// +public interface IServerEventUserManager +{ + /// + /// Adds the connections to the groups that the user has access to. + /// + /// The owner of the connection. + /// The connection to add to groups. + /// + Task AssignToGroupsAsync(ClaimsPrincipal user, string connectionId); + + /// + /// Reauthorize the user and removes all connections held by the user from groups they are no longer allowed to access. + /// + /// The user to reauthorize. + /// + Task RefreshGroupsAsync(ClaimsPrincipal user); +} diff --git a/src/Umbraco.Core/ServerEvents/IUserConnectionManager.cs b/src/Umbraco.Core/ServerEvents/IUserConnectionManager.cs new file mode 100644 index 0000000000..e4843711bc --- /dev/null +++ b/src/Umbraco.Core/ServerEvents/IUserConnectionManager.cs @@ -0,0 +1,28 @@ +namespace Umbraco.Cms.Core.ServerEvents; + +/// +/// A manager that tracks connection ids for users. +/// +public interface IUserConnectionManager +{ + /// + /// Get all connections held by a user. + /// + /// The key of the user to get connections for. + /// The users connections. + ISet GetConnections(Guid userKey); + + /// + /// Add a connection to a user. + /// + /// The key of the user to add the connection to. + /// Connection id to add. + void AddConnection(Guid userKey, string connectionId); + + /// + /// Removes a connection from a user. + /// + /// The user key to remove the connection from. + /// The connection id to remove + void RemoveConnection(Guid userKey, string connectionId); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/FakeAuthorizer.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/FakeAuthorizer.cs new file mode 100644 index 0000000000..7c5b503fbc --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/FakeAuthorizer.cs @@ -0,0 +1,20 @@ +using System.Security.Claims; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.ServerEvents; + + +internal class FakeAuthorizer : IEventSourceAuthorizer +{ + private readonly Func authorizeFunc; + + public FakeAuthorizer(IEnumerable sources, Func? authorizeFunc = null) + { + this.authorizeFunc = authorizeFunc ?? ((_, _) => true); + AuthorizableEventSources = sources; + } + + public IEnumerable AuthorizableEventSources { get; } + + public Task AuthorizeAsync(ClaimsPrincipal principal, string connectionId) => Task.FromResult(authorizeFunc(principal, connectionId)); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventAuthorizerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventAuthorizerTests.cs new file mode 100644 index 0000000000..1d428a827f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventAuthorizerTests.cs @@ -0,0 +1,104 @@ +using System.Security.Claims; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.ServerEvents; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.ServerEvents; + +[TestFixture] +public class ServerEventAuthorizerTests +{ + [Test] + public async Task CanAuthorize() + { + var sourceName = "Authorized source"; + var authorizer = new FakeAuthorizer([sourceName]); + + var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizer)); + var result = await sut.AuthorizeAsync(CreateFakeUser()); + + Assert.That(result.AuthorizedEventSources.Count(), Is.EqualTo(1)); + Assert.That(result.UnauthorizedEventSources.Count(), Is.EqualTo(0)); + Assert.That(result.AuthorizedEventSources.First(), Is.EqualTo(sourceName)); + } + + [Test] + public async Task CanUnauthorize() + { + var sourceName = "unauthorized source"; + var authorizer = new FakeAuthorizer([sourceName], (_, _) => false); + + var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizer)); + var result = await sut.AuthorizeAsync(CreateFakeUser()); + + Assert.That(result.AuthorizedEventSources.Count(), Is.EqualTo(0)); + Assert.That(result.UnauthorizedEventSources.Count(), Is.EqualTo(1)); + Assert.That(result.UnauthorizedEventSources.First(), Is.EqualTo(sourceName)); + } + + [Test] + public async Task AnyAuthorizationFailureResultInUnauthorized() + { + var sourceName = "Unauthorized source"; + + FakeAuthorizer[] authorizers = [new([sourceName]), new([sourceName], (_, _) => false), new([sourceName])]; + var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizers)); + var result = await sut.AuthorizeAsync(CreateFakeUser()); + + Assert.That(result.AuthorizedEventSources.Count(), Is.EqualTo(0)); + Assert.That(result.UnauthorizedEventSources.Count(), Is.EqualTo(1)); + Assert.That(result.UnauthorizedEventSources.First(), Is.EqualTo(sourceName)); + } + + [Test] + public async Task CanHandleMultiple() + { + string[] authorizedSources = ["first auth", "second auth", "third auth"]; + string[] unauthorizedSources = ["first unauth", "second unauth", "third unauth"]; + + FakeAuthorizer[] authorizers = [ + new(authorizedSources), + new(unauthorizedSources, (_, _) => false) + ]; + + var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizers)); + var result = await sut.AuthorizeAsync(CreateFakeUser()); + + Assert.That(result.AuthorizedEventSources, Is.EquivalentTo(authorizedSources)); + Assert.That(result.UnauthorizedEventSources, Is.EquivalentTo(unauthorizedSources)); + } + + [Test] + public async Task CanHandleMultipleAuthorizers() + { + string[] authorizedSources = ["first auth", "second auth", "third auth"]; + string[] unauthorizedSources = ["first unauth", "second unauth", "third unauth"]; + + FakeAuthorizer[] authorizers = [ + new([authorizedSources[0]]), + new([authorizedSources[1]]), + new([unauthorizedSources[0]], (_, _) => false), + new([unauthorizedSources[1]], (_, _) => false), + new([authorizedSources[2], unauthorizedSources[2]], (_, source) => source == authorizedSources[2]), + new(authorizedSources) + ]; + + var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizers)); + var result = await sut.AuthorizeAsync(CreateFakeUser()); + + Assert.That(result.AuthorizedEventSources, Is.EquivalentTo(authorizedSources)); + Assert.That(result.UnauthorizedEventSources, Is.EquivalentTo(unauthorizedSources)); + } + + private ClaimsPrincipal CreateFakeUser(Guid? key = null) => + new(new ClaimsIdentity([ + + // This is the claim that's used to store the ID + new Claim(Constants.Security.OpenIdDictSubClaimType, key is null ? Guid.NewGuid().ToString() : key.ToString()) + ])); + + private EventSourceAuthorizerCollection CreateServeEventAuthorizerCollection( + params IEnumerable authorizers) + => new(() => authorizers); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventRouterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventRouterTests.cs new file mode 100644 index 0000000000..3b6da868ba --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventRouterTests.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.SignalR; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.ServerEvents; +using Umbraco.Cms.Core.Models.ServerEvents; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.ServerEvents; + +[TestFixture] +public class ServerEventRouterTests +{ + [Test] + public async Task RouteEventRoutesToEventSourceGroup() + { + var mocks = CreateMocks(); + var groupName = "TestSource"; + var serverEvent = new ServerEvent { EventType = "TestEvent", EventSource = groupName, Key = Guid.Empty }; + mocks.HubClientsMock.Setup(x => x.Group(groupName)).Returns(mocks.HubMock.Object); + + var sut = new ServerEventRouter(mocks.HubContextMock.Object, new UserConnectionManager()); + + await sut.RouteEventAsync(serverEvent); + + // Group should only be called ONCE + mocks.HubClientsMock.Verify(x => x.Group(It.IsAny()), Times.Once); + // And that once time must be with the event source as group name + mocks.HubClientsMock.Verify(x => x.Group(groupName), Times.Once); + mocks.HubMock.Verify(x => x.notify(serverEvent), Times.Once); + } + + [Test] + public async Task NotifyUserOnlyNotifiesSpecificUser() + { + var targetUserKey = Guid.NewGuid(); + var targetUserConnections = new List { "connection1", "connection2", "connection3" }; + var nonTargetUsers = new Dictionary>(); + nonTargetUsers.Add(Guid.NewGuid(), new List { "connection4", "connection5" }); + nonTargetUsers.Add(Guid.NewGuid(), new List { "connection6", "connection7" }); + + var connectionManager = new UserConnectionManager(); + + foreach (var connection in targetUserConnections) + { + connectionManager.AddConnection(targetUserKey, connection); + } + + // Let's add some connections for other users + foreach (var connectionSet in nonTargetUsers) + { + foreach (var connection in connectionSet.Value) + { + connectionManager.AddConnection(connectionSet.Key, connection); + } + } + + var mocks = CreateMocks(); + mocks.HubClientsMock.Setup(x => x.Clients(It.IsAny>())).Returns(mocks.HubMock.Object); + + var serverEvent = new ServerEvent { EventSource = "Source", EventType = "Type", Key = Guid.Empty }; + var sut = new ServerEventRouter(mocks.HubContextMock.Object, connectionManager); + await sut.NotifyUserAsync(serverEvent, targetUserKey); + + mocks.HubClientsMock.Verify(x => x.Clients(It.IsAny>()), Times.Once()); + mocks.HubClientsMock.Verify(x => x.Clients(targetUserConnections), Times.Once()); + mocks.HubMock.Verify(x => x.notify(serverEvent), Times.Once()); + } + + [Test] + public async Task NotifyUserOnlyActsIfConnectionsExist() + { + var targetUserKey = Guid.NewGuid(); + var nonTargetUsers = new Dictionary>(); + nonTargetUsers.Add(Guid.NewGuid(), new List { "connection4", "connection5" }); + nonTargetUsers.Add(Guid.NewGuid(), new List { "connection6", "connection7" }); + + var connectionManager = new UserConnectionManager(); + + foreach (var connectionSet in nonTargetUsers) + { + foreach (var connection in connectionSet.Value) + { + connectionManager.AddConnection(connectionSet.Key, connection); + } + } + + // Note that target user has no connections. + var serverEvent = new ServerEvent { EventSource = "Source", EventType = "Type", Key = Guid.Empty }; + var mocks = CreateMocks(); + + var sut = new ServerEventRouter(mocks.HubContextMock.Object, connectionManager); + + await sut.NotifyUserAsync(serverEvent, targetUserKey); + + mocks.HubClientsMock.Verify(x => x.Clients(It.IsAny>()), Times.Never()); + mocks.HubMock.Verify(x => x.notify(serverEvent), Times.Never()); + } + + private (Mock HubMock, Mock> HubClientsMock, Mock> HubContextMock) CreateMocks() + { + var hubMock = new Mock(); + var hubClients = new Mock>(); + hubClients.Setup(x => x.All).Returns(hubMock.Object); + var hubContext = new Mock>(); + hubContext.Setup(x => x.Clients).Returns(hubClients.Object); + return (hubMock, hubClients, hubContext); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventUserManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventUserManagerTests.cs new file mode 100644 index 0000000000..54645d726b --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventUserManagerTests.cs @@ -0,0 +1,124 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.SignalR; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.ServerEvents; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.ServerEvents; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.ServerEvents; + +[TestFixture] +public class ServerEventUserManagerTests +{ + [Test] + public async Task AssignsUserToEventSourceGroup() + { + var userKey = Guid.NewGuid(); + var user = CreateFakeUser(userKey); + var authorizationService = CreateServeEventAuthorizationService(new FakeAuthorizer(["source"])); + var mocks = CreateHubContextMocks(); + + // Add a connection to the user + var connection = "connection1"; + var connectionManager = new UserConnectionManager(); + connectionManager.AddConnection(userKey, connection); + + var sut = new ServerEventUserManager(connectionManager, authorizationService, mocks.HubContextMock.Object); + await sut.AssignToGroupsAsync(user, connection); + + // Ensure AddToGroupAsync was called once, and only once with the expected parameters. + mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(connection, "source", It.IsAny()), Times.Once); + mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task DoesNotAssignUserToEventSourceGroupWhenUnauthorized() + { + var userKey = Guid.NewGuid(); + var user = CreateFakeUser(userKey); + var authorizationService = CreateServeEventAuthorizationService(new FakeAuthorizer(["source"], (_, _) => false)); + var mocks = CreateHubContextMocks(); + + // Add a connection to the user + var connection = "connection1"; + var connectionManager = new UserConnectionManager(); + connectionManager.AddConnection(userKey, connection); + + var sut = new ServerEventUserManager(connectionManager, authorizationService, mocks.HubContextMock.Object); + await sut.AssignToGroupsAsync(user, connection); + + // Ensure AddToGroupAsync was never called. + mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task RefreshGroupsAsyncRefreshesUserGroups() + { + var userKey = Guid.NewGuid(); + var user = CreateFakeUser(userKey); + var allowedSource = "allowedSource"; + var disallowedSource = "NotAllowed"; + var authorizationService = CreateServeEventAuthorizationService(new FakeAuthorizer([allowedSource]), new FakeAuthorizer([disallowedSource], (_, _) => false)); + var mocks = CreateHubContextMocks(); + + // Add a connection to the user + var connection = "connection1"; + var connectionManager = new UserConnectionManager(); + connectionManager.AddConnection(userKey, connection); + + var sut = new ServerEventUserManager(connectionManager, authorizationService, mocks.HubContextMock.Object); + await sut.RefreshGroupsAsync(user); + + // Ensure AddToGroupAsync was called once, and only once with the expected parameters. + mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(connection, allowedSource, It.IsAny()), Times.Once); + mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + + // Ensure RemoveToGroup was called for the disallowed source, and only the disallowed source. + mocks.GroupManagerMock.Verify(x => x.RemoveFromGroupAsync(connection, disallowedSource, It.IsAny()), Times.Once()); + mocks.GroupManagerMock.Verify(x => x.RemoveFromGroupAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once()); + } + + [Test] + public async Task RefreshUserGroupsDoesNothingIfNoConnections() + { + var userKey = Guid.NewGuid(); + var user = CreateFakeUser(userKey); + var authorizationService = CreateServeEventAuthorizationService(new FakeAuthorizer(["source"]), new FakeAuthorizer(["disallowedSource"], (_, _) => false)) ?? throw new ArgumentNullException("CreateServeEventAuthorizationService(new FakeAuthorizer([\"source\"]), new FakeAuthorizer([\"disallowedSource\"], (_, _) => false))"); + var mocks = CreateHubContextMocks(); + + var connectionManager = new UserConnectionManager(); + + var sut = new ServerEventUserManager(connectionManager, authorizationService, mocks.HubContextMock.Object); + await sut.RefreshGroupsAsync(user); + + // Ensure AddToGroupAsync was never called. + mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private ClaimsPrincipal CreateFakeUser(Guid key) => + new(new ClaimsIdentity([ + + // This is the claim that's used to store the ID + new Claim(Constants.Security.OpenIdDictSubClaimType, key.ToString()) + ])); + + private IServerEventAuthorizationService CreateServeEventAuthorizationService(params IEnumerable authorizers) + => new ServerEventAuthorizationService(new EventSourceAuthorizerCollection(() => authorizers)); + + private (Mock HubMock, Mock> HubClientsMock, Mock GroupManagerMock, Mock> HubContextMock) CreateHubContextMocks() + { + var hubMock = new Mock(); + + var hubClients = new Mock>(); + hubClients.Setup(x => x.All).Returns(hubMock.Object); + + var groupManagerMock = new Mock(); + + var hubContext = new Mock>(); + hubContext.Setup(x => x.Clients).Returns(hubClients.Object); + hubContext.Setup(x => x.Groups).Returns(groupManagerMock.Object); + return (hubMock, hubClients, groupManagerMock, hubContext); + } + +}