diff --git a/backend/i18n/source/backend_en.json b/backend/i18n/source/backend_en.json index 6ce133773..382e4179f 100644 --- a/backend/i18n/source/backend_en.json +++ b/backend/i18n/source/backend_en.json @@ -16,6 +16,7 @@ "apps.languages.masterLanguageNoFallbacks": "Master language cannot have fallback languages.", "apps.languages.masterLanguageNotOptional": "Master language cannot be made optional.", "apps.languages.masterLanguageNotRemovable": "Master language cannot be removed.", + "apps.maximumTotalReached": "You cannot create more apps. Please contact the support to remove this restriction from your account.", "apps.nameAlreadyExists": "An app with the same name already exists.", "apps.notImage": "File is not an image", "apps.plans.notFound": "A plan with this id does not exist.", diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs new file mode 100644 index 000000000..b96c0492f --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Translations; +using Squidex.Infrastructure.Validation; +using Squidex.Shared.Identity; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Apps.Plans +{ + public sealed class RestrictAppsCommandMiddleware : ICommandMiddleware + { + private readonly RestrictAppsOptions usageOptions; + private readonly IUserResolver userResolver; + + public RestrictAppsCommandMiddleware(IOptions usageOptions, IUserResolver userResolver) + { + this.usageOptions = usageOptions.Value; + this.userResolver = userResolver; + } + + public async Task HandleAsync(CommandContext context, NextDelegate next) + { + if (usageOptions.MaximumNumberOfApps <= 0 || context.Command is not CreateApp createApp || createApp.Actor.IsClient) + { + await next(context); + return; + } + + var totalApps = 0; + + var user = await userResolver.FindByIdAsync(createApp.Actor.Identifier); + + if (user != null) + { + totalApps = user.Claims.GetTotalApps(); + + if (totalApps >= usageOptions.MaximumNumberOfApps) + { + throw new ValidationException(T.Get("apps.maximumTotalReached")); + } + } + + await next(context); + + if (context.IsCompleted && user != null) + { + var newApps = totalApps + 1; + + await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.TotalApps, newApps.ToString(), true); + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsOptions.cs new file mode 100644 index 000000000..b782d0fc3 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsOptions.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Plans +{ + public sealed class RestrictAppsOptions + { + public int MaximumNumberOfApps { get; set; } = 0; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SecurityExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SecurityExtensions.cs index 97743f15c..1578b6794 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SecurityExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SecurityExtensions.cs @@ -6,7 +6,9 @@ // ========================================================================== using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Translations; +using Squidex.Shared; using Squidex.Shared.Identity; namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards @@ -22,7 +24,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards return; } - if (!context.User.Allows(permissionId, content.AppId.Name, content.SchemaId.Name)) + var permissions = context.User?.Claims.Permissions(); + + if (permissions == null) + { + throw new DomainForbiddenException(T.Get("common.errorNoPermission")); + } + + var permission = Permissions.ForApp(permissionId, context.App.Name, context.Schema.SchemaDef.Name); + + if (permissions.Allows(permission) != true) { throw new DomainForbiddenException(T.Get("common.errorNoPermission")); } diff --git a/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs b/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs index 9547904da..38def9db6 100644 --- a/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs +++ b/backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs @@ -30,5 +30,7 @@ namespace Squidex.Shared.Identity public const string PictureUrl = "urn:squidex:picture"; public const string PictureUrlStore = "store"; + + public const string TotalApps = "urn:squidex:internal:totalApps"; } } diff --git a/backend/src/Squidex.Shared/Identity/SquidexClaimsExtensions.cs b/backend/src/Squidex.Shared/Identity/SquidexClaimsExtensions.cs index 4cdc298f2..a4731c2dc 100644 --- a/backend/src/Squidex.Shared/Identity/SquidexClaimsExtensions.cs +++ b/backend/src/Squidex.Shared/Identity/SquidexClaimsExtensions.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Security.Claims; using Squidex.Infrastructure.Security; @@ -19,12 +20,9 @@ namespace Squidex.Shared.Identity public static PermissionSet Permissions(this IEnumerable user) { - return new PermissionSet(user.GetClaims(SquidexClaimTypes.Permissions).Select(x => new Permission(x.Value))); - } + var permissions = user.GetClaims(SquidexClaimTypes.Permissions).Select(x => x.Value); - public static bool Allows(this ClaimsPrincipal user, string id, string app = Permission.Any, string schema = Permission.Any) - { - return user.Claims.Permissions().Allows(id, app, schema); + return new PermissionSet(permissions); } public static bool IsHidden(this IEnumerable user) @@ -72,6 +70,15 @@ namespace Squidex.Shared.Identity return user.GetClaimValue(SquidexClaimTypes.DisplayName); } + public static int GetTotalApps(this IEnumerable user) + { + var value = user.GetClaimValue(SquidexClaimTypes.TotalApps); + + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result); + + return result; + } + public static bool HasClaim(this IEnumerable user, string type) { return user.GetClaims(type).Any(); diff --git a/backend/src/Squidex.Shared/Texts.it.resx b/backend/src/Squidex.Shared/Texts.it.resx index 497b0e674..a88497e4f 100644 --- a/backend/src/Squidex.Shared/Texts.it.resx +++ b/backend/src/Squidex.Shared/Texts.it.resx @@ -133,6 +133,9 @@ La lingua master non può essere rimossa. + + You cannot create more apps. Please contact the support to remove this restriction from your account. + Esiste già un'app con lo stesso nome. diff --git a/backend/src/Squidex.Shared/Texts.nl.resx b/backend/src/Squidex.Shared/Texts.nl.resx index 62d20868b..cd91315c9 100644 --- a/backend/src/Squidex.Shared/Texts.nl.resx +++ b/backend/src/Squidex.Shared/Texts.nl.resx @@ -133,6 +133,9 @@ Hoofdtaal kan niet worden verwijderd. + + You cannot create more apps. Please contact the support to remove this restriction from your account. + Er bestaat al een app met dezelfde naam. diff --git a/backend/src/Squidex.Shared/Texts.resx b/backend/src/Squidex.Shared/Texts.resx index 34f2c82f8..23a319e7d 100644 --- a/backend/src/Squidex.Shared/Texts.resx +++ b/backend/src/Squidex.Shared/Texts.resx @@ -133,6 +133,9 @@ Master language cannot be removed. + + You cannot create more apps. Please contact the support to remove this restriction from your account. + An app with the same name already exists. diff --git a/backend/src/Squidex.Shared/Texts.zh.resx b/backend/src/Squidex.Shared/Texts.zh.resx index c6e626adf..368aa94e8 100644 --- a/backend/src/Squidex.Shared/Texts.zh.resx +++ b/backend/src/Squidex.Shared/Texts.zh.resx @@ -133,6 +133,9 @@ 无法删除主语言。 + + You cannot create more apps. Please contact the support to remove this restriction from your account. + 同名应用已经存在。 diff --git a/backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs b/backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs index 64a8fd00b..2ff033f48 100644 --- a/backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs +++ b/backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs @@ -30,7 +30,9 @@ namespace Squidex.Areas.OrleansDashboard.Middlewares if (authentication.Succeeded) { - if (authentication.Principal?.Allows(Permissions.AdminOrleans) == true) + var permissions = authentication.Principal?.Claims.Permissions(); + + if (permissions?.Allows(Permissions.AdminOrleans) == true) { await next(context); } diff --git a/backend/src/Squidex/Config/Domain/CommandsServices.cs b/backend/src/Squidex/Config/Domain/CommandsServices.cs index 195c55b89..5a03376ad 100644 --- a/backend/src/Squidex/Config/Domain/CommandsServices.cs +++ b/backend/src/Squidex/Config/Domain/CommandsServices.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Squidex.Domain.Apps.Entities.Apps.DomainObject; using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Apps.Invitation; +using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Apps.Templates; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.DomainObject; @@ -34,6 +35,9 @@ namespace Squidex.Config.Domain services.Configure(config, "mode"); + services.Configure(config, + "usage"); + services.AddSingletonAs() .As(); @@ -61,6 +65,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/RestrictAppsCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/RestrictAppsCommandMiddlewareTests.cs new file mode 100644 index 000000000..8bf1b9d86 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/RestrictAppsCommandMiddlewareTests.cs @@ -0,0 +1,178 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Validation; +using Squidex.Shared.Identity; +using Squidex.Shared.Users; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Apps.Plans +{ + public sealed class RestrictAppsCommandMiddlewareTests + { + private readonly IUserResolver userResolver = A.Fake(); + private readonly ICommandBus commandBus = A.Fake(); + private readonly RestrictAppsOptions options = new RestrictAppsOptions(); + private readonly RestrictAppsCommandMiddleware sut; + + public RestrictAppsCommandMiddlewareTests() + { + sut = new RestrictAppsCommandMiddleware(Options.Create(options), userResolver); + } + + [Fact] + public async Task Should_throw_exception_if_number_of_apps_reached() + { + var userId = Guid.NewGuid().ToString(); + + var command = new CreateApp + { + Actor = RefToken.User(userId) + }; + + var commandContext = new CommandContext(command, commandBus); + + options.MaximumNumberOfApps = 3; + + var user = A.Fake(); + + A.CallTo(() => user.Id) + .Returns(userId); + + A.CallTo(() => user.Claims) + .Returns(Enumerable.Repeat(new Claim(SquidexClaimTypes.TotalApps, "5"), 1).ToList()); + + A.CallTo(() => userResolver.FindByIdAsync(userId)) + .Returns(user); + + var isNextCalled = false; + + await Assert.ThrowsAsync(() => sut.HandleAsync(commandContext, x => + { + isNextCalled = true; + + return Task.CompletedTask; + })); + + Assert.False(isNextCalled); + } + + [Fact] + public async Task Should_increment_total_apps_if_maximum_not_reached_and_completed() + { + var userId = Guid.NewGuid().ToString(); + + var command = new CreateApp + { + Actor = RefToken.User(userId) + }; + + var commandContext = new CommandContext(command, commandBus); + + options.MaximumNumberOfApps = 10; + + var user = A.Fake(); + + A.CallTo(() => user.Id) + .Returns(userId); + + A.CallTo(() => user.Claims) + .Returns(Enumerable.Repeat(new Claim(SquidexClaimTypes.TotalApps, "5"), 1).ToList()); + + A.CallTo(() => userResolver.FindByIdAsync(userId)) + .Returns(user); + + await sut.HandleAsync(commandContext, x => + { + x.Complete(true); + + return Task.CompletedTask; + }); + + A.CallTo(() => userResolver.SetClaimAsync(userId, SquidexClaimTypes.TotalApps, "6", true)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_check_usage_if_app_is_created_by_client() + { + var command = new CreateApp + { + Actor = RefToken.Client(Guid.NewGuid().ToString()) + }; + + var commandContext = new CommandContext(command, commandBus); + + options.MaximumNumberOfApps = 10; + + await sut.HandleAsync(commandContext, x => + { + x.Complete(true); + + return Task.CompletedTask; + }); + + A.CallTo(() => userResolver.FindByIdAsync(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_check_usage_if_no_maximum_configured() + { + var command = new CreateApp + { + Actor = RefToken.User(Guid.NewGuid().ToString()) + }; + + var commandContext = new CommandContext(command, commandBus); + + options.MaximumNumberOfApps = 0; + + await sut.HandleAsync(commandContext, x => + { + x.Complete(true); + + return Task.CompletedTask; + }); + + A.CallTo(() => userResolver.FindByIdAsync(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_check_usage_for_other_commands() + { + var command = new UpdateApp + { + Actor = RefToken.User(Guid.NewGuid().ToString()) + }; + + var commandContext = new CommandContext(command, commandBus); + + options.MaximumNumberOfApps = 10; + + await sut.HandleAsync(commandContext, x => + { + x.Complete(true); + + return Task.CompletedTask; + }); + + A.CallTo(() => userResolver.FindByIdAsync(A._)) + .MustNotHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs index 237f81f03..cf634dcb1 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs @@ -339,7 +339,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards { var context = CreateContext(CreateContent(Status.Draft), normalSchema); - ((ContentEntity)(ContentEntity)context.Content).CreatedBy = RefToken.User("456"); + ((ContentEntity)context.Content).CreatedBy = RefToken.User("456"); Assert.Throws(() => context.MustHavePermission(Permissions.AppContentsDelete)); }