Browse Source

Gateway to restrict total number of apps. (#760)

* Gateway to restrict total number of apps.

* Fixes.

* Fix permission check.
pull/761/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
5f73c046a7
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      backend/i18n/source/backend_en.json
  2. 62
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsCommandMiddleware.cs
  3. 14
      backend/src/Squidex.Domain.Apps.Entities/Apps/Plans/RestrictAppsOptions.cs
  4. 13
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SecurityExtensions.cs
  5. 2
      backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs
  6. 17
      backend/src/Squidex.Shared/Identity/SquidexClaimsExtensions.cs
  7. 3
      backend/src/Squidex.Shared/Texts.it.resx
  8. 3
      backend/src/Squidex.Shared/Texts.nl.resx
  9. 3
      backend/src/Squidex.Shared/Texts.resx
  10. 3
      backend/src/Squidex.Shared/Texts.zh.resx
  11. 4
      backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs
  12. 7
      backend/src/Squidex/Config/Domain/CommandsServices.cs
  13. 178
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Plans/RestrictAppsCommandMiddlewareTests.cs
  14. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/Guards/GuardContentTests.cs

1
backend/i18n/source/backend_en.json

@ -16,6 +16,7 @@
"apps.languages.masterLanguageNoFallbacks": "Master language cannot have fallback languages.", "apps.languages.masterLanguageNoFallbacks": "Master language cannot have fallback languages.",
"apps.languages.masterLanguageNotOptional": "Master language cannot be made optional.", "apps.languages.masterLanguageNotOptional": "Master language cannot be made optional.",
"apps.languages.masterLanguageNotRemovable": "Master language cannot be removed.", "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.nameAlreadyExists": "An app with the same name already exists.",
"apps.notImage": "File is not an image", "apps.notImage": "File is not an image",
"apps.plans.notFound": "A plan with this id does not exist.", "apps.plans.notFound": "A plan with this id does not exist.",

62
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<RestrictAppsOptions> 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);
}
}
}
}

14
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;
}
}

13
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/SecurityExtensions.cs

@ -6,7 +6,9 @@
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
using Squidex.Shared;
using Squidex.Shared.Identity; using Squidex.Shared.Identity;
namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
@ -22,7 +24,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards
return; 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")); throw new DomainForbiddenException(T.Get("common.errorNoPermission"));
} }

2
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 PictureUrl = "urn:squidex:picture";
public const string PictureUrlStore = "store"; public const string PictureUrlStore = "store";
public const string TotalApps = "urn:squidex:internal:totalApps";
} }
} }

17
backend/src/Squidex.Shared/Identity/SquidexClaimsExtensions.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
@ -19,12 +20,9 @@ namespace Squidex.Shared.Identity
public static PermissionSet Permissions(this IEnumerable<Claim> user) public static PermissionSet Permissions(this IEnumerable<Claim> 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 new PermissionSet(permissions);
{
return user.Claims.Permissions().Allows(id, app, schema);
} }
public static bool IsHidden(this IEnumerable<Claim> user) public static bool IsHidden(this IEnumerable<Claim> user)
@ -72,6 +70,15 @@ namespace Squidex.Shared.Identity
return user.GetClaimValue(SquidexClaimTypes.DisplayName); return user.GetClaimValue(SquidexClaimTypes.DisplayName);
} }
public static int GetTotalApps(this IEnumerable<Claim> 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<Claim> user, string type) public static bool HasClaim(this IEnumerable<Claim> user, string type)
{ {
return user.GetClaims(type).Any(); return user.GetClaims(type).Any();

3
backend/src/Squidex.Shared/Texts.it.resx

@ -133,6 +133,9 @@
<data name="apps.languages.masterLanguageNotRemovable" xml:space="preserve"> <data name="apps.languages.masterLanguageNotRemovable" xml:space="preserve">
<value>La lingua master non può essere rimossa.</value> <value>La lingua master non può essere rimossa.</value>
</data> </data>
<data name="apps.maximumTotalReached" xml:space="preserve">
<value>You cannot create more apps. Please contact the support to remove this restriction from your account.</value>
</data>
<data name="apps.nameAlreadyExists" xml:space="preserve"> <data name="apps.nameAlreadyExists" xml:space="preserve">
<value>Esiste già un'app con lo stesso nome.</value> <value>Esiste già un'app con lo stesso nome.</value>
</data> </data>

3
backend/src/Squidex.Shared/Texts.nl.resx

@ -133,6 +133,9 @@
<data name="apps.languages.masterLanguageNotRemovable" xml:space="preserve"> <data name="apps.languages.masterLanguageNotRemovable" xml:space="preserve">
<value>Hoofdtaal kan niet worden verwijderd.</value> <value>Hoofdtaal kan niet worden verwijderd.</value>
</data> </data>
<data name="apps.maximumTotalReached" xml:space="preserve">
<value>You cannot create more apps. Please contact the support to remove this restriction from your account.</value>
</data>
<data name="apps.nameAlreadyExists" xml:space="preserve"> <data name="apps.nameAlreadyExists" xml:space="preserve">
<value>Er bestaat al een app met dezelfde naam.</value> <value>Er bestaat al een app met dezelfde naam.</value>
</data> </data>

3
backend/src/Squidex.Shared/Texts.resx

@ -133,6 +133,9 @@
<data name="apps.languages.masterLanguageNotRemovable" xml:space="preserve"> <data name="apps.languages.masterLanguageNotRemovable" xml:space="preserve">
<value>Master language cannot be removed.</value> <value>Master language cannot be removed.</value>
</data> </data>
<data name="apps.maximumTotalReached" xml:space="preserve">
<value>You cannot create more apps. Please contact the support to remove this restriction from your account.</value>
</data>
<data name="apps.nameAlreadyExists" xml:space="preserve"> <data name="apps.nameAlreadyExists" xml:space="preserve">
<value>An app with the same name already exists.</value> <value>An app with the same name already exists.</value>
</data> </data>

3
backend/src/Squidex.Shared/Texts.zh.resx

@ -133,6 +133,9 @@
<data name="apps.languages.masterLanguageNotRemovable" xml:space="preserve"> <data name="apps.languages.masterLanguageNotRemovable" xml:space="preserve">
<value>无法删除主语言。</value> <value>无法删除主语言。</value>
</data> </data>
<data name="apps.maximumTotalReached" xml:space="preserve">
<value>You cannot create more apps. Please contact the support to remove this restriction from your account.</value>
</data>
<data name="apps.nameAlreadyExists" xml:space="preserve"> <data name="apps.nameAlreadyExists" xml:space="preserve">
<value>同名应用已经存在。</value> <value>同名应用已经存在。</value>
</data> </data>

4
backend/src/Squidex/Areas/OrleansDashboard/Middlewares/OrleansDashboardAuthenticationMiddleware.cs

@ -30,7 +30,9 @@ namespace Squidex.Areas.OrleansDashboard.Middlewares
if (authentication.Succeeded) 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); await next(context);
} }

7
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.DomainObject;
using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Apps.Indexes;
using Squidex.Domain.Apps.Entities.Apps.Invitation; 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.Apps.Templates;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.DomainObject; using Squidex.Domain.Apps.Entities.Assets.DomainObject;
@ -34,6 +35,9 @@ namespace Squidex.Config.Domain
services.Configure<ReadonlyOptions>(config, services.Configure<ReadonlyOptions>(config,
"mode"); "mode");
services.Configure<RestrictAppsOptions>(config,
"usage");
services.AddSingletonAs<InMemoryCommandBus>() services.AddSingletonAs<InMemoryCommandBus>()
.As<ICommandBus>(); .As<ICommandBus>();
@ -61,6 +65,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<CustomCommandMiddlewareRunner>() services.AddSingletonAs<CustomCommandMiddlewareRunner>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<RestrictAppsCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<InviteUserCommandMiddleware>() services.AddSingletonAs<InviteUserCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();

178
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<IUserResolver>();
private readonly ICommandBus commandBus = A.Fake<ICommandBus>();
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<IUser>();
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<ValidationException>(() => 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<IUser>();
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<string>._))
.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<string>._))
.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<string>._))
.MustNotHaveHappened();
}
}
}

2
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); var context = CreateContext(CreateContent(Status.Draft), normalSchema);
((ContentEntity)(ContentEntity)context.Content).CreatedBy = RefToken.User("456"); ((ContentEntity)context.Content).CreatedBy = RefToken.User("456");
Assert.Throws<DomainForbiddenException>(() => context.MustHavePermission(Permissions.AppContentsDelete)); Assert.Throws<DomainForbiddenException>(() => context.MustHavePermission(Permissions.AppContentsDelete));
} }

Loading…
Cancel
Save