Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dan.Common/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public static class Constants
public const string SafeHttpClient = "SafeHttpClient";

public const string SafeHttpClientPolicy = "SafeHttpClientPolicy";

public const string PluginHttpClient = "PluginHttpClient";

public const string LANGUAGE_CODE_NORWEGIAN_NB = "no-nb";

Expand Down
10 changes: 10 additions & 0 deletions Dan.Common/Enums/ErrorCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ public enum ErrorCode
/// An invalid JMES Path expression was supplied
/// </summary>
InvalidJmesPathExpressionException = 1027,

/// <summary>
/// Exception from upstream
/// </summary>
UpstreamException = 1028,

/// <summary>
/// Quota exceeded
/// </summary>
QuotaExceededException = 1029,

////
//// Error codes for evidence source implementations (3xxx)
Expand Down
12 changes: 11 additions & 1 deletion Dan.Common/Extensions/HostBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Reflection;
using Azure.Core.Serialization;
using Dan.Common.Handlers;
using Dan.Common.Interfaces;
using Dan.Common.Services;
using Microsoft.Azure.Functions.Worker;
Expand Down Expand Up @@ -51,7 +52,7 @@ public static IHostBuilder ConfigureDanPluginDefaults(this IHostBuilder builder)
services.AddHttpClient();
services.AddApplicationInsightsTelemetryWorkerService();
services.ConfigureFunctionsApplicationInsights();

// You will need extra configuration because AI will only log per default Warning (default AI configuration). As this is a provider-specific
// setting, it will override all non-provider (Logging:LogLevel)-based configurations.
// https://github.com/microsoft/ApplicationInsights-dotnet/blob/main/NETCORE/src/Shared/Extensions/ApplicationInsightsExtensions.cs#L427
Expand Down Expand Up @@ -98,11 +99,20 @@ public static IHostBuilder ConfigureDanPluginDefaults(this IHostBuilder builder)
services.AddHttpClient(Constants.SafeHttpClient,
client => { client.Timeout = TimeSpan.FromSeconds(httpClientTimeoutSeconds); })
.AddPolicyHandlerFromRegistry(Constants.SafeHttpClientPolicy);

// Using safehttpclient settings, but will add auth handler for talking with plugins
services.AddHttpClient(Constants.PluginHttpClient,
client => { client.Timeout = TimeSpan.FromSeconds(httpClientTimeoutSeconds); })
.AddPolicyHandlerFromRegistry(Constants.SafeHttpClientPolicy)
.AddHttpMessageHandler<PluginAuthorizationMessageHandler>();

// Add a common service to fetch information from the CCR ("Enhetsregisteret"). Using a default API-client (which just wraps a HttpClient), which
// calls a proxy in Core by default. Core uses the same service, but a different IEntityRegistryApiClientService which utilizes a distributed cache.
services.AddSingleton<IEntityRegistryService, EntityRegistryService>();
services.AddSingleton<IEntityRegistryApiClientService, DefaultEntityRegistryApiClientService>();

services.AddTransient<PluginAuthorizationMessageHandler>();
services.AddTransient<IDanPluginClientService, DanPluginClientService>();

// Try to add the first IEvidenceSourceMetadata implementation we can find in the entry assembly
var evidenceSourceMetadataServiceType = typeof(IEvidenceSourceMetadata);
Expand Down
45 changes: 45 additions & 0 deletions Dan.Common/Handlers/PluginAuthorizationMessageHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Azure.Core;
using Azure.Identity;
using Microsoft.Extensions.Configuration;

namespace Dan.Common.Handlers;

// Source: https://stackoverflow.com/questions/71187362/azure-function-to-azure-function-request-using-defaultazurecredential-and-httpcl
/// <summary>
/// Adds bearer token with azure credentials for scope
/// </summary>
public class PluginAuthorizationMessageHandler : DelegatingHandler
{
private readonly IConfiguration _configuration;
private readonly DefaultAzureCredential _credentials;

/// <summary>
/// Base constructor for azure credential handler
/// </summary>
public PluginAuthorizationMessageHandler(IConfiguration configuration)
{
_configuration = configuration;
_credentials = new DefaultAzureCredential();
}

/// <summary>
/// Adds bearer token to http request
/// </summary>
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Usually just need one scope, api://{{guid}}/.default, but supports more for special cases
var scopes = _configuration.GetSection("PluginScopes").Value?.Split(",");

// No scopes? We just send. If something requires a scope, then add that to app settings
if (scopes is not { Length: > 0 })
{
return await base.SendAsync(request, cancellationToken);
}

var tokenRequestContext = new TokenRequestContext(scopes);
var tokenResult = await _credentials.GetTokenAsync(tokenRequestContext, cancellationToken);
var authorizationHeader = new AuthenticationHeaderValue("Bearer", tokenResult.Token);
request.Headers.Authorization = authorizationHeader;
return await base.SendAsync(request, cancellationToken);
}
}
132 changes: 132 additions & 0 deletions Dan.Common/Services/DanPluginClientService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Text;
using Dan.Common.Exceptions;

namespace Dan.Common.Services;

/// <summary>
/// Service for calling DAN plugins
/// </summary>
public interface IDanPluginClientService
{
/// <summary>
/// Get dataset value
/// </summary>
public Task<T?> GetPluginDataSetAsync<T>(EvidenceHarvesterRequest request, string code, string env, bool isDefaultJson, string url = "", string source = "") where T: new();
}

/// <summary>
/// Service for calling DAN plugins
/// </summary>
public class DanPluginClientService : IDanPluginClientService
{
private readonly HttpClient _httpClient;
private const string MetadataDev = "https://dev-api.data.altinn.no/v1/public/metadata/evidencecodes";
private const string MetadataTest = "https://test-api.data.altinn.no/v1/public/metadata/evidencecodes";
private const string MetadataProd = "https://api.data.altinn.no/v1/public/metadata/evidencecodes";
private const string PluginDev = "https://func-es{0}-test-dev.azurewebsites.net/api/{1}?code={2}";
private const string PluginTest = "https://func-es{0}-prod-prod-staging.azurewebsites.net/api/{1}?code={2}";
private const string PluginProd = "https://func-es{0}-prod-prod.azurewebsites.net/api/{1}?code={2}";

/// <summary>
/// Sets up service with a safe http client built from provided factory
/// </summary>
public DanPluginClientService(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient(Constants.PluginHttpClient);
}

/// <summary>
/// Get dataset value
/// </summary>
public async Task<T?> GetPluginDataSetAsync<T>(EvidenceHarvesterRequest request, string code, string env, bool isDefaultJson, string pluginUrl = "", string source = "") where T : new()
{
HttpResponseMessage? response = default;
var result = default(T);
try
{
var url = env switch
{
"dev" => MetadataDev,
"test" => MetadataTest,
"prod" => MetadataProd,
_ => throw new ArgumentOutOfRangeException(nameof(env), "env must be dev, test or prod"),
};

var metadataResponse = await _httpClient.GetAsync(url);

EvidenceCode? evidenceCode;
if (metadataResponse.IsSuccessStatusCode)
{
var evidenceCodes = JsonConvert.DeserializeObject<List<EvidenceCode>>(await metadataResponse.Content.ReadAsStringAsync());
evidenceCode = evidenceCodes?.FirstOrDefault(x => x.EvidenceCodeName == request.EvidenceCodeName);
if (evidenceCode == null)
{
throw new EvidenceSourceTransientException(1, "Dataset not found");
}
if (!string.IsNullOrEmpty(evidenceCode.RequiredScopes))
{
throw new ArgumentOutOfRangeException(nameof(request.MPToken), $"Dataset requires maskinporten token which was not supplied");
}
}
else
{
throw new EvidenceSourceTransientException(1, "Dataset not found");
}
pluginUrl = string.IsNullOrEmpty(pluginUrl) ? GetPluginUrl(env, source, evidenceCode.EvidenceCodeName, code) : pluginUrl;
response = await _httpClient.PostAsync(pluginUrl, new StringContent(JsonConvert.SerializeObject(request),Encoding.UTF8, "application/json"));
switch (response.StatusCode)
{
case HttpStatusCode.OK:
{
var content = await response.Content.ReadAsStringAsync();
if (isDefaultJson)
{
var evidenceValues = JsonConvert.DeserializeObject<List<EvidenceValue>>(content);
var evidenceValue = evidenceValues?.First(x => x.EvidenceValueName == "default");
if (evidenceValue?.Value is null)
{
return result;
}

var stringvalue = evidenceValue.Value.ToString();
result = JsonConvert.DeserializeObject<T>(stringvalue!);
}
else
{
result = JsonConvert.DeserializeObject<T>(content);
}
}
break;
case HttpStatusCode.NoContent:
throw new EvidenceSourcePermanentClientException((int)ErrorCode.UpstreamException, "Unexpected HTTP status code from external API, no content found");
case HttpStatusCode.BadRequest:
throw new EvidenceSourcePermanentClientException((int)ErrorCode.UpstreamException, "Bad request");
case HttpStatusCode.UnprocessableEntity:
throw new EvidenceSourceTransientException((int)ErrorCode.QuotaExceededException, "Quota exceeded. Try again tomorrow.");
default:
throw new EvidenceSourcePermanentClientException((int)ErrorCode.UpstreamException, "Unexpected HTTP status code from external API");
}
}
catch (HttpRequestException ex)
{
throw new EvidenceSourcePermanentServerException((int)ErrorCode.UpstreamException, null, ex);
}
finally
{
response?.Dispose();
}
return result;
}

private static string GetPluginUrl(string env, string source, string evidenceCodeName, string code)
{
var result = env switch
{
"dev" => string.Format(PluginDev, source, evidenceCodeName, code),
"test" => string.Format(PluginTest, source, evidenceCodeName, code),
"prod" => string.Format(PluginProd, source, evidenceCodeName, code),
_ => throw new ArgumentOutOfRangeException(nameof(env), $"env must be dev, test or prod"),
};
return result;
}
}
21 changes: 17 additions & 4 deletions Dan.Core/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Reflection;
using Azure.Core.Serialization;
using Dan.Common;
using Dan.Common.Handlers;
using Dan.Common.Interfaces;
using Dan.Common.Models;
using Dan.Common.Services;
Expand Down Expand Up @@ -109,8 +111,9 @@
services.AddScoped<IAuthorizationRequestValidatorService, AuthorizationRequestValidatorService>();
services.AddScoped<IRequestContextService, RequestContextService>();
services.AddScoped<IUsageStatisticsService, UsageStatisticsService>();

services.AddTransient<ExceptionDelegatingHandler>();
services.AddTransient<PluginAuthorizationMessageHandler>();

services.AddPolicyRegistry(new PolicyRegistry()
{
Expand Down Expand Up @@ -140,22 +143,32 @@
}
});

// Default client to use in harvesting
services.AddHttpClient("SafeHttpClient", client =>
// Default clients to use in harvesting
services.AddHttpClient(Constants.SafeHttpClient, client =>
{
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.BaseAddress = new Uri(Settings.ApiUrl);
})
.AddPolicyHandlerFromRegistry("DefaultCircuitBreaker")
.AddHttpMessageHandler<ExceptionDelegatingHandler>();

services.AddHttpClient(Constants.PluginHttpClient, client =>
{
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.BaseAddress = new Uri(Settings.ApiUrl);
})
.AddPolicyHandlerFromRegistry("DefaultCircuitBreaker")
.AddHttpMessageHandler<ExceptionDelegatingHandler>()
.AddHttpMessageHandler<PluginAuthorizationMessageHandler>();

// Client used for getting evidence code lists from data sources
services.AddHttpClient("EvidenceCodesClient", client =>
{
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(25);
})
.AddHttpMessageHandler<ExceptionDelegatingHandler>();
.AddHttpMessageHandler<ExceptionDelegatingHandler>()
.AddHttpMessageHandler<PluginAuthorizationMessageHandler>();

// Client with enterprise certificate authentication
services.AddHttpClient("ECHttpClient", client =>
Expand Down
5 changes: 3 additions & 2 deletions Dan.Core/Services/AvailableEvidenceCodesService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Dan.Common.Models;
using System.Net.Http.Headers;
using Dan.Common.Models;
using Dan.Core.Config;
using Dan.Core.Extensions;
using Dan.Core.Services.Interfaces;
Expand All @@ -11,6 +12,7 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using AsyncKeyedLock;
using Azure.Identity;

namespace Dan.Core.Services;

Expand Down Expand Up @@ -205,7 +207,6 @@ private async Task AddServiceContextAuthorizationRequirements(List<EvidenceCode>
private async Task<List<EvidenceCode>> GetEvidenceCodesFromSource(EvidenceSource source)
{
var client = _httpClientFactory.CreateClient(HttpClientName);

try
{
var request = new HttpRequestMessage(HttpMethod.Get, source.Url);
Expand Down
8 changes: 4 additions & 4 deletions Dan.Core/Services/EvidenceHarvesterService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Net;
using Dan.Common;
using Dan.Common.Enums;
using Dan.Common.Models;
using Dan.Core.Config;
Expand Down Expand Up @@ -113,7 +113,7 @@ public async Task<Evidence> HarvestOpenData(EvidenceCode evidenceCode, string id

try
{
var client = _httpClientFactory.CreateClient("SafeHttpClient");
var client = _httpClientFactory.CreateClient(Constants.PluginHttpClient);
harvestedEvidence = (await EvidenceSourceHelper.DoRequest<List<EvidenceValue>>(
request,
() => client.SendAsync(request, cts.Token)))!;
Expand Down Expand Up @@ -146,7 +146,7 @@ private async Task<Stream> HarvestEvidenceStream(EvidenceCode evidenceCode, Accr
request.SetPolicyExecutionContext(new Context(request.Key(CacheArea.Absolute)));
try
{
var client = _httpClientFactory.CreateClient("SafeHttpClient");
var client = _httpClientFactory.CreateClient(Constants.PluginHttpClient);

// When attempting to stream from the evidence source, we simplify error handling
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token);
Expand Down Expand Up @@ -176,7 +176,7 @@ private async Task<List<EvidenceValue>> HarvestEvidenceValues(EvidenceCode evide
request.SetPolicyExecutionContext(new Context(request.Key(CacheArea.Absolute)));
try
{
var client = _httpClientFactory.CreateClient("SafeHttpClient");
var client = _httpClientFactory.CreateClient(Constants.PluginHttpClient);
return (await EvidenceSourceHelper.DoRequest<List<EvidenceValue>>(
request,
() => client.SendAsync(request, cts.Token)))!;
Expand Down
5 changes: 3 additions & 2 deletions Dan.Core/Services/EvidenceStatusService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Dan.Common.Enums;
using Dan.Common;
using Dan.Common.Enums;
using Dan.Common.Models;
using Dan.Core.Extensions;
using Dan.Core.Helpers;
Expand Down Expand Up @@ -168,7 +169,7 @@ private async Task<EvidenceStatusCode> GetAsynchronousEvidenceStatusCode(Accredi
};

request.JsonContent(evidenceHarvesterRequest);
var client = _clientFactory.CreateClient("SafeHttpClient");
var client = _clientFactory.CreateClient(Constants.PluginHttpClient);

return (await EvidenceSourceHelper.DoRequest<EvidenceStatusCode>(
request,
Expand Down
2 changes: 2 additions & 0 deletions Dan.PluginTest/Config/PluginConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public static class PluginConstants
public const string Source = "Source";
public const string DatasetOne = "DatasetOne";
public const string DatasetTwo = "DatasetTwo";

public const string PluginForward = "PluginForward";
}
2 changes: 1 addition & 1 deletion Dan.PluginTest/Config/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

public class Settings
{

public string PluginCode { get; set; }

Check warning on line 5 in Dan.PluginTest/Config/Settings.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Non-nullable property 'PluginCode' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 5 in Dan.PluginTest/Config/Settings.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Non-nullable property 'PluginCode' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 5 in Dan.PluginTest/Config/Settings.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Non-nullable property 'PluginCode' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 5 in Dan.PluginTest/Config/Settings.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Non-nullable property 'PluginCode' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
}
Loading
Loading