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