Skip to content

Commit 0ca3d3d

Browse files
committed
Fix Orleans authorization policy handling
1 parent 3f01ece commit 0ca3d3d

11 files changed

Lines changed: 223 additions & 77 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
run: dotnet build ManagedCode.Orleans.Identity.sln --configuration Release --no-restore -p:RunAnalyzers=true
3434

3535
- name: Test
36-
run: dotnet test ManagedCode.Orleans.Identity.sln --configuration Release --no-build --verbosity normal -p:CollectCoverage=true -p:CoverletOutput=coverage/ -p:CoverletOutputFormat=opencover
36+
run: dotnet test ManagedCode.Orleans.Identity.sln --configuration Release --no-build --verbosity normal -p:CollectCoverage=true -p:CoverletOutput=coverage/ -p:CoverletOutputFormat=opencover -p:Threshold=85 -p:ThresholdType=line -p:ThresholdStat=total
3737

3838
- name: Upload coverage reports to Codecov
3939
uses: codecov/codecov-action@v5

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
run: dotnet build ManagedCode.Orleans.Identity.sln --configuration Release --no-restore
4040

4141
- name: Test
42-
run: dotnet test ManagedCode.Orleans.Identity.sln --configuration Release --no-build --verbosity normal
42+
run: dotnet test ManagedCode.Orleans.Identity.sln --configuration Release --no-build --verbosity normal -p:CollectCoverage=true -p:CoverletOutput=coverage/ -p:CoverletOutputFormat=opencover -p:Threshold=85 -p:ThresholdType=line -p:ThresholdStat=total
4343

4444
- name: Pack NuGet packages
4545
run: dotnet pack ManagedCode.Orleans.Identity.sln -p:IncludeSymbols=false -p:SymbolPackageFormat=snupkg --configuration Release --no-build --output ./artifacts

ManagedCode.Orleans.Identity.Server/Extensions/SiloBuilderExtensions.cs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
using ManagedCode.Orleans.Identity.Server.GrainCallFilter;
2+
using Microsoft.AspNetCore.Authorization;
23
using Microsoft.Extensions.DependencyInjection;
3-
using Microsoft.Extensions.Logging;
4-
using Orleans;
54
using Orleans.Hosting;
6-
using Orleans.Runtime;
75

86
namespace ManagedCode.Orleans.Identity.Server.Extensions;
97

@@ -16,7 +14,20 @@ public static class SiloBuilderExtensions
1614
/// <returns></returns>
1715
public static ISiloBuilder AddOrleansIdentity(this ISiloBuilder siloBuilder)
1816
{
17+
ArgumentNullException.ThrowIfNull(siloBuilder);
18+
19+
siloBuilder.Services.AddAuthorizationCore();
20+
siloBuilder.AddIncomingGrainCallFilter<GrainAuthorizationIncomingFilter>();
21+
return siloBuilder;
22+
}
23+
24+
public static ISiloBuilder AddOrleansIdentity(this ISiloBuilder siloBuilder, Action<AuthorizationOptions> configureOptions)
25+
{
26+
ArgumentNullException.ThrowIfNull(siloBuilder);
27+
ArgumentNullException.ThrowIfNull(configureOptions);
28+
29+
siloBuilder.Services.AddAuthorizationCore(configureOptions);
1930
siloBuilder.AddIncomingGrainCallFilter<GrainAuthorizationIncomingFilter>();
2031
return siloBuilder;
2132
}
22-
}
33+
}

ManagedCode.Orleans.Identity.Server/GrainCallFilter/GrainAuthorizationIncomingFilter.cs

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,19 @@
1010

1111
namespace ManagedCode.Orleans.Identity.Server.GrainCallFilter;
1212

13-
public class GrainAuthorizationIncomingFilter : IIncomingGrainCallFilter
13+
public class GrainAuthorizationIncomingFilter(
14+
IAuthorizationService authorizationService,
15+
IAuthorizationPolicyProvider policyProvider) : IIncomingGrainCallFilter
1416
{
1517
private const int EmptyAttributeCount = 0;
16-
private const char RoleSeparator = ',';
1718
private const string AccessDeniedNotAuthenticated = "Access denied. User is not authenticated.";
18-
private const string AccessDeniedMissingRoles = "Access denied. User does not have required roles.";
19+
private const string AccessDeniedNotAuthorized = "Access denied. User is not authorized.";
20+
private const string UnsupportedAuthenticationSchemes =
21+
"AuthorizeAttribute.AuthenticationSchemes is not supported by Orleans grain authorization.";
1922

2023
public async Task Invoke(IIncomingGrainCallContext context)
2124
{
22-
if (IsGrainAuthorized(context, out var attributes))
25+
if (IsGrainAuthorized(context, out var authorizeData))
2326
{
2427
var user = GetUserFromRequestContext();
2528

@@ -28,10 +31,8 @@ public async Task Invoke(IIncomingGrainCallContext context)
2831
throw new UnauthorizedAccessException(AccessDeniedNotAuthenticated);
2932
}
3033

31-
if (!HasRequiredRoles(attributes, user))
32-
{
33-
throw new UnauthorizedAccessException(AccessDeniedMissingRoles);
34-
}
34+
ThrowIfUnsupportedAuthenticationSchemes(authorizeData);
35+
await AuthorizeAsync(context, user, authorizeData);
3536
}
3637

3738
await context.Invoke();
@@ -43,18 +44,18 @@ public async Task Invoke(IIncomingGrainCallContext context)
4344
return requestContext as ClaimsPrincipal;
4445
}
4546

46-
private static bool IsGrainAuthorized(IIncomingGrainCallContext context, out List<AuthorizeAttribute> attributes)
47+
private static bool IsGrainAuthorized(IIncomingGrainCallContext context, out List<AuthorizeAttribute> authorizeData)
4748
{
48-
attributes = [];
49+
authorizeData = [];
4950
var members = GetAuthorizationMembers(context);
5051

5152
if (members.Any(HasAllowAnonymousAttribute))
5253
{
5354
return false;
5455
}
5556

56-
attributes.AddRange(members.SelectMany(GetAuthorizeAttributes));
57-
return attributes.Count != EmptyAttributeCount;
57+
authorizeData.AddRange(members.SelectMany(GetAuthorizeAttributes));
58+
return authorizeData.Count != EmptyAttributeCount;
5859
}
5960

6061
private static IReadOnlyList<MemberInfo> GetAuthorizationMembers(IIncomingGrainCallContext context)
@@ -89,25 +90,30 @@ private static IEnumerable<AuthorizeAttribute> GetAuthorizeAttributes(MemberInfo
8990
.Cast<AuthorizeAttribute>();
9091
}
9192

92-
private static bool HasRequiredRoles(IEnumerable<AuthorizeAttribute> attributes, ClaimsPrincipal user)
93+
private static void ThrowIfUnsupportedAuthenticationSchemes(IEnumerable<AuthorizeAttribute> authorizeData)
9394
{
94-
return attributes
95-
.Where(attribute => !string.IsNullOrWhiteSpace(attribute.Roles))
96-
.All(attribute => HasAnyRequiredRole(attribute, user));
95+
if (authorizeData.Any(attribute => !string.IsNullOrWhiteSpace(attribute.AuthenticationSchemes)))
96+
{
97+
throw new InvalidOperationException(UnsupportedAuthenticationSchemes);
98+
}
9799
}
98100

99-
private static bool HasAnyRequiredRole(AuthorizeAttribute attribute, ClaimsPrincipal user)
101+
private async Task AuthorizeAsync(
102+
IIncomingGrainCallContext context,
103+
ClaimsPrincipal user,
104+
IEnumerable<IAuthorizeData> authorizeData)
100105
{
101-
if (string.IsNullOrWhiteSpace(attribute.Roles))
106+
var authorizationPolicy = await AuthorizationPolicy.CombineAsync(policyProvider, authorizeData);
107+
if (authorizationPolicy is null)
102108
{
103-
return true;
109+
return;
104110
}
105111

106-
var roles = attribute.Roles.Split(
107-
RoleSeparator,
108-
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
109-
);
112+
var authorizationResult = await authorizationService.AuthorizeAsync(user, context, authorizationPolicy);
110113

111-
return roles.Any(user.IsInRole);
114+
if (!authorizationResult.Succeeded)
115+
{
116+
throw new UnauthorizedAccessException(AccessDeniedNotAuthorized);
117+
}
112118
}
113119
}

ManagedCode.Orleans.Identity.Tests/AuthenticationIntegrationTests.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ namespace ManagedCode.Orleans.Identity.Tests;
1818
[Collection(nameof(TestClusterApplication))]
1919
public class AuthenticationIntegrationTests
2020
{
21+
private const int SignalRMessageTimeoutSeconds = 5;
22+
private const string ReceiveMessageMethod = "ReceiveMessage";
23+
private const string SendAuthorizedMessageMethod = "SendAuthorizedMessage";
24+
private const string SignalRMessageText = "test message";
25+
2126
private readonly ITestOutputHelper _outputHelper;
2227
private readonly TestClusterApplication _testApp;
2328
private readonly IJwtService _jwtService;
@@ -72,26 +77,21 @@ public async Task JWT_Authentication_FullFlow_Test()
7277
})
7378
.Build();
7479

75-
var messageReceived = false;
76-
var receivedMessage = string.Empty;
77-
78-
hubConnection.On<string>("ReceiveMessage", message =>
80+
var messageCompletion = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
81+
82+
hubConnection.On<string>(ReceiveMessageMethod, message =>
7983
{
80-
receivedMessage = message;
81-
messageReceived = true;
84+
messageCompletion.TrySetResult(message);
8285
_outputHelper.WriteLine($"SignalR received: {message}");
8386
});
8487

8588
await hubConnection.StartAsync();
8689
_outputHelper.WriteLine("SignalR connected with JWT");
8790

8891
// Send message that triggers grain call
89-
await hubConnection.InvokeAsync("SendAuthorizedMessage", "test message");
90-
91-
// Wait for response
92-
await Task.Delay(500);
93-
94-
messageReceived.ShouldBeTrue();
92+
await hubConnection.InvokeAsync(SendAuthorizedMessageMethod, SignalRMessageText);
93+
94+
var receivedMessage = await messageCompletion.Task.WaitAsync(TimeSpan.FromSeconds(SignalRMessageTimeoutSeconds));
9595
receivedMessage.ShouldContain("admin");
9696
receivedMessage.ShouldContain("authorized message");
9797

ManagedCode.Orleans.Identity.Tests/Cluster/Grains/IUserGrain.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,10 @@ public interface IUserGrain : IGrainWithStringKey
1414

1515
[Authorize(Roles = TestRoles.ADMIN)]
1616
Task<string> GetInterfaceAdminInfo();
17+
18+
[Authorize(Policy = TestAuthorizationPolicies.RequireAdminDepartment)]
19+
Task<string> GetPolicyInfo();
20+
21+
[Authorize(AuthenticationSchemes = TestAuthorizationPolicies.UnsupportedAuthenticationScheme)]
22+
Task<string> GetAuthenticationSchemeInfo();
1723
}
Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Linq;
21
using ManagedCode.Orleans.Identity.Core.Extensions;
32
using ManagedCode.Orleans.Identity.Tests.Constants;
43
using Microsoft.AspNetCore.Authorization;
@@ -9,33 +8,14 @@ namespace ManagedCode.Orleans.Identity.Tests.Cluster.Grains;
98
[Authorize]
109
public class UserGrain : Grain, IUserGrain
1110
{
11+
private const string AuthenticationSchemeInfo = "Authentication scheme info";
1212
private const string InterfaceAdminInfoPrefix = "Interface admin info for ";
13+
private const string PolicyInfoPrefix = "Policy info for ";
1314
private const string UnknownUserName = "Unknown";
1415

15-
// Manual authorization check until grain filters work in Orleans 9
16-
private void CheckAuthorization(params string[] requiredRoles)
17-
{
18-
var user = this.GetCurrentUser();
19-
20-
if (user == null || user.Identity?.IsAuthenticated != true)
21-
{
22-
throw new UnauthorizedAccessException("Access denied. User is not authenticated.");
23-
}
24-
25-
if (requiredRoles.Length > 0)
26-
{
27-
var userRoles = user.FindAll(System.Security.Claims.ClaimTypes.Role).Select(c => c.Value).ToHashSet();
28-
if (!requiredRoles.Any(role => userRoles.Contains(role)))
29-
{
30-
throw new UnauthorizedAccessException("Access denied. User does not have required roles.");
31-
}
32-
}
33-
}
34-
3516
[Authorize]
3617
public Task<string> GetUser()
3718
{
38-
CheckAuthorization(); // Manual check
3919
var user = this.GetCurrentUser();
4020
var username = user.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? UnknownUserName;
4121
return Task.FromResult($"Hello, {username}!");
@@ -44,7 +24,6 @@ public Task<string> GetUser()
4424
[Authorize(Roles = TestRoles.ADMIN)]
4525
public Task<string> BanUser()
4626
{
47-
CheckAuthorization(TestRoles.ADMIN); // Manual check for admin role
4827
var user = this.GetCurrentUser();
4928
var username = user.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? UnknownUserName;
5029
return Task.FromResult($"User {username} is banned");
@@ -53,7 +32,6 @@ public Task<string> BanUser()
5332
[Authorize(Roles = TestRoles.ADMIN)]
5433
public Task<string> GetAdminInfo()
5534
{
56-
CheckAuthorization(TestRoles.ADMIN); // Manual check for admin role
5735
var user = this.GetCurrentUser();
5836
var username = user.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? UnknownUserName;
5937
return Task.FromResult($"Admin info for {username}: You have admin privileges");
@@ -62,22 +40,19 @@ public Task<string> GetAdminInfo()
6240
[AllowAnonymous]
6341
public Task<string> GetPublicInfo()
6442
{
65-
// No authorization check for anonymous methods
6643
return Task.FromResult("This is public information - no authorization required");
6744
}
6845

6946
[Authorize(Roles = TestRoles.MODERATOR)]
7047
public Task<string> ModifyUser()
7148
{
72-
CheckAuthorization(TestRoles.MODERATOR); // Manual check for moderator role
7349
var user = this.GetCurrentUser();
7450
var username = user.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? UnknownUserName;
7551
return Task.FromResult($"User {username} has been modified");
7652
}
7753

7854
public Task<string> AddToList()
7955
{
80-
CheckAuthorization(); // Manual check - requires authentication (class has [Authorize])
8156
var user = this.GetCurrentUser();
8257
var username = user.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? UnknownUserName;
8358
return Task.FromResult($"User {username} added to list");
@@ -89,4 +64,16 @@ public Task<string> GetInterfaceAdminInfo()
8964
var username = user.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? UnknownUserName;
9065
return Task.FromResult($"{InterfaceAdminInfoPrefix}{username}");
9166
}
67+
68+
public Task<string> GetPolicyInfo()
69+
{
70+
var user = this.GetCurrentUser();
71+
var username = user.FindFirst(System.Security.Claims.ClaimTypes.Name)?.Value ?? UnknownUserName;
72+
return Task.FromResult($"{PolicyInfoPrefix}{username}");
73+
}
74+
75+
public Task<string> GetAuthenticationSchemeInfo()
76+
{
77+
return Task.FromResult(AuthenticationSchemeInfo);
78+
}
9279
}
Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using ManagedCode.Orleans.Identity.Server.Extensions;
2+
using ManagedCode.Orleans.Identity.Tests.Constants;
23
using Orleans.TestingHost;
34

45
namespace ManagedCode.Orleans.Identity.Tests.Cluster;
@@ -7,10 +8,21 @@ public class TestSiloConfigurations : ISiloConfigurator
78
{
89
public void Configure(ISiloBuilder siloBuilder)
910
{
10-
// Add Orleans Identity server-side components
11-
siloBuilder.AddOrleansIdentity();
11+
siloBuilder.AddOrleansIdentity(options =>
12+
{
13+
options.AddPolicy(
14+
TestAuthorizationPolicies.RequireAdminDepartment,
15+
policy =>
16+
{
17+
policy.RequireClaim(
18+
TestAuthorizationPolicies.DepartmentClaimType,
19+
TestAuthorizationPolicies.AdminDepartment
20+
);
21+
}
22+
);
23+
});
1224

1325
// For test purpose - in-memory reminder service
1426
siloBuilder.UseInMemoryReminderService();
1527
}
16-
}
28+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace ManagedCode.Orleans.Identity.Tests.Constants;
2+
3+
public static class TestAuthorizationPolicies
4+
{
5+
public const string AdminDepartment = "administration";
6+
public const string DepartmentClaimType = "department";
7+
public const string RequireAdminDepartment = "RequireAdminDepartment";
8+
public const string UnsupportedAuthenticationScheme = "UnsupportedScheme";
9+
}

0 commit comments

Comments
 (0)