diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index 248197dc1..f5b12cc82 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -43,6 +43,11 @@ namespace Squidex.Infrastructure return new HashSet(enumerable); } + public static HashSet ToHashSet(this IEnumerable enumerable, IEqualityComparer comparer) + { + return new HashSet(enumerable, comparer); + } + public static IEnumerable OrEmpty(this IEnumerable source) { return source ?? Enumerable.Empty(); diff --git a/src/Squidex.Infrastructure/Security/Permission.Part.cs b/src/Squidex.Infrastructure/Security/Permission.Part.cs new file mode 100644 index 000000000..775adc775 --- /dev/null +++ b/src/Squidex.Infrastructure/Security/Permission.Part.cs @@ -0,0 +1,81 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Squidex.Infrastructure.Security +{ + public sealed partial class Permission + { + internal struct Part + { + private static readonly char[] AlternativeSeparators = { '|' }; + private static readonly char[] MainSeparators = { '.' }; + + public readonly HashSet Alternatives; + + public readonly bool Exclusion; + + public Part(HashSet alternatives, bool exclusion) + { + Alternatives = alternatives; + + Exclusion = exclusion; + } + + public static Part[] ParsePath(string path) + { + return path + .Split(MainSeparators, StringSplitOptions.RemoveEmptyEntries) + .Select(Parse) + .ToArray(); + } + + public static Part Parse(string part) + { + var isExclusion = false; + + if (part.StartsWith(Exclude, StringComparison.OrdinalIgnoreCase)) + { + isExclusion = true; + + part = part.Substring(1); + } + + HashSet alternatives = null; + + if (part != Any) + { + alternatives = + part.Split(AlternativeSeparators, StringSplitOptions.RemoveEmptyEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + return new Part(alternatives, isExclusion); + } + + public static bool Intersects(ref Part lhs, ref Part rhs, bool allowNull) + { + if (lhs.Alternatives == null) + { + return true; + } + + if (allowNull && rhs.Alternatives == null) + { + return true; + } + + bool shouldIntersect = !(lhs.Exclusion ^ rhs.Exclusion); + + return rhs.Alternatives != null && lhs.Alternatives.Intersect(rhs.Alternatives).Any() == shouldIntersect; + } + } + } +} diff --git a/src/Squidex.Infrastructure/Security/Permission.cs b/src/Squidex.Infrastructure/Security/Permission.cs index 0864337db..c81234028 100644 --- a/src/Squidex.Infrastructure/Security/Permission.cs +++ b/src/Squidex.Infrastructure/Security/Permission.cs @@ -6,19 +6,16 @@ // ========================================================================== using System; -using System.Collections.Generic; -using System.Linq; namespace Squidex.Infrastructure.Security { - public sealed class Permission : IComparable, IEquatable + public sealed partial class Permission : IComparable, IEquatable { public const string Any = "*"; + public const string Exclude = "^"; - private static readonly char[] MainSeparators = { '.' }; - private static readonly char[] AlternativeSeparators = { '|' }; private readonly string id; - private readonly Lazy[]> idParts; + private readonly Lazy idParts; public string Id { @@ -31,20 +28,7 @@ namespace Squidex.Infrastructure.Security this.id = id; - idParts = new Lazy[]>(() => id - .Split(MainSeparators, StringSplitOptions.RemoveEmptyEntries) - .Select(x => - { - if (x == Any) - { - return null; - } - - var alternatives = x.Split(AlternativeSeparators, StringSplitOptions.RemoveEmptyEntries); - - return new HashSet(alternatives, StringComparer.OrdinalIgnoreCase); - }) - .ToArray()); + idParts = new Lazy(() => Part.ParsePath(id)); } public bool Allows(Permission permission) @@ -54,20 +38,29 @@ namespace Squidex.Infrastructure.Security return false; } - var lhs = idParts.Value; - var rhs = permission.idParts.Value; + return Covers(idParts.Value, permission.idParts.Value); + } - if (lhs.Length > rhs.Length) + public bool Includes(Permission permission) + { + if (permission == null) { return false; } - for (var i = 0; i < lhs.Length; i++) + return PartialCovers(idParts.Value, permission.idParts.Value); + } + + private static bool Covers(Part[] given, Part[] requested) + { + if (given.Length > requested.Length) { - var l = lhs[i]; - var r = rhs[i]; + return false; + } - if (l != null && (r == null || !l.Intersect(r).Any())) + for (var i = 0; i < given.Length; i++) + { + if (!Part.Intersects(ref given[i], ref requested[i], false)) { return false; } @@ -76,22 +69,11 @@ namespace Squidex.Infrastructure.Security return true; } - public bool Includes(Permission permission) + private static bool PartialCovers(Part[] given, Part[] requested) { - if (permission == null) - { - return false; - } - - var lhs = idParts.Value; - var rhs = permission.idParts.Value; - - for (var i = 0; i < Math.Min(lhs.Length, rhs.Length); i++) + for (var i = 0; i < Math.Min(given.Length, requested.Length); i++) { - var l = lhs[i]; - var r = rhs[i]; - - if (l != null && r != null && !l.Intersect(r).Any()) + if (!Part.Intersects(ref given[i], ref requested[i], true)) { return false; } diff --git a/src/Squidex.Web/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs index 5001c8838..b82b2ade3 100644 --- a/src/Squidex.Web/PermissionExtensions.cs +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http; using Squidex.Infrastructure.Security; +using AllPermissions = Squidex.Shared.Permissions; namespace Squidex.Web { @@ -17,22 +18,27 @@ namespace Squidex.Web return httpContext.Context().Permissions; } - public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet permissions = null) + public static bool Includes(this HttpContext httpContext, Permission permission, PermissionSet additional = null) { - return httpContext.Permissions().Includes(permission) || permissions?.Includes(permission) == true; + return httpContext.Permissions().Includes(permission) || additional?.Includes(permission) == true; } - public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*", PermissionSet permissions = null) + public static bool Includes(this ApiController controller, Permission permission, PermissionSet additional = null) { - return httpContext.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions); + return controller.HttpContext.Includes(permission) || additional?.Includes(permission) == true; } - public static bool HasPermission(this ApiController controller, Permission permission, PermissionSet permissions = null) + public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet additional = null) { - return controller.HttpContext.HasPermission(permission, permissions); + return httpContext.Permissions().Allows(permission) || additional?.Allows(permission) == true; } - public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*", PermissionSet permissions = null) + public static bool HasPermission(this ApiController controller, Permission permission, PermissionSet additional = null) + { + return controller.HttpContext.HasPermission(permission) || additional?.Allows(permission) == true; + } + + public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*", PermissionSet additional = null) { if (app == "*") { @@ -50,7 +56,9 @@ namespace Squidex.Web } } - return controller.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions); + var permission = AllPermissions.ForApp(id, app, schema); + + return controller.HasPermission(permission, additional); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 1eee4cdd5..426d7fbe5 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -23,6 +23,8 @@ using Squidex.Shared; using Squidex.Web; using AllPermissions = Squidex.Shared.Permissions; +#pragma warning disable RECS0033 // Convert 'if' to '||' expression + namespace Squidex.Areas.Api.Controllers.Apps.Models { public sealed class AppDto : Resource @@ -88,8 +90,15 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models result.Permissions = permissions.ToIds(); result.PlanName = plans.GetPlanForApp(app)?.Name; - result.CanAccessApi = controller.HasPermission(AllPermissions.AppApi, app.Name, "*", permissions); - result.CanAccessContent = controller.HasPermission(AllPermissions.AppContentsRead, app.Name, "*", permissions); + if (controller.Includes(AllPermissions.ForApp(AllPermissions.AppApi, app.Name), permissions)) + { + result.CanAccessApi = true; + } + + if (controller.Includes(AllPermissions.ForApp(AllPermissions.AppContents, app.Name, "*"), permissions)) + { + result.CanAccessContent = true; + } if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) { @@ -122,72 +131,72 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models AddGetLink("ping", controller.Url(x => nameof(x.GetAppPing), values)); - if (controller.HasPermission(AllPermissions.AppDelete, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppDelete, Name, additional: permissions)) { AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteApp), values)); } - if (controller.HasPermission(AllPermissions.AppAssetsRead, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppAssetsRead, Name, additional: permissions)) { AddGetLink("assets", controller.Url(x => nameof(x.GetAssets), values)); } - if (controller.HasPermission(AllPermissions.AppBackupsRead, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppBackupsRead, Name, additional: permissions)) { AddGetLink("backups", controller.Url(x => nameof(x.GetBackups), values)); } - if (controller.HasPermission(AllPermissions.AppClientsRead, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppClientsRead, Name, additional: permissions)) { AddGetLink("clients", controller.Url(x => nameof(x.GetClients), values)); } - if (controller.HasPermission(AllPermissions.AppContributorsRead, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppContributorsRead, Name, additional: permissions)) { AddGetLink("contributors", controller.Url(x => nameof(x.GetContributors), values)); } - if (controller.HasPermission(AllPermissions.AppCommon, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppCommon, Name, additional: permissions)) { AddGetLink("languages", controller.Url(x => nameof(x.GetLanguages), values)); } - if (controller.HasPermission(AllPermissions.AppCommon, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppCommon, Name, additional: permissions)) { AddGetLink("patterns", controller.Url(x => nameof(x.GetPatterns), values)); } - if (controller.HasPermission(AllPermissions.AppPlansRead, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppPlansRead, Name, additional: permissions)) { AddGetLink("plans", controller.Url(x => nameof(x.GetPlans), values)); } - if (controller.HasPermission(AllPermissions.AppRolesRead, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppRolesRead, Name, additional: permissions)) { AddGetLink("roles", controller.Url(x => nameof(x.GetRoles), values)); } - if (controller.HasPermission(AllPermissions.AppRulesRead, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppRulesRead, Name, additional: permissions)) { AddGetLink("rules", controller.Url(x => nameof(x.GetRules), values)); } - if (controller.HasPermission(AllPermissions.AppCommon, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppCommon, Name, additional: permissions)) { AddGetLink("schemas", controller.Url(x => nameof(x.GetSchemas), values)); } - if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, additional: permissions)) { AddGetLink("workflows", controller.Url(x => nameof(x.GetWorkflows), values)); } - if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, additional: permissions)) { AddPostLink("schemas/create", controller.Url(x => nameof(x.PostSchema), values)); } - if (controller.HasPermission(AllPermissions.AppAssetsCreate, Name, permissions: permissions)) + if (controller.HasPermission(AllPermissions.AppAssetsCreate, Name, additional: permissions)) { AddPostLink("assets/create", controller.Url(x => nameof(x.PostSchema), values)); } diff --git a/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs b/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs index dbff3d2ac..f76ae19ba 100644 --- a/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs @@ -35,7 +35,7 @@ namespace Squidex.Infrastructure.Security } [Fact] - public void Should_return_true_if_any_permission_gives_permission_to_requested() + public void Should_give_permission_if_any_permission_allows() { var sut = new PermissionSet( new Permission("app.contents"), @@ -45,57 +45,67 @@ namespace Squidex.Infrastructure.Security } [Fact] - public void Should_return_true_if_any_permission_includes_parent_given() + public void Should_not_give_permission_if_none_permission_allows() { var sut = new PermissionSet( new Permission("app.contents"), new Permission("app.assets")); - Assert.True(sut.Includes(new Permission("app"))); + Assert.False(sut.Allows(new Permission("app.schemas"))); } [Fact] - public void Should_return_true_if_any_permission_includes_child_given() + public void Should_not_give_permission_if_requested_is_null() { var sut = new PermissionSet( new Permission("app.contents"), new Permission("app.assets")); - Assert.True(sut.Includes(new Permission("app.contents.read"))); + Assert.False(sut.Allows(null)); } [Fact] - public void Should_return_false_if_none_permission_gives_permission_to_requested() + public void Should_include_permission_if_any_permission_includes_parent_given() { var sut = new PermissionSet( new Permission("app.contents"), new Permission("app.assets")); - Assert.False(sut.Allows(new Permission("app.schemas"))); + Assert.True(sut.Includes(new Permission("app"))); } [Fact] - public void Should_return_false_if_none_permission_includes_given() + public void Should_include_permission_if_any_permission_includes_child_given() { var sut = new PermissionSet( new Permission("app.contents"), new Permission("app.assets")); - Assert.False(sut.Includes(new Permission("other"))); + Assert.True(sut.Includes(new Permission("app.contents.read"))); } [Fact] - public void Should_return_false_if_permission_to_request_is_null() + public void Should_include_permission_even_if_negation_exists() { var sut = new PermissionSet( new Permission("app.contents"), new Permission("app.assets")); - Assert.False(sut.Allows(null)); + Assert.True(sut.Includes(new Permission("app.contents.read"))); + } + + [Fact] + public void Should_not_include_permission_if_none_permission_includes_given() + { + var sut = new PermissionSet( + new Permission("app.contents"), + new Permission("app.assets")); + + Assert.False(sut.Includes(new Permission("other"))); } [Fact] - public void Should_return_false_if_permission_to_include_is_null() + public void Should_not_include_permission_if_permission_to_include_is_null() { var sut = new PermissionSet( new Permission("app.contents"), diff --git a/tests/Squidex.Infrastructure.Tests/Security/PermissionTests.cs b/tests/Squidex.Infrastructure.Tests/Security/PermissionTests.cs index ca0aff6c2..e23c2e656 100644 --- a/tests/Squidex.Infrastructure.Tests/Security/PermissionTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Security/PermissionTests.cs @@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.Security public class PermissionTests { [Fact] - public void Should_generate_permissions() + public void Should_generate_permission() { var sut = new Permission("app.contents"); @@ -23,112 +23,133 @@ namespace Squidex.Infrastructure.Security } [Fact] - public void Should_check_when_permissions_are_not_equal() + public void Should_allow_and_include_when_permissions_are_equal() { var g = new Permission("app.contents"); - var r = new Permission("app.assets"); - - Assert.False(g.Allows(r)); + var r = new Permission("app.contents"); - Assert.False(g.Includes(r)); + Assert.True(g.Allows(r)); + Assert.True(g.Includes(r)); } [Fact] - public void Should_check_when_permissions_are_equal_with_wildcards() + public void Should_not_allow_and_include_when_permissions_are_not_equal() { - var g = new Permission("app.*"); - var r = new Permission("app.*"); - - Assert.True(g.Allows(r)); + var g = new Permission("app.contents"); + var r = new Permission("app.assets"); - Assert.True(g.Includes(r)); + Assert.False(g.Allows(r)); + Assert.False(g.Includes(r)); } [Fact] - public void Should_check_when_equal_permissions() + public void Should_allow_and_include_when_permissions_have_same_wildcards() { - var g = new Permission("app.contents"); - var r = new Permission("app.contents"); + var g = new Permission("app.*"); + var r = new Permission("app.*"); Assert.True(g.Allows(r)); - Assert.True(g.Includes(r)); } [Fact] - public void Should_check_when_given_is_parent_of_requested() + public void Should_allow_and_include_when_given_is_parent_of_requested() { var g = new Permission("app"); var r = new Permission("app.contents"); Assert.True(g.Allows(r)); - Assert.True(g.Includes(r)); } [Fact] - public void Should_check_when_requested_is_parent_of_given() + public void Should_not_allow_but_include_when_requested_is_parent_of_given() { var g = new Permission("app.contents"); var r = new Permission("app"); Assert.False(g.Allows(r)); - Assert.True(g.Includes(r)); } [Fact] - public void Should_check_when_given_is_wildcard_of_requested() + public void Should_allow_and_include_when_given_is_wildcard_of_requested() { var g = new Permission("app.*"); var r = new Permission("app.contents"); Assert.True(g.Allows(r)); - Assert.True(g.Includes(r)); } [Fact] - public void Should_check_when_requested_is_wildcard_of_given() + public void Should_not_allow_but_include_when_given_is_wildcard_of_requested() { var g = new Permission("app.contents"); var r = new Permission("app.*"); Assert.False(g.Allows(r)); - Assert.True(g.Includes(r)); } [Fact] - public void Should_check_when_given_is_has_alternatives_of_requested() + public void Should_allow_and_include_when_given_has_alternatives_of_requested() { var g = new Permission("app.contents|schemas"); var r = new Permission("app.contents"); Assert.True(g.Allows(r)); + Assert.True(g.Includes(r)); + } + [Fact] + public void Should_allow_and_include_when_given_has_not_excluded_requested() + { + var g = new Permission("app.^schemas"); + var r = new Permission("app.contents"); + + Assert.True(g.Allows(r)); Assert.True(g.Includes(r)); } [Fact] - public void Should_check_when_requested_is_has_alternatives_of_given() + public void Should_allow_and_include_when_requested_has_not_excluded_given() { var g = new Permission("app.contents"); - var r = new Permission("app.contents|schemas"); + var r = new Permission("app.^schemas"); Assert.True(g.Allows(r)); + Assert.True(g.Includes(r)); + } + + [Fact] + public void Should_not_allow_and_include_when_given_has_excluded_requested() + { + var g = new Permission("app.^contents"); + var r = new Permission("app.contents"); + Assert.False(g.Allows(r)); + Assert.False(g.Includes(r)); + } + + [Fact] + public void Should_not_allow_and_include_when_given_and_requested_have_same_exclusion() + { + var g = new Permission("app.^contents"); + var r = new Permission("app.^contents"); + + Assert.True(g.Allows(r)); Assert.True(g.Includes(r)); } [Fact] - public void Should_check_when_requested_is_null() + public void Should_allow_and_include_when_requested_is_has_alternatives_of_given() { var g = new Permission("app.contents"); + var r = new Permission("app.contents|schemas"); - Assert.False(g.Allows(null)); - - Assert.False(g.Includes(null)); + Assert.True(g.Allows(r)); + Assert.True(g.Includes(r)); } [Fact]