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);
+ }
+
+}