diff --git a/src/Common/src/Common/Extensions/ExceptionExtensions.cs b/src/Common/src/Common/Extensions/ExceptionExtensions.cs
index 903c556b27..9d3cf1919d 100644
--- a/src/Common/src/Common/Extensions/ExceptionExtensions.cs
+++ b/src/Common/src/Common/Extensions/ExceptionExtensions.cs
@@ -59,4 +59,21 @@ public static bool IsCancellation(this Exception? exception)
return false;
}
+
+ ///
+ /// Determines whether the thrown exception results from an HTTP request timeout.
+ ///
+ ///
+ /// The caught exception to inspect.
+ ///
+ public static bool IsHttpClientTimeout(this Exception? exception)
+ {
+ // See note in remarks at https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.sendasync.
+ if (exception is OperationCanceledException && exception.InnerException?.GetType() == typeof(TimeoutException))
+ {
+ return true;
+ }
+
+ return false;
+ }
}
diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs
index b320799496..440f5de94c 100644
--- a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs
+++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs
@@ -10,6 +10,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
using Steeltoe.Common;
using Steeltoe.Management.Configuration;
using Steeltoe.Management.Endpoint.Actuators.Hypermedia;
@@ -19,6 +20,7 @@ namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry;
internal sealed class CloudFoundrySecurityMiddleware
{
+ private const string BearerTokenPrefix = "Bearer ";
private readonly IOptionsMonitor _managementOptionsMonitor;
private readonly IOptionsMonitor _endpointOptionsMonitor;
private readonly IEndpointOptionsMonitorProvider[] _endpointOptionsMonitorProviderArray;
@@ -60,16 +62,16 @@ public async Task InvokeAsync(HttpContext context)
{
if (string.IsNullOrEmpty(endpointOptions.ApplicationId))
{
- _logger.LogCritical(
+ _logger.LogError(
"The Application Id could not be found. Make sure the Cloud Foundry Configuration Provider has been added to the application configuration.");
- await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.ApplicationIdMissingMessage));
+ await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.ApplicationIdMissing));
return;
}
if (string.IsNullOrEmpty(endpointOptions.Api))
{
- await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryApiMissingMessage));
+ await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryApiMissing));
return;
}
@@ -87,7 +89,7 @@ public async Task InvokeAsync(HttpContext context)
if (targetEndpointOptions.RequiredPermissions > givenPermissions.Permissions)
{
- await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage));
+ await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied));
return;
}
}
@@ -101,13 +103,13 @@ public async Task InvokeAsync(HttpContext context)
internal string GetAccessToken(HttpRequest request)
{
- if (request.Headers.TryGetValue(PermissionsProvider.AuthorizationHeaderName, out StringValues headerValue))
+ if (request.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorizationHeaderValue))
{
- string header = headerValue.ToString();
+ string authorizationValue = authorizationHeaderValue.ToString();
- if (header.StartsWith(PermissionsProvider.BearerHeaderNamePrefix, StringComparison.OrdinalIgnoreCase))
+ if (authorizationValue.StartsWith(BearerTokenPrefix, StringComparison.OrdinalIgnoreCase))
{
- return header[PermissionsProvider.BearerHeaderNamePrefix.Length..];
+ return authorizationValue[BearerTokenPrefix.Length..];
}
}
@@ -162,7 +164,9 @@ private async Task ReturnErrorAsync(HttpContext context, SecurityResult error)
_logger.LogError("Actuator Security Error: {Code} - {Message}", error.Code, error.Message);
context.Response.Headers.Append("Content-Type", "application/json;charset=UTF-8");
- // allowing override of 400-level errors is more likely to cause confusion than to be useful
+ // UseStatusCodeFromResponse was added to prevent IIS/HWC from blocking the response body on 500-level errors.
+ // Blocking 400-level error responses would be more likely to cause confusion than to be useful.
+ // See https://github.com/SteeltoeOSS/Steeltoe/issues/418 for more information.
if (_managementOptionsMonitor.CurrentValue.UseStatusCodeFromResponse || (int)error.Code < 500)
{
context.Response.StatusCode = (int)error.Code;
diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs
index 53c182585d..7b9d77c571 100644
--- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs
+++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs
@@ -19,16 +19,9 @@ namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry;
internal sealed class PermissionsProvider
{
- private const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid";
- private const string CloudfoundryNotReachableMessage = "Cloud controller not reachable";
private const string ReadSensitiveDataJsonPropertyName = "read_sensitive_data";
public const string HttpClientName = "CloudFoundrySecurity";
- public const string ApplicationIdMissingMessage = "Application ID is not available";
- public const string CloudfoundryApiMissingMessage = "Cloud controller URL is not available";
- public const string AccessDeniedMessage = "Access denied";
- public const string AuthorizationHeaderName = "Authorization";
- public const string BearerHeaderNamePrefix = "Bearer ";
- private static readonly TimeSpan GetPermissionsTimeout = TimeSpan.FromMilliseconds(5000);
+ private static readonly TimeSpan GetPermissionsTimeout = TimeSpan.FromMilliseconds(5_000);
private readonly IOptionsMonitor _optionsMonitor;
private readonly IHttpClientFactory _httpClientFactory;
@@ -55,7 +48,7 @@ public async Task GetPermissionsAsync(string accessToken, Cancel
{
if (string.IsNullOrEmpty(accessToken))
{
- return new SecurityResult(HttpStatusCode.Unauthorized, AuthorizationHeaderInvalid);
+ return new SecurityResult(HttpStatusCode.Unauthorized, Messages.AuthorizationHeaderInvalid);
}
CloudFoundryEndpointOptions options = _optionsMonitor.CurrentValue;
@@ -75,23 +68,31 @@ public async Task GetPermissionsAsync(string accessToken, Cancel
_logger.LogInformation("Cloud Foundry returned status: {HttpStatus} while obtaining permissions from: {PermissionsUri}", response.StatusCode,
checkPermissionsUri);
- return response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized
- ? new SecurityResult(HttpStatusCode.Forbidden, AccessDeniedMessage)
- : new SecurityResult(HttpStatusCode.ServiceUnavailable, CloudfoundryNotReachableMessage);
+ if (response.StatusCode is HttpStatusCode.Forbidden)
+ {
+ return new SecurityResult(HttpStatusCode.Forbidden, Messages.AccessDenied);
+ }
+
+ return (int)response.StatusCode is > 399 and < 500
+ ? new SecurityResult(HttpStatusCode.Unauthorized, Messages.InvalidToken)
+ : new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryNotReachable);
}
- EndpointPermissions permissions = await GetPermissionsAsync(response, cancellationToken);
+ EndpointPermissions permissions = await ParsePermissionsResponseAsync(response, cancellationToken);
return new SecurityResult(permissions);
}
- catch (Exception exception) when (!exception.IsCancellation())
+ catch (HttpRequestException exception)
{
- _logger.LogError(exception, "Cloud Foundry returned exception while obtaining permissions from: {PermissionsUri}", checkPermissionsUri);
-
- return new SecurityResult(HttpStatusCode.ServiceUnavailable, CloudfoundryNotReachableMessage);
+ return new SecurityResult(HttpStatusCode.ServiceUnavailable,
+ $"Exception of type '{typeof(HttpRequestException)}' with error '{exception.HttpRequestError}' was thrown");
+ }
+ catch (Exception exception) when (exception.IsHttpClientTimeout())
+ {
+ return new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout);
}
}
- public async Task GetPermissionsAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ public async Task ParsePermissionsResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(response);
@@ -127,4 +128,15 @@ private HttpClient CreateHttpClient()
httpClient.ConfigureForSteeltoe(GetPermissionsTimeout);
return httpClient;
}
+
+ internal static class Messages
+ {
+ public const string AccessDenied = "Access denied";
+ public const string ApplicationIdMissing = "Application ID is not available";
+ public const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid";
+ public const string CloudFoundryApiMissing = "Cloud controller URL is not available";
+ public const string CloudFoundryNotReachable = "Cloud controller not reachable";
+ public const string CloudFoundryTimeout = "Cloud controller request timed out";
+ public const string InvalidToken = "Invalid token";
+ }
}
diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs
new file mode 100644
index 0000000000..5d581d2d1b
--- /dev/null
+++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs
@@ -0,0 +1,43 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using RichardSzalay.MockHttp;
+using Steeltoe.Common.TestResources;
+
+namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry;
+
+internal static class CloudControllerPermissionsMock
+{
+ internal static DelegateToMockHttpClientHandler GetHttpMessageHandler()
+ {
+ var httpClientHandler = new DelegateToMockHttpClientHandler();
+
+ httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unavailable/permissions")
+ .Respond(HttpStatusCode.ServiceUnavailable, "application/json", "{}");
+
+ httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/not-found/permissions")
+ .Respond(HttpStatusCode.NotFound, "application/json", "{}");
+
+ httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unauthorized/permissions")
+ .Respond(HttpStatusCode.Unauthorized, "application/json", "{}");
+
+ httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions")
+ .Respond(HttpStatusCode.Forbidden, "application/json", "{}");
+
+ httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions")
+ .Throw(new OperationCanceledException(null, new TimeoutException()));
+
+ httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions")
+ .Throw(new HttpRequestException(HttpRequestError.NameResolutionError));
+
+ httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK,
+ "application/json", """{"read_sensitive_data": false, "read_basic_data": true}""");
+
+ httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/success/permissions").Respond(HttpStatusCode.OK, "application/json",
+ """{"read_sensitive_data": true, "read_basic_data": true}""");
+
+ return httpClientHandler;
+ }
+}
diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs
index 8823396872..8cfc505da0 100644
--- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs
+++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs
@@ -2,8 +2,10 @@
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.
+using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
+using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
@@ -14,20 +16,27 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
+using Steeltoe.Common.Http.HttpClientPooling;
using Steeltoe.Common.TestResources;
using Steeltoe.Configuration.CloudFoundry;
using Steeltoe.Management.Configuration;
using Steeltoe.Management.Endpoint.Actuators.CloudFoundry;
+using Steeltoe.Management.Endpoint.Actuators.Hypermedia;
+using Steeltoe.Management.Endpoint.Actuators.Info;
using Steeltoe.Management.Endpoint.Configuration;
namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry;
public sealed class CloudFoundrySecurityMiddlewareTest : BaseTest
{
+ private static readonly string MockAccessToken =
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwuY29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ." +
+ Convert.ToBase64String("signature"u8.ToArray());
+
private readonly EnvironmentVariableScope _scope = new("VCAP_APPLICATION", "{}");
[Fact]
- public async Task CloudFoundrySecurityMiddleware_MissingApplicationID_ReturnsServiceUnavailable()
+ public async Task MissingApplicationIdReturnsServiceUnavailable()
{
WebHostBuilder builder = TestWebHostBuilderFactory.Create();
builder.UseStartup();
@@ -47,7 +56,7 @@ public async Task CloudFoundrySecurityMiddleware_MissingApplicationID_ReturnsSer
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_MissingCloudFoundryApi_ReturnsServiceUnavailable()
+ public async Task MissingCloudFoundryApiReturnsServiceUnavailable()
{
var appSettings = new Dictionary
{
@@ -73,7 +82,7 @@ public async Task CloudFoundrySecurityMiddleware_MissingCloudFoundryApi_ReturnsS
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_TargetEndpointNotConfigured_DelegatesToEndpointMiddleware()
+ public async Task TargetEndpointNotConfiguredDelegatesToEndpointMiddleware()
{
var appSettings = new Dictionary
{
@@ -99,7 +108,7 @@ public async Task CloudFoundrySecurityMiddleware_TargetEndpointNotConfigured_Del
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_MissingAccessToken_ReturnsUnauthorized()
+ public async Task MissingAccessTokenReturnsUnauthorized()
{
var appSettings = new Dictionary
{
@@ -126,7 +135,7 @@ public async Task CloudFoundrySecurityMiddleware_MissingAccessToken_ReturnsUnaut
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_UseStatusCodeFromResponseFalse_ReturnsOkAndContent()
+ public async Task UseStatusCodeFromResponseFalseReturnsOkAndContent()
{
var appSettings = new Dictionary
{
@@ -152,7 +161,7 @@ public async Task CloudFoundrySecurityMiddleware_UseStatusCodeFromResponseFalse_
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_UseStatusCodeFromResponseFalse_ReturnsUnauthorized()
+ public async Task UseStatusCodeFromResponseFalseReturnsUnauthorized()
{
var appSettings = new Dictionary
{
@@ -180,7 +189,7 @@ public async Task CloudFoundrySecurityMiddleware_UseStatusCodeFromResponseFalse_
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundryDisabled()
+ public async Task SkipsSecurityCheckIfCloudFoundryDisabled()
{
var appSettings = new Dictionary
{
@@ -202,7 +211,7 @@ public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundr
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundryActuatorDisabled()
+ public async Task SkipsSecurityCheckIfCloudFoundryActuatorDisabled()
{
var appSettings = new Dictionary
{
@@ -224,7 +233,7 @@ public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundr
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundryActuatorDisabledViaEnvironmentVariable()
+ public async Task SkipsSecurityCheckIfCloudFoundryActuatorDisabledViaEnvironmentVariable()
{
using var scope = new EnvironmentVariableScope("MANAGEMENT__ENDPOINTS__CLOUDFOUNDRY__ENABLED", "False");
@@ -243,44 +252,7 @@ public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundr
}
[Fact]
- public async Task CloudFoundrySecurityMiddleware_InvokeAsync_ReturnsExpected()
- {
- var appSettings = new Dictionary
- {
- ["management:endpoints:info:enabled"] = "true",
- ["vcap:application:application_id"] = "foobar",
- ["vcap:application:cf_api"] = "http://localhost:9999/foo"
- };
-
- WebHostBuilder builder = TestWebHostBuilderFactory.Create();
- builder.UseStartup();
- builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(appSettings));
-
- using IWebHost host = builder.Build();
- await host.StartAsync(TestContext.Current.CancellationToken);
-
- using HttpClient client = host.GetTestClient();
- HttpResponseMessage response = await client.GetAsync(new Uri("http://localhost/cloudfoundryapplication"), TestContext.Current.CancellationToken);
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // We expect the authorization to fail, but the FindTargetEndpoint logic to work.
-
- Assert.Equal("""{"security_error":"Authorization header is missing or invalid"}""",
- await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
-
- Assert.NotNull(response.Content.Headers.ContentType);
- Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType);
-
- HttpResponseMessage response2 = await client.GetAsync(new Uri("http://localhost/cloudfoundryapplication/info"), TestContext.Current.CancellationToken);
- Assert.Equal(HttpStatusCode.Unauthorized, response2.StatusCode);
-
- Assert.Equal("""{"security_error":"Authorization header is missing or invalid"}""",
- await response2.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
-
- Assert.NotNull(response2.Content.Headers.ContentType);
- Assert.Equal("application/json", response2.Content.Headers.ContentType.MediaType);
- }
-
- [Fact]
- public async Task GetAccessToken_ReturnsExpected()
+ public async Task GetAccessTokenReturnsExpected()
{
IOptionsMonitor endpointOptionsMonitor = GetOptionsMonitorFromSettings();
IOptionsMonitor managementOptionsMonitor = GetOptionsMonitorFromSettings();
@@ -305,7 +277,7 @@ public async Task GetAccessToken_ReturnsExpected()
}
[Fact]
- public async Task GetPermissions_ReturnsExpected()
+ public async Task GetPermissionsReturnsExpected()
{
IOptionsMonitor endpointOptionsMonitor = GetOptionsMonitorFromSettings();
IOptionsMonitor managementOptionsMonitor = GetOptionsMonitorFromSettings();
@@ -327,7 +299,7 @@ public async Task GetPermissions_ReturnsExpected()
}
[Fact]
- public async Task Throws_when_Add_method_not_called()
+ public async Task ThrowsWhenAddMethodNotCalled()
{
WebApplicationBuilder builder = TestWebApplicationBuilderFactory.Create();
await using WebApplication app = builder.Build();
@@ -337,12 +309,12 @@ public async Task Throws_when_Add_method_not_called()
}
[Fact]
- public async Task Redacts_HTTP_headers()
+ public async Task RedactsHttpHeaders()
{
var appSettings = new Dictionary
{
- ["vcap:application:application_id"] = "foobar",
- ["vcap:application:cf_api"] = "http://domain-name-that-does-not-exist.com:9999/foo"
+ ["vcap:application:application_id"] = "success",
+ ["vcap:application:cf_api"] = "https://example.api.com"
};
var capturingLoggerProvider = new CapturingLoggerProvider(category => category.StartsWith("System.Net.Http.HttpClient", StringComparison.Ordinal));
@@ -353,11 +325,12 @@ public async Task Redacts_HTTP_headers()
builder.Services.AddLogging(options => options.SetMinimumLevel(LogLevel.Trace).AddProvider(capturingLoggerProvider));
builder.Services.AddCloudFoundryActuator();
await using WebApplication app = builder.Build();
+ app.Services.GetRequiredService().Using(CloudControllerPermissionsMock.GetHttpMessageHandler());
await app.StartAsync(TestContext.Current.CancellationToken);
using HttpClient httpClient = app.GetTestClient();
var requestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication"));
- requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "some");
+ requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken);
_ = await httpClient.SendAsync(requestMessage, TestContext.Current.CancellationToken);
@@ -365,6 +338,75 @@ public async Task Redacts_HTTP_headers()
logMessages.Should().Contain("Authorization: *");
}
+ [ClassData(typeof(CloudFoundrySecurityMiddlewareTestScenarios))]
+ [Theory]
+ public async Task Returns_expected_response_on_permission_check(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage,
+ string[] expectedLogs, bool useStatusCodeFromResponse)
+ {
+ var appSettings = new Dictionary
+ {
+ ["vcap:application:application_id"] = scenario,
+ ["vcap:application:cf_api"] = "https://example.api.com",
+ ["management:endpoints:info:requiredPermissions"] = "FULL",
+ ["management:endpoints:UseStatusCodeFromResponse"] = useStatusCodeFromResponse.ToString(CultureInfo.InvariantCulture)
+ };
+
+ using var loggerProvider = new CapturingLoggerProvider();
+ WebApplicationBuilder builder = TestWebApplicationBuilderFactory.CreateDefault(false);
+ builder.Logging.ClearProviders().AddProvider(loggerProvider);
+ builder.Services.AddCloudFoundryActuator();
+ builder.Services.AddHypermediaActuator();
+ builder.Services.AddInfoActuator();
+ builder.Configuration.AddInMemoryCollection(appSettings);
+
+ await using WebApplication host = builder.Build();
+ host.Services.GetRequiredService().Using(CloudControllerPermissionsMock.GetHttpMessageHandler());
+ await host.StartAsync(TestContext.Current.CancellationToken);
+
+ using var client = new HttpClient();
+ var testAuthenticationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/cloudfoundryapplication"));
+ testAuthenticationRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken);
+ var testAuthorizationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/cloudfoundryapplication/info"));
+ testAuthorizationRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken);
+
+ HttpResponseMessage response = await client.SendAsync(testAuthenticationRequestMessage, TestContext.Current.CancellationToken);
+ response.StatusCode.Should().Be(steeltoeStatusCode);
+
+ if (errorMessage != null)
+ {
+ string jsonErrorValue = JsonValue.Create(errorMessage).ToJsonString();
+ string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ errorText.Should().Be(steeltoeStatusCode == HttpStatusCode.InternalServerError ? errorMessage : $$"""{"security_error":{{jsonErrorValue}}}""");
+ }
+ else
+ {
+ string responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ responseBody.Should().BeJson("""
+ {
+ "type":"steeltoe",
+ "_links":{
+ "info":{
+ "href":"http://localhost:5000/cloudfoundryapplication/info",
+ "templated":false
+ },
+ "self":{
+ "href":"http://localhost:5000/cloudfoundryapplication",
+ "templated":false
+ }
+ }
+ }
+ """);
+ }
+
+ HttpResponseMessage fullPermissionResponse = await client.SendAsync(testAuthorizationRequestMessage, TestContext.Current.CancellationToken);
+ fullPermissionResponse.StatusCode.Should().Be(scenario == "no_sensitive_data" ? HttpStatusCode.Forbidden : steeltoeStatusCode);
+
+ string logLines = loggerProvider.GetAsText();
+ logLines.Should().ContainAll(expectedLogs);
+ }
+
protected override void Dispose(bool disposing)
{
if (disposing)
@@ -375,7 +417,7 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}
- private HttpContext CreateRequest(string method, string path)
+ private static HttpContext CreateRequest(string method, string path)
{
HttpContext context = new DefaultHttpContext
{
diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs
new file mode 100644
index 0000000000..133ee605a7
--- /dev/null
+++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs
@@ -0,0 +1,90 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the Apache 2.0 License.
+// See the LICENSE file in the project root for more information.
+
+using System.Net;
+using Steeltoe.Management.Endpoint.Actuators.CloudFoundry;
+using Messages = Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider.Messages;
+
+namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry;
+
+internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData
+{
+ private const string SuccessLog =
+ "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/success/permissions";
+
+ private readonly string _permissionsCheckForbiddenLog =
+ $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status: Forbidden while obtaining permissions from: https://example.api.com/v2/apps/forbidden/permissions";
+
+ private readonly string _permissionsCheckUnauthorizedLog =
+ $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status: Unauthorized while obtaining permissions from: https://example.api.com/v2/apps/unauthorized/permissions";
+
+ private readonly string _permissionsCheckNotFoundLog =
+ $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status: NotFound while obtaining permissions from: https://example.api.com/v2/apps/not-found/permissions";
+
+ private readonly string _middlewareForbiddenLog =
+ $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: Forbidden - {Messages.AccessDenied}";
+
+ private readonly string _middlewareExceptionLog =
+ $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: ServiceUnavailable - Exception of type 'System.Net.Http.HttpRequestException' with error 'NameResolutionError' was thrown";
+
+ private readonly string _middlewareTimeoutLog =
+ $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: ServiceUnavailable - {Messages.CloudFoundryTimeout}";
+
+ private readonly string _middlewareUnauthorizedLog =
+ $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: Unauthorized - {Messages.InvalidToken}";
+
+ private readonly string _middlewareUnavailableLog =
+ $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: ServiceUnavailable - {Messages.CloudFoundryNotReachable}";
+
+ public CloudFoundrySecurityMiddlewareTestScenarios()
+ {
+ Add("exception", HttpStatusCode.ServiceUnavailable,
+ "Exception of type 'System.Net.Http.HttpRequestException' with error 'NameResolutionError' was thrown", [_middlewareExceptionLog], true);
+
+ Add("exception", HttpStatusCode.OK, "Exception of type 'System.Net.Http.HttpRequestException' with error 'NameResolutionError' was thrown",
+ [_middlewareExceptionLog], false);
+
+ Add("forbidden", HttpStatusCode.Forbidden, Messages.AccessDenied, [
+ _permissionsCheckForbiddenLog,
+ _middlewareForbiddenLog
+ ], true);
+
+ Add("forbidden", HttpStatusCode.Forbidden, Messages.AccessDenied, [
+ _permissionsCheckForbiddenLog,
+ _middlewareForbiddenLog
+ ], false);
+
+ Add("no_sensitive_data", HttpStatusCode.OK, null, [_middlewareForbiddenLog], true);
+
+ Add("not-found", HttpStatusCode.Unauthorized, Messages.InvalidToken, [
+ _permissionsCheckNotFoundLog,
+ _middlewareUnauthorizedLog
+ ], true);
+
+ Add("not-found", HttpStatusCode.Unauthorized, Messages.InvalidToken, [
+ _permissionsCheckNotFoundLog,
+ _middlewareUnauthorizedLog
+ ], false);
+
+ Add("success", HttpStatusCode.OK, null, [SuccessLog], true);
+
+ Add("timeout", HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout, [_middlewareTimeoutLog], true);
+
+ Add("timeout", HttpStatusCode.OK, Messages.CloudFoundryTimeout, [_middlewareTimeoutLog], false);
+
+ Add("unauthorized", HttpStatusCode.Unauthorized, Messages.InvalidToken, [
+ _permissionsCheckUnauthorizedLog,
+ _middlewareUnauthorizedLog
+ ], true);
+
+ Add("unauthorized", HttpStatusCode.Unauthorized, Messages.InvalidToken, [
+ _permissionsCheckUnauthorizedLog,
+ _middlewareUnauthorizedLog
+ ], false);
+
+ Add("unavailable", HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryNotReachable, [_middlewareUnavailableLog], true);
+
+ Add("unavailable", HttpStatusCode.OK, Messages.CloudFoundryNotReachable, [_middlewareUnavailableLog], false);
+ }
+}
diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs
index de0802205a..6589f52072 100644
--- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs
+++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs
@@ -4,9 +4,11 @@
using System.Net;
using System.Net.Http.Json;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using Steeltoe.Common.Http.HttpClientPooling;
using Steeltoe.Management.Configuration;
using Steeltoe.Management.Endpoint.Actuators.CloudFoundry;
@@ -15,34 +17,87 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry;
public sealed class PermissionsProviderTest : BaseTest
{
[Fact]
- public void IsCloudFoundryRequest_ReturnsExpected()
+ public void IsCloudFoundryRequestReturnsExpected()
{
Assert.True(PermissionsProvider.IsCloudFoundryRequest("/cloudfoundryapplication"));
Assert.True(PermissionsProvider.IsCloudFoundryRequest("/cloudfoundryapplication/badpath"));
}
[Fact]
- public async Task GetPermissionsAsyncTest()
+ public async Task EmptyTokenIsUnauthorized()
{
PermissionsProvider permissionsProvider = GetPermissionsProvider();
- SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken);
- Assert.NotNull(result);
+ SecurityResult unauthorized = await permissionsProvider.GetPermissionsAsync(string.Empty, TestContext.Current.CancellationToken);
+ unauthorized.Code.Should().Be(HttpStatusCode.Unauthorized);
+ unauthorized.Message.Should().Be(PermissionsProvider.Messages.AuthorizationHeaderInvalid);
}
- [Fact]
- public async Task GetPermissionsTest()
+ [InlineData(false, true, EndpointPermissions.Restricted)]
+ [InlineData(true, true, EndpointPermissions.Full)]
+ [Theory]
+ public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitive, bool readBasic, EndpointPermissions expectedPermissions)
{
PermissionsProvider permissionsProvider = GetPermissionsProvider();
- var response = new HttpResponseMessage(HttpStatusCode.OK);
- var permissions = new Dictionary
+ var cloudControllerResponse = new HttpResponseMessage(HttpStatusCode.OK)
{
- ["read_sensitive_data"] = true
+ Content = JsonContent.Create(new Dictionary
+ {
+ ["read_sensitive_data"] = readSensitive,
+ ["read_basic_data"] = readBasic
+ })
};
- response.Content = JsonContent.Create(permissions);
- EndpointPermissions result = await permissionsProvider.GetPermissionsAsync(response, TestContext.Current.CancellationToken);
- Assert.Equal(EndpointPermissions.Full, result);
+ EndpointPermissions result = await permissionsProvider.ParsePermissionsResponseAsync(cloudControllerResponse, TestContext.Current.CancellationToken);
+ result.Should().Be(expectedPermissions);
+ }
+
+ [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryNotReachable)]
+ [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)]
+ [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)]
+ [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied)]
+ [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryTimeout)]
+ [InlineData("exception", HttpStatusCode.ServiceUnavailable,
+ "Exception of type 'System.Net.Http.HttpRequestException' with error 'NameResolutionError' was thrown")]
+ [InlineData("no_sensitive_data", HttpStatusCode.OK, "")]
+ [InlineData("success", HttpStatusCode.OK, "")]
+ [Theory]
+ public async Task Returns_expected_response_on_permission_check(string scenario, HttpStatusCode? steeltoeStatusCode, string errorMessage)
+ {
+ var appSettings = new Dictionary
+ {
+ ["vcap:application:cf_api"] = "https://example.api.com",
+ ["vcap:application:application_id"] = scenario
+ };
+
+ IOptionsMonitor optionsMonitor = GetOptionsMonitorFromSettings(appSettings!);
+
+ var services = new ServiceCollection();
+ services.AddSingleton(new ConfigurationBuilder().Build());
+ services.AddCloudFoundryActuator();
+
+ await using ServiceProvider serviceProvider = services.BuildServiceProvider(true);
+ serviceProvider.GetRequiredService().Using(CloudControllerPermissionsMock.GetHttpMessageHandler());
+ var httpClientFactory = serviceProvider.GetRequiredService();
+
+ var permissionsProvider = new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance);
+
+ SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken);
+ result.Code.Should().Be(steeltoeStatusCode);
+ result.Message.Should().Be(errorMessage);
+
+ switch (scenario)
+ {
+ case "success":
+ result.Permissions.Should().Be(EndpointPermissions.Full);
+ break;
+ case "no_sensitive_data":
+ result.Permissions.Should().Be(EndpointPermissions.Restricted);
+ break;
+ default:
+ result.Permissions.Should().Be(EndpointPermissions.None);
+ break;
+ }
}
private static PermissionsProvider GetPermissionsProvider()
diff --git a/src/Management/test/Endpoint.Test/SpringBootAdminClient/FakeServer.cs b/src/Management/test/Endpoint.Test/SpringBootAdminClient/FakeServer.cs
index c023f58e44..9d8fb2db03 100644
--- a/src/Management/test/Endpoint.Test/SpringBootAdminClient/FakeServer.cs
+++ b/src/Management/test/Endpoint.Test/SpringBootAdminClient/FakeServer.cs
@@ -18,7 +18,7 @@ public FakeServer(IConfiguration configuration)
string? urls = configuration.GetValue("urls");
Features.Set(urls != null
- ? new FakeServerAddressesFeature(urls.Split(';').ToArray())
+ ? new FakeServerAddressesFeature(urls.Split(';'))
: new FakeServerAddressesFeature(["http://localhost:5000"]));
}