V15: Notification Hub (#17776)
* Initial stab at how this could look * Authorization PoC wip * Add connection manager * Add DI to its own class * Use enum instead of string * Use groups * Refactor group management into its own service * Update a users groups when it's saved * Add saved events * Wire up deleted notifications * Ensure update date and create date is the same * Cleanup * Minor cleanup * Remove unusued usings * Move route to constant * Add docstrings to server event router * Fix and suppress warnings * Refactor to authorizer pattern * Update EventType * Remove unused enums * Add trashed events * Notify current user that they've been updated * Add broadcast We don't need it, but seems like a thing that a server event router should be able to do. * Add ServerEventRouterTests * Add ServerEventUserManagerTests * Use TimeProvider * Remove principal null check * Don't assign event type * Minor cleanup * Rename AuthorizedEventSources * Change permission for relations * Exctract event authorization into its own service * Add some tests * Update name * Add forgotten file * Rmember to add to DI
This commit is contained in:
@@ -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>$(NoWarn),NU5104</NoWarn>
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors),NU5104</WarningsNotAsErrors>
|
||||
<NoWarn>$(NoWarn),NU5104,SA1309</NoWarn>
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors),NU5104,SA1600</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- SourceLink -->
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ServerEvents;
|
||||
using Umbraco.Cms.Api.Management.ServerEvents.Authorizers;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.DependencyInjection;
|
||||
|
||||
internal static class ServerEventExtensions
|
||||
{
|
||||
internal static IUmbracoBuilder AddServerEvents(this IUmbracoBuilder builder)
|
||||
{
|
||||
builder.Services.AddSingleton<IUserConnectionManager, UserConnectionManager>();
|
||||
builder.Services.AddSingleton<IServerEventRouter, ServerEventRouter>();
|
||||
builder.Services.AddSingleton<IServerEventUserManager, ServerEventUserManager>();
|
||||
builder.Services.AddSingleton<IServerEventAuthorizationService, ServerEventAuthorizationService>();
|
||||
builder.AddNotificationAsyncHandler<UserSavedNotification, UserConnectionRefresher>();
|
||||
|
||||
builder
|
||||
.AddEvents()
|
||||
.AddAuthorizers();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static IUmbracoBuilder AddEvents(this IUmbracoBuilder builder)
|
||||
{
|
||||
builder.AddNotificationAsyncHandler<ContentSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<ContentTypeSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MediaSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MediaTypeSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MemberSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MemberTypeSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MemberGroupSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<DataTypeSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<LanguageSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<ScriptSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<StylesheetSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<TemplateSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<DictionaryItemSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<DomainSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<PartialViewSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<PublicAccessEntrySavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<RelationSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<RelationTypeSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<UserGroupSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<UserSavedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<WebhookSavedNotification, ServerEventSender>();
|
||||
|
||||
builder.AddNotificationAsyncHandler<ContentDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<ContentTypeDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MediaDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MediaTypeDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MemberDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MemberTypeDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MemberGroupDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<DataTypeDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<LanguageDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<ScriptDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<StylesheetDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<TemplateDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<DictionaryItemDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<DomainDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<PartialViewDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<PublicAccessEntryDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<RelationDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<RelationTypeDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<UserGroupDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<UserDeletedNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<WebhookDeletedNotification, ServerEventSender>();
|
||||
|
||||
builder.AddNotificationAsyncHandler<ContentMovedToRecycleBinNotification, ServerEventSender>();
|
||||
builder.AddNotificationAsyncHandler<MediaMovedToRecycleBinNotification, ServerEventSender>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static IUmbracoBuilder AddAuthorizers(this IUmbracoBuilder builder)
|
||||
{
|
||||
builder.EventSourceAuthorizers()
|
||||
.Append<DocumentEventAuthorizer>()
|
||||
.Append<DocumentTypeEventAuthorizer>()
|
||||
.Append<MediaEventAuthorizer>()
|
||||
.Append<MediaTypeEventAuthorizer>()
|
||||
.Append<MemberEventAuthorizer>()
|
||||
.Append<MemberGroupEventAuthorizer>()
|
||||
.Append<MemberTypeEventAuthorizer>()
|
||||
.Append<DataTypeEventAuthorizer>()
|
||||
.Append<LanguageEventAuthorizer>()
|
||||
.Append<ScriptEventAuthorizer>()
|
||||
.Append<StylesheetEventAuthorizer>()
|
||||
.Append<TemplateEventAuthorizer>()
|
||||
.Append<DictionaryItemEventAuthorizer>()
|
||||
.Append<DomainEventAuthorizer>()
|
||||
.Append<PartialViewEventAuthorizer>()
|
||||
.Append<PublicAccessEntryEventAuthorizer>()
|
||||
.Append<RelationEventAuthorizer>()
|
||||
.Append<RelationTypeEventAuthorizer>()
|
||||
.Append<UserGroupEventAuthorizer>()
|
||||
.Append<UserEventAuthorizer>()
|
||||
.Append<WebhookEventAuthorizer>();
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddCorsPolicy()
|
||||
.AddWebhooks()
|
||||
.AddPreview()
|
||||
.AddServerEvents()
|
||||
.AddPasswordConfiguration()
|
||||
.AddSupplemenataryLocalizedTextFileSources()
|
||||
.AddUserData()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.DataType;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
@@ -16,17 +18,35 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
private readonly PropertyEditorCollection _propertyEditorCollection;
|
||||
private readonly IDataValueEditorFactory _dataValueEditorFactory;
|
||||
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer)
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_dataTypeContainerService = dataTypeContainerService;
|
||||
_propertyEditorCollection = propertyEditorCollection;
|
||||
_dataValueEditorFactory = dataValueEditorFactory;
|
||||
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
[Obsolete("Use constructor that takes a TimeProvider")]
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer)
|
||||
: this(
|
||||
dataTypeContainerService,
|
||||
propertyEditorCollection,
|
||||
dataValueEditorFactory,
|
||||
configurationEditorJsonSerializer,
|
||||
StaticServiceProvider.Instance.GetRequiredService<TimeProvider>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -44,6 +64,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(parentAttempt.Status, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer));
|
||||
}
|
||||
|
||||
DateTime createDate = _timeProvider.GetLocalNow().DateTime;
|
||||
var dataType = new DataType(editor, _configurationEditorJsonSerializer)
|
||||
{
|
||||
Name = requestModel.Name,
|
||||
@@ -51,7 +72,8 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
DatabaseType = GetEditorValueStorageType(editor),
|
||||
ConfigurationData = MapConfigurationData(requestModel, editor),
|
||||
ParentId = parentAttempt.Result,
|
||||
CreateDate = DateTime.Now,
|
||||
CreateDate = createDate,
|
||||
UpdateDate = createDate,
|
||||
};
|
||||
|
||||
if (requestModel.Id.HasValue)
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Management.Controllers.Security;
|
||||
using Umbraco.Cms.Api.Management.ServerEvents;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
@@ -54,6 +55,7 @@ public sealed class BackOfficeAreaRoutes : IAreaRoutes
|
||||
case RuntimeLevel.Run:
|
||||
MapMinimalBackOffice(endpoints);
|
||||
endpoints.MapHub<BackofficeHub>(_umbracoPathSegment + Constants.Web.BackofficeSignalRHub);
|
||||
endpoints.MapHub<ServerEventHub>(_umbracoPathSegment + Constants.Web.ServerEventSignalRHub);
|
||||
break;
|
||||
case RuntimeLevel.BootFailed:
|
||||
case RuntimeLevel.Unknown:
|
||||
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.DataType];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessDataTypes;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.DictionaryItem];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessDictionary;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Document];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessDocuments;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.DocumentType];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessDocumentTypes;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Domain];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessDocuments;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Language];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessLanguages;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Media];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessMediaOrMediaTypes;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.MediaType];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessMediaTypes;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Member];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessMembersOrMemberTypes;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.MemberGroup];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessMemberGroups;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.MemberType];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessMemberTypes;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.PartialView];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessPartialViews;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.PublicAccessEntry];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessDocuments;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Relation];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessDocumentsOrMediaOrMembersOrContentTypes;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.RelationType];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessRelationTypes;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Script];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessScripts;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Stylesheet];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessStylesheets;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Template];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessTemplates;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.User];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.SectionAccessUsers;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.UserGroup];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.SectionAccessUsers;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources => [Constants.ServerEvents.EventSource.Webhook];
|
||||
|
||||
protected override string Policy => AuthorizationPolicies.TreeAccessWebhooks;
|
||||
}
|
||||
@@ -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<string> AuthorizableEventSources { get; }
|
||||
|
||||
protected abstract string Policy { get; }
|
||||
|
||||
public async Task<bool> AuthorizeAsync(ClaimsPrincipal principal, string eventSource)
|
||||
{
|
||||
AuthorizationResult result = await _authorizationService.AuthorizeAsync(principal, Policy);
|
||||
return result.Succeeded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Security.Claims;
|
||||
using Umbraco.Cms.Core.Models.ServerEvents;
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ServerEvents;
|
||||
|
||||
internal sealed class ServerEventAuthorizationService : IServerEventAuthorizationService
|
||||
{
|
||||
private readonly EventSourceAuthorizerCollection _eventSourceAuthorizers;
|
||||
private Dictionary<string, List<IEventSourceAuthorizer>>? _groupedAuthorizersByEventSource;
|
||||
|
||||
public ServerEventAuthorizationService(EventSourceAuthorizerCollection eventSourceAuthorizers)
|
||||
{
|
||||
_eventSourceAuthorizers = eventSourceAuthorizers;
|
||||
}
|
||||
|
||||
private Dictionary<string, List<IEventSourceAuthorizer>> GroupedAuthorizersByEventSource
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_groupedAuthorizersByEventSource is not null)
|
||||
{
|
||||
return _groupedAuthorizersByEventSource;
|
||||
}
|
||||
|
||||
_groupedAuthorizersByEventSource = GetGroupedAuthorizersByEventSource();
|
||||
return _groupedAuthorizersByEventSource;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> AuthorizableEventSources => GroupedAuthorizersByEventSource.Keys;
|
||||
|
||||
private Dictionary<string, List<IEventSourceAuthorizer>> GetGroupedAuthorizersByEventSource()
|
||||
{
|
||||
var groupedAuthorizers = new Dictionary<string, List<IEventSourceAuthorizer>>();
|
||||
|
||||
foreach (IEventSourceAuthorizer eventSourceAuthorizer in _eventSourceAuthorizers)
|
||||
{
|
||||
foreach (var eventSource in eventSourceAuthorizer.AuthorizableEventSources)
|
||||
{
|
||||
if (groupedAuthorizers.TryGetValue(eventSource, out List<IEventSourceAuthorizer>? authorizers) is false)
|
||||
{
|
||||
authorizers = [eventSourceAuthorizer];
|
||||
groupedAuthorizers[eventSource] = authorizers;
|
||||
}
|
||||
|
||||
authorizers.Add(eventSourceAuthorizer);
|
||||
}
|
||||
}
|
||||
|
||||
return groupedAuthorizers;
|
||||
}
|
||||
|
||||
public async Task<SeverEventAuthorizationResult> AuthorizeAsync(ClaimsPrincipal user)
|
||||
{
|
||||
var authorizedEventSources = new List<string>();
|
||||
var unauthorizedEventSources = new List<string>();
|
||||
|
||||
foreach (var eventSource in AuthorizableEventSources)
|
||||
{
|
||||
if (GroupedAuthorizersByEventSource.TryGetValue(eventSource, out List<IEventSourceAuthorizer>? authorizers) is false || authorizers.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No authorizers found for event source {eventSource}");
|
||||
}
|
||||
|
||||
// There may have been registered multiple authorizers for the same event source, in that case if any authorizer fails, the user is unauthorized.
|
||||
var isAuthorized = false;
|
||||
foreach (IEventSourceAuthorizer authorizer in authorizers)
|
||||
{
|
||||
isAuthorized = await authorizer.AuthorizeAsync(user, eventSource);
|
||||
if (isAuthorized is false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthorized)
|
||||
{
|
||||
authorizedEventSources.Add(eventSource);
|
||||
}
|
||||
else
|
||||
{
|
||||
unauthorizedEventSources.Add(eventSource);
|
||||
}
|
||||
}
|
||||
|
||||
return new SeverEventAuthorizationResult
|
||||
{
|
||||
AuthorizedEventSources = authorizedEventSources,
|
||||
UnauthorizedEventSources = unauthorizedEventSources,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<IServerEventHub>
|
||||
{
|
||||
private readonly IUserConnectionManager _userConnectionManager;
|
||||
private readonly IServerEventUserManager _serverEventUserManager;
|
||||
|
||||
public ServerEventHub(
|
||||
IUserConnectionManager userConnectionManager,
|
||||
IServerEventUserManager serverEventUserManager)
|
||||
{
|
||||
_userConnectionManager = userConnectionManager;
|
||||
_serverEventUserManager = serverEventUserManager;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
ClaimsPrincipal? principal = Context.User;
|
||||
Guid? userKey = principal?.Identity?.GetUserKey();
|
||||
|
||||
if (userKey is null)
|
||||
{
|
||||
Context.Abort();
|
||||
return;
|
||||
}
|
||||
|
||||
_userConnectionManager.AddConnection(userKey.Value, Context.ConnectionId);
|
||||
await _serverEventUserManager.AssignToGroupsAsync(principal!, Context.ConnectionId);
|
||||
}
|
||||
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
ClaimsPrincipal? principal = Context.User;
|
||||
Guid? userKey = principal?.Identity?.GetUserKey();
|
||||
|
||||
if (userKey is null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_userConnectionManager.RemoveConnection(userKey.Value, Context.ConnectionId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <inheritdoc />
|
||||
internal sealed class ServerEventRouter : IServerEventRouter
|
||||
{
|
||||
private readonly IHubContext<ServerEventHub, IServerEventHub> _eventHub;
|
||||
private readonly IUserConnectionManager _connectionManager;
|
||||
|
||||
public ServerEventRouter(
|
||||
IHubContext<ServerEventHub, IServerEventHub> eventHub,
|
||||
IUserConnectionManager connectionManager)
|
||||
{
|
||||
_eventHub = eventHub;
|
||||
_connectionManager = connectionManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RouteEventAsync(ServerEvent serverEvent)
|
||||
=> _eventHub.Clients.Group(serverEvent.EventSource).notify(serverEvent);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task NotifyUserAsync(ServerEvent serverEvent, Guid userKey)
|
||||
{
|
||||
ISet<string> userConnections = _connectionManager.GetConnections(userKey);
|
||||
|
||||
if (userConnections.Any() is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _eventHub.Clients.Clients(userConnections).notify(serverEvent);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task BroadcastEventAsync(ServerEvent serverEvent) => await _eventHub.Clients.All.notify(serverEvent);
|
||||
}
|
||||
256
src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventSender.cs
Normal file
256
src/Umbraco.Cms.Api.Management/ServerEvents/ServerEventSender.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models.Entities;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Models.ServerEvents;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ServerEvents;
|
||||
|
||||
internal sealed class ServerEventSender :
|
||||
INotificationAsyncHandler<ContentSavedNotification>,
|
||||
INotificationAsyncHandler<ContentTypeSavedNotification>,
|
||||
INotificationAsyncHandler<MediaSavedNotification>,
|
||||
INotificationAsyncHandler<MediaTypeSavedNotification>,
|
||||
INotificationAsyncHandler<MemberSavedNotification>,
|
||||
INotificationAsyncHandler<MemberTypeSavedNotification>,
|
||||
INotificationAsyncHandler<MemberGroupSavedNotification>,
|
||||
INotificationAsyncHandler<DataTypeSavedNotification>,
|
||||
INotificationAsyncHandler<LanguageSavedNotification>,
|
||||
INotificationAsyncHandler<ScriptSavedNotification>,
|
||||
INotificationAsyncHandler<StylesheetSavedNotification>,
|
||||
INotificationAsyncHandler<TemplateSavedNotification>,
|
||||
INotificationAsyncHandler<DictionaryItemSavedNotification>,
|
||||
INotificationAsyncHandler<DomainSavedNotification>,
|
||||
INotificationAsyncHandler<PartialViewSavedNotification>,
|
||||
INotificationAsyncHandler<PublicAccessEntrySavedNotification>,
|
||||
INotificationAsyncHandler<RelationSavedNotification>,
|
||||
INotificationAsyncHandler<RelationTypeSavedNotification>,
|
||||
INotificationAsyncHandler<UserGroupSavedNotification>,
|
||||
INotificationAsyncHandler<UserSavedNotification>,
|
||||
INotificationAsyncHandler<WebhookSavedNotification>,
|
||||
INotificationAsyncHandler<ContentDeletedNotification>,
|
||||
INotificationAsyncHandler<ContentTypeDeletedNotification>,
|
||||
INotificationAsyncHandler<MediaDeletedNotification>,
|
||||
INotificationAsyncHandler<MediaTypeDeletedNotification>,
|
||||
INotificationAsyncHandler<MemberDeletedNotification>,
|
||||
INotificationAsyncHandler<MemberTypeDeletedNotification>,
|
||||
INotificationAsyncHandler<MemberGroupDeletedNotification>,
|
||||
INotificationAsyncHandler<DataTypeDeletedNotification>,
|
||||
INotificationAsyncHandler<LanguageDeletedNotification>,
|
||||
INotificationAsyncHandler<ScriptDeletedNotification>,
|
||||
INotificationAsyncHandler<StylesheetDeletedNotification>,
|
||||
INotificationAsyncHandler<TemplateDeletedNotification>,
|
||||
INotificationAsyncHandler<DictionaryItemDeletedNotification>,
|
||||
INotificationAsyncHandler<DomainDeletedNotification>,
|
||||
INotificationAsyncHandler<PartialViewDeletedNotification>,
|
||||
INotificationAsyncHandler<PublicAccessEntryDeletedNotification>,
|
||||
INotificationAsyncHandler<RelationDeletedNotification>,
|
||||
INotificationAsyncHandler<RelationTypeDeletedNotification>,
|
||||
INotificationAsyncHandler<UserGroupDeletedNotification>,
|
||||
INotificationAsyncHandler<UserDeletedNotification>,
|
||||
INotificationAsyncHandler<WebhookDeletedNotification>,
|
||||
INotificationAsyncHandler<ContentMovedToRecycleBinNotification>,
|
||||
INotificationAsyncHandler<MediaMovedToRecycleBinNotification>
|
||||
{
|
||||
private readonly IServerEventRouter _serverEventRouter;
|
||||
|
||||
public ServerEventSender(IServerEventRouter serverEventRouter)
|
||||
{
|
||||
_serverEventRouter = serverEventRouter;
|
||||
}
|
||||
|
||||
private async Task NotifySavedAsync<T>(SavedNotification<T> notification, string source)
|
||||
where T : IEntity
|
||||
{
|
||||
foreach (T entity in notification.SavedEntities)
|
||||
{
|
||||
var eventModel = new ServerEvent
|
||||
{
|
||||
EventType = entity.CreateDate == entity.UpdateDate
|
||||
? Constants.ServerEvents.EventType.Created : Constants.ServerEvents.EventType.Updated,
|
||||
Key = entity.Key,
|
||||
EventSource = source,
|
||||
};
|
||||
|
||||
await _serverEventRouter.RouteEventAsync(eventModel);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NotifyDeletedAsync<T>(DeletedNotification<T> 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<T>(MovedToRecycleBinNotification<T> notification, string source)
|
||||
where T : IEntity
|
||||
{
|
||||
foreach (MoveToRecycleBinEventInfo<T> 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);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Umbraco.Cms.Core.Models.ServerEvents;
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ServerEvents;
|
||||
|
||||
/// <inheritdoc />
|
||||
internal sealed class ServerEventUserManager : IServerEventUserManager
|
||||
{
|
||||
private readonly IUserConnectionManager _userConnectionManager;
|
||||
private readonly IServerEventAuthorizationService _serverEventAuthorizationService;
|
||||
private readonly IHubContext<ServerEventHub, IServerEventHub> _eventHub;
|
||||
|
||||
public ServerEventUserManager(
|
||||
IUserConnectionManager userConnectionManager,
|
||||
IServerEventAuthorizationService serverEventAuthorizationService,
|
||||
IHubContext<ServerEventHub, IServerEventHub> eventHub)
|
||||
{
|
||||
_userConnectionManager = userConnectionManager;
|
||||
_serverEventAuthorizationService = serverEventAuthorizationService;
|
||||
_eventHub = eventHub;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task AssignToGroupsAsync(ClaimsPrincipal user, string connectionId)
|
||||
{
|
||||
SeverEventAuthorizationResult authorizationResult = await _serverEventAuthorizationService.AuthorizeAsync(user);
|
||||
|
||||
foreach (var authorizedEventSource in authorizationResult.AuthorizedEventSources)
|
||||
{
|
||||
await _eventHub.Groups.AddToGroupAsync(connectionId, authorizedEventSource);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string> connections = _userConnectionManager.GetConnections(userKey.Value);
|
||||
|
||||
// If there's no connection there's nothing to do.
|
||||
if (connections.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SeverEventAuthorizationResult authorizationResult = await _serverEventAuthorizationService.AuthorizeAsync(user);
|
||||
|
||||
// Add the user to the authorized groups, and remove them from the unauthorized groups.
|
||||
// Note that it's safe to add a user to a group multiple times, so we don't have ot worry about that.
|
||||
foreach (var authorizedEventSource in authorizationResult.AuthorizedEventSources)
|
||||
{
|
||||
foreach (var connection in connections)
|
||||
{
|
||||
await _eventHub.Groups.AddToGroupAsync(connection, authorizedEventSource);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var unauthorizedEventSource in authorizationResult.UnauthorizedEventSources)
|
||||
{
|
||||
foreach (var connection in connections)
|
||||
{
|
||||
await _eventHub.Groups.RemoveFromGroupAsync(connection, unauthorizedEventSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ServerEvents;
|
||||
|
||||
/// <inheritdoc />
|
||||
internal sealed class UserConnectionManager : IUserConnectionManager
|
||||
{
|
||||
// We use a normal dictionary instead of ConcurrentDictionary, since we need to lock the set anyways.
|
||||
private readonly Dictionary<Guid, HashSet<string>> _connections = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ISet<string> GetConnections(Guid userKey)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _connections.TryGetValue(userKey, out HashSet<string>? connections) ? connections : [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddConnection(Guid userKey, string connectionId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_connections.TryGetValue(userKey, out HashSet<string>? connections) is false)
|
||||
{
|
||||
connections = [];
|
||||
_connections[userKey] = connections;
|
||||
}
|
||||
|
||||
connections.Add(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RemoveConnection(Guid userKey, string connectionId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_connections.TryGetValue(userKey, out HashSet<string>? connections) is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
connections.Remove(connectionId);
|
||||
if (connections.Count == 0)
|
||||
{
|
||||
_connections.Remove(userKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// updates the user's connections if any, when a user is saved
|
||||
/// </summary>
|
||||
internal sealed class UserConnectionRefresher : INotificationAsyncHandler<UserSavedNotification>
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
65
src/Umbraco.Core/Constants-ServerEvents.cs
Normal file
65
src/Umbraco.Core/Constants-ServerEvents.cs
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ public static partial class Constants
|
||||
/// </summary>
|
||||
public const string ManagementApiPath = "/management/api/";
|
||||
public const string BackofficeSignalRHub = "/backofficeHub";
|
||||
public const string ServerEventSignalRHub = "/serverEventHub";
|
||||
|
||||
public static class Routing
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Media.EmbedProviders;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
using Umbraco.Cms.Core.Snippets;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Cms.Core.Webhooks;
|
||||
@@ -107,6 +108,9 @@ public static partial class UmbracoBuilderExtensions
|
||||
public static ContentFinderCollectionBuilder ContentFinders(this IUmbracoBuilder builder)
|
||||
=> builder.WithCollectionBuilder<ContentFinderCollectionBuilder>();
|
||||
|
||||
public static EventSourceAuthorizerCollectionBuilder EventSourceAuthorizers(this IUmbracoBuilder builder)
|
||||
=> builder.WithCollectionBuilder<EventSourceAuthorizerCollectionBuilder>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the editor validators collection builder.
|
||||
/// </summary>
|
||||
|
||||
10
src/Umbraco.Core/Models/ServerEvents/ServerEvent.cs
Normal file
10
src/Umbraco.Core/Models/ServerEvents/ServerEvent.cs
Normal file
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Umbraco.Cms.Core.Models.ServerEvents;
|
||||
|
||||
public class SeverEventAuthorizationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// The list of events the user should be authorized to listen to
|
||||
/// </summary>
|
||||
public required IEnumerable<string> AuthorizedEventSources { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The list of events the user should not be authorized to listen to
|
||||
/// </summary>
|
||||
public required IEnumerable<string> UnauthorizedEventSources { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Umbraco.Cms.Core.Composing;
|
||||
|
||||
namespace Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
public class EventSourceAuthorizerCollection : BuilderCollectionBase<IEventSourceAuthorizer>
|
||||
{
|
||||
public EventSourceAuthorizerCollection(Func<IEnumerable<IEventSourceAuthorizer>> items)
|
||||
: base(items)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Umbraco.Cms.Core.Composing;
|
||||
|
||||
namespace Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
public class EventSourceAuthorizerCollectionBuilder : OrderedCollectionBuilderBase<EventSourceAuthorizerCollectionBuilder, EventSourceAuthorizerCollection, IEventSourceAuthorizer>
|
||||
{
|
||||
protected override EventSourceAuthorizerCollectionBuilder This => this;
|
||||
}
|
||||
22
src/Umbraco.Core/ServerEvents/IEventSourceAuthorizer.cs
Normal file
22
src/Umbraco.Core/ServerEvents/IEventSourceAuthorizer.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes a Claims principal to access an event source.
|
||||
/// </summary>
|
||||
public interface IEventSourceAuthorizer
|
||||
{
|
||||
/// <summary>
|
||||
/// The event sources this authorizer authorizes for.
|
||||
/// </summary>
|
||||
public IEnumerable<string> AuthorizableEventSources { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes a Claims principal to access an event source.
|
||||
/// </summary>
|
||||
/// <param name="principal">The principal that being authorized.</param>
|
||||
/// <param name="eventSource">The event source to authorize the principal for.</param>
|
||||
/// <returns>True is authorized, false if unauthorized.</returns>
|
||||
Task<bool> AuthorizeAsync(ClaimsPrincipal principal, string eventSource);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Security.Claims;
|
||||
using Umbraco.Cms.Core.Models.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
public interface IServerEventAuthorizationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Authorizes a user to listen to server events.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to authorize.</param>
|
||||
/// <returns>The authorization result, containing all authorized event sources, and unauthorized event sources.</returns>
|
||||
Task<SeverEventAuthorizationResult> AuthorizeAsync(ClaimsPrincipal user);
|
||||
}
|
||||
10
src/Umbraco.Core/ServerEvents/IServerEventHub.cs
Normal file
10
src/Umbraco.Core/ServerEvents/IServerEventHub.cs
Normal file
@@ -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
|
||||
}
|
||||
32
src/Umbraco.Core/ServerEvents/IServerEventRouter.cs
Normal file
32
src/Umbraco.Core/ServerEvents/IServerEventRouter.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Umbraco.Cms.Core.Models.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
/// <summary>
|
||||
/// Routes server events to the correct users.
|
||||
/// </summary>
|
||||
public interface IServerEventRouter
|
||||
{
|
||||
/// <summary>
|
||||
/// Route a server event the users that has permissions to see it.
|
||||
/// </summary>
|
||||
/// <param name="serverEvent">The server event to route.</param>
|
||||
/// <returns></returns>
|
||||
Task RouteEventAsync(ServerEvent serverEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Notify a specific user about a server event.
|
||||
/// <remarks>Does not consider authorization.</remarks>
|
||||
/// </summary>
|
||||
/// <param name="serverEvent">The server event to send to the user.</param>
|
||||
/// <param name="userKey">Key of the user.</param>
|
||||
/// <returns></returns>
|
||||
Task NotifyUserAsync(ServerEvent serverEvent, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Broadcast a server event to all users, regardless of authorization.
|
||||
/// </summary>
|
||||
/// <param name="serverEvent">The event to broadcast.</param>
|
||||
/// <returns></returns>
|
||||
Task BroadcastEventAsync(ServerEvent serverEvent);
|
||||
}
|
||||
24
src/Umbraco.Core/ServerEvents/IServerEventUserManager.cs
Normal file
24
src/Umbraco.Core/ServerEvents/IServerEventUserManager.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
/// <summary>
|
||||
/// Manages group access for a user.
|
||||
/// </summary>
|
||||
public interface IServerEventUserManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the connections to the groups that the user has access to.
|
||||
/// </summary>
|
||||
/// <param name="user">The owner of the connection.</param>
|
||||
/// <param name="connectionId">The connection to add to groups.</param>
|
||||
/// <returns></returns>
|
||||
Task AssignToGroupsAsync(ClaimsPrincipal user, string connectionId);
|
||||
|
||||
/// <summary>
|
||||
/// Reauthorize the user and removes all connections held by the user from groups they are no longer allowed to access.
|
||||
/// </summary>
|
||||
/// <param name="user">The user to reauthorize.</param>
|
||||
/// <returns></returns>
|
||||
Task RefreshGroupsAsync(ClaimsPrincipal user);
|
||||
}
|
||||
28
src/Umbraco.Core/ServerEvents/IUserConnectionManager.cs
Normal file
28
src/Umbraco.Core/ServerEvents/IUserConnectionManager.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
/// <summary>
|
||||
/// A manager that tracks connection ids for users.
|
||||
/// </summary>
|
||||
public interface IUserConnectionManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Get all connections held by a user.
|
||||
/// </summary>
|
||||
/// <param name="userKey">The key of the user to get connections for.</param>
|
||||
/// <returns>The users connections.</returns>
|
||||
ISet<string> GetConnections(Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Add a connection to a user.
|
||||
/// </summary>
|
||||
/// <param name="userKey">The key of the user to add the connection to.</param>
|
||||
/// <param name="connectionId">Connection id to add.</param>
|
||||
void AddConnection(Guid userKey, string connectionId);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a connection from a user.
|
||||
/// </summary>
|
||||
/// <param name="userKey">The user key to remove the connection from.</param>
|
||||
/// <param name="connectionId">The connection id to remove</param>
|
||||
void RemoveConnection(Guid userKey, string connectionId);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Security.Claims;
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.ServerEvents;
|
||||
|
||||
|
||||
internal class FakeAuthorizer : IEventSourceAuthorizer
|
||||
{
|
||||
private readonly Func<ClaimsPrincipal, string, bool> authorizeFunc;
|
||||
|
||||
public FakeAuthorizer(IEnumerable<string> sources, Func<ClaimsPrincipal, string, bool>? authorizeFunc = null)
|
||||
{
|
||||
this.authorizeFunc = authorizeFunc ?? ((_, _) => true);
|
||||
AuthorizableEventSources = sources;
|
||||
}
|
||||
|
||||
public IEnumerable<string> AuthorizableEventSources { get; }
|
||||
|
||||
public Task<bool> AuthorizeAsync(ClaimsPrincipal principal, string connectionId) => Task.FromResult(authorizeFunc(principal, connectionId));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Security.Claims;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Api.Management.ServerEvents;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.ServerEvents;
|
||||
|
||||
[TestFixture]
|
||||
public class ServerEventAuthorizerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task CanAuthorize()
|
||||
{
|
||||
var sourceName = "Authorized source";
|
||||
var authorizer = new FakeAuthorizer([sourceName]);
|
||||
|
||||
var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizer));
|
||||
var result = await sut.AuthorizeAsync(CreateFakeUser());
|
||||
|
||||
Assert.That(result.AuthorizedEventSources.Count(), Is.EqualTo(1));
|
||||
Assert.That(result.UnauthorizedEventSources.Count(), Is.EqualTo(0));
|
||||
Assert.That(result.AuthorizedEventSources.First(), Is.EqualTo(sourceName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CanUnauthorize()
|
||||
{
|
||||
var sourceName = "unauthorized source";
|
||||
var authorizer = new FakeAuthorizer([sourceName], (_, _) => false);
|
||||
|
||||
var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizer));
|
||||
var result = await sut.AuthorizeAsync(CreateFakeUser());
|
||||
|
||||
Assert.That(result.AuthorizedEventSources.Count(), Is.EqualTo(0));
|
||||
Assert.That(result.UnauthorizedEventSources.Count(), Is.EqualTo(1));
|
||||
Assert.That(result.UnauthorizedEventSources.First(), Is.EqualTo(sourceName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AnyAuthorizationFailureResultInUnauthorized()
|
||||
{
|
||||
var sourceName = "Unauthorized source";
|
||||
|
||||
FakeAuthorizer[] authorizers = [new([sourceName]), new([sourceName], (_, _) => false), new([sourceName])];
|
||||
var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizers));
|
||||
var result = await sut.AuthorizeAsync(CreateFakeUser());
|
||||
|
||||
Assert.That(result.AuthorizedEventSources.Count(), Is.EqualTo(0));
|
||||
Assert.That(result.UnauthorizedEventSources.Count(), Is.EqualTo(1));
|
||||
Assert.That(result.UnauthorizedEventSources.First(), Is.EqualTo(sourceName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CanHandleMultiple()
|
||||
{
|
||||
string[] authorizedSources = ["first auth", "second auth", "third auth"];
|
||||
string[] unauthorizedSources = ["first unauth", "second unauth", "third unauth"];
|
||||
|
||||
FakeAuthorizer[] authorizers = [
|
||||
new(authorizedSources),
|
||||
new(unauthorizedSources, (_, _) => false)
|
||||
];
|
||||
|
||||
var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizers));
|
||||
var result = await sut.AuthorizeAsync(CreateFakeUser());
|
||||
|
||||
Assert.That(result.AuthorizedEventSources, Is.EquivalentTo(authorizedSources));
|
||||
Assert.That(result.UnauthorizedEventSources, Is.EquivalentTo(unauthorizedSources));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CanHandleMultipleAuthorizers()
|
||||
{
|
||||
string[] authorizedSources = ["first auth", "second auth", "third auth"];
|
||||
string[] unauthorizedSources = ["first unauth", "second unauth", "third unauth"];
|
||||
|
||||
FakeAuthorizer[] authorizers = [
|
||||
new([authorizedSources[0]]),
|
||||
new([authorizedSources[1]]),
|
||||
new([unauthorizedSources[0]], (_, _) => false),
|
||||
new([unauthorizedSources[1]], (_, _) => false),
|
||||
new([authorizedSources[2], unauthorizedSources[2]], (_, source) => source == authorizedSources[2]),
|
||||
new(authorizedSources)
|
||||
];
|
||||
|
||||
var sut = new ServerEventAuthorizationService(CreateServeEventAuthorizerCollection(authorizers));
|
||||
var result = await sut.AuthorizeAsync(CreateFakeUser());
|
||||
|
||||
Assert.That(result.AuthorizedEventSources, Is.EquivalentTo(authorizedSources));
|
||||
Assert.That(result.UnauthorizedEventSources, Is.EquivalentTo(unauthorizedSources));
|
||||
}
|
||||
|
||||
private ClaimsPrincipal CreateFakeUser(Guid? key = null) =>
|
||||
new(new ClaimsIdentity([
|
||||
|
||||
// This is the claim that's used to store the ID
|
||||
new Claim(Constants.Security.OpenIdDictSubClaimType, key is null ? Guid.NewGuid().ToString() : key.ToString())
|
||||
]));
|
||||
|
||||
private EventSourceAuthorizerCollection CreateServeEventAuthorizerCollection(
|
||||
params IEnumerable<IEventSourceAuthorizer> authorizers)
|
||||
=> new(() => authorizers);
|
||||
}
|
||||
@@ -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<string>()), 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<string> { "connection1", "connection2", "connection3" };
|
||||
var nonTargetUsers = new Dictionary<Guid, List<string>>();
|
||||
nonTargetUsers.Add(Guid.NewGuid(), new List<string> { "connection4", "connection5" });
|
||||
nonTargetUsers.Add(Guid.NewGuid(), new List<string> { "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<IReadOnlyList<string>>())).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<IReadOnlyList<string>>()), 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<Guid, List<string>>();
|
||||
nonTargetUsers.Add(Guid.NewGuid(), new List<string> { "connection4", "connection5" });
|
||||
nonTargetUsers.Add(Guid.NewGuid(), new List<string> { "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<IReadOnlyList<string>>()), Times.Never());
|
||||
mocks.HubMock.Verify(x => x.notify(serverEvent), Times.Never());
|
||||
}
|
||||
|
||||
private (Mock<IServerEventHub> HubMock, Mock<IHubClients<IServerEventHub>> HubClientsMock, Mock<IHubContext<ServerEventHub, IServerEventHub>> HubContextMock) CreateMocks()
|
||||
{
|
||||
var hubMock = new Mock<IServerEventHub>();
|
||||
var hubClients = new Mock<IHubClients<IServerEventHub>>();
|
||||
hubClients.Setup(x => x.All).Returns(hubMock.Object);
|
||||
var hubContext = new Mock<IHubContext<ServerEventHub, IServerEventHub>>();
|
||||
hubContext.Setup(x => x.Clients).Returns(hubClients.Object);
|
||||
return (hubMock, hubClients, hubContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Api.Management.ServerEvents;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.ServerEvents;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.ServerEvents;
|
||||
|
||||
[TestFixture]
|
||||
public class ServerEventUserManagerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task AssignsUserToEventSourceGroup()
|
||||
{
|
||||
var userKey = Guid.NewGuid();
|
||||
var user = CreateFakeUser(userKey);
|
||||
var authorizationService = CreateServeEventAuthorizationService(new FakeAuthorizer(["source"]));
|
||||
var mocks = CreateHubContextMocks();
|
||||
|
||||
// Add a connection to the user
|
||||
var connection = "connection1";
|
||||
var connectionManager = new UserConnectionManager();
|
||||
connectionManager.AddConnection(userKey, connection);
|
||||
|
||||
var sut = new ServerEventUserManager(connectionManager, authorizationService, mocks.HubContextMock.Object);
|
||||
await sut.AssignToGroupsAsync(user, connection);
|
||||
|
||||
// Ensure AddToGroupAsync was called once, and only once with the expected parameters.
|
||||
mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(connection, "source", It.IsAny<CancellationToken>()), Times.Once);
|
||||
mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DoesNotAssignUserToEventSourceGroupWhenUnauthorized()
|
||||
{
|
||||
var userKey = Guid.NewGuid();
|
||||
var user = CreateFakeUser(userKey);
|
||||
var authorizationService = CreateServeEventAuthorizationService(new FakeAuthorizer(["source"], (_, _) => false));
|
||||
var mocks = CreateHubContextMocks();
|
||||
|
||||
// Add a connection to the user
|
||||
var connection = "connection1";
|
||||
var connectionManager = new UserConnectionManager();
|
||||
connectionManager.AddConnection(userKey, connection);
|
||||
|
||||
var sut = new ServerEventUserManager(connectionManager, authorizationService, mocks.HubContextMock.Object);
|
||||
await sut.AssignToGroupsAsync(user, connection);
|
||||
|
||||
// Ensure AddToGroupAsync was never called.
|
||||
mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshGroupsAsyncRefreshesUserGroups()
|
||||
{
|
||||
var userKey = Guid.NewGuid();
|
||||
var user = CreateFakeUser(userKey);
|
||||
var allowedSource = "allowedSource";
|
||||
var disallowedSource = "NotAllowed";
|
||||
var authorizationService = CreateServeEventAuthorizationService(new FakeAuthorizer([allowedSource]), new FakeAuthorizer([disallowedSource], (_, _) => false));
|
||||
var mocks = CreateHubContextMocks();
|
||||
|
||||
// Add a connection to the user
|
||||
var connection = "connection1";
|
||||
var connectionManager = new UserConnectionManager();
|
||||
connectionManager.AddConnection(userKey, connection);
|
||||
|
||||
var sut = new ServerEventUserManager(connectionManager, authorizationService, mocks.HubContextMock.Object);
|
||||
await sut.RefreshGroupsAsync(user);
|
||||
|
||||
// Ensure AddToGroupAsync was called once, and only once with the expected parameters.
|
||||
mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(connection, allowedSource, It.IsAny<CancellationToken>()), Times.Once);
|
||||
mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()), Times.Once());
|
||||
mocks.GroupManagerMock.Verify(x => x.RemoveFromGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshUserGroupsDoesNothingIfNoConnections()
|
||||
{
|
||||
var userKey = Guid.NewGuid();
|
||||
var user = CreateFakeUser(userKey);
|
||||
var authorizationService = CreateServeEventAuthorizationService(new FakeAuthorizer(["source"]), new FakeAuthorizer(["disallowedSource"], (_, _) => false)) ?? throw new ArgumentNullException("CreateServeEventAuthorizationService(new FakeAuthorizer([\"source\"]), new FakeAuthorizer([\"disallowedSource\"], (_, _) => false))");
|
||||
var mocks = CreateHubContextMocks();
|
||||
|
||||
var connectionManager = new UserConnectionManager();
|
||||
|
||||
var sut = new ServerEventUserManager(connectionManager, authorizationService, mocks.HubContextMock.Object);
|
||||
await sut.RefreshGroupsAsync(user);
|
||||
|
||||
// Ensure AddToGroupAsync was never called.
|
||||
mocks.GroupManagerMock.Verify(x => x.AddToGroupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
private ClaimsPrincipal CreateFakeUser(Guid key) =>
|
||||
new(new ClaimsIdentity([
|
||||
|
||||
// This is the claim that's used to store the ID
|
||||
new Claim(Constants.Security.OpenIdDictSubClaimType, key.ToString())
|
||||
]));
|
||||
|
||||
private IServerEventAuthorizationService CreateServeEventAuthorizationService(params IEnumerable<IEventSourceAuthorizer> authorizers)
|
||||
=> new ServerEventAuthorizationService(new EventSourceAuthorizerCollection(() => authorizers));
|
||||
|
||||
private (Mock<IServerEventHub> HubMock, Mock<IHubClients<IServerEventHub>> HubClientsMock, Mock<IGroupManager> GroupManagerMock, Mock<IHubContext<ServerEventHub, IServerEventHub>> HubContextMock) CreateHubContextMocks()
|
||||
{
|
||||
var hubMock = new Mock<IServerEventHub>();
|
||||
|
||||
var hubClients = new Mock<IHubClients<IServerEventHub>>();
|
||||
hubClients.Setup(x => x.All).Returns(hubMock.Object);
|
||||
|
||||
var groupManagerMock = new Mock<IGroupManager>();
|
||||
|
||||
var hubContext = new Mock<IHubContext<ServerEventHub, IServerEventHub>>();
|
||||
hubContext.Setup(x => x.Clients).Returns(hubClients.Object);
|
||||
hubContext.Setup(x => x.Groups).Returns(groupManagerMock.Object);
|
||||
return (hubMock, hubClients, groupManagerMock, hubContext);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user