diff --git a/Directory.Build.props b/Directory.Build.props
index d2c4e87700aa..a3b7db5b3753 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 000000000000..1273bfcdd20c
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/ServerEventExtensions.cs
@@ -0,0 +1,105 @@
+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.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 90993aa382f2..4ef42524c4da 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 IUmbracoBuilder AddUmbracoManagementApi(this IUmbracoBuilder build
.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 32b8478d5371..56bb8170d1d3 100644
--- a/src/Umbraco.Cms.Api.Management/Factories/DataTypePresentationFactory.cs
+++ b/src/Umbraco.Cms.Api.Management/Factories/DataTypePresentationFactory.cs
@@ -44,6 +44,7 @@ public async Task> CreateAsync(Creat
return Attempt.FailWithStatus(parentAttempt.Status, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer));
}
+ var createDate = DateTime.Now;
var dataType = new DataType(editor, _configurationEditorJsonSerializer)
{
Name = requestModel.Name,
@@ -51,7 +52,8 @@ public async Task> CreateAsync(Creat
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 a12e1acb2e0e..e7f0a597a65b 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.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 void CreateRoutes(IEndpointRouteBuilder endpoints)
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 000000000000..023302ff1ae1
--- /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 AuthorizedEventSources => [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 000000000000..b54325d32bc0
--- /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 AuthorizedEventSources => [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 000000000000..e2e39ca6eebc
--- /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 AuthorizedEventSources => [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 000000000000..5a8b67fc70ac
--- /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 AuthorizedEventSources => [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 000000000000..ffb06b32464a
--- /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 AuthorizedEventSources => [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 000000000000..bd50f56b151f
--- /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 AuthorizedEventSources => [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 000000000000..f3fd93f45fe7
--- /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 AuthorizedEventSources => [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 000000000000..67aa6a2e9681
--- /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 AuthorizedEventSources => [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 000000000000..d3dac6f7d32e
--- /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 AuthorizedEventSources => [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 000000000000..8f8a1c25d4e5
--- /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 AuthorizedEventSources => [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 000000000000..f2e406623dce
--- /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 AuthorizedEventSources => [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 000000000000..b05576275779
--- /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 AuthorizedEventSources => [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 000000000000..923a3b63b9b6
--- /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 AuthorizedEventSources => [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 000000000000..1471b1933ae3
--- /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 AuthorizedEventSources => [Constants.ServerEvents.EventSource.Relation];
+
+ protected override string Policy => AuthorizationPolicies.TreeAccessDocuments;
+}
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 000000000000..0f7c4a5a9e76
--- /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 AuthorizedEventSources => [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 000000000000..a80c91c28c0e
--- /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 AuthorizedEventSources => [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 000000000000..061a84b02c81
--- /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 AuthorizedEventSources => [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 000000000000..ce5a38b3ef88
--- /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 AuthorizedEventSources => [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 000000000000..d6c9c5e90305
--- /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 AuthorizedEventSources => [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 000000000000..a766f88f4c4c
--- /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 AuthorizedEventSources => [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 000000000000..fa6776160ea0
--- /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 AuthorizedEventSources => [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 000000000000..925d99e3ff81
--- /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 AuthorizedEventSources { 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/ServerEventHub.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventHub.cs
new file mode 100644
index 000000000000..5cefc2d0d932
--- /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 (principal is null || 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 (principal is null || 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 000000000000..c9f66bc8ca56
--- /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 000000000000..4d704d74ec9a
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventSender.cs
@@ -0,0 +1,260 @@
+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)
+ {
+ string eventType = Constants.ServerEvents.EventType.Updated;
+ if (entity.CreateDate == entity.UpdateDate)
+ {
+ // This is a new entity
+ eventType = Constants.ServerEvents.EventType.Created;
+ }
+
+ var eventModel = new ServerEvent
+ {
+ EventType = eventType,
+ 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 000000000000..6c7fa347b751
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventUserManager.cs
@@ -0,0 +1,84 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.SignalR;
+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 EventSourceAuthorizerCollection _eventSourceAuthorizerCollection;
+ private readonly IHubContext _eventHub;
+
+ public ServerEventUserManager(
+ IUserConnectionManager userConnectionManager,
+ EventSourceAuthorizerCollection eventSourceAuthorizerCollection,
+ IHubContext eventHub)
+ {
+ _userConnectionManager = userConnectionManager;
+ _eventSourceAuthorizerCollection = eventSourceAuthorizerCollection;
+ _eventHub = eventHub;
+ }
+
+ ///
+ public async Task AssignToGroupsAsync(ClaimsPrincipal user, string connectionId)
+ {
+ foreach (IEventSourceAuthorizer authorizer in _eventSourceAuthorizerCollection)
+ {
+ foreach (var eventSource in authorizer.AuthorizedEventSources)
+ {
+ var isAuthorized = await authorizer.AuthorizeAsync(user, eventSource);
+ if (isAuthorized)
+ {
+ await _eventHub.Groups.AddToGroupAsync(connectionId, eventSource);
+ }
+ }
+ }
+ }
+
+ ///
+ 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;
+ }
+
+ foreach (IEventSourceAuthorizer authorizer in _eventSourceAuthorizerCollection)
+ {
+ foreach (var eventSource in authorizer.AuthorizedEventSources)
+ {
+ var isAuthorized = await authorizer.AuthorizeAsync(user, eventSource);
+
+ if (isAuthorized)
+ {
+ foreach (var connection in connections)
+ {
+ await _eventHub.Groups.AddToGroupAsync(connection, eventSource);
+ }
+
+ continue;
+ }
+
+ foreach (var connection in connections)
+ {
+ await _eventHub.Groups.RemoveFromGroupAsync(connection, eventSource);
+ }
+ }
+ }
+ }
+}
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 000000000000..69bcd284e89f
--- /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 000000000000..b9dd9c6c8b9c
--- /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 000000000000..e84e62d35023
--- /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 65b460ba697b..de5e5d1f7a51 100644
--- a/src/Umbraco.Core/Constants-Web.cs
+++ b/src/Umbraco.Core/Constants-Web.cs
@@ -57,6 +57,7 @@ public const string
///
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 d6f7b480aaa1..2ec4a7248007 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.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 ActionCollectionBuilder Actions(this IUmbracoBuilder builder)
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 000000000000..299fd66392c5
--- /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/ServerEvents/EventSourceAuthorizerCollection.cs b/src/Umbraco.Core/ServerEvents/EventSourceAuthorizerCollection.cs
new file mode 100644
index 000000000000..b62db65df8b6
--- /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 000000000000..36fdd432c8af
--- /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 000000000000..6942415d7970
--- /dev/null
+++ b/src/Umbraco.Core/ServerEvents/IEventSourceAuthorizer.cs
@@ -0,0 +1,13 @@
+using System.Security.Claims;
+
+namespace Umbraco.Cms.Core.ServerEvents;
+
+///
+/// Authorizes a Claims principal to access an event source.
+///
+public interface IEventSourceAuthorizer
+{
+ public IEnumerable AuthorizedEventSources { get; }
+
+ Task AuthorizeAsync(ClaimsPrincipal principal, string eventSource);
+}
diff --git a/src/Umbraco.Core/ServerEvents/IServerEventHub.cs b/src/Umbraco.Core/ServerEvents/IServerEventHub.cs
new file mode 100644
index 000000000000..77892c541891
--- /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 000000000000..a968b74ba43c
--- /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 000000000000..d13befe530f9
--- /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 000000000000..e4843711bce4
--- /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/ServerEventRouterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventRouterTests.cs
new file mode 100644
index 000000000000..3b6da868ba88
--- /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 000000000000..2f73204b84a7
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/ServerEvents/ServerEventUserManagerTests.cs
@@ -0,0 +1,138 @@
+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 authorizers = CreateAuthorizerCollection(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, authorizers, 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 authorizers = CreateAuthorizerCollection(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, authorizers, 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 authorizers = CreateAuthorizerCollection(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, authorizers, 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 authorizers = CreateAuthorizerCollection(new FakeAuthorizer(["source"]), new FakeAuthorizer(["disallowedSource"], (_, _) => false));
+ var mocks = CreateHubContextMocks();
+
+ var connectionManager = new UserConnectionManager();
+
+ var sut = new ServerEventUserManager(connectionManager, authorizers, 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 EventSourceAuthorizerCollection CreateAuthorizerCollection(params IEnumerable authorizers)
+ => new(() => 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);
+ }
+
+ private class FakeAuthorizer : IEventSourceAuthorizer
+ {
+ private readonly Func authorizeFunc;
+
+ public FakeAuthorizer(IEnumerable sources, Func? authorizeFunc = null)
+ {
+ this.authorizeFunc = authorizeFunc ?? ((_, _) => true);
+ AuthorizedEventSources = sources;
+ }
+
+ public IEnumerable AuthorizedEventSources { get; }
+
+ public Task AuthorizeAsync(ClaimsPrincipal principal, string connectionId) => Task.FromResult(authorizeFunc(principal, connectionId));
+ }
+}