Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Correspondence client #897

Merged
merged 77 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from 65 commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
39136b8
Beginnings of correspondence client
martinothamar Oct 16, 2024
fc9e997
tmp
martinothamar Oct 17, 2024
4ba6cf8
Internal MaskinportenClient
martinothamar Oct 17, 2024
7299a43
Updates tests
danielskovli Oct 17, 2024
be33175
more scaffolding
martinothamar Oct 17, 2024
083df5a
Add Maskinporten token exchange
martinothamar Oct 22, 2024
1df5f48
Adds debugging, telemetry, scaffolding
danielskovli Oct 22, 2024
03f8e71
Some notes
danielskovli Oct 23, 2024
6203cf5
Implements TokenAuthority in MaskinportenDelegatingHandler
danielskovli Oct 23, 2024
35c8502
Restructure, housekeeping, fixes tests
danielskovli Oct 23, 2024
8cfcf4e
WIP
danielskovli Oct 24, 2024
e1fee68
WIP
danielskovli Oct 25, 2024
c006e08
WIP
danielskovli Oct 28, 2024
382036a
Builder pattern
danielskovli Oct 29, 2024
8b8b222
WIP testing
danielskovli Oct 30, 2024
2123ad1
Builder improvements, WIP testing
danielskovli Oct 31, 2024
a89de05
WIP testing, housekeeping
danielskovli Nov 1, 2024
d6bc8dc
Finishes builder tests
danielskovli Nov 4, 2024
1522b5a
Serialization tests
danielskovli Nov 4, 2024
525b9d5
Merge remote-tracking branch 'origin/main' into correspondence-client
danielskovli Nov 5, 2024
491a416
Removes `Attachments` from `CorrespondenceContentBuilder`
danielskovli Nov 5, 2024
825442d
Removes `Attachment.Sender` property
danielskovli Nov 5, 2024
faf3172
Client functional
danielskovli Nov 6, 2024
282518a
Implements new AccessToken struct, improves CorrespondenceClient & fr…
danielskovli Nov 7, 2024
453c44a
Fixes and extends tests, implements JsonConverter for OrganisationNum…
danielskovli Nov 7, 2024
c39786b
Avoids parsing null-responses from correspondence server
danielskovli Nov 7, 2024
4d6f067
Housekeeping
danielskovli Nov 7, 2024
9309d56
Renames internal Telemetry.Correspondence class
danielskovli Nov 8, 2024
7277f89
Merge remote-tracking branch 'origin/main' into correspondence-client
danielskovli Nov 8, 2024
007d7f4
Removes todo note
danielskovli Nov 8, 2024
1f9d160
CodeQL feedback
danielskovli Nov 8, 2024
e454280
Implements JsonPropertyName attributes for CorrespondenceResponse
danielskovli Nov 8, 2024
546b1b8
Improves testing
danielskovli Nov 8, 2024
ec69299
Improves builder interface naming, scaffolds a new test to ensure cor…
danielskovli Nov 8, 2024
4e8de23
Tweaks SendCorrespondencePayload
danielskovli Nov 11, 2024
71d8e04
Renames and makes `serialise` method internal, removes interfaces, ad…
danielskovli Nov 12, 2024
13c8adb
Removes CorrespondenceAttachment.RestrictionName
danielskovli Nov 12, 2024
af3dc72
Stores CorrespondenceAttachment.Data as `ReadOnlyMemory<byte>` instea…
danielskovli Nov 12, 2024
fc709ee
Adds Null/Empty checks to builder `With...`methods, instead of just i…
danielskovli Nov 12, 2024
65bbc08
Cleans up Maskinporten models and implementation details
danielskovli Nov 12, 2024
a3175b3
Unnecessary using
danielskovli Nov 12, 2024
087dc13
Bah
danielskovli Nov 12, 2024
ab19286
Merge remote-tracking branch 'origin/main' into correspondence-client
danielskovli Nov 12, 2024
3bf29f6
Use reference comparison for filename clash algorithm
danielskovli Nov 13, 2024
1dd31ae
Mega WIP
danielskovli Nov 14, 2024
78edeaa
Implements GetStatus endpoint and associated data models
danielskovli Nov 15, 2024
2ef5721
Merge remote-tracking branch 'origin/main' into correspondence-client
danielskovli Nov 15, 2024
6f43e2e
Removes dead code
danielskovli Nov 15, 2024
b8c79d3
Implements GetStatus endpoint and data models, NationalIdentityNumber…
danielskovli Nov 19, 2024
3d09060
chore: File scoped namespace
danielskovli Nov 19, 2024
012fee9
Spelling and housekeeping
danielskovli Nov 19, 2024
4648e8c
Nullable DueDateTime in status response
danielskovli Nov 19, 2024
4206789
camelCase props re https://github.com/Altinn/altinn-correspondence/is…
danielskovli Nov 19, 2024
f59f58e
Cleanup
danielskovli Nov 19, 2024
02ad7dd
Merge branch 'main' into correspondence-client
danielskovli Nov 19, 2024
f9da567
`Value` properties for value types (where possible) instead of `Get()…
danielskovli Nov 20, 2024
71d5ad8
Data types as per PR feedback
danielskovli Nov 20, 2024
f624877
Builder improvements
danielskovli Nov 20, 2024
0618a42
Moves internal Maskinporten and CorrespondenceClient extensions to th…
danielskovli Nov 20, 2024
b54006d
Housekeeping
danielskovli Nov 20, 2024
723f759
Simplify JWT token model (#915)
martinothamar Nov 20, 2024
d70a8f8
Merge remote-tracking branch 'origin/main' into correspondence-client
danielskovli Nov 20, 2024
d5a466e
Housekeeping
danielskovli Nov 20, 2024
2ff9b64
Disposes HttpResponseMessage
danielskovli Nov 20, 2024
04d8f0c
Removes dead code
danielskovli Nov 20, 2024
1d1ac5c
some telemetry changes
martinothamar Nov 21, 2024
28f576f
Removes builders for `ExternalReference` and `ReplyOptions`
danielskovli Nov 21, 2024
bc3351f
Merge remote-tracking branch 'origin/correspondence-client' into corr…
danielskovli Nov 21, 2024
46f352f
Unifies json converter naming and access modifiers
danielskovli Nov 21, 2024
62bac52
Removes dead code
danielskovli Nov 21, 2024
25f4964
Reorders ContentBuilder and improves tests
danielskovli Nov 21, 2024
4f365a7
Merge remote-tracking branch 'origin/main' into correspondence-client
danielskovli Nov 21, 2024
4ca0614
Updated csharpier formatting
danielskovli Nov 21, 2024
ef9663b
Renames correspondence base class
danielskovli Nov 21, 2024
2a80bfb
Improves test coverage
danielskovli Nov 21, 2024
e174b3d
Tests builder overloads for value types
danielskovli Nov 21, 2024
62b8cef
Fixes CorrespondenceContent.Attachments accessibility
danielskovli Nov 21, 2024
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
40 changes: 31 additions & 9 deletions src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Altinn.App.Core.Features.Maskinporten;
using Altinn.App.Core.Features.Maskinporten.Delegates;
using Altinn.App.Core.Features.Maskinporten.Constants;
using Altinn.App.Core.Features.Maskinporten.Extensions;

namespace Altinn.App.Api.Extensions;

Expand All @@ -10,25 +11,46 @@ public static class HttpClientBuilderExtensions
{
/// <summary>
/// <para>
/// Sets up a <see cref="MaskinportenDelegatingHandler"/> middleware for the supplied <see cref="HttpClient"/>,
/// which will inject an Authorization header with a Bearer token for all requests.
/// Authorises all requests with Maskinporten using the provided scopes,
/// and injects the resulting token in the Authorization header using the Bearer scheme.
/// </para>
/// <para>
/// If your target API does <em>not</em> use this authentication scheme, you should consider implementing
/// <see cref="MaskinportenClient.GetAccessToken"/> directly and handling authorization details manually.
/// If your target API does <em>not</em> use this authorisation scheme, you should consider implementing
/// <see cref="MaskinportenClient.GetAccessToken"/> directly and handling the specifics manually.
/// </para>
/// </summary>
/// <param name="builder">The Http client builder</param>
/// <param name="scope">The scope to claim authorization for with Maskinporten</param>
/// <param name="additionalScopes">Additional scopes as required</param>
public static IHttpClientBuilder UseMaskinportenAuthorization(
public static IHttpClientBuilder UseMaskinportenAuthorisation(
this IHttpClientBuilder builder,
string scope,
params string[] additionalScopes
)
{
var scopes = new[] { scope }.Concat(additionalScopes);
var factory = ActivatorUtilities.CreateFactory<MaskinportenDelegatingHandler>([typeof(IEnumerable<string>),]);
return builder.AddHttpMessageHandler(provider => factory(provider, [scopes]));
return builder.AddMaskinportenHttpMessageHandler(scope, additionalScopes, TokenAuthorities.Maskinporten);
}

/// <summary>
/// <para>
/// Authorises all requests with Maskinporten using the provided scopes.
/// The resulting token is then exchanged for an Altinn issued token and injected in
/// the Authorization header using the Bearer scheme.
/// </para>
/// <para>
/// If your target API does <em>not</em> use this authorisation scheme, you should consider implementing
/// <see cref="MaskinportenClient.GetAltinnExchangedToken(IEnumerable{string}, CancellationToken)"/> directly and handling the specifics manually.
/// </para>
/// </summary>
/// <param name="builder">The Http client builder</param>
/// <param name="scope">The scope to claim authorization for with Maskinporten</param>
/// <param name="additionalScopes">Additional scopes as required</param>
public static IHttpClientBuilder UseMaskinportenAltinnAuthorisation(
this IHttpClientBuilder builder,
string scope,
params string[] additionalScopes
)
{
return builder.AddMaskinportenHttpMessageHandler(scope, additionalScopes, TokenAuthorities.AltinnTokenExchange);
}
}
38 changes: 7 additions & 31 deletions src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
using Altinn.App.Core.Constants;
using Altinn.App.Core.Extensions;
using Altinn.App.Core.Features;
using Altinn.App.Core.Features.Correspondence.Extensions;
using Altinn.App.Core.Features.Maskinporten;
using Altinn.App.Core.Features.Maskinporten.Extensions;
using Altinn.App.Core.Features.Maskinporten.Models;
using Altinn.Common.PEP.Authorization;
using Altinn.Common.PEP.Clients;
Expand Down Expand Up @@ -82,7 +84,6 @@ IWebHostEnvironment env

services.AddPlatformServices(config, env);
services.AddAppServices(config, env);
services.AddMaskinportenClient();
services.ConfigureDataProtection();

var useOpenTelemetrySetting = config.GetValue<bool?>("AppSettings:UseOpenTelemetry");
Expand All @@ -97,6 +98,11 @@ IWebHostEnvironment env
AddApplicationInsights(services, config, env);
}

// AddMaskinportenClient adds a keyed service. This needs to happen after AddApplicationInsights,
// due to a bug in app insights: https://github.com/microsoft/ApplicationInsights-dotnet/issues/2828
services.AddMaskinportenClient();
services.AddCorrespondenceClient();

AddAuthenticationScheme(services, config, env);
AddAuthorizationPolicies(services);
AddAntiforgery(services);
Expand Down Expand Up @@ -159,23 +165,6 @@ string configSectionPath
return services;
}

/// <summary>
/// Adds a singleton <see cref="AddMaskinportenClient"/> service to the service collection.
/// If no <see cref="MaskinportenSettings"/> configuration is found, it binds one to the path "MaskinportenSettings".
/// </summary>
/// <param name="services">The service collection</param>
private static IServiceCollection AddMaskinportenClient(this IServiceCollection services)
{
if (services.GetOptionsDescriptor<MaskinportenSettings>() is null)
{
services.ConfigureMaskinportenClient("MaskinportenSettings");
}

services.AddSingleton<IMaskinportenClient, MaskinportenClient>();

return services;
}

/// <summary>
/// Adds Application Insights to the service collection.
/// </summary>
Expand Down Expand Up @@ -492,19 +481,6 @@ private static void AddAntiforgery(IServiceCollection services)
services.TryAddSingleton<ValidateAntiforgeryTokenIfAuthCookieAuthorizationFilter>();
}

private static IServiceCollection RemoveOptions<TOptions>(this IServiceCollection services)
where TOptions : class
{
var descriptor = services.GetOptionsDescriptor<TOptions>();

if (descriptor is not null)
{
services.Remove(descriptor);
}

return services;
}

private static (string? Key, string? ConnectionString) GetAppInsightsConfig(
IConfiguration config,
IHostEnvironment env
Expand Down
39 changes: 11 additions & 28 deletions src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using Altinn.App.Core.Extensions;
using Microsoft.Extensions.FileProviders;
using Altinn.App.Core.Features.Maskinporten.Extensions;

namespace Altinn.App.Api.Extensions;

Expand Down Expand Up @@ -29,36 +29,19 @@ public static void ConfigureAppWebHost(this IWebHostBuilder builder, string[] ar

configBuilder.AddInMemoryCollection(config);

configBuilder.AddMaskinportenSettingsFile(context);
configBuilder.AddMaskinportenSettingsFile(
context,
"MaskinportenSettingsFilepath",
"/mnt/app-secrets/maskinporten-settings.json"
);
configBuilder.AddMaskinportenSettingsFile(
context,
"MaskinportenSettingsInternalFilepath",
"/mnt/app-secrets/maskinporten-settings-internal.json"
);

configBuilder.LoadAppConfig(args);
}
);
}

private static IConfigurationBuilder AddMaskinportenSettingsFile(
this IConfigurationBuilder configurationBuilder,
WebHostBuilderContext context
)
{
string jsonProvidedPath =
context.Configuration.GetValue<string>("MaskinportenSettingsFilepath")
?? "/mnt/app-secrets/maskinporten-settings.json";
string jsonAbsolutePath = Path.GetFullPath(jsonProvidedPath);

if (File.Exists(jsonAbsolutePath))
{
string jsonDir = Path.GetDirectoryName(jsonAbsolutePath) ?? string.Empty;
string jsonFile = Path.GetFileName(jsonAbsolutePath);

configurationBuilder.AddJsonFile(
provider: new PhysicalFileProvider(jsonDir),
path: jsonFile,
optional: true,
reloadOnChange: true
);
}

return configurationBuilder;
}
}
159 changes: 79 additions & 80 deletions src/Altinn.App.Api/Helpers/RequestHandling/RequestPartValidator.cs
Original file line number Diff line number Diff line change
@@ -1,115 +1,114 @@
using Altinn.Platform.Storage.Interface.Models;

namespace Altinn.App.Api.Helpers.RequestHandling
namespace Altinn.App.Api.Helpers.RequestHandling;

/// <summary>
/// Represents a validator of a single <see cref="RequestPart"/> with the help of app metadata
/// </summary>
public class RequestPartValidator
{
private readonly Application _appInfo;

/// <summary>
/// Represents a validator of a single <see cref="RequestPart"/> with the help of app metadata
/// Initialises a new instance of the <see cref="RequestPartValidator"/> class with the given application info.
/// </summary>
public class RequestPartValidator
/// <param name="appInfo">The application metadata to use when validating a <see cref="RequestPart"/>.</param>
public RequestPartValidator(Application appInfo)
{
private readonly Application _appInfo;
_appInfo = appInfo;
}

/// <summary>
/// Initialises a new instance of the <see cref="RequestPartValidator"/> class with the given application info.
/// </summary>
/// <param name="appInfo">The application metadata to use when validating a <see cref="RequestPart"/>.</param>
public RequestPartValidator(Application appInfo)
/// <summary>
/// Operation that can validate a <see cref="RequestPart"/> using the internal <see cref="Application"/>.
/// </summary>
/// <param name="part">The request part to be validated.</param>
/// <returns>null if no errors where found. Otherwise an error message.</returns>
public string? ValidatePart(RequestPart part)
{
if (part.Name == "instance")
{
_appInfo = appInfo;
}
if (!part.ContentType.StartsWith("application/json", StringComparison.Ordinal))
{
return $"Unexpected Content-Type '{part.ContentType}' of embedded instance template. Expecting 'application/json'";
}

/// <summary>
/// Operation that can validate a <see cref="RequestPart"/> using the internal <see cref="Application"/>.
/// </summary>
/// <param name="part">The request part to be validated.</param>
/// <returns>null if no errors where found. Otherwise an error message.</returns>
public string? ValidatePart(RequestPart part)
//// TODO: Validate that the element can be read as an instance?
}
else
{
if (part.Name == "instance")
DataType? dataType = _appInfo.DataTypes.Find(e => e.Id == part.Name);
if (dataType == null)
{
if (!part.ContentType.StartsWith("application/json", StringComparison.Ordinal))
{
return $"Unexpected Content-Type '{part.ContentType}' of embedded instance template. Expecting 'application/json'";
}
return $"Multipart section named, '{part.Name}' does not correspond to an element type in application metadata";
}

//// TODO: Validate that the element can be read as an instance?
if (part.ContentType == null)
{
return $"The multipart section named {part.Name} is missing Content-Type.";
}
else
{
DataType? dataType = _appInfo.DataTypes.Find(e => e.Id == part.Name);
if (dataType == null)
{
return $"Multipart section named, '{part.Name}' does not correspond to an element type in application metadata";
}

if (part.ContentType == null)
{
return $"The multipart section named {part.Name} is missing Content-Type.";
}
else
{
string contentTypeWithoutEncoding = part.ContentType.Split(";")[0];

// restrict content type if allowedContentTypes is specified
if (
dataType.AllowedContentTypes != null
&& dataType.AllowedContentTypes.Count > 0
&& !dataType.AllowedContentTypes.Contains(contentTypeWithoutEncoding)
)
{
return $"The multipart section named {part.Name} has a Content-Type '{part.ContentType}' which is invalid for element type '{dataType.Id}'";
}
}

long contentSize = part.FileSize != 0 ? part.FileSize : part.Bytes.Length;

if (contentSize == 0)
{
return $"The multipart section named {part.Name} has no data. Cannot process empty part.";
}
string contentTypeWithoutEncoding = part.ContentType.Split(";")[0];

// restrict content type if allowedContentTypes is specified
if (
dataType.MaxSize.HasValue
&& dataType.MaxSize > 0
&& contentSize > (long)dataType.MaxSize.Value * 1024 * 1024
dataType.AllowedContentTypes != null
&& dataType.AllowedContentTypes.Count > 0
&& !dataType.AllowedContentTypes.Contains(contentTypeWithoutEncoding)
)
{
return $"The multipart section named {part.Name} exceeds the size limit of element type '{dataType.Id}'";
return $"The multipart section named {part.Name} has a Content-Type '{part.ContentType}' which is invalid for element type '{dataType.Id}'";
}
}

return null;
long contentSize = part.FileSize != 0 ? part.FileSize : part.Bytes.Length;

if (contentSize == 0)
{
return $"The multipart section named {part.Name} has no data. Cannot process empty part.";
}

if (
dataType.MaxSize.HasValue
&& dataType.MaxSize > 0
&& contentSize > (long)dataType.MaxSize.Value * 1024 * 1024
)
{
return $"The multipart section named {part.Name} exceeds the size limit of element type '{dataType.Id}'";
}
}

/// <summary>
/// Operation that can validate a list of <see cref="RequestPart"/> elements using the internal <see cref="Application"/>.
/// </summary>
/// <param name="parts">The list of request parts to be validated.</param>
/// <returns>null if no errors where found. Otherwise an error message.</returns>
public string? ValidateParts(List<RequestPart> parts)
return null;
}

/// <summary>
/// Operation that can validate a list of <see cref="RequestPart"/> elements using the internal <see cref="Application"/>.
/// </summary>
/// <param name="parts">The list of request parts to be validated.</param>
/// <returns>null if no errors where found. Otherwise an error message.</returns>
public string? ValidateParts(List<RequestPart> parts)
{
foreach (RequestPart part in parts)
{
foreach (RequestPart part in parts)
string? partError = ValidatePart(part);
if (partError != null)
{
string? partError = ValidatePart(part);
if (partError != null)
{
return partError;
}
return partError;
}
}

foreach (DataType dataType in _appInfo.DataTypes)
foreach (DataType dataType in _appInfo.DataTypes)
{
if (dataType.MaxCount > 0)
{
if (dataType.MaxCount > 0)
int partCount = parts.Count(p => p.Name == dataType.Id);
if (dataType.MaxCount < partCount)
{
int partCount = parts.Count(p => p.Name == dataType.Id);
if (dataType.MaxCount < partCount)
{
return $"The list of parts contains more elements of type '{dataType.Id}' than the element type allows.";
}
return $"The list of parts contains more elements of type '{dataType.Id}' than the element type allows.";
}
}

return null;
}

Check notice

Code scanning / CodeQL

Missed opportunity to use Where Note

This foreach loop
implicitly filters its target sequence
- consider filtering the sequence explicitly using '.Where(...)'.

return null;
}
}
5 changes: 5 additions & 0 deletions src/Altinn.App.Core/Configuration/PlatformSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@
/// </summary>
public string ApiNotificationEndpoint { get; set; } = "http://localhost:5101/notifications/api/v1/";

/// <summary>
/// Gets or sets the url for the Correspondence API endpoint.
/// </summary>
public string ApiCorrespondenceEndpoint { get; set; } = "http://localhost:5101/correspondence/api/v1/"; // TODO: which port for localtest?

Check warning on line 52 in src/Altinn.App.Core/Configuration/PlatformSettings.cs

View workflow job for this annotation

GitHub Actions / Static code analysis

Complete the task associated to this 'TODO' comment. (https://rules.sonarsource.com/csharp/RSPEC-1135)

/// <summary>
/// Gets or sets the subscription key value to use in requests against the platform.
/// A new subscription key is generated automatically every time an app is deployed to an environment. The new key is then automatically
Expand Down
Loading
Loading