diff --git a/Application/EdFi.Ods.Api/Container/Modules/ApplicationModule.cs b/Application/EdFi.Ods.Api/Container/Modules/ApplicationModule.cs index 032cca8daa..c89fb1d831 100644 --- a/Application/EdFi.Ods.Api/Container/Modules/ApplicationModule.cs +++ b/Application/EdFi.Ods.Api/Container/Modules/ApplicationModule.cs @@ -464,6 +464,11 @@ void RegisterMiddleware() .AsSelf() .SingleInstance(); + builder.RegisterType() + .As() + .AsSelf() + .SingleInstance(); + builder.RegisterType() .As() .AsSelf() @@ -492,6 +497,9 @@ void RegisterMiddleware() .SingleInstance(); builder.RegisterType().SingleInstance(); + + builder.RegisterType().SingleInstance(); + builder.RegisterType().EnableClassInterceptors().SingleInstance(); builder.RegisterType() diff --git a/Application/EdFi.Ods.Api/ExceptionHandling/AuthenticateResultTranslator.cs b/Application/EdFi.Ods.Api/ExceptionHandling/AuthenticateResultTranslator.cs new file mode 100644 index 0000000000..1ea5ffd9c5 --- /dev/null +++ b/Application/EdFi.Ods.Api/ExceptionHandling/AuthenticateResultTranslator.cs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using EdFi.Ods.Common.Exceptions; +using EdFi.Ods.Common.ProblemDetails; +using Microsoft.AspNetCore.Authentication; + +namespace EdFi.Ods.Api.ExceptionHandling; + +public class AuthenticateResultTranslator +{ + private readonly IEdFiProblemDetailsProvider _problemDetailsProvider; + + public AuthenticateResultTranslator(IEdFiProblemDetailsProvider problemDetailsProvider) + { + _problemDetailsProvider = problemDetailsProvider; + } + + public IEdFiProblemDetails GetProblemDetails(AuthenticateResult result) + { + if (result.None) + { + return new SecurityAuthenticationException( + SecurityAuthenticationException.DefaultDetail, + "Authorization header is missing."); + } + + return new SecurityAuthenticationException( + SecurityAuthenticationException.DefaultDetail, + result.Failure.Message, + result.Failure); + } +} \ No newline at end of file diff --git a/Application/EdFi.Ods.Api/Extensions/ApplicationBuilderExtensions.cs b/Application/EdFi.Ods.Api/Extensions/ApplicationBuilderExtensions.cs index d5ad445ff2..92b35f12eb 100644 --- a/Application/EdFi.Ods.Api/Extensions/ApplicationBuilderExtensions.cs +++ b/Application/EdFi.Ods.Api/Extensions/ApplicationBuilderExtensions.cs @@ -22,6 +22,9 @@ public static IApplicationBuilder UseTenantIdentification(this IApplicationBuild public static IApplicationBuilder UseRequestCorrelation(this IApplicationBuilder builder) => builder.UseMiddleware(); + public static IApplicationBuilder UseComplementErrorDetails(this IApplicationBuilder builder) + => builder.UseMiddleware(); + /// /// Adds the to the specified , which enables authentication capabilities. /// diff --git a/Application/EdFi.Ods.Api/Helpers/ControllerHelpers.cs b/Application/EdFi.Ods.Api/Helpers/ControllerHelpers.cs index 9b3b931183..91eee3744a 100644 --- a/Application/EdFi.Ods.Api/Helpers/ControllerHelpers.cs +++ b/Application/EdFi.Ods.Api/Helpers/ControllerHelpers.cs @@ -4,6 +4,7 @@ // See the LICENSE and NOTICES files in the project root for more information. using System.Net; +using EdFi.Ods.Common.Exceptions; using Microsoft.AspNetCore.Mvc; namespace EdFi.Ods.Api.Helpers @@ -14,11 +15,13 @@ public static class ControllerHelpers /// Gets an IActionResult returning a 404 Not Found status with no response body. /// /// - public static IActionResult NotFound() - { - return new ObjectResult(null) + public static IActionResult NotFound(string error) + { + var problemDetails = new NotFoundException(error); + + return new ObjectResult(problemDetails) { - StatusCode = (int) HttpStatusCode.NotFound, + StatusCode = problemDetails.Status, }; } } diff --git a/Application/EdFi.Ods.Api/Middleware/ComplementErrorDetailsMiddleware.cs b/Application/EdFi.Ods.Api/Middleware/ComplementErrorDetailsMiddleware.cs new file mode 100644 index 0000000000..ce2f7cbd1b --- /dev/null +++ b/Application/EdFi.Ods.Api/Middleware/ComplementErrorDetailsMiddleware.cs @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using System.Threading.Tasks; +using EdFi.Ods.Api.Extensions; +using EdFi.Ods.Common.Constants; +using EdFi.Ods.Common.Exceptions; +using EdFi.Ods.Common.Logging; +using log4net; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace EdFi.Ods.Api.Middleware; + +/// +/// Implements middleware that inspects the response and complements missing response details. +/// +public class ComplementErrorDetailsMiddleware : IMiddleware +{ + private readonly ILogContextAccessor _logContextAccessor; + + private readonly ILog _logger = LogManager.GetLogger(typeof(ComplementErrorDetailsMiddleware)); + + public ComplementErrorDetailsMiddleware( + ILogContextAccessor logContextAccessor) + { + _logContextAccessor = logContextAccessor; + } + + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + await next(context); + + if (context.Response is { StatusCode: 404, HasStarted: false }) + { + var problemDetails = new NotFoundException( + NotFoundException.DefaultDetail, + $"Path '{context.Request.Path}' does not exist. Check the resource name and try again."); + + string correlationId = (string)_logContextAccessor.GetValue(CorrelationConstants.LogContextKey); + problemDetails.CorrelationId = correlationId; + + await context.Response.WriteProblemDetailsAsync(problemDetails); + + _logger.Error(problemDetails.Message); + } + + if (context.Response is { StatusCode: 405, HasStarted: false }) + { + var problemDetails = context.Request.Method == "POST" ? + new MethodNotAllowedException(MethodNotAllowedException.DefaultPostDetail) : + new MethodNotAllowedException(); + + string correlationId = (string)_logContextAccessor.GetValue(CorrelationConstants.LogContextKey); + problemDetails.CorrelationId = correlationId; + + await context.Response.WriteProblemDetailsAsync(problemDetails); + + _logger.Error(problemDetails.Message); + } + } +} diff --git a/Application/EdFi.Ods.Api/Middleware/EdFiApiAuthenticationMiddleware.cs b/Application/EdFi.Ods.Api/Middleware/EdFiApiAuthenticationMiddleware.cs index 6c602bfae7..146ba430b1 100644 --- a/Application/EdFi.Ods.Api/Middleware/EdFiApiAuthenticationMiddleware.cs +++ b/Application/EdFi.Ods.Api/Middleware/EdFiApiAuthenticationMiddleware.cs @@ -5,6 +5,11 @@ using System; using System.Threading.Tasks; +using EdFi.Ods.Api.ExceptionHandling; +using EdFi.Ods.Api.Extensions; +using EdFi.Ods.Common.Constants; +using EdFi.Ods.Common.Exceptions; +using EdFi.Ods.Common.Logging; using EdFi.Ods.Common.Security; using log4net; using Microsoft.AspNetCore.Authentication; @@ -16,17 +21,23 @@ namespace EdFi.Ods.Api.Middleware public class EdFiApiAuthenticationMiddleware { private readonly IApiClientContextProvider _apiClientContextProvider; + private readonly AuthenticateResultTranslator _authenticateResultTranslator; private readonly RequestDelegate _next; + private readonly ILogContextAccessor _logContextAccessor; - public EdFiApiAuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemes, - IApiClientContextProvider apiClientContextProvider) + public EdFiApiAuthenticationMiddleware(RequestDelegate next, + IAuthenticationSchemeProvider schemes, + IApiClientContextProvider apiClientContextProvider, + AuthenticateResultTranslator authenticateResultTranslator, + ILogContextAccessor logContextAccessor) { if (schemes != null) { _next = next ?? throw new ArgumentNullException(nameof(next)); _apiClientContextProvider = apiClientContextProvider; - + _authenticateResultTranslator = authenticateResultTranslator; Schemes = schemes; + _logContextAccessor = logContextAccessor; } else { @@ -71,13 +82,25 @@ public async Task Invoke(HttpContext context) // NOTE: For our use case we set the api key context into the call context storage. The rest of this code // is the default implementation and may need to be update on version updates. // see https://github.com/dotnet/aspnetcore/blob/v3.1.9/src/Security/Authentication/Core/src/AuthenticationMiddleware.cs - var apiClientContext = (ApiClientContext) result.Ticket.Properties.Parameters[nameof(ApiClientContext)]; + var apiClientContext = (ApiClientContext)result.Ticket.Properties.Parameters[nameof(ApiClientContext)]; _apiClientContextProvider.SetApiClientContext(apiClientContext); LogicalThreadContext.Properties[nameof(apiClientContext.ApiClientId)] = apiClientContext.ApiClientId; } + else if (context.Request.Path.StartsWithSegments("/data") || + context.Request.Path.StartsWithSegments("/composites") || + context.Request.Path.StartsWithSegments("/changeQueries")) + { + string correlationId = (string)_logContextAccessor.GetValue(CorrelationConstants.LogContextKey); + + var problemDetails = (EdFiProblemDetailsExceptionBase)_authenticateResultTranslator.GetProblemDetails(result); + + problemDetails.CorrelationId = correlationId; + + await context.Response.WriteProblemDetailsAsync(problemDetails); + } } - await _next(context); - } + await _next(context); } } +} diff --git a/Application/EdFi.Ods.Api/Middleware/EdFiOAuthAuthenticationHandler.cs b/Application/EdFi.Ods.Api/Middleware/EdFiOAuthAuthenticationHandler.cs index 3da164de26..e51e8c3824 100644 --- a/Application/EdFi.Ods.Api/Middleware/EdFiOAuthAuthenticationHandler.cs +++ b/Application/EdFi.Ods.Api/Middleware/EdFiOAuthAuthenticationHandler.cs @@ -4,7 +4,6 @@ // See the LICENSE and NOTICES files in the project root for more information. using System; -using System.Configuration; using System.Net.Http.Headers; using System.Text.Encodings.Web; using System.Threading.Tasks; @@ -12,6 +11,7 @@ using EdFi.Ods.Api.Providers; using EdFi.Ods.Common.Exceptions; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -56,7 +56,7 @@ protected override async Task HandleAuthenticateAsync() if (!authHeader.Scheme.EqualsIgnoreCase(BearerHeaderScheme)) { _logger.LogDebug(UnknownAuthorizationHeaderScheme); - return AuthenticateResult.NoResult(); + return AuthenticateResult.Fail(UnknownAuthorizationHeaderScheme); } // If the token value is missing, fail authentication @@ -66,11 +66,6 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Fail(MissingAuthorizationHeaderBearerTokenValue); } } - catch (ConfigurationException) - { - // The Security repository couldn't open a connection to the Security database - throw; - } catch (Exception ex) { _logger.LogError(ex, "Token authentication failed..."); @@ -92,5 +87,15 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Fail(ex); } } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + // Base method will set the StatusCode to 401 without checking if the response HasStarted. + // If authentication fails in EdFiApiAuthenticationMiddleware, we dont need to set Status code again. + if (Context.Response.StatusCode == StatusCodes.Status401Unauthorized && Context.Response.HasStarted) + return Task.FromResult(0); + + return base.HandleChallengeAsync(properties); + } } } \ No newline at end of file diff --git a/Application/EdFi.Ods.Api/Startup/ApplicationBuilder.cs b/Application/EdFi.Ods.Api/Startup/ApplicationBuilder.cs new file mode 100644 index 0000000000..9ccf13d4bf --- /dev/null +++ b/Application/EdFi.Ods.Api/Startup/ApplicationBuilder.cs @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +namespace EdFi.Ods.Api.Startup +//namespace Microsoft.AspNetCore.Builder +{ + /// + /// Custom implementation for . + /// Pulled from .NET 6 release branch. + /// + // TODO: Remove once migration to .NET 8 is done + public class EdFiCustomApplicationBuilder : IApplicationBuilder + { + private const string ServerFeaturesKey = "server.Features"; + private const string ApplicationServicesKey = "application.Services"; + + private readonly List> _components = new(); + + /// + /// Initializes a new instance of . + /// + /// The for application services. + public EdFiCustomApplicationBuilder(IServiceProvider serviceProvider) + { + Properties = new Dictionary(StringComparer.Ordinal); + ApplicationServices = serviceProvider; + } + + /// + /// Initializes a new instance of . + /// + /// The for application services. + /// The server instance that hosts the application. + public EdFiCustomApplicationBuilder(IServiceProvider serviceProvider, object server) + : this(serviceProvider) + { + SetProperty(ServerFeaturesKey, server); + } + + private EdFiCustomApplicationBuilder(IApplicationBuilder builder) + { + Properties = new CopyOnWriteDictionary(builder.Properties, StringComparer.Ordinal); + } + + /// + /// Gets the for application services. + /// + public IServiceProvider ApplicationServices + { + get + { + return GetProperty(ApplicationServicesKey)!; + } + set + { + SetProperty(ApplicationServicesKey, value); + } + } + + /// + /// Gets the for server features. + /// + public IFeatureCollection ServerFeatures + { + get + { + return GetProperty(ServerFeaturesKey)!; + } + } + + /// + /// Gets a set of properties for . + /// + public IDictionary Properties { get; } + + private T GetProperty(string key) + { + return Properties.TryGetValue(key, out var value) ? (T)value : default(T); + } + + private void SetProperty(string key, T value) + { + Properties[key] = value; + } + + /// + /// Adds the middleware to the application request pipeline. + /// + /// The middleware. + /// An instance of after the operation has completed. + public IApplicationBuilder Use(Func middleware) + { + _components.Add(middleware); + return this; + } + + /// + /// Creates a copy of this application builder. + /// + /// The created clone has the same properties as the current instance, but does not copy + /// the request pipeline. + /// + /// + /// The cloned instance. + public IApplicationBuilder New() + { + return new EdFiCustomApplicationBuilder((IApplicationBuilder)this); + } + + /// + /// Produces a that executes added middlewares. + /// + /// The . + public RequestDelegate Build() + { + RequestDelegate app = context => + { + // If we reach the end of the pipeline, but we have an endpoint, then something unexpected has happened. + // This could happen if user code sets an endpoint, but they forgot to add the UseEndpoint middleware. + var endpoint = context.GetEndpoint(); + var endpointRequestDelegate = endpoint?.RequestDelegate; + if (endpointRequestDelegate != null) + { + var message = + $"The request reached the end of the pipeline without executing the endpoint: '{endpoint!.DisplayName}'. " + + $"Please register the EndpointMiddleware using '{nameof(IApplicationBuilder)}.UseEndpoints(...)' if using " + + $"routing."; + throw new InvalidOperationException(message); + } + + // ========================== + // NOTE: Customization BEGIN + // -------------------------- + + // Flushing the response and calling through to the next middleware in the pipeline is + // a user error, but don't attempt to set the status code if this happens. It leads to a confusing + // behavior where the client response looks fine, but the server side logic results in an exception. + if (!context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + } + + // -------------------------- + // NOTE: Customization END + // ========================== + + return Task.CompletedTask; + }; + + for (var c = _components.Count - 1; c >= 0; c--) + { + app = _components[c](app); + } + + return app; + } + } + + // TODO: Remove once migration to .NET 8 is done + internal class CopyOnWriteDictionary : IDictionary where TKey : notnull + { + private readonly IDictionary _sourceDictionary; + private readonly IEqualityComparer _comparer; + private IDictionary _innerDictionary; + + public CopyOnWriteDictionary( + IDictionary sourceDictionary, + IEqualityComparer comparer) + { + ArgumentNullException.ThrowIfNull(sourceDictionary); + ArgumentNullException.ThrowIfNull(comparer); + + _sourceDictionary = sourceDictionary; + _comparer = comparer; + } + + private IDictionary ReadDictionary + { + get + { + return _innerDictionary ?? _sourceDictionary; + } + } + + private IDictionary WriteDictionary + { + get + { + if (_innerDictionary == null) + { + _innerDictionary = new Dictionary(_sourceDictionary, + _comparer); + } + + return _innerDictionary; + } + } + + public ICollection Keys + { + get + { + return ReadDictionary.Keys; + } + } + + public ICollection Values + { + get + { + return ReadDictionary.Values; + } + } + + public int Count + { + get + { + return ReadDictionary.Count; + } + } + + public bool IsReadOnly + { + get + { + return false; + } + } + + public TValue this[TKey key] + { + get + { + return ReadDictionary[key]; + } + set + { + WriteDictionary[key] = value; + } + } + + public bool ContainsKey(TKey key) + { + return ReadDictionary.ContainsKey(key); + } + + public void Add(TKey key, TValue value) + { + WriteDictionary.Add(key, value); + } + + public bool Remove(TKey key) + { + return WriteDictionary.Remove(key); + } + + public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) + { + return ReadDictionary.TryGetValue(key, out value); + } + + public void Add(KeyValuePair item) + { + WriteDictionary.Add(item); + } + + public void Clear() + { + WriteDictionary.Clear(); + } + + public bool Contains(KeyValuePair item) + { + return ReadDictionary.Contains(item); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + ReadDictionary.CopyTo(array, arrayIndex); + } + + public bool Remove(KeyValuePair item) + { + return WriteDictionary.Remove(item); + } + + public IEnumerator> GetEnumerator() + { + return ReadDictionary.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } + + // TODO: Remove once migration to .NET 8 is done + public class CustomApplicationBuilderFactory : IApplicationBuilderFactory + { + private readonly IServiceProvider _serviceProvider; + + /// + /// Initialize a new factory instance with an . + /// + /// The used to resolve dependencies and initialize components. + public CustomApplicationBuilderFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + /// Create an builder given a . + /// + /// An of HTTP features. + /// An configured with . + public IApplicationBuilder CreateBuilder(IFeatureCollection serverFeatures) + { + return new EdFiCustomApplicationBuilder(_serviceProvider, serverFeatures); + } + } +} \ No newline at end of file diff --git a/Application/EdFi.Ods.Api/Startup/OdsStartupBase.cs b/Application/EdFi.Ods.Api/Startup/OdsStartupBase.cs index 22dd5c5854..adfe189897 100644 --- a/Application/EdFi.Ods.Api/Startup/OdsStartupBase.cs +++ b/Application/EdFi.Ods.Api/Startup/OdsStartupBase.cs @@ -46,6 +46,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc.ApplicationParts; @@ -87,6 +88,9 @@ protected OdsStartupBase(IWebHostEnvironment webHostEnvironment, IConfiguration public void ConfigureServices(IServiceCollection services) { + // TODO: Remove once migration to .NET 8 is done + services.AddSingleton(); + // Provide access to the web host environment through the container services.AddSingleton(_webHostEnvironment); @@ -349,6 +353,9 @@ public void Configure( } app.UseEdFiApiAuthentication(); + + app.UseComplementErrorDetails(); + app.UseAuthorization(); // Identifies the current ODS instance for the request @@ -367,7 +374,7 @@ public void Configure( } app.UseWhen(context => - context.Request.Path.StartsWithSegments("/data") || + context.Request.Path.StartsWithSegments("/data") || context.Request.Path.StartsWithSegments("/composites"), builder => builder.UseRequestResponseDetailsLogger()); diff --git a/Application/EdFi.Ods.Common/Exceptions/EdFiProblemDetailsExceptionBase.cs b/Application/EdFi.Ods.Common/Exceptions/EdFiProblemDetailsExceptionBase.cs index a8c502a865..f0c5fef932 100644 --- a/Application/EdFi.Ods.Common/Exceptions/EdFiProblemDetailsExceptionBase.cs +++ b/Application/EdFi.Ods.Common/Exceptions/EdFiProblemDetailsExceptionBase.cs @@ -27,6 +27,9 @@ namespace EdFi.Ods.Common.Exceptions; │ │ ┌──────────────────────────────┐ │ └──┤ BadRequestParameterException | │ └──────────────────────────────┘ + │ ┌─────────────────────────────────┐ + ├──┤ SecurityAuthenticationException | 401 Unauthorized + │ └─────────────────────────────────┘ │ ┌────────────────────────────────┐ ├──┤ SecurityAuthorizationException | 403 Forbidden │ └────────────────────────────────┘ @@ -76,6 +79,9 @@ namespace EdFi.Ods.Common.Exceptions; │ ┌───────────────────────────────┐ ├──┤ SnapshotsAreReadOnlyException | 405 Method Not Allowed │ └───────────────────────────────┘ + │ ┌───────────────────────────┐ + ├──┤ MethodNotAllowedException | 405 Method Not Allowed + │ └───────────────────────────┘ │ ┌──────────────────────┐ ├──┤ NotModifiedException | 304 Not Modified │ └──────────────────────┘ diff --git a/Application/EdFi.Ods.Common/Exceptions/MethodNotAllowedException.cs b/Application/EdFi.Ods.Common/Exceptions/MethodNotAllowedException.cs new file mode 100644 index 0000000000..e4bd8f9109 --- /dev/null +++ b/Application/EdFi.Ods.Common/Exceptions/MethodNotAllowedException.cs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; + +namespace EdFi.Ods.Common.Exceptions; + +public class MethodNotAllowedException : EdFiProblemDetailsExceptionBase +{ + // Fields containing override values for Problem Details + private const string TypePart = "method-not-allowed"; + private const string TitleText = "Method Not Allowed"; + private const int StatusValue = StatusCodes.Status405MethodNotAllowed; + + public const string DefaultPutDeleteDetail = "Id is required in the URL."; + public const string DefaultPostDetail = "Remove Id from the URL."; + + public MethodNotAllowedException() + : base(DefaultPutDeleteDetail, DefaultPutDeleteDetail) { } + + public MethodNotAllowedException(string detail) + : base(detail, detail) { } + + // --------------------------- + // Boilerplate for overrides + // --------------------------- + public override string Title { get => TitleText; } + + public override int Status { get => StatusValue; } + + protected override IEnumerable GetTypeParts() + { + foreach (var part in base.GetTypeParts()) + { + yield return part; + } + + yield return TypePart; + } + // --------------------------- +} diff --git a/Application/EdFi.Ods.Common/Exceptions/SecurityAuthenticationException.cs b/Application/EdFi.Ods.Common/Exceptions/SecurityAuthenticationException.cs new file mode 100644 index 0000000000..e4330c06a1 --- /dev/null +++ b/Application/EdFi.Ods.Common/Exceptions/SecurityAuthenticationException.cs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; + +namespace EdFi.Ods.Common.Exceptions; + +public class SecurityAuthenticationException : EdFiProblemDetailsExceptionBase +{ + // Fields containing override values for Problem Details + private const string TypePart = "security:authentication"; + private const string TitleText = "Authentication Failed"; + private const int StatusValue = StatusCodes.Status401Unauthorized; + + public const string DefaultDetail = "Access could not be authorized."; + + /// + /// Initializes a new instance of the ProblemDetails-based class using + /// the supplied detail, and error (which is made available in the collection + /// as well as used for the exception's property to be used for exception logging). + /// + /// + /// + public SecurityAuthenticationException(string detail, string error) + : base(detail, error) + { + if (error != null) + { + ((IEdFiProblemDetails)this).Errors = new[] { error }; + } + } + + /// + /// Initializes a new instance of the ProblemDetails-based class using + /// the supplied detail, and error and inner exception. + /// + /// + /// + /// + public SecurityAuthenticationException(string detail, string error, Exception innerException) + : base(detail, error, innerException) + { + if (error != null) + { + ((IEdFiProblemDetails)this).Errors = new[] { error }; + } + } + + // --------------------------- + // Boilerplate for overrides + // --------------------------- + public override string Title { get => TitleText; } + + public override int Status { get => StatusValue; } + + protected override IEnumerable GetTypeParts() + { + foreach (var part in base.GetTypeParts()) + { + yield return part; + } + + yield return TypePart; + } + // --------------------------- +} diff --git a/Application/EdFi.Ods.Features/ChangeQueries/Controllers/DeletesController.cs b/Application/EdFi.Ods.Features/ChangeQueries/Controllers/DeletesController.cs index 66bb25371e..4e3071f3e7 100644 --- a/Application/EdFi.Ods.Features/ChangeQueries/Controllers/DeletesController.cs +++ b/Application/EdFi.Ods.Features/ChangeQueries/Controllers/DeletesController.cs @@ -76,13 +76,13 @@ public async Task Get(string schema, string resource, [FromQuery] { _logger.Debug("ChangeQueries is not enabled."); - return ControllerHelpers.NotFound(); + return ControllerHelpers.NotFound("ChangeQueries is not enabled."); } if (!_domainModelProvider.GetDomainModel() .ResourceModel.TryGetResourceByApiCollectionName(schema, resource, out var resourceClass)) { - return ControllerHelpers.NotFound(); + return ControllerHelpers.NotFound($"The resource {resource} could not be found."); } var parameterMessages = urlQueryParametersRequest.Validate(_defaultPageLimitSize).ToArray(); diff --git a/Application/EdFi.Ods.Features/ChangeQueries/Controllers/KeyChangesController.cs b/Application/EdFi.Ods.Features/ChangeQueries/Controllers/KeyChangesController.cs index fc813595d2..a2b3bfcf66 100644 --- a/Application/EdFi.Ods.Features/ChangeQueries/Controllers/KeyChangesController.cs +++ b/Application/EdFi.Ods.Features/ChangeQueries/Controllers/KeyChangesController.cs @@ -79,13 +79,13 @@ public async Task Get( { _logger.Debug("ChangeQueries is not enabled."); - return ControllerHelpers.NotFound(); + return ControllerHelpers.NotFound("ChangeQueries is not enabled."); } if (!_domainModelProvider.GetDomainModel() .ResourceModel.TryGetResourceByApiCollectionName(schema, resource, out var resourceClass)) { - return ControllerHelpers.NotFound(); + return ControllerHelpers.NotFound($"The resource {resource} could not be found."); } var parameterMessages = urlQueryParametersRequest.Validate(_defaultPageLimitSize).ToArray(); diff --git a/Postman Test Suite/Ed-Fi ODS-API ChangeQueries Key Changes and Deletes Test Suite.postman_collection.json b/Postman Test Suite/Ed-Fi ODS-API ChangeQueries Key Changes and Deletes Test Suite.postman_collection.json index f853c2e6f8..5797d21ba9 100644 --- a/Postman Test Suite/Ed-Fi ODS-API ChangeQueries Key Changes and Deletes Test Suite.postman_collection.json +++ b/Postman Test Suite/Ed-Fi ODS-API ChangeQueries Key Changes and Deletes Test Suite.postman_collection.json @@ -10868,6 +10868,15 @@ "exec": [ "pm.test(\"Response is 404\", () => {\r", " pm.expect(pm.response.code).to.equal(404)\r", + "});\r", + "\r", + "pm.test(\"Should return a message indicating that the resource could not be found.\", () => {\r", + " const problemDetails = pm.response.json();\r", + "\r", + " pm.expect(pm.response.code).equal(problemDetails.status);\r", + " pm.expect(problemDetails.type).to.equal(\"urn:ed-fi:api:not-found\");\r", + " pm.expect(problemDetails.detail).to.equal(\"The resource nonExisting could not be found.\");\r", + "\r", "});" ], "type": "text/javascript" @@ -10902,6 +10911,15 @@ "exec": [ "pm.test(\"Response is 404\", () => {\r", " pm.expect(pm.response.code).to.equal(404)\r", + "});\r", + "\r", + "pm.test(\"Should return a message indicating that the resource could not be found.\", () => {\r", + " const problemDetails = pm.response.json();\r", + "\r", + " pm.expect(pm.response.code).equal(problemDetails.status);\r", + " pm.expect(problemDetails.type).to.equal(\"urn:ed-fi:api:not-found\");\r", + " pm.expect(problemDetails.detail).to.equal(\"The resource nonExisting could not be found.\");\r", + "\r", "});" ], "type": "text/javascript" diff --git a/Postman Test Suite/Ed-Fi ODS-API Integration Test Suite ResponseTests.postman_collection.json b/Postman Test Suite/Ed-Fi ODS-API Integration Test Suite ResponseTests.postman_collection.json index 3c08f56fe2..ad68b7a5f2 100644 --- a/Postman Test Suite/Ed-Fi ODS-API Integration Test Suite ResponseTests.postman_collection.json +++ b/Postman Test Suite/Ed-Fi ODS-API Integration Test Suite ResponseTests.postman_collection.json @@ -2748,7 +2748,15 @@ "pm.test(\"Status code is 404\", () => {", " pm.expect(pm.response.code).to.equal(404);", "});", - "" + "", + "pm.test(\"Should return a message indicating that the specified resource item could not be found.\", () => {", + " const problemDetails = pm.response.json();", + "", + " pm.expect(pm.response.code).equal(problemDetails.status);", + " pm.expect(problemDetails.type).to.equal(\"urn:ed-fi:api:not-found\");", + " pm.expect(problemDetails.detail).to.equal(\"The specified resource item could not be found.\");", + "", + "});" ], "type": "text/javascript" } @@ -3056,6 +3064,58 @@ } ] }, + { + "name": "when_getting_a_resource_from_an_nonexistent_endpoint", + "item": [ + { + "name": "api_should_fail_with_404_code", + "item": [ + { + "name": "api_should_fail_with_404_code", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 404\", () => {\r", + " pm.expect(pm.response.code).to.equal(404);\r", + "});\r", + "\r", + "pm.test(\"Should return a message indicating that the specified resource could not be found.\", () => {\r", + " const problemDetails = pm.response.json();\r", + "\r", + " pm.expect(pm.response.code).equal(problemDetails.status);\r", + " pm.expect(problemDetails.type).to.equal(\"urn:ed-fi:api:not-found\");\r", + " pm.expect(problemDetails.detail).to.equal(\"The specified resource could not be found.\");\r", + "\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{ApiBaseUrl}}/data/v3/ed-fi/Student", + "host": [ + "{{ApiBaseUrl}}" + ], + "path": [ + "data", + "v3", + "ed-fi", + "Student" + ] + } + }, + "response": [] + } + ] + } + ] + }, { "name": "ReferenceLinkTests", "item": [ @@ -5414,6 +5474,73 @@ ] } ] + }, + { + "name": "when_posting_a_resource_including_the_id", + "item": [ + { + "name": "api_should_fail_with_405_code", + "item": [ + { + "name": "api_should_fail_with_405_code", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "const uuid = require('uuid');\r", + "function newGuid() { return uuid.v4().toString().replace(/[^a-zA-Z0-9 ]/g,\"\"); }\r", + "function createScenarioId() { return newGuid().substring(0,5); }\r", + "pm.environment.set('scenarioId', createScenarioId());\r", + "const scenarioId = pm.environment.get('scenarioId');\r", + "pm.environment.set('supplied:'+scenarioId+':programdGuid', newGuid());" + ], + "type": "text/javascript" + } + }, + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 405\", () => {\r", + " pm.expect(pm.response.code).to.equal(405);\r", + "});\r", + "\r", + "pm.test(\"Should return a message indicating that the Id should be removed from the URL.\", () => {\r", + " const problemDetails = pm.response.json();\r", + "\r", + " pm.expect(pm.response.code).equal(problemDetails.status);\r", + " pm.expect(problemDetails.type).to.equal(\"urn:ed-fi:api:method-not-allowed\");\r", + " pm.expect(problemDetails.detail).to.equal(\"Remove Id from the URL.\");\r", + "\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "{{ApiBaseUrl}}/data/v3/ed-fi/Programs/{{supplied:{{scenarioId}}:programdGuid}}", + "host": [ + "{{ApiBaseUrl}}" + ], + "path": [ + "data", + "v3", + "ed-fi", + "Programs", + "{{supplied:{{scenarioId}}:programdGuid}}" + ] + } + }, + "response": [] + } + ] + } + ] } ] }, @@ -7438,6 +7565,59 @@ ] } ] + }, + { + "name": "when_putting_a_resource_omitting_the_id", + "item": [ + { + "name": "api_should_fail_with_405_code", + "item": [ + { + "name": "api_should_fail_with_405_code", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 405\", () => {\r", + " pm.expect(pm.response.code).to.equal(405);\r", + "});\r", + "\r", + "pm.test(\"Should return a message indicating that the Id is required in the URL.\", () => {\r", + " const problemDetails = pm.response.json();\r", + "\r", + " pm.expect(pm.response.code).equal(problemDetails.status);\r", + " pm.expect(problemDetails.type).to.equal(\"urn:ed-fi:api:method-not-allowed\");\r", + " pm.expect(problemDetails.detail).to.equal(\"Id is required in the URL.\");\r", + "\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "PUT", + "header": [], + "url": { + "raw": "{{ApiBaseUrl}}/data/v3/ed-fi/students/", + "host": [ + "{{ApiBaseUrl}}" + ], + "path": [ + "data", + "v3", + "ed-fi", + "students", + "" + ] + } + }, + "response": [] + } + ] + } + ] } ] }, @@ -7784,7 +7964,15 @@ "pm.test(\"Status code is 404\", () => {", " pm.expect(pm.response.code).to.equal(404);", "});", - "" + "", + "pm.test(\"Should return a message indicating that the resource to delete was not found.\", () => {", + " const problemDetails = pm.response.json();", + "", + " pm.expect(pm.response.code).equal(problemDetails.status);", + " pm.expect(problemDetails.type).to.equal(\"urn:ed-fi:api:not-found\");", + " pm.expect(problemDetails.detail).to.equal(\"Resource to delete was not found.\");", + "", + "});" ], "type": "text/javascript" } @@ -8257,6 +8445,59 @@ ] } ] + }, + { + "name": "when_deleting_a_resource_omitting_the_id", + "item": [ + { + "name": "api_should_fail_with_405_code", + "item": [ + { + "name": "api_should_fail_with_405_code", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code is 405\", () => {\r", + " pm.expect(pm.response.code).to.equal(405);\r", + "});\r", + "\r", + "pm.test(\"Should return a message indicating that the Id is required in the URL.\", () => {\r", + " const problemDetails = pm.response.json();\r", + "\r", + " pm.expect(pm.response.code).equal(problemDetails.status);\r", + " pm.expect(problemDetails.type).to.equal(\"urn:ed-fi:api:method-not-allowed\");\r", + " pm.expect(problemDetails.detail).to.equal(\"Id is required in the URL.\");\r", + "\r", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{ApiBaseUrl}}/data/v3/ed-fi/Programs/", + "host": [ + "{{ApiBaseUrl}}" + ], + "path": [ + "data", + "v3", + "ed-fi", + "Programs", + "" + ] + } + }, + "response": [] + } + ] + } + ] } ] },