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"])); }