Compare commits

...
Author SHA1 Message Date
Sven Geusens cf4d68e37d Added DB migration 2025-03-03 09:16:43 +01:00
Sven Geusens d48ad7463b Added ExternalId Granular permissions support 2025-02-28 14:30:06 +01:00
Sven Geusens 600276b481 Added unittests 2025-02-28 14:30:06 +01:00
Sven Geusens 0adaa55856 Refactoring
Moved decision logic into an authorizer
Split classes from the attribute file into their own files in the right projects
Removed the test controller
2025-02-28 14:30:05 +01:00
Sven Geusens 79efde18b5 Add ScopeMatchingBehaviour and more comment clarification 2025-02-28 14:28:36 +01:00
Sven Geusens fd2a7cfcad Renaming and xml doc clarification 2025-02-28 14:28:36 +01:00
Sven Geusens f039a7be67 POC of ContextualAuthorizeAttribute 2025-02-28 14:28:36 +01:00
14 changed files with 567 additions and 3 deletions
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Validation.AspNetCore;
using Umbraco.Cms.Api.Management.Security.Authorization.Content;
using Umbraco.Cms.Api.Management.Security.Authorization.Contextual;
using Umbraco.Cms.Api.Management.Security.Authorization.DenyLocalLogin;
using Umbraco.Cms.Api.Management.Security.Authorization.Dictionary;
using Umbraco.Cms.Api.Management.Security.Authorization.Media;
@@ -30,6 +31,7 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
builder.Services.AddSingleton<IAuthorizationHandler, UserPermissionHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, AllowedApplicationHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, BackOfficeHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, ContextualPermissionHandler>();
builder.Services.AddAuthorization(CreatePolicies);
return builder;
@@ -139,5 +141,11 @@ internal static class BackOfficeAuthPolicyBuilderExtensions
policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
policy.Requirements.Add(new UserPermissionRequirement());
});
options.AddPolicy(ContextualPermissionHandler.ContextualPermissionsPolicyAlias, policy =>
{
policy.AuthenticationSchemes.Add(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme);
policy.Requirements.Add(new ContextualPermissionRequirement());
});
}
}
@@ -0,0 +1,70 @@
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Contextual;
/// <summary>
/// Authorizes a certain permission (read,write,browse,...) within a given context (umbraco, my-package,...)
/// against the <see cref="IGranularPermission"> granular permissions</see> defined on all <see cref="IUserGroup">user groups</see> the <see cref="IUser">user</see> is part off.
/// This is accomplished by validating the <see cref="ContextualPermissionHandler.ContextualPermissionsPolicyAlias">Contextual Permissions Policy</see> trough the <see cref="IAuthorizationService"/>
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class ContextualAuthorizeAttribute : Attribute, IAsyncAuthorizationFilter
{
public ContextualAuthorizeAttribute(string permission, string context)
{
Permissions = permission.Yield();
Context = context;
PermissionMatchingBehaviour = PermissionMatchingBehaviour.All;
PermissionScopeMatchingBehaviour = PermissionScopeMatchingBehaviour.Any;
}
public ContextualAuthorizeAttribute(
IEnumerable<string> permissions,
string context,
PermissionMatchingBehaviour permissionMatchingBehaviour,
PermissionScopeMatchingBehaviour permissionScopeMatchingBehaviour)
{
Permissions = permissions;
Context = context;
PermissionMatchingBehaviour = permissionMatchingBehaviour;
PermissionScopeMatchingBehaviour = permissionScopeMatchingBehaviour;
}
private IEnumerable<string> Permissions { get; }
private string Context { get; }
private PermissionMatchingBehaviour PermissionMatchingBehaviour { get; }
private PermissionScopeMatchingBehaviour PermissionScopeMatchingBehaviour { get; }
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
IAuthorizationService authorizationService = context.HttpContext.RequestServices.GetRequiredService<IAuthorizationService>();
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
context.HttpContext.User,
ContextualPermissionResource.WithSetup(Permissions, Context, PermissionMatchingBehaviour, PermissionScopeMatchingBehaviour),
ContextualPermissionHandler.ContextualPermissionsPolicyAlias);
if (authorizationResult.Succeeded is false)
{
context.HttpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
context.HttpContext.Response.HttpContext.Features.Get<IHttpResponseFeature>()!.ReasonPhrase =
"Invalid contextual permission";
context.Result = new JsonResult("Invalid contextual permission")
{
Value = new { Status = "Unauthorized", Message = "Invalid contextual permission" },
};
}
}
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
using Umbraco.Cms.Core.Security.Authorization;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Contextual;
/// Authorizes a certain permission (read,write,browse,...) within a given context (umbraco, my-package,...)
/// against the <see cref="IGranularPermission">granular permissions</see> defined on all <see cref="IUserGroup">user groups</see> the <see cref="IUser">user</see> is part off.
/// Permissions and Context are checked on a InvariantCultureIgnoreCase basis.
public class ContextualPermissionHandler : MustSatisfyRequirementAuthorizationHandler<ContextualPermissionRequirement,
ContextualPermissionResource>
{
public const string ContextualPermissionsPolicyAlias = "Umbraco.ContextualPermissions";
private readonly IAuthorizationHelper _authorizationHelper;
private readonly IContextualPermissionAuthorizer _contextualPermissionAuthorizer;
public ContextualPermissionHandler(
IAuthorizationHelper authorizationHelper,
IContextualPermissionAuthorizer contextualPermissionAuthorizer)
{
_authorizationHelper = authorizationHelper;
_contextualPermissionAuthorizer = contextualPermissionAuthorizer;
}
protected override async Task<bool> IsAuthorized(
AuthorizationHandlerContext context,
ContextualPermissionRequirement requirement,
ContextualPermissionResource resource)
{
IUser user = _authorizationHelper.GetUmbracoUser(context.User);
return _contextualPermissionAuthorizer.IsDenied(user, resource) is false;
}
}
@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Authorization;
namespace Umbraco.Cms.Api.Management.Security.Authorization.Contextual;
public class ContextualPermissionRequirement : IAuthorizationRequirement
{
}
@@ -433,6 +433,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
Services.AddSingleton<IMediaPermissionAuthorizer, MediaPermissionAuthorizer>();
Services.AddSingleton<IUserGroupPermissionAuthorizer, UserGroupPermissionAuthorizer>();
Services.AddSingleton<IUserPermissionAuthorizer, UserPermissionAuthorizer>();
Services.AddSingleton<IContextualPermissionAuthorizer, ContextualPermissionAuthorizer>();
// Segments
Services.AddUnique<ISegmentService, NoopSegmentService>();
@@ -0,0 +1,11 @@
namespace Umbraco.Cms.Core.Models.Membership.Permissions;
public interface IExternalGranularPermission : IGranularPermission
{
new Guid Key { get; set; }
Guid? IGranularPermission.Key
{
get => Key;
}
}
@@ -0,0 +1,21 @@
using Umbraco.Cms.Core.Models.Membership;
namespace Umbraco.Cms.Core.Security.Authorization;
public class ContextualPermissionAuthorizer : IContextualPermissionAuthorizer
{
public bool IsDenied(IUser currentUser, ContextualPermissionResource resource)
{
return resource.PermissionMatchingBehaviour == PermissionMatchingBehaviour.Any
? !resource.Permissions.Any(ContextualMatch)
: !resource.Permissions.All(ContextualMatch);
bool ContextualMatch(string permissionToCheck) =>
currentUser.Groups.SelectMany(g => g.GranularPermissions)
.Any(definedContextualPermission =>
(resource.PermissionScopeMatchingBehaviour == PermissionScopeMatchingBehaviour.Any
|| (resource.PermissionScopeMatchingBehaviour == PermissionScopeMatchingBehaviour.ContextWideOnly && definedContextualPermission.Key is null))
&& definedContextualPermission.Context.Equals(resource.Context, StringComparison.InvariantCultureIgnoreCase)
&& definedContextualPermission.Permission.Equals(permissionToCheck, StringComparison.InvariantCultureIgnoreCase));
}
}
@@ -0,0 +1,70 @@
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Security.Authorization;
public class ContextualPermissionResource : IPermissionResource
{
public static ContextualPermissionResource WithPermission(string permission, string context) =>
WithAllPermissions(permission.Yield(), context);
public static ContextualPermissionResource WithContextWidePermission(string permission, string context) =>
WithAllContextWidePermissions(permission.Yield(), context);
public static ContextualPermissionResource WithAnyContextWidePermissions(IEnumerable<string> permissions, string context) =>
new(permissions, context, PermissionMatchingBehaviour.Any, PermissionScopeMatchingBehaviour.ContextWideOnly);
public static ContextualPermissionResource WithAllContextWidePermissions(IEnumerable<string> permissions, string context) =>
new(permissions, context, PermissionMatchingBehaviour.All, PermissionScopeMatchingBehaviour.ContextWideOnly);
public static ContextualPermissionResource WithAnyPermissions(IEnumerable<string> permissions, string context) =>
new(permissions, context, PermissionMatchingBehaviour.Any, PermissionScopeMatchingBehaviour.Any);
public static ContextualPermissionResource WithAllPermissions(IEnumerable<string> permissions, string context) =>
new(permissions, context, PermissionMatchingBehaviour.All, PermissionScopeMatchingBehaviour.Any);
public static ContextualPermissionResource WithSetup(
IEnumerable<string> permissions,
string context,
PermissionMatchingBehaviour permissionMatchingBehaviour,
PermissionScopeMatchingBehaviour permissionScopeMatchingBehaviour) =>
new(permissions, context, permissionMatchingBehaviour, permissionScopeMatchingBehaviour);
private ContextualPermissionResource(
IEnumerable<string> permissions,
string context,
PermissionMatchingBehaviour permissionMatchingBehaviour,
PermissionScopeMatchingBehaviour permissionScopeMatchingBehaviour)
{
Permissions = permissions;
Context = context;
PermissionMatchingBehaviour = permissionMatchingBehaviour;
PermissionScopeMatchingBehaviour = permissionScopeMatchingBehaviour;
}
public IEnumerable<string> Permissions { get; }
public string Context { get; }
public PermissionMatchingBehaviour PermissionMatchingBehaviour { get; }
public PermissionScopeMatchingBehaviour PermissionScopeMatchingBehaviour { get; }
}
public enum PermissionMatchingBehaviour
{
Any,
All,
}
public enum PermissionScopeMatchingBehaviour
{
/// <summary>
/// Both context wide and granular
/// </summary>
Any,
/// <summary>
/// Only Context wide
/// </summary>
ContextWideOnly,
}
@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.Membership;
namespace Umbraco.Cms.Core.Security.Authorization;
public interface IContextualPermissionAuthorizer
{
bool IsDenied(IUser currentUser, ContextualPermissionResource resource);
}
@@ -111,5 +111,8 @@ public class UmbracoPlan : MigrationPlan
// To 15.3.0
To<V_15_3_0.AddNameAndDescriptionToWebhooks>("{7B11F01E-EE33-4B0B-81A1-F78F834CA45B}");
// To 16.0.0
To<V_16_0_0.AddExternalIdToGranularPermissions>("{7B5D73D9-7C7C-4695-A918-655C4C09D181}");
}
}
@@ -0,0 +1,102 @@
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_0_0;
// todo move to corescope?
public class AddExternalIdToGranularPermissions : UnscopedMigrationBase
{
private readonly ICoreScopeProvider _scopeProvider;
private const string NewColumnName = "umbracoExternalIds";
private const string NewIndex = "IX_umbracoUserGroup2GranularPermissionDto_ExternalUniqueId";
public AddExternalIdToGranularPermissions(
IMigrationContext context,
ICoreScopeProvider scopeProvider)
: base(context)
{
_scopeProvider = scopeProvider;
}
protected override void Migrate()
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
if (ColumnExists(Constants.DatabaseSchema.Tables.UserGroup2GranularPermission, NewColumnName) is false)
{
if (DatabaseType == DatabaseType.SQLite)
{
UpdateColumnSqlLite();
}
else
{
UpdateColumnSqlServer();
}
}
if (IndexExists(NewIndex) is false)
{
CreateIndex<UserGroup2GranularPermissionDto>(NewIndex);
}
Context.Complete();
scope.Complete();
}
protected void UpdateColumnSqlServer()
{
Alter.Table(Constants.DatabaseSchema.Tables.UserGroup2GranularPermission)
.AddColumn(NewColumnName)
.AsGuid()
.Nullable()
.Do();
}
protected void UpdateColumnSqlLite()
{
var oldEntities = Database.Fetch<OldUserGroup2GranularPermissionDto>();
var newEntities = oldEntities.Select(e => new UserGroup2GranularPermissionDto
{
Id = e.Id,
Context = e.Context,
Permission = e.Permission,
UniqueId = e.UniqueId,
UserGroupKey = e.UserGroupKey
});
Delete.Table(Constants.DatabaseSchema.Tables.UserGroup2GranularPermission).Do();
Create.Table<UserGroup2GranularPermissionDto>().Do();
Database.InsertBulk(newEntities);
}
[TableName(Constants.DatabaseSchema.Tables.UserGroup2GranularPermission)]
private class OldUserGroup2GranularPermissionDto
{
[Column("id")]
[PrimaryKeyColumn(Name = "PK_umbracoUserGroup2GranularPermissionDto", AutoIncrement = true)]
public int Id { get; set; }
[Column("userGroupKey")]
[Index(IndexTypes.NonClustered, Name = "IX_umbracoUserGroup2GranularPermissionDto_UserGroupKey_UniqueId",
IncludeColumns = "uniqueId")]
[ForeignKey(typeof(UserGroupDto), Column = "key")]
public Guid UserGroupKey { get; set; }
[Column("uniqueId")]
[ForeignKey(typeof(NodeDto), Column = "uniqueId")]
[NullSetting(NullSetting = NullSettings.Null)]
[Index(IndexTypes.NonClustered, Name = "IX_umbracoUserGroup2GranularPermissionDto_UniqueId")]
public Guid? UniqueId { get; set; }
[Column("permission")]
[NullSetting(NullSetting = NullSettings.NotNull)]
public required string Permission { get; set; }
[Column("context")]
[NullSetting(NullSetting = NullSettings.NotNull)]
public required string Context { get; set; }
}
}
@@ -17,12 +17,23 @@ public class UserGroup2GranularPermissionDto
[ForeignKey(typeof(UserGroupDto), Column = "key")]
public Guid UserGroupKey { get; set; }
/// <summary>
/// Unique Umbraco Id
/// </summary>
[Column("uniqueId")]
[ForeignKey(typeof(NodeDto), Column = "uniqueId")]
[NullSetting(NullSetting = NullSettings.Null)]
[Index(IndexTypes.NonClustered, Name = "IX_umbracoUserGroup2GranularPermissionDto_UniqueId")]
public Guid? UniqueId { get; set; }
/// <summary>
/// A Unique Id within the <see cref="Context"/> that is not an Umbraco node
/// </summary>
[Column("externalUniqueId")]
[NullSetting(NullSetting = NullSettings.Null)]
[Index(IndexTypes.NonClustered, Name = "IX_umbracoUserGroup2GranularPermissionDto_ExternalUniqueId")]
public Guid? ExternalUniqueId { get; set; }
[Column("permission")]
[NullSetting(NullSetting = NullSettings.NotNull)]
public required string Permission { get; set; }
@@ -539,11 +539,18 @@ public class UserGroupRepository : EntityRepositoryBase<int, IUserGroup>, IUserG
{
var dto = new UserGroup2GranularPermissionDto
{
UserGroupKey = userGroup.Key, Permission = permission.Permission, Context = permission.Context
UserGroupKey = userGroup.Key,
Permission = permission.Permission,
Context = permission.Context,
};
if (permission is INodeGranularPermission nodeGranularPermission)
switch (permission)
{
dto.UniqueId = nodeGranularPermission.Key;
case INodeGranularPermission nodeGranularPermission:
dto.UniqueId = nodeGranularPermission.Key;
break;
case IExternalGranularPermission externalGranularPermission:
dto.ExternalUniqueId = externalGranularPermission.Key;
break;
}
return dto;
@@ -0,0 +1,210 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Models.Membership.Permissions;
using Umbraco.Cms.Core.Security.Authorization;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Security.Authorization;
[TestFixture]
public class ContextualPermissionAuthorizerTests
{
private const string FirstContext = "firstContext";
private const string SecondContext = "secondContext";
private const string FirstPermission = "firstPermission";
private const string SecondPermission = "secondPermission";
private const string ThirdPermission = "thirdPermission";
[Test]
public void IsDenied_True_NoGranularPermissions()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>());
var resource = ContextualPermissionResource.WithPermission(FirstPermission, FirstContext);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsTrue(isDenied);
}
[Test]
public void IsDenied_True_IncorrectGranularPermission()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>
{
new UnknownTypeGranularPermission { Context = FirstContext, Permission = SecondPermission },
});
var resource = ContextualPermissionResource.WithPermission(FirstPermission, FirstContext);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsTrue(isDenied);
}
[Test]
public void IsDenied_True_IncorrectGranularPermissions()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>
{
new UnknownTypeGranularPermission { Context = FirstContext, Permission = ThirdPermission },
new UnknownTypeGranularPermission { Context = SecondContext, Permission = FirstPermission },
});
var resource =
ContextualPermissionResource.WithAnyPermissions([FirstPermission, SecondPermission], FirstContext);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsTrue(isDenied);
}
[Test]
public void IsDenied_True_NotEnoughGranularPermissions()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>
{
new UnknownTypeGranularPermission { Context = FirstContext, Permission = SecondPermission },
new UnknownTypeGranularPermission { Context = FirstContext, Permission = ThirdPermission },
});
var resource =
ContextualPermissionResource.WithAllPermissions([FirstPermission, SecondPermission], FirstContext);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsTrue(isDenied);
}
[Test]
public void IsDenied_True_HasGranularPermissionsButToSpecific()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>
{
new DocumentGranularPermission { Permission = FirstPermission, Key = Guid.NewGuid() },
});
var resource =
ContextualPermissionResource.WithContextWidePermission(FirstPermission, DocumentGranularPermission.ContextType);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsTrue(isDenied);
}
[Test]
public void IsDenied_False_HasGranularPermission()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>
{
new UnknownTypeGranularPermission { Context = FirstContext, Permission = FirstPermission },
});
var resource = ContextualPermissionResource.WithPermission(FirstPermission, FirstContext);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsFalse(isDenied);
}
[Test]
public void IsDenied_False_HasAnyGranularPermissions()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>
{
new UnknownTypeGranularPermission { Context = FirstContext, Permission = SecondPermission },
});
var resource =
ContextualPermissionResource.WithAnyPermissions([FirstPermission, SecondPermission], FirstContext);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsFalse(isDenied);
}
[Test]
public void IsDenied_False_HasMoreThanGranularPermissions()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>
{
new UnknownTypeGranularPermission { Context = FirstContext, Permission = FirstPermission },
new UnknownTypeGranularPermission { Context = FirstContext, Permission = SecondPermission },
});
var resource =
ContextualPermissionResource.WithAllPermissions([FirstPermission, SecondPermission], FirstContext);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsFalse(isDenied);
}
[Test]
public void IsDenied_False_HasSpecificGranularPermissionForAnyMatch()
{
// arrange
var user = BuildUser(new HashSet<IGranularPermission>
{
new DocumentGranularPermission { Permission = FirstPermission, Key = Guid.NewGuid() },
});
var resource =
ContextualPermissionResource.WithPermission(FirstPermission, DocumentGranularPermission.ContextType);
var sut = new ContextualPermissionAuthorizer();
// act
var isDenied = sut.IsDenied(user, resource);
// assert
Assert.IsFalse(isDenied);
}
private IUser BuildUser(ISet<IGranularPermission> granularPermissions)
{
var userGroups = new List<IReadOnlyUserGroup>();
var groupMock = new Mock<IReadOnlyUserGroup>();
groupMock
.SetupGet(group => group.GranularPermissions)
.Returns(granularPermissions);
userGroups.Add(groupMock.Object);
var userMock = new Mock<IUser>();
userMock
.SetupGet(u => u.Groups)
.Returns(userGroups);
return userMock.Object;
}
}