Skip to content
Open
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
35 changes: 35 additions & 0 deletions src/Altinn.App.Core/Extensions/HttpClientExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,41 @@ public static async Task<HttpResponseMessage> GetStreamingAsync(
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
}

/// <summary>
/// Extension that add authorization header to request
/// </summary>
/// <param name="httpClient">The HttpClient</param>
/// <param name="authorizationToken">the authorization token (jwt)</param>
/// <param name="requestUri">The request Uri</param>
/// <param name="content">The http content</param>
/// <param name="platformAccessToken">The platformAccess tokens</param>
/// <param name="cancellationToken">The cancellation token</param>
/// <returns>A HttpResponseMessage</returns>
public static async Task<HttpResponseMessage> PatchAsync(
this HttpClient httpClient,
string authorizationToken,
string requestUri,
HttpContent? content,
string? platformAccessToken = null,
CancellationToken cancellationToken = default
)
{
using HttpRequestMessage request = new(HttpMethod.Patch, requestUri);
request.Content = content;

request.Headers.Authorization = new AuthenticationHeaderValue(
Constants.AuthorizationSchemes.Bearer,
authorizationToken
);

if (!string.IsNullOrEmpty(platformAccessToken))
{
request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken);
}

return await httpClient.SendAsync(request, cancellationToken);
}

/// <summary>
/// Extension that add authorization header to request
/// </summary>
Expand Down
4 changes: 4 additions & 0 deletions src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
using Altinn.App.Core.Internal.Data;
using Altinn.App.Core.Internal.Events;
using Altinn.App.Core.Internal.Expressions;
using Altinn.App.Core.Internal.InstanceLocking;
using Altinn.App.Core.Internal.Instances;
using Altinn.App.Core.Internal.Language;
using Altinn.App.Core.Internal.Pdf;
Expand Down Expand Up @@ -115,6 +116,7 @@ IWebHostEnvironment env
services.AddHttpClient<IText, TextClient>();
#pragma warning restore CS0618 // Type or member is obsolete
services.AddHttpClient<IProcessClient, ProcessClient>();
services.AddHttpClient<InstanceLockClient>();
services.AddHttpClient<IPersonClient, PersonClient>();
services.AddHttpClient<IAccessManagementClient, AccessManagementClient>();

Expand Down Expand Up @@ -368,6 +370,8 @@ private static void AddProcessServices(IServiceCollection services)
services.AddTransient<IAbandonTaskEventHandler, AbandonTaskEventHandler>();
services.AddTransient<IEndEventEventHandler, EndEventEventHandler>();

services.AddScoped<IInstanceLocker, InstanceLocker>();

// Process tasks
services.AddTransient<IProcessTask, DataProcessTask>();
services.AddTransient<IProcessTask, ConfirmationProcessTask>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Diagnostics;

namespace Altinn.App.Core.Features;

partial class Telemetry
{
internal Activity? StartAcquireInstanceLockActivity(Guid instanceGuid, int instanceOwnerPartyId)
{
var activity = ActivitySource.StartActivity("AcquireInstanceLock");
activity?.SetInstanceId(instanceGuid);
activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId);

return activity;
}

internal Activity? StartReleaseInstanceLockActivity(Guid instanceGuid, int instanceOwnerPartyId)
{
var activity = ActivitySource.StartActivity("ReleaseInstanceLock");
activity?.SetInstanceId(instanceGuid);
activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId);

return activity;
}
}
116 changes: 69 additions & 47 deletions src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
/// <param name="cancellationToken">A cancellation token to cancel reading the content.</param>
/// <returns>The constructed <see cref="PlatformHttpResponseSnapshotException"/>.</returns>
public static async Task<PlatformHttpResponseSnapshotException> CreateAndDisposeHttpResponse(
HttpResponseMessage response,

Check notice

Code scanning / CodeQL

Missed 'using' opportunity Note

This variable is manually
disposed
in a
finally block
- consider a C# using statement as a preferable resource management technique.
CancellationToken cancellationToken = default
)
{
Expand All @@ -78,69 +78,91 @@
cancellationToken
);

string headers = FlattenHeaders(response.Headers, response.Content?.Headers, response.TrailingHeaders);
string message = BuildMessage((int)response.StatusCode, response.ReasonPhrase, content, truncated);

// Build a sanitized, non-streaming HttpResponseMessage for the base class
var safeResponse = new HttpResponseMessage(response.StatusCode)
return Create(message, response, content, truncated);
}
finally
{
try
{
ReasonPhrase = response.ReasonPhrase,
Version = response.Version,
};

// Copy normal headers
foreach (KeyValuePair<string, IEnumerable<string>> h in response.Headers)
response.Dispose();
}
catch
{
if (_redactedHeaders.Contains(h.Key))
{
safeResponse.Headers.TryAddWithoutValidation(h.Key, [Redacted]);
}
else
{
safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value);
}
/* ignore dispose failures */
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.
}
}

/// <summary>
/// Creates a new <see cref="PlatformHttpResponseSnapshotException"/> by snapshotting
/// the provided <see cref="HttpResponseMessage"/> into immutable string values,
/// constructing a sanitized clone for the base class.
/// </summary>
/// <param name="message">The exception message.</param>
/// <param name="response">The HTTP response to snapshot.</param>
/// <param name="content">The response body content as a string (possibly truncated).</param>
/// <param name="contentTruncated">Whether the content was truncated.</param>
/// <returns>The constructed <see cref="PlatformHttpResponseSnapshotException"/>.</returns>
public static PlatformHttpResponseSnapshotException Create(
string message,
HttpResponseMessage response,
string? content = null,
bool contentTruncated = false
)
{
string headers = FlattenHeaders(response.Headers, response.Content?.Headers, response.TrailingHeaders);
content ??= string.Empty;

// Attach a diagnostic snapshot body for legacy consumers
StringContent safeContent = new StringContent(content, Encoding.UTF8);
safeContent.Headers.ContentType = response.Content?.Headers?.ContentType;
safeResponse.Content = safeContent;
// Build a sanitized, non-streaming HttpResponseMessage for the base class
var safeResponse = new HttpResponseMessage(response.StatusCode)
{
ReasonPhrase = response.ReasonPhrase,
Version = response.Version,
};

// Copy trailing headers if present (HTTP/2+)
foreach (KeyValuePair<string, IEnumerable<string>> h in response.TrailingHeaders)
// Copy normal headers
foreach (KeyValuePair<string, IEnumerable<string>> h in response.Headers)
{
if (_redactedHeaders.Contains(h.Key))
{
if (_redactedHeaders.Contains(h.Key))
{
safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, [Redacted]);
}
else
{
safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, h.Value);
}
safeResponse.Headers.TryAddWithoutValidation(h.Key, [Redacted]);
}
else
{
safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value);
}

return new PlatformHttpResponseSnapshotException(
safeResponse,
statusCode: (int)response.StatusCode,
reasonPhrase: response.ReasonPhrase,
httpVersion: response.Version?.ToString() ?? string.Empty,
headers: headers,
content: content,
contentTruncated: truncated,
message: message
);
}
finally

// Attach a diagnostic snapshot body for legacy consumers
StringContent safeContent = new StringContent(content, Encoding.UTF8);
safeContent.Headers.ContentType = response.Content?.Headers?.ContentType;
safeResponse.Content = safeContent;

// Copy trailing headers if present (HTTP/2+)
foreach (KeyValuePair<string, IEnumerable<string>> h in response.TrailingHeaders)
{
try
if (_redactedHeaders.Contains(h.Key))
{
response.Dispose();
safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, [Redacted]);
}
catch
else
{
/* ignore dispose failures */
safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, h.Value);
}
}

return new PlatformHttpResponseSnapshotException(
safeResponse,
statusCode: (int)response.StatusCode,
reasonPhrase: response.ReasonPhrase,
httpVersion: response.Version?.ToString() ?? string.Empty,
headers: headers,
content: content,
contentTruncated: contentTruncated,
message: message
);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Altinn.App.Core.Configuration;
using Altinn.App.Core.Constants;
using Altinn.App.Core.Extensions;
using Altinn.App.Core.Features;
using Altinn.App.Core.Helpers;
using Altinn.App.Core.Internal.Auth;
using Altinn.Platform.Storage.Interface.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Altinn.App.Core.Infrastructure.Clients.Storage;

internal sealed class InstanceLockClient
{
private readonly ILogger<InstanceLockClient> _logger;
private readonly HttpClient _client;
private readonly Telemetry? _telemetry;
private readonly IAuthenticationTokenResolver _authenticationTokenResolver;

private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser();

private const string LockTokenHeaderName = "Altinn-Storage-Lock-Token";

public InstanceLockClient(
IOptions<PlatformSettings> platformSettings,
ILogger<InstanceLockClient> logger,
IAuthenticationTokenResolver authenticationTokenResolver,
HttpClient httpClient,
Telemetry? telemetry = null
)
{
_logger = logger;
_authenticationTokenResolver = authenticationTokenResolver;
httpClient.BaseAddress = new Uri(platformSettings.Value.ApiStorageEndpoint);
httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, platformSettings.Value.SubscriptionKey);
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_client = httpClient;
_telemetry = telemetry;
}

public async Task<string> AcquireInstanceLock(
Guid instanceGuid,
int instanceOwnerPartyId,
TimeSpan expiration,
StorageAuthenticationMethod? authenticationMethod = null,
CancellationToken cancellationToken = default
)
{
using var activity = _telemetry?.StartAcquireInstanceLockActivity(instanceGuid, instanceOwnerPartyId);
string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/lock";

var token = await _authenticationTokenResolver.GetAccessToken(
authenticationMethod ?? _defaultAuthenticationMethod,
cancellationToken: cancellationToken
);

var request = new InstanceLockRequest { TtlSeconds = (int)expiration.TotalSeconds };
var content = JsonContent.Create(request);

using var response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken);

if (!response.IsSuccessStatusCode)
{
throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response, cancellationToken);
}

string? lockToken = null;
try
{
var lockResponse = await response.Content.ReadFromJsonAsync<InstanceLockResponse>(
cancellationToken: cancellationToken
);
lockToken = lockResponse?.LockToken;
}
catch (Exception e) when (e is JsonException || e is InvalidOperationException)
{
_logger.LogError(e, "Error reading response from the lock acquisition endpoint.");
}

if (string.IsNullOrEmpty(lockToken))
{
throw PlatformHttpResponseSnapshotException.Create(
"The response from the lock acquisition endpoint was not expected.",
response
);
}

return lockToken;
}

public async Task ReleaseInstanceLock(
Guid instanceGuid,
int instanceOwnerPartyId,
string lockToken,
StorageAuthenticationMethod? authenticationMethod = null,
CancellationToken cancellationToken = default
)
{
using var activity = _telemetry?.StartReleaseInstanceLockActivity(instanceGuid, instanceOwnerPartyId);
string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/lock";
var instanceLockRequest = new InstanceLockRequest { TtlSeconds = 0 };

var userToken = await _authenticationTokenResolver.GetAccessToken(
authenticationMethod ?? _defaultAuthenticationMethod,
cancellationToken: cancellationToken
);

using HttpRequestMessage request = new(HttpMethod.Patch, apiUrl);
request.Content = JsonContent.Create(instanceLockRequest);
request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationSchemes.Bearer, userToken);
request.Headers.Add(LockTokenHeaderName, lockToken);

using var response = await _client.SendAsync(request, cancellationToken);

if (!response.IsSuccessStatusCode)
{
throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response, cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Altinn.App.Core.Internal.InstanceLocking;

internal interface IInstanceLocker : IAsyncDisposable
{
ValueTask LockAsync();

ValueTask LockAsync(TimeSpan ttl);
}
Loading
Loading