diff --git a/Microsoft.Health.Fhir.sln b/Microsoft.Health.Fhir.sln index 67afe8abba..020178e287 100644 --- a/Microsoft.Health.Fhir.sln +++ b/Microsoft.Health.Fhir.sln @@ -66,6 +66,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Health.Extensions EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Health.Fhir.SqlServer.UnitTests", "src\Microsoft.Health.Fhir.SqlServer.UnitTests\Microsoft.Health.Fhir.SqlServer.UnitTests.csproj", "{E02E5224-32CD-490F-B1E5-8509AD669334}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Health.Fhir.KeyVault", "src\Microsoft.Health.Fhir.KeyVault\Microsoft.Health.Fhir.KeyVault.csproj", "{5A7E7D1B-B307-45FD-8230-C4E331EAB2CD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -168,6 +170,10 @@ Global {E02E5224-32CD-490F-B1E5-8509AD669334}.Debug|Any CPU.Build.0 = Debug|Any CPU {E02E5224-32CD-490F-B1E5-8509AD669334}.Release|Any CPU.ActiveCfg = Release|Any CPU {E02E5224-32CD-490F-B1E5-8509AD669334}.Release|Any CPU.Build.0 = Release|Any CPU + {5A7E7D1B-B307-45FD-8230-C4E331EAB2CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A7E7D1B-B307-45FD-8230-C4E331EAB2CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A7E7D1B-B307-45FD-8230-C4E331EAB2CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A7E7D1B-B307-45FD-8230-C4E331EAB2CD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -198,6 +204,7 @@ Global {87849B3F-5D12-41CA-A082-FAC065EF9FD8} = {8AD2A324-DAB5-4380-94A5-31F7D817C384} {4DDF9576-E22C-460A-937F-0EE0FEA6DB87} = {8AD2A324-DAB5-4380-94A5-31F7D817C384} {E02E5224-32CD-490F-B1E5-8509AD669334} = {8AD2A324-DAB5-4380-94A5-31F7D817C384} + {5A7E7D1B-B307-45FD-8230-C4E331EAB2CD} = {8AD2A324-DAB5-4380-94A5-31F7D817C384} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution RESX_SortFileContentOnSave = True diff --git a/samples/templates/default-azuredeploy.json b/samples/templates/default-azuredeploy.json index ea10d43466..cbaa3803b1 100644 --- a/samples/templates/default-azuredeploy.json +++ b/samples/templates/default-azuredeploy.json @@ -285,7 +285,8 @@ "permissions": { "secrets": [ "get", - "list" + "list", + "set" ] } } diff --git a/src/Microsoft.Health.Fhir.Api.UnitTests/Controllers/ExportControllerTests.cs b/src/Microsoft.Health.Fhir.Api.UnitTests/Controllers/ExportControllerTests.cs index b8da647254..ea99554c24 100644 --- a/src/Microsoft.Health.Fhir.Api.UnitTests/Controllers/ExportControllerTests.cs +++ b/src/Microsoft.Health.Fhir.Api.UnitTests/Controllers/ExportControllerTests.cs @@ -26,6 +26,9 @@ public class ExportControllerTests private IFhirRequestContextAccessor _fhirRequestContextAccessor = Substitute.For(); private IUrlResolver _urlResolver = Substitute.For(); + private const string DestinationType = "destinationType"; + private const string DestinationConnection = "destinationConnection"; + public ExportControllerTests() { _exportEnabledController = GetController(new ExportJobConfiguration() { Enabled = true }); @@ -36,7 +39,7 @@ public async Task GivenAnExportRequest_WhenDisabled_ThenRequestNotValidException { var exportController = GetController(new ExportJobConfiguration() { Enabled = false }); - await Assert.ThrowsAsync(() => exportController.Export()); + await Assert.ThrowsAsync(() => exportController.Export(DestinationType, DestinationConnection)); } [Fact] diff --git a/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Filters/ValidateExportHeadersFilterAttributeTests.cs b/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Filters/ValidateExportHeadersFilterAttributeTests.cs deleted file mode 100644 index b34d6935c0..0000000000 --- a/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Filters/ValidateExportHeadersFilterAttributeTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System.Collections.Generic; -using Hl7.Fhir.Rest; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.AspNetCore.Routing; -using Microsoft.Health.Fhir.Api.Features.Filters; -using Microsoft.Health.Fhir.Core.Exceptions; -using Microsoft.Net.Http.Headers; -using Xunit; - -namespace Microsoft.Health.Fhir.Api.UnitTests.Features.Filters -{ - public class ValidateExportHeadersFilterAttributeTests - { - private const string CorrectAcceptHeaderValue = ContentType.JSON_CONTENT_HEADER; - private const string CorrectPreferHeaderValue = "respond-async"; - private const string PreferHeaderName = "Prefer"; - - [Theory] - [InlineData("application/fhir+xml")] - [InlineData("application/xml")] - [InlineData("text/xml")] - [InlineData("application/json")] - [InlineData("*/*")] - public void GiveARequestWithInvalidAcceptHeader_WhenGettingAnExportOperationRequest_ThenAResourceNotValidExceptionShouldBeThrown(string acceptHeader) - { - var filter = new ValidateExportHeadersFilterAttribute(); - var context = CreateContext(); - - context.HttpContext.Request.Headers.Add(HeaderNames.Accept, acceptHeader); - - Assert.Throws(() => filter.OnActionExecuting(context)); - } - - [Fact] - public void GiveARequestWithNoAcceptHeader_WhenGettingAnExportOperationRequest_ThenAResourceNotValidExceptionShouldBeThrown() - { - var filter = new ValidateExportHeadersFilterAttribute(); - var context = CreateContext(); - - context.HttpContext.Request.Headers.Add(PreferHeaderName, CorrectPreferHeaderValue); - - Assert.Throws(() => filter.OnActionExecuting(context)); - } - - [Theory] - [InlineData("respond-async, wait = 10")] - [InlineData("return-content")] - [InlineData("*")] - public void GiveARequestWithInvalidPreferHeader_WhenGettingAnExportOperationRequest_ThenAResourceNotValidExceptionShouldBeThrown(string preferHeader) - { - var filter = new ValidateExportHeadersFilterAttribute(); - var context = CreateContext(); - - context.HttpContext.Request.Headers.Add(PreferHeaderName, preferHeader); - - Assert.Throws(() => filter.OnActionExecuting(context)); - } - - [Fact] - public void GiveARequestWithNoPreferHeader_WhenGettingAnExportOperationRequest_ThenAResourceNotValidExceptionShouldBeThrown() - { - var filter = new ValidateExportHeadersFilterAttribute(); - var context = CreateContext(); - - context.HttpContext.Request.Headers.Add(HeaderNames.Accept, CorrectAcceptHeaderValue); - - Assert.Throws(() => filter.OnActionExecuting(context)); - } - - [Fact] - public void GiveARequestWithValidAcceptAndPreferHeader_WhenGettingAnExportOperationRequest_ThenTheResultIsSuccessful() - { - var filter = new ValidateExportHeadersFilterAttribute(); - var context = CreateContext(); - - context.HttpContext.Request.Headers.Add(HeaderNames.Accept, CorrectAcceptHeaderValue); - context.HttpContext.Request.Headers.Add(PreferHeaderName, CorrectPreferHeaderValue); - - filter.OnActionExecuting(context); - } - - private static ActionExecutingContext CreateContext() - { - return new ActionExecutingContext( - new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()), - new List(), - new Dictionary(), - FilterTestsHelper.CreateMockExportController()); - } - } -} diff --git a/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Filters/ValidateExportRequestFilterAttributeTests.cs b/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Filters/ValidateExportRequestFilterAttributeTests.cs new file mode 100644 index 0000000000..4caac7eb2a --- /dev/null +++ b/src/Microsoft.Health.Fhir.Api.UnitTests/Features/Filters/ValidateExportRequestFilterAttributeTests.cs @@ -0,0 +1,207 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using Hl7.Fhir.Rest; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Internal; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Health.Fhir.Api.Features.Filters; +using Microsoft.Health.Fhir.Core.Configs; +using Microsoft.Health.Fhir.Core.Exceptions; +using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Net.Http.Headers; +using NSubstitute; +using Xunit; + +namespace Microsoft.Health.Fhir.Api.UnitTests.Features.Filters +{ + public class ValidateExportRequestFilterAttributeTests + { + private const string CorrectAcceptHeaderValue = ContentType.JSON_CONTENT_HEADER; + private const string CorrectPreferHeaderValue = "respond-async"; + private const string PreferHeaderName = "Prefer"; + private const string SupportedDestinationType = "AnySupportedDestinationType"; + + [Theory] + [InlineData("application/fhir+xml")] + [InlineData("application/xml")] + [InlineData("text/xml")] + [InlineData("application/json")] + [InlineData("*/*")] + public void GiveARequestWithInvalidAcceptHeader_WhenGettingAnExportOperationRequest_ThenARequestNotValidExceptionShouldBeThrown(string acceptHeader) + { + var filter = GetFilter(); + var context = CreateContextWithParams(); + + context.HttpContext.Request.Headers.Add(HeaderNames.Accept, acceptHeader); + + Assert.Throws(() => filter.OnActionExecuting(context)); + } + + [Fact] + public void GiveARequestWithNoAcceptHeader_WhenGettingAnExportOperationRequest_ThenARequestNotValidExceptionShouldBeThrown() + { + var filter = GetFilter(); + var context = CreateContextWithParams(); + + context.HttpContext.Request.Headers.Add(PreferHeaderName, CorrectPreferHeaderValue); + + Assert.Throws(() => filter.OnActionExecuting(context)); + } + + [Theory] + [InlineData("respond-async, wait = 10")] + [InlineData("return-content")] + [InlineData("*")] + public void GiveARequestWithInvalidPreferHeader_WhenGettingAnExportOperationRequest_ThenARequestNotValidExceptionShouldBeThrown(string preferHeader) + { + var filter = GetFilter(); + var context = CreateContextWithParams(); + + context.HttpContext.Request.Headers.Add(PreferHeaderName, preferHeader); + + Assert.Throws(() => filter.OnActionExecuting(context)); + } + + [Fact] + public void GiveARequestWithNoPreferHeader_WhenGettingAnExportOperationRequest_ThenARequestNotValidExceptionShouldBeThrown() + { + var filter = GetFilter(); + var context = CreateContextWithParams(); + + context.HttpContext.Request.Headers.Add(HeaderNames.Accept, CorrectAcceptHeaderValue); + + Assert.Throws(() => filter.OnActionExecuting(context)); + } + + [Fact] + public void GiveARequestWithValidAcceptAndPreferHeader_WhenGettingAnExportOperationRequest_ThenTheResultIsSuccessful() + { + var filter = GetFilter(); + var context = CreateContextWithParams(); + + context.HttpContext.Request.Headers.Add(HeaderNames.Accept, CorrectAcceptHeaderValue); + context.HttpContext.Request.Headers.Add(PreferHeaderName, CorrectPreferHeaderValue); + + filter.OnActionExecuting(context); + } + + [Fact] + public void GivenARequestWithCorrectHeadersAndMissingDestinationTypeParam_WhenGettingAnExportOperationRequest_ThenARequestNotValidExceptionShouldBeThrown() + { + var filter = GetFilter(); + var queryParams = new Dictionary() + { + { KnownQueryParameterNames.DestinationConnectionSettings, "destination" }, + }; + + var context = CreateContextWithParams(queryParams); + + context.HttpContext.Request.Headers.Add(HeaderNames.Accept, CorrectAcceptHeaderValue); + context.HttpContext.Request.Headers.Add(PreferHeaderName, CorrectPreferHeaderValue); + + Assert.Throws(() => filter.OnActionExecuting(context)); + } + + [Fact] + public void GivenARequestWithCorrectHeadersAndMissingDestinationConnectionParam_WhenGettingAnExportOperationRequest_ThenARequestNotValidExceptionShouldBeThrown() + { + var filter = GetFilter(); + var queryParams = new Dictionary() + { + { KnownQueryParameterNames.DestinationType, SupportedDestinationType }, + }; + + var context = CreateContextWithParams(queryParams); + + context.HttpContext.Request.Headers.Add(HeaderNames.Accept, CorrectAcceptHeaderValue); + context.HttpContext.Request.Headers.Add(PreferHeaderName, CorrectPreferHeaderValue); + + Assert.Throws(() => filter.OnActionExecuting(context)); + } + + [Fact] + public void GivenARequestWithCorrectHeadersAndDestinationTypeThatIsNotSupported_WhenGettingAnExportOperationRequest_ThenARequestNotValidExceptionShouldBeThrown() + { + var filter = GetFilter(); + var queryParams = new Dictionary() + { + { KnownQueryParameterNames.DestinationType, "Azure" }, + { KnownQueryParameterNames.DestinationConnectionSettings, "destination" }, + }; + + var context = CreateContextWithParams(queryParams); + + context.HttpContext.Request.Headers.Add(HeaderNames.Accept, CorrectAcceptHeaderValue); + context.HttpContext.Request.Headers.Add(PreferHeaderName, CorrectPreferHeaderValue); + + Assert.Throws(() => filter.OnActionExecuting(context)); + } + + [Fact] + public void GivenARequestWithCorrectHeadersAndUnsupportedQueryParameter_WhenGettingAnExportOperationRequest_ThenARequestNotValidExceptionShouldBeThrown() + { + var filter = GetFilter(); + var queryParams = new Dictionary() + { + { KnownQueryParameterNames.DestinationType, SupportedDestinationType }, + { KnownQueryParameterNames.DestinationConnectionSettings, "destination" }, + { KnownQueryParameterNames.Since, "forever" }, + }; + + var context = CreateContextWithParams(queryParams); + + context.HttpContext.Request.Headers.Add(HeaderNames.Accept, CorrectAcceptHeaderValue); + context.HttpContext.Request.Headers.Add(PreferHeaderName, CorrectPreferHeaderValue); + + Assert.Throws(() => filter.OnActionExecuting(context)); + } + + private static ActionExecutingContext CreateContextWithParams(Dictionary queryParams = null) + { + var context = new ActionExecutingContext( + new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()), + new List(), + new Dictionary(), + FilterTestsHelper.CreateMockExportController()); + + if (queryParams == null) + { + queryParams = new Dictionary(); + queryParams.Add(KnownQueryParameterNames.DestinationType, SupportedDestinationType); + queryParams.Add(KnownQueryParameterNames.DestinationConnectionSettings, "connectionString"); + } + + context.HttpContext.Request.Query = new QueryCollection(queryParams); + return context; + } + + private static ValidateExportRequestFilterAttribute GetFilter(ExportJobConfiguration exportJobConfig = null) + { + if (exportJobConfig == null) + { + exportJobConfig = new ExportJobConfiguration(); + exportJobConfig.Enabled = true; + exportJobConfig.SupportedDestinations.Add(SupportedDestinationType); + } + + var opConfig = new OperationsConfiguration() + { + Export = exportJobConfig, + }; + + IOptions options = Substitute.For>(); + options.Value.Returns(opConfig); + + return new ValidateExportRequestFilterAttribute(options); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Api/Controllers/ExportController.cs b/src/Microsoft.Health.Fhir.Api/Controllers/ExportController.cs index 3b456e1638..1c001a0993 100644 --- a/src/Microsoft.Health.Fhir.Api/Controllers/ExportController.cs +++ b/src/Microsoft.Health.Fhir.Api/Controllers/ExportController.cs @@ -21,6 +21,7 @@ using Microsoft.Health.Fhir.Core.Configs; using Microsoft.Health.Fhir.Core.Exceptions; using Microsoft.Health.Fhir.Core.Extensions; +using Microsoft.Health.Fhir.Core.Features; using Microsoft.Health.Fhir.Core.Features.Context; using Microsoft.Health.Fhir.Core.Features.Operations; using Microsoft.Health.Fhir.Core.Features.Routing; @@ -74,16 +75,18 @@ public ExportController( [HttpGet] [Route(KnownRoutes.Export)] - [ValidateExportHeadersFilter] + [ServiceFilter(typeof(ValidateExportRequestFilterAttribute))] [AuditEventType(AuditEventSubType.Export)] - public async Task Export() + public async Task Export( + [FromQuery(Name = KnownQueryParameterNames.DestinationType)] string destinationType, + [FromQuery(Name = KnownQueryParameterNames.DestinationConnectionSettings)] string destinationConnectionString) { if (!_exportConfig.Enabled) { throw new RequestNotValidException(string.Format(Resources.UnsupportedOperation, OperationsConstants.Export)); } - CreateExportResponse response = await _mediator.ExportAsync(_fhirRequestContextAccessor.FhirRequestContext.Uri); + CreateExportResponse response = await _mediator.ExportAsync(_fhirRequestContextAccessor.FhirRequestContext.Uri, destinationType, destinationConnectionString); var exportResult = ExportResult.Accepted(); exportResult.SetContentLocationHeader(_urlResolver, OperationsConstants.Export, response.JobId); @@ -93,7 +96,7 @@ public async Task Export() [HttpGet] [Route(KnownRoutes.ExportResourceType)] - [ValidateExportHeadersFilter] + [ServiceFilter(typeof(ValidateExportRequestFilterAttribute))] [AuditEventType(AuditEventSubType.Export)] public IActionResult ExportResourceType(string type) { @@ -108,7 +111,7 @@ public IActionResult ExportResourceType(string type) [HttpGet] [Route(KnownRoutes.ExportResourceTypeById)] - [ValidateExportHeadersFilter] + [ServiceFilter(typeof(ValidateExportRequestFilterAttribute))] [AuditEventType(AuditEventSubType.Export)] public IActionResult ExportResourceTypeById(string type, string id) { diff --git a/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateExportHeadersFilterAttribute.cs b/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateExportHeadersFilterAttribute.cs deleted file mode 100644 index dab764dd28..0000000000 --- a/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateExportHeadersFilterAttribute.cs +++ /dev/null @@ -1,43 +0,0 @@ -// ------------------------------------------------------------------------------------------------- -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. -// ------------------------------------------------------------------------------------------------- - -using System; -using EnsureThat; -using Hl7.Fhir.Rest; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Health.Fhir.Core.Exceptions; -using Microsoft.Net.Http.Headers; - -namespace Microsoft.Health.Fhir.Api.Features.Filters -{ - /// - /// A filter that validates the Accept and Prefer headers in the request and short-circuits the pipeline if they are invalid. - /// - [AttributeUsage(AttributeTargets.Method)] - internal class ValidateExportHeadersFilterAttribute : ActionFilterAttribute - { - private const string PreferHeaderName = "Prefer"; - private const string PreferHeaderExpectedValue = "respond-async"; - - public override void OnActionExecuting(ActionExecutingContext context) - { - EnsureArg.IsNotNull(context, nameof(context)); - - if (!context.HttpContext.Request.Headers.TryGetValue(HeaderNames.Accept, out var acceptHeaderValue) || - acceptHeaderValue.Count != 1 || - !string.Equals(acceptHeaderValue[0], ContentType.JSON_CONTENT_HEADER, StringComparison.OrdinalIgnoreCase)) - { - throw new RequestNotValidException(string.Format(Resources.UnsupportedHeaderValue, HeaderNames.Accept)); - } - - if (!context.HttpContext.Request.Headers.TryGetValue(PreferHeaderName, out var preferHeaderValue) || - preferHeaderValue.Count != 1 || - !string.Equals(preferHeaderValue[0], PreferHeaderExpectedValue, StringComparison.OrdinalIgnoreCase)) - { - throw new RequestNotValidException(string.Format(Resources.UnsupportedHeaderValue, PreferHeaderName)); - } - } - } -} diff --git a/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateExportRequestFilterAttribute.cs b/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateExportRequestFilterAttribute.cs new file mode 100644 index 0000000000..d51e4a52a1 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Api/Features/Filters/ValidateExportRequestFilterAttribute.cs @@ -0,0 +1,92 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using EnsureThat; +using Hl7.Fhir.Rest; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Health.Fhir.Core.Configs; +using Microsoft.Health.Fhir.Core.Exceptions; +using Microsoft.Health.Fhir.Core.Features; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.Health.Fhir.Api.Features.Filters +{ + /// + /// A filter that validates the headers and parameters present in the export request. + /// Short-circuits the pipeline if they are invalid. + /// + [AttributeUsage(AttributeTargets.Method)] + internal class ValidateExportRequestFilterAttribute : ActionFilterAttribute + { + private const string PreferHeaderName = "Prefer"; + private const string PreferHeaderExpectedValue = "respond-async"; + private readonly HashSet _supportedDestinationTypes; + + // For now we will use a hardcoded list to determine what query parameters we will + // allow for export requests. In the future, once we add export and other operations + // to the capabilities statement, we can derive this list from there (via the ConformanceProvider). + private readonly HashSet _supportedQueryParams; + + public ValidateExportRequestFilterAttribute(IOptions operationsConfig) + { + EnsureArg.IsNotNull(operationsConfig?.Value?.Export, nameof(operationsConfig)); + + _supportedDestinationTypes = new HashSet(operationsConfig.Value.Export.SupportedDestinations, StringComparer.Ordinal); + _supportedQueryParams = new HashSet(StringComparer.Ordinal) + { + KnownQueryParameterNames.DestinationType, + KnownQueryParameterNames.DestinationConnectionSettings, + }; + } + + public override void OnActionExecuting(ActionExecutingContext context) + { + EnsureArg.IsNotNull(context, nameof(context)); + + if (!context.HttpContext.Request.Headers.TryGetValue(HeaderNames.Accept, out var acceptHeaderValue) || + acceptHeaderValue.Count != 1 || + !string.Equals(acceptHeaderValue[0], ContentType.JSON_CONTENT_HEADER, StringComparison.OrdinalIgnoreCase)) + { + throw new RequestNotValidException(string.Format(Resources.UnsupportedHeaderValue, HeaderNames.Accept)); + } + + if (!context.HttpContext.Request.Headers.TryGetValue(PreferHeaderName, out var preferHeaderValue) || + preferHeaderValue.Count != 1 || + !string.Equals(preferHeaderValue[0], PreferHeaderExpectedValue, StringComparison.OrdinalIgnoreCase)) + { + throw new RequestNotValidException(string.Format(Resources.UnsupportedHeaderValue, PreferHeaderName)); + } + + IQueryCollection queryCollection = context.HttpContext.Request.Query; + + // Validate that the request does not contain query parameters that are not supported. + foreach (string paramName in queryCollection.Keys) + { + if (!_supportedQueryParams.Contains(paramName)) + { + throw new RequestNotValidException(string.Format(Resources.UnsupportedParameter, paramName)); + } + } + + if (!queryCollection.TryGetValue(KnownQueryParameterNames.DestinationType, out StringValues destinationTypeValue) + || string.IsNullOrWhiteSpace(destinationTypeValue) + || !_supportedDestinationTypes.Contains(destinationTypeValue)) + { + throw new RequestNotValidException(string.Format(Resources.UnsupportedParameterValue, KnownQueryParameterNames.DestinationType)); + } + + if (!queryCollection.TryGetValue(KnownQueryParameterNames.DestinationConnectionSettings, out StringValues destinationSettingValue) + || string.IsNullOrWhiteSpace(destinationSettingValue)) + { + throw new RequestNotValidException(string.Format(Resources.UnsupportedParameterValue, KnownQueryParameterNames.DestinationConnectionSettings)); + } + } + } +} diff --git a/src/Microsoft.Health.Fhir.Api/Modules/FhirModule.cs b/src/Microsoft.Health.Fhir.Api/Modules/FhirModule.cs index 087eb89a62..894429986c 100644 --- a/src/Microsoft.Health.Fhir.Api/Modules/FhirModule.cs +++ b/src/Microsoft.Health.Fhir.Api/Modules/FhirModule.cs @@ -77,6 +77,7 @@ public void Load(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // HTML // If UI is supported, then add the formatter so that the diff --git a/src/Microsoft.Health.Fhir.Api/Resources.Designer.cs b/src/Microsoft.Health.Fhir.Api/Resources.Designer.cs index 768097911a..ab50f58f60 100644 --- a/src/Microsoft.Health.Fhir.Api/Resources.Designer.cs +++ b/src/Microsoft.Health.Fhir.Api/Resources.Designer.cs @@ -267,6 +267,24 @@ public static string UnsupportedOperation { } } + /// + /// Looks up a localized string similar to The "{0}" parameter is not supported.. + /// + public static string UnsupportedParameter { + get { + return ResourceManager.GetString("UnsupportedParameter", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The supplied value for "{0}" paramter is invalid.. + /// + public static string UnsupportedParameterValue { + get { + return ResourceManager.GetString("UnsupportedParameterValue", resourceCulture); + } + } + /// /// Looks up a localized string similar to Requested operation does not support resource type: {0}.. /// diff --git a/src/Microsoft.Health.Fhir.Api/Resources.resx b/src/Microsoft.Health.Fhir.Api/Resources.resx index fcd0f2361b..d7a9370a18 100644 --- a/src/Microsoft.Health.Fhir.Api/Resources.resx +++ b/src/Microsoft.Health.Fhir.Api/Resources.resx @@ -192,6 +192,14 @@ The requested "{0}" operation is not supported. {0} is the operation name + + The "{0}" parameter is not supported. + {0} is the parameter name. + + + The supplied value for "{0}" paramter is invalid. + {0} is parameter name + Requested operation does not support resource type: {0}. {0} is the resource type. diff --git a/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Operations/Export/CreateExportRequestHandlerTests.cs b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Operations/Export/CreateExportRequestHandlerTests.cs index 08a1218bee..c65a641055 100644 --- a/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Operations/Export/CreateExportRequestHandlerTests.cs +++ b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/Operations/Export/CreateExportRequestHandlerTests.cs @@ -13,6 +13,8 @@ using Microsoft.Health.Fhir.Core.Features.Operations.Export; using Microsoft.Health.Fhir.Core.Features.Operations.Export.Models; using Microsoft.Health.Fhir.Core.Features.Persistence; +using Microsoft.Health.Fhir.Core.Features.SecretStore; +using Microsoft.Health.Fhir.Core.Messages.Export; using NSubstitute; using Xunit; @@ -21,13 +23,16 @@ namespace Microsoft.Health.Fhir.Core.UnitTests.Features.Operations.Export public class CreateExportRequestHandlerTests { private readonly IFhirOperationDataStore _fhirOperationDataStore = Substitute.For(); + private readonly ISecretStore _secretStore = Substitute.For(); private readonly IMediator _mediator; private const string RequestUrl = "https://localhost/$export/"; + private const string DestinationType = "destinationType"; + private const string ConnectionString = "destinationConnection"; public CreateExportRequestHandlerTests() { var collection = new ServiceCollection(); - collection.Add(x => new CreateExportRequestHandler(_fhirOperationDataStore)).Singleton().AsSelf().AsImplementedInterfaces(); + collection.Add(x => new CreateExportRequestHandler(_fhirOperationDataStore, _secretStore)).Singleton().AsSelf().AsImplementedInterfaces(); ServiceProvider provider = collection.BuildServiceProvider(); _mediator = new Mediator(type => provider.GetService(type)); @@ -36,12 +41,13 @@ public CreateExportRequestHandlerTests() [Fact] public async void GivenAFhirMediator_WhenSavingAnExportJobSucceeds_ThenResponseShouldBeSuccess() { - var exportOutcome = new ExportJobOutcome(new ExportJobRecord(new Uri(RequestUrl)), WeakETag.FromVersionId("eTag")); - _fhirOperationDataStore.CreateExportJobAsync(Arg.Any(), Arg.Any()).Returns(exportOutcome); + var eTag = WeakETag.FromVersionId("eTag"); + _fhirOperationDataStore.CreateExportJobAsync(Arg.Any(), Arg.Any()).Returns(x => new ExportJobOutcome((ExportJobRecord)x[0], eTag)); - var outcome = await _mediator.ExportAsync(new Uri(RequestUrl)); + CreateExportResponse createResponse = await _mediator.ExportAsync(new Uri(RequestUrl), DestinationType, ConnectionString); - Assert.NotEmpty(outcome.JobId); + Assert.NotEmpty(createResponse.JobId); + await _secretStore.ReceivedWithAnyArgs(1).SetSecretAsync(null, null); } } } diff --git a/src/Microsoft.Health.Fhir.Core/Configs/ExportJobConfiguration.cs b/src/Microsoft.Health.Fhir.Core/Configs/ExportJobConfiguration.cs index d4057245a5..c44ab8c1e5 100644 --- a/src/Microsoft.Health.Fhir.Core/Configs/ExportJobConfiguration.cs +++ b/src/Microsoft.Health.Fhir.Core/Configs/ExportJobConfiguration.cs @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------- using System; +using System.Collections.Generic; namespace Microsoft.Health.Fhir.Core.Configs { @@ -19,5 +20,10 @@ public class ExportJobConfiguration public TimeSpan JobHeartbeatTimeoutThreshold { get; set; } = TimeSpan.FromMinutes(10); public TimeSpan JobPollingFrequency { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// List of destinations that are supported for export operation. + /// + public HashSet SupportedDestinations { get; } = new HashSet(StringComparer.Ordinal); } } diff --git a/src/Microsoft.Health.Fhir.Core/Extensions/FhirMediatorExtensions.cs b/src/Microsoft.Health.Fhir.Core/Extensions/FhirMediatorExtensions.cs index 64c0e94145..cf8f216a87 100644 --- a/src/Microsoft.Health.Fhir.Core/Extensions/FhirMediatorExtensions.cs +++ b/src/Microsoft.Health.Fhir.Core/Extensions/FhirMediatorExtensions.cs @@ -122,12 +122,19 @@ public static async Task GetCapabilitiesAsync(this IMediato return response.CapabilityStatement; } - public static async Task ExportAsync(this IMediator mediator, Uri requestUri, CancellationToken cancellationToken = default) + public static async Task ExportAsync( + this IMediator mediator, + Uri requestUri, + string destinationType, + string destinationConnectionString, + CancellationToken cancellationToken = default) { EnsureArg.IsNotNull(mediator, nameof(mediator)); EnsureArg.IsNotNull(requestUri, nameof(requestUri)); + EnsureArg.IsNotNullOrWhiteSpace(destinationType, nameof(destinationType)); + EnsureArg.IsNotNullOrWhiteSpace(destinationConnectionString, nameof(destinationConnectionString)); - var request = new CreateExportRequest(requestUri); + var request = new CreateExportRequest(requestUri, destinationType, destinationConnectionString); var response = await mediator.Send(request, cancellationToken); return response; diff --git a/src/Microsoft.Health.Fhir.Core/Features/KnownQueryParameterNames.cs b/src/Microsoft.Health.Fhir.Core/Features/KnownQueryParameterNames.cs index 34526bccd5..7f89275119 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/KnownQueryParameterNames.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/KnownQueryParameterNames.cs @@ -27,5 +27,9 @@ public static class KnownQueryParameterNames public const string Count = HttpUtil.HISTORY_PARAM_COUNT; public const string Since = HttpUtil.HISTORY_PARAM_SINCE; + + public const string DestinationType = "_destinationType"; + + public const string DestinationConnectionSettings = "_destinationConnectionSettings"; } } diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/CreateExportRequestHandler.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/CreateExportRequestHandler.cs index 39cc391ff5..e1da3ed33b 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/CreateExportRequestHandler.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/CreateExportRequestHandler.cs @@ -8,6 +8,7 @@ using EnsureThat; using MediatR; using Microsoft.Health.Fhir.Core.Features.Operations.Export.Models; +using Microsoft.Health.Fhir.Core.Features.SecretStore; using Microsoft.Health.Fhir.Core.Messages.Export; namespace Microsoft.Health.Fhir.Core.Features.Operations.Export @@ -15,12 +16,15 @@ namespace Microsoft.Health.Fhir.Core.Features.Operations.Export public class CreateExportRequestHandler : IRequestHandler { private IFhirOperationDataStore _fhirOperationDataStore; + private ISecretStore _secretStore; - public CreateExportRequestHandler(IFhirOperationDataStore fhirOperationDataStore) + public CreateExportRequestHandler(IFhirOperationDataStore fhirOperationDataStore, ISecretStore secretStore) { EnsureArg.IsNotNull(fhirOperationDataStore, nameof(fhirOperationDataStore)); + EnsureArg.IsNotNull(secretStore, nameof(secretStore)); _fhirOperationDataStore = fhirOperationDataStore; + _secretStore = secretStore; } public async Task Handle(CreateExportRequest request, CancellationToken cancellationToken) @@ -31,7 +35,11 @@ public async Task Handle(CreateExportRequest request, Canc // and handle it accordingly. For now we just assume all export jobs are unique and create a new one. var jobRecord = new ExportJobRecord(request.RequestUri); - ExportJobOutcome result = await _fhirOperationDataStore.CreateExportJobAsync(jobRecord, cancellationToken); + + // Store the destination secret + SecretWrapper result = await _secretStore.SetSecretAsync(jobRecord.SecretName, request.DestinationInfo.ToJson()); + + ExportJobOutcome outcome = await _fhirOperationDataStore.CreateExportJobAsync(jobRecord, cancellationToken); // If job creation had failed we would have thrown an exception. return new CreateExportResponse(jobRecord.Id); diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/Models/DestinationInfo.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/Models/DestinationInfo.cs new file mode 100644 index 0000000000..52d2f17252 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/Models/DestinationInfo.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using EnsureThat; +using Newtonsoft.Json; + +namespace Microsoft.Health.Fhir.Core.Features.Operations.Export.Models +{ + public class DestinationInfo + { + public DestinationInfo(string destinationType, string destinationConnectionString) + { + EnsureArg.IsNotNullOrWhiteSpace(destinationType, nameof(destinationType)); + EnsureArg.IsNotNullOrWhiteSpace(destinationConnectionString, nameof(destinationConnectionString)); + + DestinationType = destinationType; + DestinationConnectionString = destinationConnectionString; + } + + [JsonConstructor] + protected DestinationInfo() + { + } + + [JsonProperty("destinationType")] + public string DestinationType { get; private set; } + + [JsonProperty("destinationConnectionString")] + public string DestinationConnectionString { get; private set; } + + public string ToJson() + { + return JsonConvert.SerializeObject(this); + } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/Models/ExportJobRecord.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/Models/ExportJobRecord.cs index 9163bbb342..d6a579c690 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/Models/ExportJobRecord.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/Export/Models/ExportJobRecord.cs @@ -15,6 +15,8 @@ namespace Microsoft.Health.Fhir.Core.Features.Operations.Export.Models /// public class ExportJobRecord { + private const string SecretPrefix = "Export-Destination-"; + public ExportJobRecord(Uri exportRequestUri) { EnsureArg.IsNotNull(exportRequestUri, nameof(exportRequestUri)); @@ -26,6 +28,7 @@ public ExportJobRecord(Uri exportRequestUri) Status = OperationStatus.Queued; Id = Guid.NewGuid().ToString(); QueuedTime = DateTimeOffset.UtcNow; + SecretName = SecretPrefix + Id; } [JsonConstructor] @@ -36,6 +39,9 @@ protected ExportJobRecord() [JsonProperty(JobRecordProperties.Request)] public Uri RequestUri { get; private set; } + [JsonProperty(JobRecordProperties.SecretName)] + public string SecretName { get; private set; } + [JsonProperty(JobRecordProperties.Id)] public string Id { get; private set; } diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/JobRecordProperties.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/JobRecordProperties.cs index 2749a9e546..83b39582e8 100644 --- a/src/Microsoft.Health.Fhir.Core/Features/Operations/JobRecordProperties.cs +++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/JobRecordProperties.cs @@ -15,6 +15,8 @@ public static class JobRecordProperties public const string Id = "id"; + public const string SecretName = "secretName"; + public const string Hash = "hash"; public const string Status = "status"; diff --git a/src/Microsoft.Health.Fhir.Core/Features/SecretStore/ISecretStore.cs b/src/Microsoft.Health.Fhir.Core/Features/SecretStore/ISecretStore.cs new file mode 100644 index 0000000000..f554f2fa62 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/SecretStore/ISecretStore.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System.Threading.Tasks; + +namespace Microsoft.Health.Fhir.Core.Features.SecretStore +{ + public interface ISecretStore + { + Task GetSecretAsync(string secretName); + + Task SetSecretAsync(string secretName, string secretValue); + + Task DeleteSecretAsync(string secretName); + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Features/SecretStore/SecretWrapper.cs b/src/Microsoft.Health.Fhir.Core/Features/SecretStore/SecretWrapper.cs new file mode 100644 index 0000000000..4f79838f37 --- /dev/null +++ b/src/Microsoft.Health.Fhir.Core/Features/SecretStore/SecretWrapper.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using EnsureThat; + +namespace Microsoft.Health.Fhir.Core.Features.SecretStore +{ + /// + /// Class that acts as a wrapper around the data that needs to be stored in . + /// Currently only contains the secret name and value, but can be expanded depending on usage. + /// + public class SecretWrapper + { + public SecretWrapper(string secretName, string secretValue) + { + EnsureArg.IsNotNullOrWhiteSpace(secretName, nameof(secretName)); + EnsureArg.IsNotNullOrWhiteSpace(secretValue, nameof(secretValue)); + + SecretName = secretName; + SecretValue = secretValue; + } + + public string SecretName { get; } + + public string SecretValue { get; } + } +} diff --git a/src/Microsoft.Health.Fhir.Core/Messages/Export/CreateExportRequest.cs b/src/Microsoft.Health.Fhir.Core/Messages/Export/CreateExportRequest.cs index d540273320..1202df6775 100644 --- a/src/Microsoft.Health.Fhir.Core/Messages/Export/CreateExportRequest.cs +++ b/src/Microsoft.Health.Fhir.Core/Messages/Export/CreateExportRequest.cs @@ -6,6 +6,7 @@ using System; using EnsureThat; using MediatR; +using Microsoft.Health.Fhir.Core.Features.Operations.Export.Models; namespace Microsoft.Health.Fhir.Core.Messages.Export { @@ -18,6 +19,18 @@ public CreateExportRequest(Uri requestUri) RequestUri = requestUri; } + public CreateExportRequest(Uri requestUri, string destinationType, string destinationConnectionString) + { + EnsureArg.IsNotNull(requestUri, nameof(requestUri)); + EnsureArg.IsNotNullOrWhiteSpace(destinationType, nameof(destinationType)); + EnsureArg.IsNotNullOrWhiteSpace(destinationConnectionString, nameof(destinationConnectionString)); + + RequestUri = requestUri; + DestinationInfo = new DestinationInfo(destinationType, destinationConnectionString); + } + public Uri RequestUri { get; } + + public DestinationInfo DestinationInfo { get; } } } diff --git a/src/Microsoft.Health.Fhir.KeyVault/Configs/KeyVaultConfiguration.cs b/src/Microsoft.Health.Fhir.KeyVault/Configs/KeyVaultConfiguration.cs new file mode 100644 index 0000000000..9f0e844336 --- /dev/null +++ b/src/Microsoft.Health.Fhir.KeyVault/Configs/KeyVaultConfiguration.cs @@ -0,0 +1,12 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +namespace Microsoft.Health.Fhir.KeyVault.Configs +{ + public class KeyVaultConfiguration + { + public string Endpoint { get; set; } + } +} diff --git a/src/Microsoft.Health.Fhir.KeyVault/InMemorySecretStore.cs b/src/Microsoft.Health.Fhir.KeyVault/InMemorySecretStore.cs new file mode 100644 index 0000000000..dfa4ee7899 --- /dev/null +++ b/src/Microsoft.Health.Fhir.KeyVault/InMemorySecretStore.cs @@ -0,0 +1,58 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using EnsureThat; +using Microsoft.Health.Fhir.Core.Features.SecretStore; + +namespace Microsoft.Health.Fhir.KeyVault +{ + /// + /// Implementation of to be used for local tests and deployments. + /// + public class InMemorySecretStore : ISecretStore + { + private Dictionary _secrets = new Dictionary(StringComparer.Ordinal); + + public Task GetSecretAsync(string secretName) + { + EnsureArg.IsNotNullOrWhiteSpace(secretName); + + SecretWrapper wrapper = null; + if (_secrets.TryGetValue(secretName, out string secretValue)) + { + wrapper = new SecretWrapper(secretName, secretValue); + } + + return Task.FromResult(wrapper); + } + + public Task SetSecretAsync(string secretName, string secretValue) + { + EnsureArg.IsNotNullOrWhiteSpace(secretName); + EnsureArg.IsNotNullOrWhiteSpace(secretValue); + + _secrets.Add(secretName, secretValue); + + return Task.FromResult(new SecretWrapper(secretName, secretValue)); + } + + public Task DeleteSecretAsync(string secretName) + { + EnsureArg.IsNotNullOrWhiteSpace(secretName); + + SecretWrapper wrapper = null; + if (_secrets.TryGetValue(secretName, out string secretValue)) + { + wrapper = new SecretWrapper(secretName, secretValue); + _secrets.Remove(secretName); + } + + return Task.FromResult(wrapper); + } + } +} diff --git a/src/Microsoft.Health.Fhir.KeyVault/KeyVaultSecretStore.cs b/src/Microsoft.Health.Fhir.KeyVault/KeyVaultSecretStore.cs new file mode 100644 index 0000000000..964095f238 --- /dev/null +++ b/src/Microsoft.Health.Fhir.KeyVault/KeyVaultSecretStore.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using System.Threading.Tasks; +using EnsureThat; +using Microsoft.Azure.KeyVault; +using Microsoft.Azure.KeyVault.Models; +using Microsoft.Health.Fhir.Core.Features.SecretStore; + +namespace Microsoft.Health.Fhir.KeyVault +{ + /// + /// Implementation of that uses Azure Key Vault underneath. + /// + public class KeyVaultSecretStore : ISecretStore + { + private IKeyVaultClient _keyVaultClient; + private Uri _keyVaultUri; + + public KeyVaultSecretStore(IKeyVaultClient keyVaultClient, Uri keyVaultUri) + { + EnsureArg.IsNotNull(keyVaultClient, nameof(keyVaultClient)); + EnsureArg.IsNotNull(keyVaultUri, nameof(keyVaultUri)); + + _keyVaultClient = keyVaultClient; + _keyVaultUri = keyVaultUri; + } + + public async Task GetSecretAsync(string secretName) + { + EnsureArg.IsNotNullOrWhiteSpace(secretName); + + SecretBundle result = await _keyVaultClient.GetSecretAsync(_keyVaultUri.AbsoluteUri, secretName); + + return new SecretWrapper(result.Id, result.Value); + } + + public async Task SetSecretAsync(string secretName, string secretValue) + { + EnsureArg.IsNotNullOrWhiteSpace(secretName, nameof(secretName)); + EnsureArg.IsNotNullOrWhiteSpace(secretValue, nameof(secretValue)); + + SecretBundle result = await _keyVaultClient.SetSecretAsync(_keyVaultUri.AbsoluteUri, secretName, secretValue); + + return new SecretWrapper(result.Id, result.Value); + } + + public async Task DeleteSecretAsync(string secretName) + { + EnsureArg.IsNotNullOrWhiteSpace(secretName, nameof(secretName)); + + SecretBundle result = await _keyVaultClient.DeleteSecretAsync(_keyVaultUri.AbsoluteUri, secretName); + + return new SecretWrapper(result.Id, result.Value); + } + } +} diff --git a/src/Microsoft.Health.Fhir.KeyVault/Microsoft.Health.Fhir.KeyVault.csproj b/src/Microsoft.Health.Fhir.KeyVault/Microsoft.Health.Fhir.KeyVault.csproj new file mode 100644 index 0000000000..edb49bfd10 --- /dev/null +++ b/src/Microsoft.Health.Fhir.KeyVault/Microsoft.Health.Fhir.KeyVault.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp2.2 + ..\..\CustomAnalysisRules.ruleset + + + + + + + + + + + + + + + + diff --git a/src/Microsoft.Health.Fhir.KeyVault/Registration/FhirServerBuilderSecretStoreRegistrationExtensions.cs b/src/Microsoft.Health.Fhir.KeyVault/Registration/FhirServerBuilderSecretStoreRegistrationExtensions.cs new file mode 100644 index 0000000000..a2a52f4993 --- /dev/null +++ b/src/Microsoft.Health.Fhir.KeyVault/Registration/FhirServerBuilderSecretStoreRegistrationExtensions.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information. +// ------------------------------------------------------------------------------------------------- + +using System; +using EnsureThat; +using Microsoft.Azure.KeyVault; +using Microsoft.Azure.Services.AppAuthentication; +using Microsoft.Extensions.Configuration; +using Microsoft.Health.Extensions.DependencyInjection; +using Microsoft.Health.Fhir.Core.Features.SecretStore; +using Microsoft.Health.Fhir.Core.Registration; +using Microsoft.Health.Fhir.KeyVault; +using Microsoft.Health.Fhir.KeyVault.Configs; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class FhirServerBuilderSecretStoreRegistrationExtensions + { + private const string KeyVaultConfigurationName = "KeyVault"; + + public static IFhirServerBuilder AddKeyVaultSecretStore(this IFhirServerBuilder fhirServerBuilder, IConfiguration configuration) + { + EnsureArg.IsNotNull(fhirServerBuilder, nameof(fhirServerBuilder)); + EnsureArg.IsNotNull(configuration, nameof(configuration)); + + // Get the KeyVault endpoint mentioned in the config. It is not necessary the KeyVault config + // section is present (depending on how we choose to implement ISecretStore). But even if it is + // not present, GetSection will return an empty IConfigurationSection. And we will end up creating + // an InMemoryKeyVaultSecretStore in that scenario also. + var keyVaultConfig = new KeyVaultConfiguration(); + configuration.GetSection(KeyVaultConfigurationName).Bind(keyVaultConfig); + + if (string.IsNullOrWhiteSpace(keyVaultConfig.Endpoint)) + { + fhirServerBuilder.Services.Add() + .Singleton() + .AsService(); + } + else + { + fhirServerBuilder.Services.Add((sp) => + { + var tokenProvider = new AzureServiceTokenProvider(); + var kvClient = new KeyVaultClient(new KeyVaultClient.AuthenticationCallback(tokenProvider.KeyVaultTokenCallback)); + + return new KeyVaultSecretStore(kvClient, new Uri(keyVaultConfig.Endpoint)); + }) + .Singleton() + .AsService(); + } + + return fhirServerBuilder; + } + } +} diff --git a/src/Microsoft.Health.Fhir.Web/Microsoft.Health.Fhir.Web.csproj b/src/Microsoft.Health.Fhir.Web/Microsoft.Health.Fhir.Web.csproj index b643f3ddb1..31a767e95c 100644 --- a/src/Microsoft.Health.Fhir.Web/Microsoft.Health.Fhir.Web.csproj +++ b/src/Microsoft.Health.Fhir.Web/Microsoft.Health.Fhir.Web.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Microsoft.Health.Fhir.Web/Startup.cs b/src/Microsoft.Health.Fhir.Web/Startup.cs index a26d9e0254..e4eba09ad1 100644 --- a/src/Microsoft.Health.Fhir.Web/Startup.cs +++ b/src/Microsoft.Health.Fhir.Web/Startup.cs @@ -26,7 +26,8 @@ public virtual void ConfigureServices(IServiceCollection services) services.AddFhirServer(Configuration) .AddExportWorker() - .AddCosmosDb(Configuration); + .AddCosmosDb(Configuration) + .AddKeyVaultSecretStore(Configuration); AddApplicationInsightsTelemetry(services); } diff --git a/src/Microsoft.Health.Fhir.Web/appsettings.json b/src/Microsoft.Health.Fhir.Web/appsettings.json index 0a83f9aafd..0a46507f79 100644 --- a/src/Microsoft.Health.Fhir.Web/appsettings.json +++ b/src/Microsoft.Health.Fhir.Web/appsettings.json @@ -37,7 +37,8 @@ "Enabled": false, "MaximumNumberOfConcurrentJobsAllowed": 1, "JobHeartbeatTimeoutThreshold": "00:10:00", - "JobPollingFrequency": "00:00:10" + "JobPollingFrequency": "00:00:10", + "SupportedDestinations": [ "" ] } } }, diff --git a/test/Configuration/exporttestconfiguration.json b/test/Configuration/exporttestconfiguration.json index de6cfa824c..d5eb9ce646 100644 --- a/test/Configuration/exporttestconfiguration.json +++ b/test/Configuration/exporttestconfiguration.json @@ -2,7 +2,8 @@ "FhirServer": { "Operations": { "Export": { - "Enabled": true + "Enabled": true, + "SupportedDestinations": [ "AzureBlockBlob" ] } } } diff --git a/test/Microsoft.Health.Fhir.Tests.E2E/Common/FhirClient.cs b/test/Microsoft.Health.Fhir.Tests.E2E/Common/FhirClient.cs index eb77fa28f8..b416ba100b 100644 --- a/test/Microsoft.Health.Fhir.Tests.E2E/Common/FhirClient.cs +++ b/test/Microsoft.Health.Fhir.Tests.E2E/Common/FhirClient.cs @@ -14,6 +14,7 @@ using Hl7.Fhir.Model; using Hl7.Fhir.Rest; using Hl7.Fhir.Serialization; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Health.Fhir.Core.Features.Persistence; using Microsoft.Health.Fhir.Core.Features.Security; using Microsoft.Net.Http.Headers; @@ -243,9 +244,10 @@ public async Task> SearchPostAsync(string resourceType, par return await CreateResponseAsync(response); } - public async Task ExportAsync() + public async Task ExportAsync(Dictionary queryParams) { - var message = new HttpRequestMessage(HttpMethod.Get, "$export"); + string path = QueryHelpers.AddQueryString("$export", queryParams); + var message = new HttpRequestMessage(HttpMethod.Get, path); message.Headers.Add("Accept", "application/fhir+json"); message.Headers.Add("Prefer", "respond-async"); diff --git a/test/Microsoft.Health.Fhir.Tests.E2E/Rest/BasicAuthTests.cs b/test/Microsoft.Health.Fhir.Tests.E2E/Rest/BasicAuthTests.cs index 30c784d334..9442f75046 100644 --- a/test/Microsoft.Health.Fhir.Tests.E2E/Rest/BasicAuthTests.cs +++ b/test/Microsoft.Health.Fhir.Tests.E2E/Rest/BasicAuthTests.cs @@ -4,6 +4,7 @@ // ------------------------------------------------------------------------------------------------- using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Threading.Tasks; @@ -28,6 +29,11 @@ public class BasicAuthTests : IClassFixture> private const string ForbiddenMessage = "Forbidden: Authorization failed."; private const string UnauthorizedMessage = "Unauthorized: Authentication failed."; private const string Invalidtoken = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImNmNWRmMGExNzY5ZWIzZTFkOGRiNWIxMGZiOWY3ZTk0IiwidHlwIjoiSldUIn0.eyJuYmYiOjE1NDQ2ODQ1NzEsImV4cCI6MTU0NDY4ODE3MSwiaXNzIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6NDQzNDgiLCJhdWQiOlsiaHR0cHM6Ly9sb2NhbGhvc3Q6NDQzNDgvcmVzb3VyY2VzIiwiZmhpci1haSJdLCJjbGllbnRfaWQiOiJzZXJ2aWNlY2xpZW50Iiwicm9sZXMiOiJhZG1pbiIsImFwcGlkIjoic2VydmljZWNsaWVudCIsInNjb3BlIjpbImZoaXItYWkiXX0.SKSvy6Jxzwsv1ZSi0PO4Pdq6QDZ6mBJIRxUPgoPlz2JpiB6GMXu5u0n1IpS6zOXihGkGhegjtcqj-6TKE6Ou5uhQ0VTnmf-NxcYKFl48aDihcGem--qa2V8GC7na549Ctj1PLXoYUbovV4LB27Kj3X83sZVnWdHqg_G0AKo4xm7hr23VUvJ1D73lEcYaGd5K9GXHNgUrJO5v288y0uCXZ5ByNDJ-K6Xi7_68dLdshlIiHaeIBuC3rhchSf2hdglkQgOyo4g4gT_HfKjwdrrpGzepNXOPQEwtUs_o2uriXAd7FfbL_Q4ORiDWPXkmwBXqo7uUfg-2SnT3DApc3PuA0"; + private readonly Dictionary _exportQueryParams = new Dictionary() + { + { "_destinationType", "AzureBlockBlob" }, + { "_destinationConnectionSettings", "connectionString" }, + }; public BasicAuthTests(HttpIntegrationTestFixture fixture) { @@ -184,7 +190,7 @@ public async Task WhenExportResources_GivenAUserWithNoExportPermissions_TheServe { await Client.RunAsUser(TestUsers.ReadOnlyUser, TestApplications.NativeClient); - FhirException fhirException = await Assert.ThrowsAsync(async () => await Client.ExportAsync()); + FhirException fhirException = await Assert.ThrowsAsync(async () => await Client.ExportAsync(_exportQueryParams)); Assert.Equal(ForbiddenMessage, fhirException.Message); Assert.Equal(HttpStatusCode.Forbidden, fhirException.StatusCode); } @@ -195,7 +201,7 @@ public async Task WhenExportResources_GivenAUserWithExportPermissions_TheServerS { await Client.RunAsUser(TestUsers.ExportUser, TestApplications.NativeClient); - await Client.ExportAsync(); + await Client.ExportAsync(_exportQueryParams); } } } diff --git a/test/Microsoft.Health.Fhir.Tests.E2E/Rest/ExportTests.cs b/test/Microsoft.Health.Fhir.Tests.E2E/Rest/ExportTests.cs index 693282d6cd..bbe7c28091 100644 --- a/test/Microsoft.Health.Fhir.Tests.E2E/Rest/ExportTests.cs +++ b/test/Microsoft.Health.Fhir.Tests.E2E/Rest/ExportTests.cs @@ -4,10 +4,12 @@ // ------------------------------------------------------------------------------------------------- using System; +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Threading.Tasks; using Hl7.Fhir.Rest; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Health.Fhir.Core.Features.Operations; using Microsoft.Health.Fhir.Tests.Common.FixtureParameters; using Microsoft.Health.Fhir.Web; @@ -21,6 +23,9 @@ public class ExportTests : IClassFixture> { private readonly HttpClient _client; private const string PreferHeaderName = "Prefer"; + private const string DestinationTypeQueryParamName = "_destinationType"; + private const string DestinationConnectionQueryParamName = "_destinationConnectionSettings"; + private const string SupportedDestinationType = "AzureBlockBlob"; public ExportTests(HttpIntegrationTestFixture fixture) { @@ -30,7 +35,7 @@ public ExportTests(HttpIntegrationTestFixture fixture) [Theory] [InlineData("Patient/$export")] [InlineData("Group/id/$export")] - public async Task GivenExportIsEnabled_WhenRequestingExportWithCorrectHeaders_ThenServerShouldReturnNotImplemented(string path) + public async Task GivenExportIsEnabled_WhenRequestingExportWithCorrectHeadersAndParams_ThenServerShouldReturnNotImplemented(string path) { HttpRequestMessage request = GenerateExportRequest(path); @@ -40,25 +45,66 @@ public async Task GivenExportIsEnabled_WhenRequestingExportWithCorrectHeaders_Th } [Fact] - public async Task GivenExportIsEnabled_WhenRequestingExportWithCorrectHeaders_ThenServerShouldReturnAcceptedAndNonEmptyContentLocationHeader() + public async Task GivenExportIsEnabled_WhenRequestingExportWithCorrectHeadersAndParams_ThenServerShouldReturnAcceptedAndNonEmptyContentLocationHeader() { - string path = "$export"; - HttpRequestMessage request = GenerateExportRequest(path); + HttpRequestMessage request = GenerateExportRequest(); HttpResponseMessage response = await _client.SendAsync(request); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); var uri = response.Content.Headers.ContentLocation; - Assert.False(string.IsNullOrEmpty(uri.ToString())); + Assert.False(string.IsNullOrWhiteSpace(uri.ToString())); + } + + [Fact] + public async Task GivenExportIsEnabled_WhenRequestingExportWithMissingDestinationConnectionParam_ThenServerShouldReturnBadRequest() + { + var queryParam = new Dictionary() + { + { DestinationTypeQueryParamName, SupportedDestinationType }, + }; + HttpRequestMessage request = GenerateExportRequest(queryParams: queryParam); + + HttpResponseMessage response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GivenExportIsEnabled_WhenRequestingExportWithMissingDestinationTypeParam_ThenServerShouldReturnBadRequest() + { + var queryParam = new Dictionary() + { + { DestinationConnectionQueryParamName, "destinationConnection" }, + }; + HttpRequestMessage request = GenerateExportRequest(queryParams: queryParam); + + HttpResponseMessage response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GivenExportIsEnabled_WhenRequestingExportWithUnsupportedDestinationTypeParam_ThenServerShouldReturnBadRequest() + { + var queryParam = new Dictionary() + { + { DestinationTypeQueryParamName, "unsupportedDestinationType" }, + { DestinationConnectionQueryParamName, "destinationConnection" }, + }; + HttpRequestMessage request = GenerateExportRequest(queryParams: queryParam); + + HttpResponseMessage response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task GivenExportJobExists_WhenRequestingExportStatus_ThenServerShouldReturnAccepted() { // Sending an export request so that a job record will be created in the system. - string path = "$export"; - HttpRequestMessage request = GenerateExportRequest(path); + HttpRequestMessage request = GenerateExportRequest(); HttpResponseMessage response = await _client.SendAsync(request); @@ -122,17 +168,29 @@ public async Task GivenExportIsEnabled_WhenRequestingExportWithInvalidPreferHead private HttpRequestMessage GenerateExportRequest( string path = "$export", string acceptHeader = ContentType.JSON_CONTENT_HEADER, - string preferHeader = "respond-async") + string preferHeader = "respond-async", + Dictionary queryParams = null) { var request = new HttpRequestMessage { Method = HttpMethod.Get, }; - request.RequestUri = new Uri(_client.BaseAddress, path); request.Headers.Add(HeaderNames.Accept, acceptHeader); request.Headers.Add(PreferHeaderName, preferHeader); + if (queryParams == null) + { + queryParams = new Dictionary() + { + { DestinationTypeQueryParamName, SupportedDestinationType }, + { DestinationConnectionQueryParamName, "connectionString" }, + }; + } + + path = QueryHelpers.AddQueryString(path, queryParams); + request.RequestUri = new Uri(_client.BaseAddress, path); + return request; } } diff --git a/test/Microsoft.Health.Fhir.Tests.Integration/Features/Operations/FhirOperationDataStoreTests.cs b/test/Microsoft.Health.Fhir.Tests.Integration/Features/Operations/FhirOperationDataStoreTests.cs index 59b3d579d6..4b66819fd8 100644 --- a/test/Microsoft.Health.Fhir.Tests.Integration/Features/Operations/FhirOperationDataStoreTests.cs +++ b/test/Microsoft.Health.Fhir.Tests.Integration/Features/Operations/FhirOperationDataStoreTests.cs @@ -15,7 +15,9 @@ using Microsoft.Health.Fhir.Core.Features.Operations; using Microsoft.Health.Fhir.Core.Features.Operations.Export; using Microsoft.Health.Fhir.Core.Features.Operations.Export.Models; +using Microsoft.Health.Fhir.Core.Features.SecretStore; using Microsoft.Health.Fhir.Core.Messages.Export; +using Microsoft.Health.Fhir.KeyVault; using Microsoft.Health.Fhir.Tests.Common.FixtureParameters; using Microsoft.Health.Fhir.Tests.Integration.Persistence; using Xunit; @@ -27,16 +29,18 @@ public class FhirOperationDataStoreTests : IClassFixture), new CreateExportRequestHandler(_dataStore)); + collection.AddSingleton(typeof(IRequestHandler), new CreateExportRequestHandler(_dataStore, _secretStore)); collection.AddSingleton(typeof(IRequestHandler), new GetExportRequestHandler(_dataStore)); ServiceProvider services = collection.BuildServiceProvider(); @@ -59,7 +63,7 @@ public async Task GivenANewExportRequest_WhenCreatingExportJob_ThenGetsJobCreate { var requestUri = new Uri("https://localhost/$export"); - CreateExportResponse result = await _mediator.ExportAsync(requestUri); + CreateExportResponse result = await _mediator.ExportAsync(requestUri, "destinationType", "connectionString"); Assert.NotNull(result); Assert.NotEmpty(result.JobId); @@ -69,7 +73,7 @@ public async Task GivenANewExportRequest_WhenCreatingExportJob_ThenGetsJobCreate public async Task GivenExportJobThatExists_WhenGettingExportStatus_ThenGetsHttpStatuscodeAccepted() { var requestUri = new Uri("https://localhost/$export"); - var result = await _mediator.ExportAsync(requestUri); + var result = await _mediator.ExportAsync(requestUri, "destinationType", "connectionString"); requestUri = new Uri("https://localhost/_operation/export/" + result.JobId); var exportStatus = await _mediator.GetExportStatusAsync(requestUri, result.JobId); diff --git a/test/Microsoft.Health.Fhir.Tests.Integration/Microsoft.Health.Fhir.Tests.Integration.csproj b/test/Microsoft.Health.Fhir.Tests.Integration/Microsoft.Health.Fhir.Tests.Integration.csproj index 1857b9f88a..9ec8c77ecd 100644 --- a/test/Microsoft.Health.Fhir.Tests.Integration/Microsoft.Health.Fhir.Tests.Integration.csproj +++ b/test/Microsoft.Health.Fhir.Tests.Integration/Microsoft.Health.Fhir.Tests.Integration.csproj @@ -17,6 +17,7 @@ +