Skip to content

Commit 4f1c90b

Browse files
authored
Allow specifying authz policies on endpoints (#41153)
1 parent a8cc3cf commit 4f1c90b

10 files changed

+345
-11
lines changed

src/Security/Authorization/Core/src/AuthorizationPolicy.cs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,24 @@ public static AuthorizationPolicy Combine(IEnumerable<AuthorizationPolicy> polic
108108
/// A new <see cref="AuthorizationPolicy"/> which represents the combination of the
109109
/// authorization policies provided by the specified <paramref name="policyProvider"/>.
110110
/// </returns>
111-
public static async Task<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider, IEnumerable<IAuthorizeData> authorizeData)
111+
public static Task<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider,
112+
IEnumerable<IAuthorizeData> authorizeData) => CombineAsync(policyProvider, authorizeData,
113+
Enumerable.Empty<AuthorizationPolicy>());
114+
115+
/// <summary>
116+
/// Combines the <see cref="AuthorizationPolicy"/> provided by the specified
117+
/// <paramref name="policyProvider"/>.
118+
/// </summary>
119+
/// <param name="policyProvider">A <see cref="IAuthorizationPolicyProvider"/> which provides the policies to combine.</param>
120+
/// <param name="authorizeData">A collection of authorization data used to apply authorization to a resource.</param>
121+
/// <param name="policies">A collection of <see cref="AuthorizationPolicy"/> policies to combine.</param>
122+
/// <returns>
123+
/// A new <see cref="AuthorizationPolicy"/> which represents the combination of the
124+
/// authorization policies provided by the specified <paramref name="policyProvider"/>.
125+
/// </returns>
126+
public static async Task<AuthorizationPolicy?> CombineAsync(IAuthorizationPolicyProvider policyProvider,
127+
IEnumerable<IAuthorizeData> authorizeData,
128+
IEnumerable<AuthorizationPolicy> policies)
112129
{
113130
if (policyProvider == null)
114131
{
@@ -120,6 +137,8 @@ public static AuthorizationPolicy Combine(IEnumerable<AuthorizationPolicy> polic
120137
throw new ArgumentNullException(nameof(authorizeData));
121138
}
122139

140+
var anyPolicies = policies.Any();
141+
123142
// Avoid allocating enumerator if the data is known to be empty
124143
var skipEnumeratingData = false;
125144
if (authorizeData is IList<IAuthorizeData> dataList)
@@ -137,7 +156,7 @@ public static AuthorizationPolicy Combine(IEnumerable<AuthorizationPolicy> polic
137156
policyBuilder = new AuthorizationPolicyBuilder();
138157
}
139158

140-
var useDefaultPolicy = true;
159+
var useDefaultPolicy = !(anyPolicies);
141160
if (!string.IsNullOrWhiteSpace(authorizeDatum.Policy))
142161
{
143162
var policy = await policyProvider.GetPolicyAsync(authorizeDatum.Policy).ConfigureAwait(false);
@@ -176,6 +195,16 @@ public static AuthorizationPolicy Combine(IEnumerable<AuthorizationPolicy> polic
176195
}
177196
}
178197

198+
if (anyPolicies)
199+
{
200+
policyBuilder ??= new();
201+
202+
foreach (var policy in policies)
203+
{
204+
policyBuilder.Combine(policy);
205+
}
206+
}
207+
179208
// If we have no policy by now, use the fallback policy if we have one
180209
if (policyBuilder == null)
181210
{

src/Security/Authorization/Core/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
*REMOVED*~Microsoft.AspNetCore.Authorization.DefaultAuthorizationService.DefaultAuthorizationService(Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider! policyProvider, Microsoft.AspNetCore.Authorization.IAuthorizationHandlerProvider! handlers, Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Authorization.DefaultAuthorizationService!>! logger, Microsoft.AspNetCore.Authorization.IAuthorizationHandlerContextFactory! contextFactory, Microsoft.AspNetCore.Authorization.IAuthorizationEvaluator! evaluator, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Authorization.AuthorizationOptions!>! options) -> void
44
Microsoft.AspNetCore.Authorization.DefaultAuthorizationPolicyProvider.DefaultAuthorizationPolicyProvider(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Authorization.AuthorizationOptions!>! options) -> void
55
Microsoft.AspNetCore.Authorization.DefaultAuthorizationService.DefaultAuthorizationService(Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider! policyProvider, Microsoft.AspNetCore.Authorization.IAuthorizationHandlerProvider! handlers, Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Authorization.DefaultAuthorizationService!>! logger, Microsoft.AspNetCore.Authorization.IAuthorizationHandlerContextFactory! contextFactory, Microsoft.AspNetCore.Authorization.IAuthorizationEvaluator! evaluator, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Authorization.AuthorizationOptions!>! options) -> void
6+
static Microsoft.AspNetCore.Authorization.AuthorizationPolicy.CombineAsync(Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider! policyProvider, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Authorization.IAuthorizeData!>! authorizeData, System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Authorization.AuthorizationPolicy!>! policies) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Authorization.AuthorizationPolicy?>!

src/Security/Authorization/Policy/src/AuthorizationEndpointConventionBuilderExtensions.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,55 @@ public static TBuilder RequireAuthorization<TBuilder>(this TBuilder builder, par
7979
return builder;
8080
}
8181

82+
/// <summary>
83+
/// Adds an authorization policy to the endpoint(s).
84+
/// </summary>
85+
/// <param name="builder">The endpoint convention builder.</param>
86+
/// <param name="policy">The <see cref="AuthorizationPolicy"/> policy.</param>
87+
/// <returns>The original convention builder parameter.</returns>
88+
public static TBuilder RequireAuthorization<TBuilder>(this TBuilder builder, AuthorizationPolicy policy)
89+
where TBuilder : IEndpointConventionBuilder
90+
{
91+
if (builder == null)
92+
{
93+
throw new ArgumentNullException(nameof(builder));
94+
}
95+
96+
if (policy == null)
97+
{
98+
throw new ArgumentNullException(nameof(policy));
99+
}
100+
101+
RequirePolicyCore(builder, policy);
102+
return builder;
103+
}
104+
105+
/// <summary>
106+
/// Adds an new authorization policy configured by a callback to the endpoint(s).
107+
/// </summary>
108+
/// <typeparam name="TBuilder"></typeparam>
109+
/// <param name="builder">The endpoint convention builder.</param>
110+
/// <param name="configurePolicy">The callback used to configure the policy.</param>
111+
/// <returns>The original convention builder parameter.</returns>
112+
public static TBuilder RequireAuthorization<TBuilder>(this TBuilder builder, Action<AuthorizationPolicyBuilder> configurePolicy)
113+
where TBuilder : IEndpointConventionBuilder
114+
{
115+
if (builder == null)
116+
{
117+
throw new ArgumentNullException(nameof(builder));
118+
}
119+
120+
if (configurePolicy == null)
121+
{
122+
throw new ArgumentNullException(nameof(configurePolicy));
123+
}
124+
125+
var policyBuilder = new AuthorizationPolicyBuilder();
126+
configurePolicy(policyBuilder);
127+
RequirePolicyCore(builder, policyBuilder.Build());
128+
return builder;
129+
}
130+
82131
/// <summary>
83132
/// Allows anonymous access to the endpoint by adding <see cref="AllowAnonymousAttribute" /> to the endpoint metadata. This will bypass
84133
/// all authorization checks for the endpoint including the default authorization policy and fallback authorization policy.
@@ -94,6 +143,20 @@ public static TBuilder AllowAnonymous<TBuilder>(this TBuilder builder) where TBu
94143
return builder;
95144
}
96145

146+
private static void RequirePolicyCore<TBuilder>(TBuilder builder, AuthorizationPolicy policy)
147+
where TBuilder : IEndpointConventionBuilder
148+
{
149+
builder.Add(endpointBuilder =>
150+
{
151+
// Only add an authorize attribute if there isn't one
152+
if (!endpointBuilder.Metadata.Any(meta => meta is IAuthorizeData))
153+
{
154+
endpointBuilder.Metadata.Add(new AuthorizeAttribute());
155+
}
156+
endpointBuilder.Metadata.Add(policy);
157+
});
158+
}
159+
97160
private static void RequireAuthorizationCore<TBuilder>(TBuilder builder, IEnumerable<IAuthorizeData> authorizeData)
98161
where TBuilder : IEndpointConventionBuilder
99162
{
@@ -105,4 +168,5 @@ private static void RequireAuthorizationCore<TBuilder>(TBuilder builder, IEnumer
105168
}
106169
});
107170
}
171+
108172
}

src/Security/Authorization/Policy/src/AuthorizationMiddleware.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,11 @@ public async Task Invoke(HttpContext context)
5757

5858
// IMPORTANT: Changes to authorization logic should be mirrored in MVC's AuthorizeFilter
5959
var authorizeData = endpoint?.Metadata.GetOrderedMetadata<IAuthorizeData>() ?? Array.Empty<IAuthorizeData>();
60-
var policy = await AuthorizationPolicy.CombineAsync(_policyProvider, authorizeData);
60+
61+
var policies = endpoint?.Metadata.GetOrderedMetadata<AuthorizationPolicy>() ?? Array.Empty<AuthorizationPolicy>();
62+
63+
var policy = await AuthorizationPolicy.CombineAsync(_policyProvider, authorizeData, policies);
64+
6165
if (policy == null)
6266
{
6367
await _next(context);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
static Microsoft.AspNetCore.Builder.AuthorizationEndpointConventionBuilderExtensions.RequireAuthorization<TBuilder>(this TBuilder builder, Microsoft.AspNetCore.Authorization.AuthorizationPolicy! policy) -> TBuilder
3+
static Microsoft.AspNetCore.Builder.AuthorizationEndpointConventionBuilderExtensions.RequireAuthorization<TBuilder>(this TBuilder builder, System.Action<Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder!>! configurePolicy) -> TBuilder

src/Security/Authorization/test/AuthorizationEndpointConventionBuilderExtensionsTests.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,106 @@ public void RequireAuthorization_ChainedCall()
117117
Assert.True(chainedBuilder.TestProperty);
118118
}
119119

120+
[Fact]
121+
public void RequireAuthorization_Policy()
122+
{
123+
// Arrange
124+
var builder = new TestEndpointConventionBuilder();
125+
var policy = new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build();
126+
127+
// Act
128+
builder.RequireAuthorization(policy);
129+
130+
// Assert
131+
var convention = Assert.Single(builder.Conventions);
132+
133+
var endpointModel = new RouteEndpointBuilder((context) => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0);
134+
convention(endpointModel);
135+
136+
Assert.Equal(2, endpointModel.Metadata.Count);
137+
var authMetadata = Assert.IsAssignableFrom<IAuthorizeData>(endpointModel.Metadata[0]);
138+
Assert.Null(authMetadata.Policy);
139+
140+
Assert.Equal(policy, endpointModel.Metadata[1]);
141+
}
142+
143+
[Fact]
144+
public void RequireAuthorization_PolicyCallback()
145+
{
146+
// Arrange
147+
var builder = new TestEndpointConventionBuilder();
148+
var requirement = new TestRequirement();
149+
150+
// Act
151+
builder.RequireAuthorization(policyBuilder => policyBuilder.Requirements.Add(requirement));
152+
153+
// Assert
154+
var convention = Assert.Single(builder.Conventions);
155+
156+
var endpointModel = new RouteEndpointBuilder((context) => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0);
157+
convention(endpointModel);
158+
159+
Assert.Equal(2, endpointModel.Metadata.Count);
160+
var authMetadata = Assert.IsAssignableFrom<IAuthorizeData>(endpointModel.Metadata[0]);
161+
Assert.Null(authMetadata.Policy);
162+
163+
var policy = Assert.IsAssignableFrom<AuthorizationPolicy>(endpointModel.Metadata[1]);
164+
Assert.Equal(1, policy.Requirements.Count);
165+
Assert.Equal(requirement, policy.Requirements[0]);
166+
}
167+
168+
[Fact]
169+
public void RequireAuthorization_PolicyCallbackWithAuthorize()
170+
{
171+
// Arrange
172+
var builder = new TestEndpointConventionBuilder();
173+
var authorize = new AuthorizeAttribute();
174+
var requirement = new TestRequirement();
175+
176+
// Act
177+
builder.RequireAuthorization(policyBuilder => policyBuilder.Requirements.Add(requirement));
178+
179+
// Assert
180+
var convention = Assert.Single(builder.Conventions);
181+
182+
var endpointModel = new RouteEndpointBuilder((context) => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0);
183+
endpointModel.Metadata.Add(authorize);
184+
convention(endpointModel);
185+
186+
// Confirm that we don't add another authorize if one already exists
187+
Assert.Equal(2, endpointModel.Metadata.Count);
188+
Assert.Equal(authorize, endpointModel.Metadata[0]);
189+
var policy = Assert.IsAssignableFrom<AuthorizationPolicy>(endpointModel.Metadata[1]);
190+
Assert.Equal(1, policy.Requirements.Count);
191+
Assert.Equal(requirement, policy.Requirements[0]);
192+
}
193+
194+
[Fact]
195+
public void RequireAuthorization_PolicyWithAuthorize()
196+
{
197+
// Arrange
198+
var builder = new TestEndpointConventionBuilder();
199+
var policy = new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build();
200+
var authorize = new AuthorizeAttribute();
201+
202+
// Act
203+
builder.RequireAuthorization(policy);
204+
205+
// Assert
206+
var convention = Assert.Single(builder.Conventions);
207+
208+
var endpointModel = new RouteEndpointBuilder((context) => Task.CompletedTask, RoutePatternFactory.Parse("/"), 0);
209+
endpointModel.Metadata.Add(authorize);
210+
convention(endpointModel);
211+
212+
// Confirm that we don't add another authorize if one already exists
213+
Assert.Equal(2, endpointModel.Metadata.Count);
214+
Assert.Equal(authorize, endpointModel.Metadata[0]);
215+
Assert.Equal(policy, endpointModel.Metadata[1]);
216+
}
217+
218+
class TestRequirement : IAuthorizationRequirement { }
219+
120220
[Fact]
121221
public void AllowAnonymous_Default()
122222
{

src/Security/Authorization/test/AuthorizationMiddlewareTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,28 @@ public async Task OnAuthorizationAsync_WillCallPolicyProvider()
207207
Assert.Equal(3, next.CalledCount);
208208
}
209209

210+
[Fact]
211+
public async Task CanApplyPolicyDirectlyToEndpoint()
212+
{
213+
// Arrange
214+
var calledPolicy = false;
215+
var policy = new AuthorizationPolicyBuilder().RequireAssertion(_ =>
216+
{
217+
calledPolicy = true;
218+
return true;
219+
}).Build();
220+
221+
var policyProvider = new Mock<IAuthorizationPolicyProvider>();
222+
policyProvider.Setup(p => p.GetDefaultPolicyAsync()).ReturnsAsync(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
223+
var next = new TestRequestDelegate();
224+
var middleware = CreateMiddleware(next.Invoke, policyProvider.Object);
225+
var context = GetHttpContext(anonymous: false, endpoint: CreateEndpoint(new AuthorizeAttribute(), policy));
226+
227+
// Act & Assert
228+
await middleware.Invoke(context);
229+
Assert.True(calledPolicy);
230+
}
231+
210232
[Fact]
211233
public async Task Invoke_ValidClaimShouldNotFail()
212234
{

src/Security/Authorization/test/AuthorizationPolicyFacts.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,29 @@ public async Task CanCombineAuthorizeAttributes()
4343
Assert.Single(combined.Requirements.OfType<RolesAuthorizationRequirement>());
4444
}
4545

46+
[Fact]
47+
public async Task CanReplaceDefaultPolicyDirectly()
48+
{
49+
// Arrange
50+
var attributes = new AuthorizeAttribute[] {
51+
new AuthorizeAttribute(),
52+
new AuthorizeAttribute(),
53+
};
54+
55+
var policies = new[] { new AuthorizationPolicyBuilder().RequireAssertion(_ => true).Build() };
56+
57+
var options = new AuthorizationOptions();
58+
59+
var provider = new DefaultAuthorizationPolicyProvider(Options.Create(options));
60+
61+
// Act
62+
var combined = await AuthorizationPolicy.CombineAsync(provider, attributes, policies);
63+
64+
// Assert
65+
Assert.Equal(1, combined.Requirements.Count);
66+
Assert.Empty(combined.Requirements.OfType<DenyAnonymousAuthorizationRequirement>());
67+
}
68+
4669
[Fact]
4770
public async Task CanReplaceDefaultPolicy()
4871
{

0 commit comments

Comments
 (0)