diff --git a/.github/workflows/dotnet-ci.yml b/.github/workflows/dotnet-ci.yml
new file mode 100644
index 00000000..6b8816f8
--- /dev/null
+++ b/.github/workflows/dotnet-ci.yml
@@ -0,0 +1,59 @@
+name: .NET
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+
+env:
+ JAVA_VERSION: '21'
+ DOTNET_VERSION: '8.0.x'
+
+jobs:
+ build:
+ name: Build, Test and Analyse
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Shallow clones disabled for a better relevancy of SC analysis
+
+ - name: Setup .NET ${{ env.DOTNET_VERSION }}
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Set up JDK ${{ env.JAVA_VERSION }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ env.JAVA_VERSION }}
+ distribution: 'microsoft'
+
+ - name: Cache SonarCloud packages
+ uses: actions/cache@v4
+ with:
+ path: ~\sonar\cache
+ key: ${{ runner.os }}-sonar
+ restore-keys: ${{ runner.os }}-sonar
+
+ - name: Install SonarCloud scanners
+ run: dotnet tool install --global dotnet-sonarscanner
+
+ - name: Install dotnet reportgenerator
+ run: dotnet tool install --global dotnet-reportgenerator-globaltool
+
+ - name: Add nuget package source
+ run: dotnet nuget add source --username USERNAME --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/DFE-Digital/index.json"
+
+ - name: Restore dependencies
+ run: dotnet restore DfE.ExternalApplications.Api.sln
+
+ - name: Build, Test and Analyze
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+ run: |
+ dotnet-sonarscanner begin /d:sonar.qualitygate.wait=true /d:sonar.scanner.skipJreProvisioning=true /k:"DFE-Digital_external-applications-api" /o:"dfe-digital" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io"
+ dotnet build DfE.ExternalApplications.Api.sln --no-restore
+ dotnet test DfE.ExternalApplications.Api.sln --no-build --verbosity normal --collect:"XPlat Code Coverage"
+ dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
diff --git a/DfE.ExternalApplications.Api.sln b/DfE.ExternalApplications.Api.sln
index b3bc36e5..00c08686 100644
--- a/DfE.ExternalApplications.Api.sln
+++ b/DfE.ExternalApplications.Api.sln
@@ -28,6 +28,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.ExternalApplications.Do
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.ExternalApplications.Tests.Common", "src\Tests\DfE.ExternalApplications.Tests.Common\DfE.ExternalApplications.Tests.Common.csproj", "{8380EFBB-A438-4044-B610-3B786E4BE6E9}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DfE.ExternalApplications.Api.Tests", "src\Tests\DfE.ExternalApplications.Api.Tests\DfE.ExternalApplications.Api.Tests.csproj", "{A78AE476-38B1-4CD8-95DB-4D7B0C4FEEAE}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -78,6 +80,10 @@ Global
{8380EFBB-A438-4044-B610-3B786E4BE6E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8380EFBB-A438-4044-B610-3B786E4BE6E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8380EFBB-A438-4044-B610-3B786E4BE6E9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A78AE476-38B1-4CD8-95DB-4D7B0C4FEEAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A78AE476-38B1-4CD8-95DB-4D7B0C4FEEAE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A78AE476-38B1-4CD8-95DB-4D7B0C4FEEAE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A78AE476-38B1-4CD8-95DB-4D7B0C4FEEAE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -88,6 +94,7 @@ Global
{EBD53896-11C7-4C3E-BDB2-08C7F41937F7} = {0BD5A3FB-191B-43C6-A983-C9663BCFAF31}
{24013A30-D950-4025-B1F1-B8A7AC23DE6F} = {0BD5A3FB-191B-43C6-A983-C9663BCFAF31}
{8380EFBB-A438-4044-B610-3B786E4BE6E9} = {0BD5A3FB-191B-43C6-A983-C9663BCFAF31}
+ {A78AE476-38B1-4CD8-95DB-4D7B0C4FEEAE} = {0BD5A3FB-191B-43C6-A983-C9663BCFAF31}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {B4C4F019-D87A-4303-8802-0FD892EFE991}
diff --git a/src/DfE.ExternalApplications.Api.Client/DfE.ExternalApplications.Api.Client.csproj b/src/DfE.ExternalApplications.Api.Client/DfE.ExternalApplications.Api.Client.csproj
index 3023621b..04e4a9e7 100644
--- a/src/DfE.ExternalApplications.Api.Client/DfE.ExternalApplications.Api.Client.csproj
+++ b/src/DfE.ExternalApplications.Api.Client/DfE.ExternalApplications.Api.Client.csproj
@@ -22,7 +22,8 @@
-
+
+
@@ -32,4 +33,10 @@
+
+
+ ..\..\..\..\..\..\..\Program Files\dotnet\packs\Microsoft.AspNetCore.App.Ref\8.0.7\ref\net8.0\Microsoft.AspNetCore.Authentication.Abstractions.dll
+
+
+
diff --git a/src/DfE.ExternalApplications.Api.Client/Extensions/ServiceCollectionExtensions.cs b/src/DfE.ExternalApplications.Api.Client/Extensions/ServiceCollectionExtensions.cs
index c79a7b94..90fc5c1c 100644
--- a/src/DfE.ExternalApplications.Api.Client/Extensions/ServiceCollectionExtensions.cs
+++ b/src/DfE.ExternalApplications.Api.Client/Extensions/ServiceCollectionExtensions.cs
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using DfE.ExternalApplications.Api.Client.Security;
using DfE.ExternalApplications.Api.Client.Settings;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -43,10 +44,12 @@ public static IServiceCollection AddApiClient
{
var tokenService = serviceProvider.GetRequiredService();
- return new BearerTokenHandler(tokenService);
+ var httpContextAccessor = serviceProvider.GetRequiredService();
+
+ return new BearerTokenHandler(tokenService, httpContextAccessor);
});
}
return services;
}
}
-}
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Api.Client/Generated/Client.g.cs b/src/DfE.ExternalApplications.Api.Client/Generated/Client.g.cs
index 3e58ff49..36bfc4be 100644
--- a/src/DfE.ExternalApplications.Api.Client/Generated/Client.g.cs
+++ b/src/DfE.ExternalApplications.Api.Client/Generated/Client.g.cs
@@ -30,7 +30,7 @@ namespace DfE.ExternalApplications.Client
using System = global::System;
[System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
- public partial class TemplatesClient : ITemplatesClient
+ public partial class ApplicationsClient : IApplicationsClient
{
#pragma warning disable 8618
private string _baseUrl;
@@ -41,7 +41,7 @@ public partial class TemplatesClient : ITemplatesClient
private Newtonsoft.Json.JsonSerializerSettings _instanceSettings;
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
- public TemplatesClient(string baseUrl, System.Net.Http.HttpClient httpClient)
+ public ApplicationsClient(string baseUrl, System.Net.Http.HttpClient httpClient)
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
{
BaseUrl = baseUrl;
@@ -78,28 +78,115 @@ public string BaseUrl
partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response);
///
- /// Returns the latest template schema for the specified template name if the user has access.
+ /// Returns all applications the current user can access.
///
- /// The latest template schema.
- /// A server side error occurred.
- public virtual System.Threading.Tasks.Task GetLatestTemplateSchemaAsync(string templateName, System.Guid userId)
+ /// A list of applications accessible to the user.
+ /// A server side error occurred.
+ public virtual System.Threading.Tasks.Task> GetMyApplicationsAsync()
{
- return GetLatestTemplateSchemaAsync(templateName, userId, System.Threading.CancellationToken.None);
+ return GetMyApplicationsAsync(System.Threading.CancellationToken.None);
}
/// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
///
- /// Returns the latest template schema for the specified template name if the user has access.
+ /// Returns all applications the current user can access.
///
- /// The latest template schema.
- /// A server side error occurred.
- public virtual async System.Threading.Tasks.Task GetLatestTemplateSchemaAsync(string templateName, System.Guid userId, System.Threading.CancellationToken cancellationToken)
+ /// A list of applications accessible to the user.
+ /// A server side error occurred.
+ public virtual async System.Threading.Tasks.Task> GetMyApplicationsAsync(System.Threading.CancellationToken cancellationToken)
+ {
+ var client_ = _httpClient;
+ var disposeClient_ = false;
+ try
+ {
+ using (var request_ = new System.Net.Http.HttpRequestMessage())
+ {
+ request_.Method = new System.Net.Http.HttpMethod("GET");
+ request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json"));
+
+ var urlBuilder_ = new System.Text.StringBuilder();
+ if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
+ // Operation Path: "v1/me/applications"
+ urlBuilder_.Append("v1/me/applications");
+
+ PrepareRequest(client_, request_, urlBuilder_);
+
+ var url_ = urlBuilder_.ToString();
+ request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);
+
+ PrepareRequest(client_, request_, url_);
+
+ var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var disposeResponse_ = true;
+ try
+ {
+ var headers_ = new System.Collections.Generic.Dictionary>();
+ foreach (var item_ in response_.Headers)
+ headers_[item_.Key] = item_.Value;
+ if (response_.Content != null && response_.Content.Headers != null)
+ {
+ foreach (var item_ in response_.Content.Headers)
+ headers_[item_.Key] = item_.Value;
+ }
+
+ ProcessResponse(client_, response_);
+
+ var status_ = (int)response_.StatusCode;
+ if (status_ == 200)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ return objectResponse_.Object;
+ }
+ else
+ if (status_ == 401)
+ {
+ string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
+ throw new ExternalApplicationsException("Unauthorized no valid user token", status_, responseText_, headers_, null);
+ }
+ else
+ {
+ var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
+ throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
+ }
+ }
+ finally
+ {
+ if (disposeResponse_)
+ response_.Dispose();
+ }
+ }
+ }
+ finally
+ {
+ if (disposeClient_)
+ client_.Dispose();
+ }
+ }
+
+ ///
+ /// Returns all applications for the user by {email}.
+ ///
+ /// Applications for the user.
+ /// A server side error occurred.
+ public virtual System.Threading.Tasks.Task> GetApplicationsForUserAsync(string email)
{
- if (templateName == null)
- throw new System.ArgumentNullException("templateName");
+ return GetApplicationsForUserAsync(email, System.Threading.CancellationToken.None);
+ }
- if (userId == null)
- throw new System.ArgumentNullException("userId");
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Returns all applications for the user by {email}.
+ ///
+ /// Applications for the user.
+ /// A server side error occurred.
+ public virtual async System.Threading.Tasks.Task> GetApplicationsForUserAsync(string email, System.Threading.CancellationToken cancellationToken)
+ {
+ if (email == null)
+ throw new System.ArgumentNullException("email");
var client_ = _httpClient;
var disposeClient_ = false;
@@ -112,11 +199,10 @@ public virtual async System.Threading.Tasks.Task GetLatestTem
var urlBuilder_ = new System.Text.StringBuilder();
if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
- // Operation Path: "v1/Templates/{templateName}/schema/{userId}"
- urlBuilder_.Append("v1/Templates/");
- urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(templateName, System.Globalization.CultureInfo.InvariantCulture)));
- urlBuilder_.Append("/schema/");
- urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(userId, System.Globalization.CultureInfo.InvariantCulture)));
+ // Operation Path: "v1/Users/{email}/applications"
+ urlBuilder_.Append("v1/Users/");
+ urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(email, System.Globalization.CultureInfo.InvariantCulture)));
+ urlBuilder_.Append("/applications");
PrepareRequest(client_, request_, urlBuilder_);
@@ -143,10 +229,10 @@ public virtual async System.Threading.Tasks.Task GetLatestTem
var status_ = (int)response_.StatusCode;
if (status_ == 200)
{
- var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false);
if (objectResponse_.Object == null)
{
- throw new PersonsApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
}
return objectResponse_.Object;
}
@@ -154,12 +240,12 @@ public virtual async System.Threading.Tasks.Task GetLatestTem
if (status_ == 400)
{
string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
- throw new PersonsApiException("Request was invalid or access denied.", status_, responseText_, headers_, null);
+ throw new ExternalApplicationsException("Email cannot be null or empty.", status_, responseText_, headers_, null);
}
else
{
var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
- throw new PersonsApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
+ throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
}
}
finally
@@ -209,7 +295,7 @@ protected virtual async System.Threading.Tasks.Task> Rea
catch (Newtonsoft.Json.JsonException exception)
{
var message = "Could not deserialize the response body string as " + typeof(T).FullName + ".";
- throw new PersonsApiException(message, (int)response.StatusCode, responseText, headers, exception);
+ throw new ExternalApplicationsException(message, (int)response.StatusCode, responseText, headers, exception);
}
}
else
@@ -228,7 +314,7 @@ protected virtual async System.Threading.Tasks.Task> Rea
catch (Newtonsoft.Json.JsonException exception)
{
var message = "Could not deserialize the response body stream as " + typeof(T).FullName + ".";
- throw new PersonsApiException(message, (int)response.StatusCode, string.Empty, headers, exception);
+ throw new ExternalApplicationsException(message, (int)response.StatusCode, string.Empty, headers, exception);
}
}
}
@@ -289,7 +375,7 @@ private string ConvertToString(object value, System.Globalization.CultureInfo cu
}
[System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
- public partial class UsersClient : IUsersClient
+ public partial class TemplatesClient : ITemplatesClient
{
#pragma warning disable 8618
private string _baseUrl;
@@ -300,7 +386,7 @@ public partial class UsersClient : IUsersClient
private Newtonsoft.Json.JsonSerializerSettings _instanceSettings;
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
- public UsersClient(string baseUrl, System.Net.Http.HttpClient httpClient)
+ public TemplatesClient(string baseUrl, System.Net.Http.HttpClient httpClient)
#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
{
BaseUrl = baseUrl;
@@ -336,11 +422,605 @@ public string BaseUrl
partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder);
partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response);
+ ///
+ /// Returns the latest template schema for the specified template name if the user has access.
+ ///
+ /// The latest template schema.
+ /// A server side error occurred.
+ public virtual System.Threading.Tasks.Task GetLatestTemplateSchemaAsync(System.Guid templateId)
+ {
+ return GetLatestTemplateSchemaAsync(templateId, System.Threading.CancellationToken.None);
+ }
+
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Returns the latest template schema for the specified template name if the user has access.
+ ///
+ /// The latest template schema.
+ /// A server side error occurred.
+ public virtual async System.Threading.Tasks.Task GetLatestTemplateSchemaAsync(System.Guid templateId, System.Threading.CancellationToken cancellationToken)
+ {
+ if (templateId == null)
+ throw new System.ArgumentNullException("templateId");
+
+ var client_ = _httpClient;
+ var disposeClient_ = false;
+ try
+ {
+ using (var request_ = new System.Net.Http.HttpRequestMessage())
+ {
+ request_.Method = new System.Net.Http.HttpMethod("GET");
+ request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json"));
+
+ var urlBuilder_ = new System.Text.StringBuilder();
+ if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
+ // Operation Path: "v1/Templates/{templateId}/schema"
+ urlBuilder_.Append("v1/Templates/");
+ urlBuilder_.Append(System.Uri.EscapeDataString(ConvertToString(templateId, System.Globalization.CultureInfo.InvariantCulture)));
+ urlBuilder_.Append("/schema");
+
+ PrepareRequest(client_, request_, urlBuilder_);
+
+ var url_ = urlBuilder_.ToString();
+ request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);
+
+ PrepareRequest(client_, request_, url_);
+
+ var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var disposeResponse_ = true;
+ try
+ {
+ var headers_ = new System.Collections.Generic.Dictionary>();
+ foreach (var item_ in response_.Headers)
+ headers_[item_.Key] = item_.Value;
+ if (response_.Content != null && response_.Content.Headers != null)
+ {
+ foreach (var item_ in response_.Content.Headers)
+ headers_[item_.Key] = item_.Value;
+ }
+
+ ProcessResponse(client_, response_);
+
+ var status_ = (int)response_.StatusCode;
+ if (status_ == 200)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ return objectResponse_.Object;
+ }
+ else
+ if (status_ == 400)
+ {
+ string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
+ throw new ExternalApplicationsException("Request was invalid or access denied.", status_, responseText_, headers_, null);
+ }
+ else
+ {
+ var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
+ throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
+ }
+ }
+ finally
+ {
+ if (disposeResponse_)
+ response_.Dispose();
+ }
+ }
+ }
+ finally
+ {
+ if (disposeClient_)
+ client_.Dispose();
+ }
+ }
+
+ protected struct ObjectResponseResult
+ {
+ public ObjectResponseResult(T responseObject, string responseText)
+ {
+ this.Object = responseObject;
+ this.Text = responseText;
+ }
+
+ public T Object { get; }
+
+ public string Text { get; }
+ }
+
+ public bool ReadResponseAsString { get; set; }
+
+ protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken)
+ {
+ if (response == null || response.Content == null)
+ {
+ return new ObjectResponseResult(default(T), string.Empty);
+ }
+
+ if (ReadResponseAsString)
+ {
+ var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ try
+ {
+ var typedBody = Newtonsoft.Json.JsonConvert.DeserializeObject(responseText, JsonSerializerSettings);
+ return new ObjectResponseResult(typedBody, responseText);
+ }
+ catch (Newtonsoft.Json.JsonException exception)
+ {
+ var message = "Could not deserialize the response body string as " + typeof(T).FullName + ".";
+ throw new ExternalApplicationsException(message, (int)response.StatusCode, responseText, headers, exception);
+ }
+ }
+ else
+ {
+ try
+ {
+ using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
+ using (var streamReader = new System.IO.StreamReader(responseStream))
+ using (var jsonTextReader = new Newtonsoft.Json.JsonTextReader(streamReader))
+ {
+ var serializer = Newtonsoft.Json.JsonSerializer.Create(JsonSerializerSettings);
+ var typedBody = serializer.Deserialize(jsonTextReader);
+ return new ObjectResponseResult(typedBody, string.Empty);
+ }
+ }
+ catch (Newtonsoft.Json.JsonException exception)
+ {
+ var message = "Could not deserialize the response body stream as " + typeof(T).FullName + ".";
+ throw new ExternalApplicationsException(message, (int)response.StatusCode, string.Empty, headers, exception);
+ }
+ }
+ }
+
+ private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo)
+ {
+ if (value == null)
+ {
+ return "";
+ }
+
+ if (value is System.Enum)
+ {
+ var name = System.Enum.GetName(value.GetType(), value);
+ if (name != null)
+ {
+ var field = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name);
+ if (field != null)
+ {
+ var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field, typeof(System.Runtime.Serialization.EnumMemberAttribute))
+ as System.Runtime.Serialization.EnumMemberAttribute;
+ if (attribute != null)
+ {
+ return attribute.Value != null ? attribute.Value : name;
+ }
+ }
+
+ var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo));
+ return converted == null ? string.Empty : converted;
+ }
+ }
+ else if (value is bool)
+ {
+ return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant();
+ }
+ else if (value is byte[])
+ {
+ return System.Convert.ToBase64String((byte[]) value);
+ }
+ else if (value is string[])
+ {
+ return string.Join(",", (string[])value);
+ }
+ else if (value.GetType().IsArray)
+ {
+ var valueArray = (System.Array)value;
+ var valueTextArray = new string[valueArray.Length];
+ for (var i = 0; i < valueArray.Length; i++)
+ {
+ valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo);
+ }
+ return string.Join(",", valueTextArray);
+ }
+
+ var result = System.Convert.ToString(value, cultureInfo);
+ return result == null ? "" : result;
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
+ public partial class TokensClient : ITokensClient
+ {
+ #pragma warning disable 8618
+ private string _baseUrl;
+ #pragma warning restore 8618
+
+ private System.Net.Http.HttpClient _httpClient;
+ private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true);
+ private Newtonsoft.Json.JsonSerializerSettings _instanceSettings;
+
+ #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
+ public TokensClient(string baseUrl, System.Net.Http.HttpClient httpClient)
+ #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
+ {
+ BaseUrl = baseUrl;
+ _httpClient = httpClient;
+ Initialize();
+ }
+
+ private static Newtonsoft.Json.JsonSerializerSettings CreateSerializerSettings()
+ {
+ var settings = new Newtonsoft.Json.JsonSerializerSettings();
+ UpdateJsonSerializerSettings(settings);
+ return settings;
+ }
+
+ public string BaseUrl
+ {
+ get { return _baseUrl; }
+ set
+ {
+ _baseUrl = value;
+ if (!string.IsNullOrEmpty(_baseUrl) && !_baseUrl.EndsWith("/"))
+ _baseUrl += '/';
+ }
+ }
+
+ protected Newtonsoft.Json.JsonSerializerSettings JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } }
+
+ static partial void UpdateJsonSerializerSettings(Newtonsoft.Json.JsonSerializerSettings settings);
+
+ partial void Initialize();
+
+ partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url);
+ partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder);
+ partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response);
+
+ ///
+ /// Exchanges an DSI token for our ExternalApplications InternalUser JWT.
+ ///
+ /// A server side error occurred.
+ public virtual System.Threading.Tasks.Task ExchangeAsync(ExchangeTokenDto request)
+ {
+ return ExchangeAsync(request, System.Threading.CancellationToken.None);
+ }
+
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Exchanges an DSI token for our ExternalApplications InternalUser JWT.
+ ///
+ /// A server side error occurred.
+ public virtual async System.Threading.Tasks.Task ExchangeAsync(ExchangeTokenDto request, System.Threading.CancellationToken cancellationToken)
+ {
+ if (request == null)
+ throw new System.ArgumentNullException("request");
+
+ var client_ = _httpClient;
+ var disposeClient_ = false;
+ try
+ {
+ using (var request_ = new System.Net.Http.HttpRequestMessage())
+ {
+ var json_ = Newtonsoft.Json.JsonConvert.SerializeObject(request, JsonSerializerSettings);
+ var content_ = new System.Net.Http.StringContent(json_);
+ content_.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/json");
+ request_.Content = content_;
+ request_.Method = new System.Net.Http.HttpMethod("POST");
+ request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json"));
+
+ var urlBuilder_ = new System.Text.StringBuilder();
+ if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
+ // Operation Path: "v1/Tokens/exchange"
+ urlBuilder_.Append("v1/Tokens/exchange");
+
+ PrepareRequest(client_, request_, urlBuilder_);
+
+ var url_ = urlBuilder_.ToString();
+ request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);
+
+ PrepareRequest(client_, request_, url_);
+
+ var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var disposeResponse_ = true;
+ try
+ {
+ var headers_ = new System.Collections.Generic.Dictionary>();
+ foreach (var item_ in response_.Headers)
+ headers_[item_.Key] = item_.Value;
+ if (response_.Content != null && response_.Content.Headers != null)
+ {
+ foreach (var item_ in response_.Content.Headers)
+ headers_[item_.Key] = item_.Value;
+ }
+
+ ProcessResponse(client_, response_);
+
+ var status_ = (int)response_.StatusCode;
+ if (status_ == 200)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ return objectResponse_.Object;
+ }
+ else
+ {
+ var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
+ throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
+ }
+ }
+ finally
+ {
+ if (disposeResponse_)
+ response_.Dispose();
+ }
+ }
+ }
+ finally
+ {
+ if (disposeClient_)
+ client_.Dispose();
+ }
+ }
+
+ protected struct ObjectResponseResult
+ {
+ public ObjectResponseResult(T responseObject, string responseText)
+ {
+ this.Object = responseObject;
+ this.Text = responseText;
+ }
+
+ public T Object { get; }
+
+ public string Text { get; }
+ }
+
+ public bool ReadResponseAsString { get; set; }
+
+ protected virtual async System.Threading.Tasks.Task> ReadObjectResponseAsync(System.Net.Http.HttpResponseMessage response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Threading.CancellationToken cancellationToken)
+ {
+ if (response == null || response.Content == null)
+ {
+ return new ObjectResponseResult(default(T), string.Empty);
+ }
+
+ if (ReadResponseAsString)
+ {
+ var responseText = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ try
+ {
+ var typedBody = Newtonsoft.Json.JsonConvert.DeserializeObject(responseText, JsonSerializerSettings);
+ return new ObjectResponseResult(typedBody, responseText);
+ }
+ catch (Newtonsoft.Json.JsonException exception)
+ {
+ var message = "Could not deserialize the response body string as " + typeof(T).FullName + ".";
+ throw new ExternalApplicationsException(message, (int)response.StatusCode, responseText, headers, exception);
+ }
+ }
+ else
+ {
+ try
+ {
+ using (var responseStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
+ using (var streamReader = new System.IO.StreamReader(responseStream))
+ using (var jsonTextReader = new Newtonsoft.Json.JsonTextReader(streamReader))
+ {
+ var serializer = Newtonsoft.Json.JsonSerializer.Create(JsonSerializerSettings);
+ var typedBody = serializer.Deserialize(jsonTextReader);
+ return new ObjectResponseResult(typedBody, string.Empty);
+ }
+ }
+ catch (Newtonsoft.Json.JsonException exception)
+ {
+ var message = "Could not deserialize the response body stream as " + typeof(T).FullName + ".";
+ throw new ExternalApplicationsException(message, (int)response.StatusCode, string.Empty, headers, exception);
+ }
+ }
+ }
+
+ private string ConvertToString(object value, System.Globalization.CultureInfo cultureInfo)
+ {
+ if (value == null)
+ {
+ return "";
+ }
+
+ if (value is System.Enum)
+ {
+ var name = System.Enum.GetName(value.GetType(), value);
+ if (name != null)
+ {
+ var field = System.Reflection.IntrospectionExtensions.GetTypeInfo(value.GetType()).GetDeclaredField(name);
+ if (field != null)
+ {
+ var attribute = System.Reflection.CustomAttributeExtensions.GetCustomAttribute(field, typeof(System.Runtime.Serialization.EnumMemberAttribute))
+ as System.Runtime.Serialization.EnumMemberAttribute;
+ if (attribute != null)
+ {
+ return attribute.Value != null ? attribute.Value : name;
+ }
+ }
+
+ var converted = System.Convert.ToString(System.Convert.ChangeType(value, System.Enum.GetUnderlyingType(value.GetType()), cultureInfo));
+ return converted == null ? string.Empty : converted;
+ }
+ }
+ else if (value is bool)
+ {
+ return System.Convert.ToString((bool)value, cultureInfo).ToLowerInvariant();
+ }
+ else if (value is byte[])
+ {
+ return System.Convert.ToBase64String((byte[]) value);
+ }
+ else if (value is string[])
+ {
+ return string.Join(",", (string[])value);
+ }
+ else if (value.GetType().IsArray)
+ {
+ var valueArray = (System.Array)value;
+ var valueTextArray = new string[valueArray.Length];
+ for (var i = 0; i < valueArray.Length; i++)
+ {
+ valueTextArray[i] = ConvertToString(valueArray.GetValue(i), cultureInfo);
+ }
+ return string.Join(",", valueTextArray);
+ }
+
+ var result = System.Convert.ToString(value, cultureInfo);
+ return result == null ? "" : result;
+ }
+ }
+
+ [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
+ public partial class UsersClient : IUsersClient
+ {
+ #pragma warning disable 8618
+ private string _baseUrl;
+ #pragma warning restore 8618
+
+ private System.Net.Http.HttpClient _httpClient;
+ private static System.Lazy _settings = new System.Lazy(CreateSerializerSettings, true);
+ private Newtonsoft.Json.JsonSerializerSettings _instanceSettings;
+
+ #pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
+ public UsersClient(string baseUrl, System.Net.Http.HttpClient httpClient)
+ #pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
+ {
+ BaseUrl = baseUrl;
+ _httpClient = httpClient;
+ Initialize();
+ }
+
+ private static Newtonsoft.Json.JsonSerializerSettings CreateSerializerSettings()
+ {
+ var settings = new Newtonsoft.Json.JsonSerializerSettings();
+ UpdateJsonSerializerSettings(settings);
+ return settings;
+ }
+
+ public string BaseUrl
+ {
+ get { return _baseUrl; }
+ set
+ {
+ _baseUrl = value;
+ if (!string.IsNullOrEmpty(_baseUrl) && !_baseUrl.EndsWith("/"))
+ _baseUrl += '/';
+ }
+ }
+
+ protected Newtonsoft.Json.JsonSerializerSettings JsonSerializerSettings { get { return _instanceSettings ?? _settings.Value; } }
+
+ static partial void UpdateJsonSerializerSettings(Newtonsoft.Json.JsonSerializerSettings settings);
+
+ partial void Initialize();
+
+ partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, string url);
+ partial void PrepareRequest(System.Net.Http.HttpClient client, System.Net.Http.HttpRequestMessage request, System.Text.StringBuilder urlBuilder);
+ partial void ProcessResponse(System.Net.Http.HttpClient client, System.Net.Http.HttpResponseMessage response);
+
+ ///
+ /// Returns all my permissions.
+ ///
+ /// A UserPermission object representing the User's Permissions.
+ /// A server side error occurred.
+ public virtual System.Threading.Tasks.Task> GetMyPermissionsAsync()
+ {
+ return GetMyPermissionsAsync(System.Threading.CancellationToken.None);
+ }
+
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Returns all my permissions.
+ ///
+ /// A UserPermission object representing the User's Permissions.
+ /// A server side error occurred.
+ public virtual async System.Threading.Tasks.Task> GetMyPermissionsAsync(System.Threading.CancellationToken cancellationToken)
+ {
+ var client_ = _httpClient;
+ var disposeClient_ = false;
+ try
+ {
+ using (var request_ = new System.Net.Http.HttpRequestMessage())
+ {
+ request_.Method = new System.Net.Http.HttpMethod("GET");
+ request_.Headers.Accept.Add(System.Net.Http.Headers.MediaTypeWithQualityHeaderValue.Parse("application/json"));
+
+ var urlBuilder_ = new System.Text.StringBuilder();
+ if (!string.IsNullOrEmpty(_baseUrl)) urlBuilder_.Append(_baseUrl);
+ // Operation Path: "v1/me/permissions"
+ urlBuilder_.Append("v1/me/permissions");
+
+ PrepareRequest(client_, request_, urlBuilder_);
+
+ var url_ = urlBuilder_.ToString();
+ request_.RequestUri = new System.Uri(url_, System.UriKind.RelativeOrAbsolute);
+
+ PrepareRequest(client_, request_, url_);
+
+ var response_ = await client_.SendAsync(request_, System.Net.Http.HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+ var disposeResponse_ = true;
+ try
+ {
+ var headers_ = new System.Collections.Generic.Dictionary>();
+ foreach (var item_ in response_.Headers)
+ headers_[item_.Key] = item_.Value;
+ if (response_.Content != null && response_.Content.Headers != null)
+ {
+ foreach (var item_ in response_.Content.Headers)
+ headers_[item_.Key] = item_.Value;
+ }
+
+ ProcessResponse(client_, response_);
+
+ var status_ = (int)response_.StatusCode;
+ if (status_ == 200)
+ {
+ var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false);
+ if (objectResponse_.Object == null)
+ {
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ }
+ return objectResponse_.Object;
+ }
+ else
+ if (status_ == 401)
+ {
+ string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
+ throw new ExternalApplicationsException("Unauthorized \u2013 no valid user token", status_, responseText_, headers_, null);
+ }
+ else
+ {
+ var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
+ throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
+ }
+ }
+ finally
+ {
+ if (disposeResponse_)
+ response_.Dispose();
+ }
+ }
+ }
+ finally
+ {
+ if (disposeClient_)
+ client_.Dispose();
+ }
+ }
+
///
/// Returns all permissions for the user by {email}.
///
/// A UserPermission object representing the User's Permissions.
- /// A server side error occurred.
+ /// A server side error occurred.
public virtual System.Threading.Tasks.Task> GetAllPermissionsForUserAsync(string email)
{
return GetAllPermissionsForUserAsync(email, System.Threading.CancellationToken.None);
@@ -351,7 +1031,7 @@ public string BaseUrl
/// Returns all permissions for the user by {email}.
///
/// A UserPermission object representing the User's Permissions.
- /// A server side error occurred.
+ /// A server side error occurred.
public virtual async System.Threading.Tasks.Task> GetAllPermissionsForUserAsync(string email, System.Threading.CancellationToken cancellationToken)
{
if (email == null)
@@ -401,7 +1081,7 @@ public string BaseUrl
var objectResponse_ = await ReadObjectResponseAsync>(response_, headers_, cancellationToken).ConfigureAwait(false);
if (objectResponse_.Object == null)
{
- throw new PersonsApiException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
+ throw new ExternalApplicationsException("Response was null which was not expected.", status_, objectResponse_.Text, headers_, null);
}
return objectResponse_.Object;
}
@@ -409,12 +1089,12 @@ public string BaseUrl
if (status_ == 400)
{
string responseText_ = ( response_.Content == null ) ? string.Empty : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
- throw new PersonsApiException("Email cannot be null or empty.", status_, responseText_, headers_, null);
+ throw new ExternalApplicationsException("Email cannot be null or empty.", status_, responseText_, headers_, null);
}
else
{
var responseData_ = response_.Content == null ? null : await response_.Content.ReadAsStringAsync().ConfigureAwait(false);
- throw new PersonsApiException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
+ throw new ExternalApplicationsException("The HTTP status code of the response was not expected (" + status_ + ").", status_, responseData_, headers_, null);
}
}
finally
@@ -464,7 +1144,7 @@ protected virtual async System.Threading.Tasks.Task> Rea
catch (Newtonsoft.Json.JsonException exception)
{
var message = "Could not deserialize the response body string as " + typeof(T).FullName + ".";
- throw new PersonsApiException(message, (int)response.StatusCode, responseText, headers, exception);
+ throw new ExternalApplicationsException(message, (int)response.StatusCode, responseText, headers, exception);
}
}
else
@@ -483,7 +1163,7 @@ protected virtual async System.Threading.Tasks.Task> Rea
catch (Newtonsoft.Json.JsonException exception)
{
var message = "Could not deserialize the response body stream as " + typeof(T).FullName + ".";
- throw new PersonsApiException(message, (int)response.StatusCode, string.Empty, headers, exception);
+ throw new ExternalApplicationsException(message, (int)response.StatusCode, string.Empty, headers, exception);
}
}
}
diff --git a/src/DfE.ExternalApplications.Api.Client/Generated/Contracts.g.cs b/src/DfE.ExternalApplications.Api.Client/Generated/Contracts.g.cs
index 1c48c29c..0f085bd7 100644
--- a/src/DfE.ExternalApplications.Api.Client/Generated/Contracts.g.cs
+++ b/src/DfE.ExternalApplications.Api.Client/Generated/Contracts.g.cs
@@ -25,6 +25,41 @@ namespace DfE.ExternalApplications.Client.Contracts
{
using System = global::System;
+ [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
+ public partial interface IApplicationsClient
+ {
+ ///
+ /// Returns all applications the current user can access.
+ ///
+ /// A list of applications accessible to the user.
+ /// A server side error occurred.
+ System.Threading.Tasks.Task> GetMyApplicationsAsync();
+
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Returns all applications the current user can access.
+ ///
+ /// A list of applications accessible to the user.
+ /// A server side error occurred.
+ System.Threading.Tasks.Task> GetMyApplicationsAsync(System.Threading.CancellationToken cancellationToken);
+
+ ///
+ /// Returns all applications for the user by {email}.
+ ///
+ /// Applications for the user.
+ /// A server side error occurred.
+ System.Threading.Tasks.Task> GetApplicationsForUserAsync(string email);
+
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Returns all applications for the user by {email}.
+ ///
+ /// Applications for the user.
+ /// A server side error occurred.
+ System.Threading.Tasks.Task> GetApplicationsForUserAsync(string email, System.Threading.CancellationToken cancellationToken);
+
+ }
+
[System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
public partial interface ITemplatesClient
{
@@ -32,27 +67,60 @@ public partial interface ITemplatesClient
/// Returns the latest template schema for the specified template name if the user has access.
///
/// The latest template schema.
- /// A server side error occurred.
- System.Threading.Tasks.Task GetLatestTemplateSchemaAsync(string templateName, System.Guid userId);
+ /// A server side error occurred.
+ System.Threading.Tasks.Task GetLatestTemplateSchemaAsync(System.Guid templateId);
/// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
///
/// Returns the latest template schema for the specified template name if the user has access.
///
/// The latest template schema.
- /// A server side error occurred.
- System.Threading.Tasks.Task GetLatestTemplateSchemaAsync(string templateName, System.Guid userId, System.Threading.CancellationToken cancellationToken);
+ /// A server side error occurred.
+ System.Threading.Tasks.Task GetLatestTemplateSchemaAsync(System.Guid templateId, System.Threading.CancellationToken cancellationToken);
+
+ }
+
+ [System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
+ public partial interface ITokensClient
+ {
+ ///
+ /// Exchanges an DSI token for our ExternalApplications InternalUser JWT.
+ ///
+ /// A server side error occurred.
+ System.Threading.Tasks.Task ExchangeAsync(ExchangeTokenDto request);
+
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Exchanges an DSI token for our ExternalApplications InternalUser JWT.
+ ///
+ /// A server side error occurred.
+ System.Threading.Tasks.Task ExchangeAsync(ExchangeTokenDto request, System.Threading.CancellationToken cancellationToken);
}
[System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
public partial interface IUsersClient
{
+ ///
+ /// Returns all my permissions.
+ ///
+ /// A UserPermission object representing the User's Permissions.
+ /// A server side error occurred.
+ System.Threading.Tasks.Task> GetMyPermissionsAsync();
+
+ /// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
+ ///
+ /// Returns all my permissions.
+ ///
+ /// A UserPermission object representing the User's Permissions.
+ /// A server side error occurred.
+ System.Threading.Tasks.Task> GetMyPermissionsAsync(System.Threading.CancellationToken cancellationToken);
+
///
/// Returns all permissions for the user by {email}.
///
/// A UserPermission object representing the User's Permissions.
- /// A server side error occurred.
+ /// A server side error occurred.
System.Threading.Tasks.Task> GetAllPermissionsForUserAsync(string email);
/// A cancellation token that can be used by other objects or threads to receive notice of cancellation.
@@ -60,7 +128,7 @@ public partial interface IUsersClient
/// Returns all permissions for the user by {email}.
///
/// A UserPermission object representing the User's Permissions.
- /// A server side error occurred.
+ /// A server side error occurred.
System.Threading.Tasks.Task> GetAllPermissionsForUserAsync(string email, System.Threading.CancellationToken cancellationToken);
}
@@ -70,7 +138,7 @@ public partial interface IUsersClient
[System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
- public partial class PersonsApiException : System.Exception
+ public partial class ExternalApplicationsException : System.Exception
{
public int StatusCode { get; private set; }
@@ -78,7 +146,7 @@ public partial class PersonsApiException : System.Exception
public System.Collections.Generic.IReadOnlyDictionary> Headers { get; private set; }
- public PersonsApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Exception innerException)
+ public ExternalApplicationsException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, System.Exception innerException)
: base(message + "\n\nStatus: " + statusCode + "\nResponse: \n" + ((response == null) ? "(null)" : response.Substring(0, response.Length >= 512 ? 512 : response.Length)), innerException)
{
StatusCode = statusCode;
@@ -93,11 +161,11 @@ public override string ToString()
}
[System.CodeDom.Compiler.GeneratedCode("NSwag", "14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))")]
- public partial class PersonsApiException : PersonsApiException
+ public partial class ExternalApplicationsException : ExternalApplicationsException
{
public TResult Result { get; private set; }
- public PersonsApiException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, TResult result, System.Exception innerException)
+ public ExternalApplicationsException(string message, int statusCode, string response, System.Collections.Generic.IReadOnlyDictionary> headers, TResult result, System.Exception innerException)
: base(message, statusCode, response, headers, innerException)
{
Result = result;
diff --git a/src/DfE.ExternalApplications.Api.Client/Generated/swagger.json b/src/DfE.ExternalApplications.Api.Client/Generated/swagger.json
index d5734527..48213f62 100644
--- a/src/DfE.ExternalApplications.Api.Client/Generated/swagger.json
+++ b/src/DfE.ExternalApplications.Api.Client/Generated/swagger.json
@@ -6,32 +6,88 @@
"version": "1.0.0"
},
"paths": {
- "/v1/Templates/{templateName}/schema/{userId}": {
+ "/v1/me/applications": {
"get": {
"tags": [
- "Templates"
+ "Applications"
],
- "summary": "Returns the latest template schema for the specified template name if the user has access.",
- "operationId": "Templates_GetLatestTemplateSchema",
+ "summary": "Returns all applications the current user can access.",
+ "operationId": "Applications_GetMyApplications",
+ "responses": {
+ "200": {
+ "description": "A list of applications accessible to the user.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ApplicationDto"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized no valid user token"
+ }
+ }
+ }
+ },
+ "/v1/Users/{email}/applications": {
+ "get": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Returns all applications for the user by {email}.",
+ "operationId": "Applications_GetApplicationsForUser",
"parameters": [
{
- "name": "templateName",
+ "name": "email",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"x-position": 1
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Applications for the user.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ApplicationDto"
+ }
+ }
+ }
+ }
},
+ "400": {
+ "description": "Email cannot be null or empty."
+ }
+ }
+ }
+ },
+ "/v1/Templates/{templateId}/schema": {
+ "get": {
+ "tags": [
+ "Templates"
+ ],
+ "summary": "Returns the latest template schema for the specified template name if the user has access.",
+ "operationId": "Templates_GetLatestTemplateSchema",
+ "parameters": [
{
- "name": "userId",
+ "name": "templateId",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "guid"
},
- "x-position": 2
+ "x-position": 1
}
],
"responses": {
@@ -51,6 +107,66 @@
}
}
},
+ "/v1/Tokens/exchange": {
+ "post": {
+ "tags": [
+ "Tokens"
+ ],
+ "summary": "Exchanges an DSI token for our ExternalApplications InternalUser JWT.",
+ "operationId": "Tokens_Exchange",
+ "requestBody": {
+ "x-name": "request",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExchangeTokenDto"
+ }
+ }
+ },
+ "required": true,
+ "x-position": 1
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ExchangeTokenDto"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/v1/me/permissions": {
+ "get": {
+ "tags": [
+ "Users"
+ ],
+ "summary": "Returns all my permissions.",
+ "operationId": "Users_GetMyPermissions",
+ "responses": {
+ "200": {
+ "description": "A UserPermission object representing the User's Permissions.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/UserPermissionDto"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized – no valid user token"
+ }
+ }
+ }
+ },
"/v1/Users/{email}/permissions": {
"get": {
"tags": [
@@ -92,19 +208,62 @@
},
"components": {
"schemas": {
- "TemplateSchemaDto": {
+ "ApplicationDto": {
"type": "object",
"additionalProperties": false,
"properties": {
- "versionNumber": {
+ "applicationId": {
+ "type": "string",
+ "format": "guid"
+ },
+ "applicationReference": {
"type": "string"
},
- "jsonSchema": {
+ "templateVersionId": {
+ "type": "string",
+ "format": "guid"
+ },
+ "templateName": {
"type": "string"
},
+ "status": {
+ "type": "integer",
+ "format": "int32",
+ "nullable": true
+ },
+ "dateCreated": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "dateSubmitted": {
+ "type": "string",
+ "format": "date-time",
+ "nullable": true
+ }
+ }
+ },
+ "TemplateSchemaDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
"templateId": {
"type": "string",
"format": "guid"
+ },
+ "versionNumber": {
+ "type": "string"
+ },
+ "jsonSchema": {
+ "type": "string"
+ }
+ }
+ },
+ "ExchangeTokenDto": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "accessToken": {
+ "type": "string"
}
}
},
@@ -114,16 +273,40 @@
"properties": {
"applicationId": {
"type": "string",
- "format": "guid"
+ "format": "guid",
+ "nullable": true
},
"resourceKey": {
"type": "string"
},
+ "resourceType": {
+ "$ref": "#/components/schemas/ResourceType"
+ },
"accessType": {
"$ref": "#/components/schemas/AccessType"
}
}
},
+ "ResourceType": {
+ "type": "string",
+ "description": "",
+ "x-enumNames": [
+ "Application",
+ "User",
+ "TaskGroup",
+ "Task",
+ "Page",
+ "Field"
+ ],
+ "enum": [
+ "Application",
+ "User",
+ "TaskGroup",
+ "Task",
+ "Page",
+ "Field"
+ ]
+ },
"AccessType": {
"type": "string",
"description": "",
diff --git a/src/DfE.ExternalApplications.Api.Client/Security/BearerTokenHandler.cs b/src/DfE.ExternalApplications.Api.Client/Security/BearerTokenHandler.cs
index cfe43b9b..3d76444f 100644
--- a/src/DfE.ExternalApplications.Api.Client/Security/BearerTokenHandler.cs
+++ b/src/DfE.ExternalApplications.Api.Client/Security/BearerTokenHandler.cs
@@ -1,16 +1,28 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
namespace DfE.ExternalApplications.Api.Client.Security
{
[ExcludeFromCodeCoverage]
- public class BearerTokenHandler(ITokenAcquisitionService tokenAcquisitionService) : DelegatingHandler
+ public class BearerTokenHandler(ITokenAcquisitionService tokenAcquisitionService, IHttpContextAccessor httpCtx) : DelegatingHandler
{
protected override async Task SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
{
var token = await tokenAcquisitionService.GetTokenAsync();
- request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ // Service-To-Service token
+ request.Headers.Add("X-Service-Authorization", token);
+
+ // User Token ExtIdP
+ var userToken = await httpCtx.HttpContext!
+ .GetTokenAsync("id_token");
+ if (!string.IsNullOrEmpty(userToken))
+ {
+ request.Headers.Authorization =
+ new AuthenticationHeaderValue("Bearer", userToken);
+ }
return await base.SendAsync(request, cancellationToken);
}
diff --git a/src/DfE.ExternalApplications.Api.Client/nswag.json b/src/DfE.ExternalApplications.Api.Client/nswag.json
index 06f65cf4..fe40d0f8 100644
--- a/src/DfE.ExternalApplications.Api.Client/nswag.json
+++ b/src/DfE.ExternalApplications.Api.Client/nswag.json
@@ -66,7 +66,7 @@
"disposeHttpClient": false,
"protectedMethods": [],
"generateExceptionClasses": true,
- "exceptionClass": "PersonsApiException",
+ "exceptionClass": "ExternalApplicationsException",
"wrapDtoExceptions": true,
"useHttpClientCreationMethod": false,
"httpClientType": "System.Net.Http.HttpClient",
diff --git a/src/DfE.ExternalApplications.Api/Controllers/ApplicationsController.cs b/src/DfE.ExternalApplications.Api/Controllers/ApplicationsController.cs
new file mode 100644
index 00000000..7f0bd86a
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Controllers/ApplicationsController.cs
@@ -0,0 +1,56 @@
+using Asp.Versioning;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.ExternalApplications.Application.Applications.Queries;
+using DfE.ExternalApplications.Infrastructure.Security;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Swashbuckle.AspNetCore.Annotations;
+
+namespace DfE.ExternalApplications.Api.Controllers;
+
+[ApiController]
+[ApiVersion("1.0")]
+[Route("v{version:apiVersion}/[controller]")]
+public class ApplicationsController(ISender sender) : ControllerBase
+{
+ ///
+ /// Returns all applications the current user can access.
+ ///
+ [HttpGet("/v{version:apiVersion}/me/applications")]
+ [Authorize(AuthenticationSchemes = AuthConstants.UserScheme)]
+ [SwaggerResponse(200, "A list of applications accessible to the user.", typeof(IReadOnlyCollection))]
+ [SwaggerResponse(401, "Unauthorized no valid user token")]
+ [Authorize(Policy = "CanReadAnyApplication")]
+ public async Task GetMyApplicationsAsync(
+ CancellationToken cancellationToken)
+ {
+ var query = new GetMyApplicationsQuery();
+ var result = await sender.Send(query, cancellationToken);
+
+ if (!result.IsSuccess)
+ return BadRequest(result.Error);
+
+ return Ok(result.Value);
+ }
+
+ ///
+ /// Returns all applications for the user by {email}.
+ ///
+ [HttpGet("/v{version:apiVersion}/Users/{email}/applications")]
+ [SwaggerResponse(200, "Applications for the user.", typeof(IReadOnlyCollection))]
+ [SwaggerResponse(400, "Email cannot be null or empty.")]
+ [Authorize(Policy = "CanReadAnyApplication")]
+ public async Task GetApplicationsForUserAsync(
+ [FromRoute] string email,
+ CancellationToken cancellationToken)
+ {
+ var query = new GetApplicationsForUserQuery(email);
+ var result = await sender.Send(query, cancellationToken);
+
+ if (!result.IsSuccess)
+ return BadRequest(result.Error);
+
+ return Ok(result.Value);
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs b/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs
index f59362eb..dca2e593 100644
--- a/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs
+++ b/src/DfE.ExternalApplications.Api/Controllers/TemplatesController.cs
@@ -5,6 +5,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
+using System.Security.Claims;
namespace DfE.ExternalApplications.Api.Controllers;
@@ -16,20 +17,21 @@ public class TemplatesController(ISender sender) : ControllerBase
///
/// Returns the latest template schema for the specified template name if the user has access.
///
- [HttpGet("{templateName}/schema/{userId}")]
+ [HttpGet("{templateId}/schema")]
[SwaggerResponse(200, "The latest template schema.", typeof(TemplateSchemaDto))]
[SwaggerResponse(400, "Request was invalid or access denied.")]
- [AllowAnonymous]
+ [Authorize(Policy = "CanReadTemplate")]
public async Task GetLatestTemplateSchemaAsync(
- [FromRoute] string templateName,
- [FromRoute] Guid userId,
- CancellationToken cancellationToken)
+ [FromRoute] Guid templateId, CancellationToken cancellationToken)
{
- var query = new GetLatestTemplateSchemaQuery(templateName, userId);
+ var email = User.FindFirstValue(ClaimTypes.Email)
+ ?? throw new InvalidOperationException("No email claim in token");
+
+ var query = new GetLatestTemplateSchemaQuery(templateId, email);
var result = await sender.Send(query, cancellationToken);
if (!result.IsSuccess)
- return BadRequest((object?)result.Error);
+ return BadRequest(result.Error);
return Ok(result.Value);
}
diff --git a/src/DfE.ExternalApplications.Api/Controllers/TokensController.cs b/src/DfE.ExternalApplications.Api/Controllers/TokensController.cs
new file mode 100644
index 00000000..b671253c
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Controllers/TokensController.cs
@@ -0,0 +1,31 @@
+using Asp.Versioning;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.ExternalApplications.Application.Common.Models;
+using DfE.ExternalApplications.Application.Users.Queries;
+using DfE.ExternalApplications.Infrastructure.Security;
+using MediatR;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DfE.ExternalApplications.Api.Controllers
+{
+ [ApiController]
+ [ApiVersion("1.0")]
+ [Route("v{version:apiVersion}/[controller]")]
+ public class TokensController(ISender sender) : ControllerBase
+ {
+ ///
+ /// Exchanges an DSI token for our ExternalApplications InternalUser JWT.
+ ///
+ [HttpPost("exchange")]
+ [Authorize(AuthenticationSchemes = AuthConstants.AzureAdScheme, Policy = "SvcCanReadWrite")]
+ public async Task> Exchange(
+ [FromBody] ExchangeTokenDto request,
+ CancellationToken ct)
+ {
+ var result = await sender.Send(
+ new ExchangeTokenQuery(request.AccessToken), ct);
+ return Ok(result);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Api/Controllers/UsersController.cs b/src/DfE.ExternalApplications.Api/Controllers/UsersController.cs
index 21db282f..6e6413bd 100644
--- a/src/DfE.ExternalApplications.Api/Controllers/UsersController.cs
+++ b/src/DfE.ExternalApplications.Api/Controllers/UsersController.cs
@@ -1,6 +1,8 @@
+using System.Security.Claims;
using Asp.Versioning;
using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
using DfE.ExternalApplications.Application.Users.Queries;
+using DfE.ExternalApplications.Infrastructure.Security;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -13,13 +15,33 @@ namespace DfE.ExternalApplications.Api.Controllers;
[Route("v{version:apiVersion}/[controller]")]
public class UsersController(ISender sender) : ControllerBase
{
+ ///
+ /// Returns all my permissions.
+ ///
+ [HttpGet("/v{version:apiVersion}/me/permissions")]
+ [Authorize(AuthenticationSchemes = AuthConstants.UserScheme)]
+ [SwaggerResponse(200, "A UserPermission object representing the User's Permissions.", typeof(IReadOnlyCollection))]
+ [SwaggerResponse(401, "Unauthorized – no valid user token")]
+ [Authorize(Policy = "CanReadUser")]
+ public async Task GetMyPermissionsAsync(
+ CancellationToken cancellationToken)
+ {
+ var query = new GetMyPermissionsQuery();
+ var result = await sender.Send(query, cancellationToken);
+
+ if (!result.IsSuccess)
+ return BadRequest(result.Error);
+
+ return Ok(result.Value);
+ }
+
///
/// Returns all permissions for the user by {email}.
///
[HttpGet("{email}/permissions")]
[SwaggerResponse(200, "A UserPermission object representing the User's Permissions.", typeof(IReadOnlyCollection))]
[SwaggerResponse(400, "Email cannot be null or empty.")]
- [AllowAnonymous]
+ [Authorize(Policy = "CanReadUser")]
public async Task GetAllPermissionsForUserAsync(
[FromRoute] string email,
CancellationToken cancellationToken)
diff --git a/src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj b/src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj
index b6d9c16e..281f302e 100644
--- a/src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj
+++ b/src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj
@@ -3,14 +3,14 @@
true
$(NoWarn);1591
- 8c1ad605-0dd4-443a-ad18-dd22bbb2a9d9
net8.0
+ f714ca7a-fc08-46ff-b0cc-373d6f04cf4c
-
+
diff --git a/src/DfE.ExternalApplications.Api/Program.cs b/src/DfE.ExternalApplications.Api/Program.cs
index c494776a..a1983ce0 100644
--- a/src/DfE.ExternalApplications.Api/Program.cs
+++ b/src/DfE.ExternalApplications.Api/Program.cs
@@ -12,6 +12,7 @@
using System.Reflection;
using System.Text;
using System.Text.Json.Serialization;
+using DfE.ExternalApplications.Api.Security;
using TelemetryConfiguration = Microsoft.ApplicationInsights.Extensibility.TelemetryConfiguration;
namespace DfE.ExternalApplications.Api
@@ -63,6 +64,7 @@ public static async Task Main(string[] args)
builder.Services.AddApplicationDependencyGroup(builder.Configuration);
builder.Services.AddInfrastructureDependencyGroup(builder.Configuration);
+ builder.Services.AddCustomAuthorization(builder.Configuration);
builder.Services.AddOptions()
.Configure((swaggerUiOptions, httpContextAccessor) =>
diff --git a/src/DfE.ExternalApplications.Api/Security/AuthorizationExtensions.cs b/src/DfE.ExternalApplications.Api/Security/AuthorizationExtensions.cs
new file mode 100644
index 00000000..d2a4f8b6
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/AuthorizationExtensions.cs
@@ -0,0 +1,122 @@
+using System.Diagnostics.CodeAnalysis;
+using System.IdentityModel.Tokens.Jwt;
+using System.Text;
+using DfE.CoreLibs.Contracts.ExternalApplications.Enums;
+using DfE.CoreLibs.Security;
+using DfE.CoreLibs.Security.Authorization;
+using DfE.CoreLibs.Security.Configurations;
+using DfE.CoreLibs.Security.Interfaces;
+using DfE.CoreLibs.Security.Services;
+using DfE.ExternalApplications.Api.Security.Handlers;
+using DfE.ExternalApplications.Infrastructure.Security;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Identity.Web;
+using Microsoft.IdentityModel.Tokens;
+
+namespace DfE.ExternalApplications.Api.Security
+{
+ [ExcludeFromCodeCoverage]
+ public static class AuthorizationExtensions
+ {
+ public static IServiceCollection AddCustomAuthorization(this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddExternalIdentityValidation(configuration);
+
+ // Config
+ services
+ .Configure(
+ configuration.GetSection("DfESignIn"));
+
+ var tokenSettings = new TokenSettings();
+ configuration.GetSection("Authorization:TokenSettings").Bind(tokenSettings);
+
+ services.AddUserTokenService(configuration);
+
+ services.AddAuthentication(options =>
+ {
+ options.DefaultAuthenticateScheme = AuthConstants.CompositeScheme;
+ options.DefaultChallengeScheme = AuthConstants.CompositeScheme;
+ })
+ .AddPolicyScheme(AuthConstants.CompositeScheme, "CompositeAuth", options =>
+ {
+ options.ForwardDefaultSelector = context =>
+ {
+ var header = context.Request.Headers["Authorization"].FirstOrDefault();
+ if (header?.StartsWith("Bearer ") == true)
+ {
+ var token = header.Substring(7);
+ var handler = new JwtSecurityTokenHandler();
+ var jwt = handler.ReadJwtToken(token);
+ return jwt.Issuer == tokenSettings.Issuer
+ ? AuthConstants.UserScheme
+ : AuthConstants.AzureAdScheme;
+ }
+ return AuthConstants.AzureAdScheme;
+ };
+ })
+ .AddJwtBearer(AuthConstants.UserScheme, opts =>
+ {
+ opts.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = tokenSettings.Issuer,
+ ValidateAudience = true,
+ ValidAudience = tokenSettings.Audience,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = new SymmetricSecurityKey(
+ Encoding.UTF8.GetBytes(tokenSettings.SecretKey))
+ };
+ })
+ .AddMicrosoftIdentityWebApi(
+ configuration.GetSection(AuthConstants.AzureAdSection),
+ jwtBearerScheme: AuthConstants.AzureAdScheme,
+ subscribeToJwtBearerMiddlewareDiagnosticsEvents: false);
+
+ // set-up and define User and Template Permission policies
+ var policyCustomizations = new Dictionary>
+ {
+ ["CanReadTemplate"] = pb =>
+ {
+ pb.AddAuthenticationSchemes(AuthConstants.CompositeScheme);
+ pb.RequireAuthenticatedUser();
+ pb.AddRequirements(new Handlers.TemplatePermissionRequirement(AccessType.Read.ToString()));
+ },
+ ["CanReadUser"] = pb =>
+ {
+ pb.AddAuthenticationSchemes(AuthConstants.CompositeScheme);
+ pb.RequireAuthenticatedUser();
+ pb.AddRequirements(new Handlers.UserPermissionRequirement(AccessType.Read.ToString()));
+ },
+ ["CanReadApplication"] = pb =>
+ {
+ pb.AddAuthenticationSchemes(AuthConstants.CompositeScheme);
+ pb.RequireAuthenticatedUser();
+ pb.AddRequirements(new Handlers.ApplicationPermissionRequirement(AccessType.Read.ToString()));
+ },
+ ["CanReadAnyApplication"] = pb =>
+ {
+ pb.AddAuthenticationSchemes(AuthConstants.CompositeScheme);
+ pb.RequireAuthenticatedUser();
+ pb.AddRequirements(new Handlers.ApplicationListPermissionRequirement(AccessType.Read.ToString()));
+ }
+ };
+
+ services.AddApplicationAuthorization(
+ configuration,
+ policyCustomizations: policyCustomizations,
+ apiAuthenticationScheme: AuthConstants.CompositeScheme,
+ configureResourcePolicies: null);
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddTransient();
+ services.AddTransient();
+
+ return services;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationListPermissionHandler.cs b/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationListPermissionHandler.cs
new file mode 100644
index 00000000..fda51aaa
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationListPermissionHandler.cs
@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace DfE.ExternalApplications.Api.Security.Handlers;
+
+///
+/// Authorization handler that succeeds when the user has at least one application read permission claim.
+///
+public sealed class ApplicationListPermissionHandler : AuthorizationHandler
+{
+ protected override Task HandleRequirementAsync(
+ AuthorizationHandlerContext context,
+ ApplicationListPermissionRequirement requirement)
+ {
+ var hasClaim = context.User.Claims.Any(c =>
+ c.Type == "permission" &&
+ c.Value.StartsWith("Application:", StringComparison.OrdinalIgnoreCase) &&
+ c.Value.EndsWith($":{requirement.Action}", StringComparison.OrdinalIgnoreCase));
+
+ if (hasClaim)
+ context.Succeed(requirement);
+
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationListPermissionRequirement.cs b/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationListPermissionRequirement.cs
new file mode 100644
index 00000000..68092fb3
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationListPermissionRequirement.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace DfE.ExternalApplications.Api.Security.Handlers;
+
+///
+/// Authorization requirement for reading the list of applications a user can access.
+///
+public sealed class ApplicationListPermissionRequirement(string action) : IAuthorizationRequirement
+{
+ public string Action { get; } = action;
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationPermissionHandler.cs b/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationPermissionHandler.cs
new file mode 100644
index 00000000..4ebccac0
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationPermissionHandler.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Authorization;
+using System.Security.Claims;
+
+namespace DfE.ExternalApplications.Api.Security.Handlers
+{
+ ///
+ /// Authorization handler that checks user permission claims for a specific application resource.
+ ///
+ public sealed class ApplicationPermissionHandler(IHttpContextAccessor accessor)
+ : AuthorizationHandler
+ {
+ protected override Task HandleRequirementAsync(
+ AuthorizationHandlerContext context,
+ ApplicationPermissionRequirement requirement)
+ {
+ var applicationId = accessor.HttpContext?.Request.RouteValues["applicationId"]?.ToString();
+ if (string.IsNullOrWhiteSpace(applicationId))
+ return Task.CompletedTask;
+
+ var expected = $"Application:{applicationId}:{requirement.Action}";
+ var hasClaim = context.User.Claims.Any(c =>
+ c.Type == "permission" &&
+ string.Equals(c.Value, expected, StringComparison.OrdinalIgnoreCase));
+
+ if (hasClaim)
+ context.Succeed(requirement);
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationPermissionRequirement.cs b/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationPermissionRequirement.cs
new file mode 100644
index 00000000..d51f3887
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/Handlers/ApplicationPermissionRequirement.cs
@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace DfE.ExternalApplications.Api.Security.Handlers
+{
+ ///
+ /// Authorization requirement for applications resource actions.
+ ///
+ public sealed class ApplicationPermissionRequirement(string action) : IAuthorizationRequirement
+ {
+ public string Action { get; } = action;
+ }
+}
diff --git a/src/DfE.ExternalApplications.Api/Security/Handlers/TemplatePermissionHandler.cs b/src/DfE.ExternalApplications.Api/Security/Handlers/TemplatePermissionHandler.cs
new file mode 100644
index 00000000..67559c9e
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/Handlers/TemplatePermissionHandler.cs
@@ -0,0 +1,27 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace DfE.ExternalApplications.Api.Security.Handlers
+{
+ public sealed class TemplatePermissionHandler(IHttpContextAccessor accessor)
+ : AuthorizationHandler
+ {
+ protected override Task HandleRequirementAsync(
+ AuthorizationHandlerContext context,
+ TemplatePermissionRequirement requirement)
+ {
+ var templateId = accessor.HttpContext?.Request.RouteValues["templateId"]?.ToString();
+ if (string.IsNullOrWhiteSpace(templateId))
+ return Task.CompletedTask;
+
+ var expected = $"Template:{templateId}:{requirement.Action}";
+ var hasClaim = context.User.Claims.Any(c =>
+ c.Type == "permission" &&
+ string.Equals(c.Value, expected, StringComparison.OrdinalIgnoreCase));
+
+ if (hasClaim)
+ context.Succeed(requirement);
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/DfE.ExternalApplications.Api/Security/Handlers/TemplatePermissionRequirement.cs b/src/DfE.ExternalApplications.Api/Security/Handlers/TemplatePermissionRequirement.cs
new file mode 100644
index 00000000..6b0083c3
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/Handlers/TemplatePermissionRequirement.cs
@@ -0,0 +1,9 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace DfE.ExternalApplications.Api.Security.Handlers
+{
+ public sealed class TemplatePermissionRequirement(string action) : IAuthorizationRequirement
+ {
+ public string Action { get; } = action;
+ }
+}
diff --git a/src/DfE.ExternalApplications.Api/Security/Handlers/UserPermissionHandler.cs b/src/DfE.ExternalApplications.Api/Security/Handlers/UserPermissionHandler.cs
new file mode 100644
index 00000000..0fd5c8bd
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/Handlers/UserPermissionHandler.cs
@@ -0,0 +1,40 @@
+using Microsoft.AspNetCore.Authorization;
+using System.Security.Claims;
+
+namespace DfE.ExternalApplications.Api.Security.Handlers
+{
+ ///
+ /// Authorization handler that checks user permission claims for a specific user resource.
+ ///
+ public sealed class UserPermissionHandler(IHttpContextAccessor accessor)
+ : AuthorizationHandler
+ {
+ protected override Task HandleRequirementAsync(
+ AuthorizationHandlerContext context,
+ UserPermissionRequirement requirement)
+ {
+ var httpContext = accessor.HttpContext;
+ var resourceKey = httpContext?.Request.RouteValues["email"]?.ToString();
+
+ if (string.IsNullOrWhiteSpace(resourceKey))
+ resourceKey = context.User.FindFirstValue(ClaimTypes.Email);
+
+ if (string.IsNullOrWhiteSpace(resourceKey))
+ resourceKey = context.User.FindFirst("appid")?.Value
+ ?? context.User.FindFirst("azp")?.Value;
+
+ if (string.IsNullOrWhiteSpace(resourceKey))
+ return Task.CompletedTask;
+
+ var expected = $"User:{resourceKey}:{requirement.Action}";
+ var hasClaim = context.User.Claims.Any(c =>
+ c.Type == "permission" &&
+ string.Equals(c.Value, expected, StringComparison.OrdinalIgnoreCase));
+
+ if (hasClaim)
+ context.Succeed(requirement);
+
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/src/DfE.ExternalApplications.Api/Security/Handlers/UserPermissionRequirement.cs b/src/DfE.ExternalApplications.Api/Security/Handlers/UserPermissionRequirement.cs
new file mode 100644
index 00000000..6c37ed7d
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/Handlers/UserPermissionRequirement.cs
@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace DfE.ExternalApplications.Api.Security.Handlers
+{
+ ///
+ /// Authorization requirement for user resource actions.
+ ///
+ public sealed class UserPermissionRequirement(string action) : IAuthorizationRequirement
+ {
+ public string Action { get; } = action;
+ }
+}
diff --git a/src/DfE.ExternalApplications.Api/Security/PermissionClaimProvider.cs b/src/DfE.ExternalApplications.Api/Security/PermissionClaimProvider.cs
new file mode 100644
index 00000000..8ab583a6
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/PermissionClaimProvider.cs
@@ -0,0 +1,48 @@
+using System.Security.Claims;
+using DfE.CoreLibs.Security.Interfaces;
+using DfE.ExternalApplications.Application.Users.Queries;
+using MediatR;
+using Microsoft.IdentityModel.JsonWebTokens;
+
+namespace DfE.ExternalApplications.Api.Security
+{
+ public class PermissionsClaimProvider(ISender sender, ILogger logger) : ICustomClaimProvider
+ {
+ public async Task> GetClaimsAsync(ClaimsPrincipal principal)
+ {
+ var issuer = principal.FindFirst(JwtRegisteredClaimNames.Iss)?.Value
+ ?? principal.FindFirst("iss")?.Value;
+ if (string.IsNullOrEmpty(issuer) ||
+ !issuer.Contains("windows.net", StringComparison.OrdinalIgnoreCase))
+ {
+ return Array.Empty();
+ }
+
+ var clientId = principal.FindFirst("appid")?.Value;
+
+ if (string.IsNullOrEmpty(clientId))
+ {
+ logger.LogWarning("PermissionsClaimProvider() > Azure token had no appid");
+ return Array.Empty();
+ }
+
+ var query = new GetAllUserPermissionsByExternalProviderIdQuery(clientId);
+ var result = await sender.Send(query);
+
+ if (result is { IsSuccess: false })
+ {
+ logger.LogWarning($"PermissionsClaimProvider() > Failed to return the user permissions for Azure AppId:{clientId}");
+ return Array.Empty();
+ }
+
+ return result.Value == null ?
+ Array.Empty() :
+ result.Value.Select(p =>
+ new Claim(
+ "permission",
+ $"{p.ResourceType}:{p.ResourceKey}:{p.AccessType}"
+ )
+ );
+ }
+ }
+}
diff --git a/src/DfE.ExternalApplications.Api/Security/TemplatePermissionClaimProvider.cs b/src/DfE.ExternalApplications.Api/Security/TemplatePermissionClaimProvider.cs
new file mode 100644
index 00000000..ab18b29d
--- /dev/null
+++ b/src/DfE.ExternalApplications.Api/Security/TemplatePermissionClaimProvider.cs
@@ -0,0 +1,40 @@
+using System.Security.Claims;
+using DfE.CoreLibs.Security.Interfaces;
+using DfE.ExternalApplications.Application.TemplatePermissions.Queries;
+using MediatR;
+using Microsoft.IdentityModel.JsonWebTokens;
+
+namespace DfE.ExternalApplications.Api.Security;
+public class TemplatePermissionsClaimProvider(ISender sender, ILogger logger) : ICustomClaimProvider
+{
+ public async Task> GetClaimsAsync(ClaimsPrincipal principal)
+ {
+ var issuer = principal.FindFirst(JwtRegisteredClaimNames.Iss)?.Value
+ ?? principal.FindFirst("iss")?.Value;
+ if (string.IsNullOrEmpty(issuer) || !issuer.Contains("windows.net", StringComparison.OrdinalIgnoreCase))
+ return Array.Empty();
+
+ var clientId = principal.FindFirst("appid")?.Value;
+ if (string.IsNullOrEmpty(clientId))
+ {
+ logger.LogWarning("TemplatePermissionsClaimProvider() > Azure token had no appid");
+ return Array.Empty();
+ }
+
+ var query = new GetTemplatePermissionsForUserByExternalProviderIdQuery(clientId);
+ var result = await sender.Send(query);
+
+ if (result is { IsSuccess: false })
+ {
+ logger.LogWarning($"TemplatePermissionsClaimProvider() > Failed to return the template permissions for Azure AppId:{clientId}");
+ return Array.Empty();
+ }
+
+ return result.Value == null
+ ? Array.Empty()
+ : result.Value.Select(p => new Claim(
+ "permission",
+ $"Template:{p.TemplateId}:{p.AccessType}")
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Api/Swagger/AuthenticationHeaderOperationFilter.cs b/src/DfE.ExternalApplications.Api/Swagger/AuthenticationHeaderOperationFilter.cs
index ef0a0e7a..ae9421a5 100644
--- a/src/DfE.ExternalApplications.Api/Swagger/AuthenticationHeaderOperationFilter.cs
+++ b/src/DfE.ExternalApplications.Api/Swagger/AuthenticationHeaderOperationFilter.cs
@@ -9,14 +9,8 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
operation.Security ??= new List();
- var securityScheme = new OpenApiSecurityScheme
+ var userScheme = new OpenApiSecurityScheme
{
- Scheme = "Bearer",
- BearerFormat = "JWT",
- In = ParameterLocation.Header,
- Name = "Authorization",
- Type = SecuritySchemeType.Http,
- Description = "Input your Bearer token in this format - Bearer {your token here}",
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
@@ -24,11 +18,21 @@ public void Apply(OpenApiOperation operation, OperationFilterContext context)
}
};
- operation.Security.Add(new OpenApiSecurityRequirement
+ var svcScheme = new OpenApiSecurityScheme
{
+ Reference = new OpenApiReference
{
- securityScheme, new List()
+ Type = ReferenceType.SecurityScheme,
+ Id = "ServiceBearer"
}
+ };
+ operation.Security.Add(new OpenApiSecurityRequirement
+ {
+ [userScheme] = Array.Empty()
+ });
+ operation.Security.Add(new OpenApiSecurityRequirement
+ {
+ [svcScheme] = Array.Empty()
});
}
}
diff --git a/src/DfE.ExternalApplications.Api/Swagger/SwaggerOptions.cs b/src/DfE.ExternalApplications.Api/Swagger/SwaggerOptions.cs
index 776a07b6..db3cc9d9 100644
--- a/src/DfE.ExternalApplications.Api/Swagger/SwaggerOptions.cs
+++ b/src/DfE.ExternalApplications.Api/Swagger/SwaggerOptions.cs
@@ -39,11 +39,32 @@ public void Configure(SwaggerGenOptions options)
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
- Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",
+ Description = "User JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
- Scheme = "Bearer"
+ Scheme = "bearer",
+ BearerFormat = "JWT",
+ Reference = new OpenApiReference
+ {
+ Type = ReferenceType.SecurityScheme,
+ Id = "Bearer"
+ }
+ });
+
+ options.AddSecurityDefinition("ServiceBearer", new OpenApiSecurityScheme
+ {
+ Description = "Service-to-service bearer token. Example: X-Service-Authorization: {token}",
+ Name = "X-Service-Authorization",
+ In = ParameterLocation.Header,
+ Type = SecuritySchemeType.Http,
+ Scheme = "bearer",
+ BearerFormat = "JWT",
+ Reference = new OpenApiReference
+ {
+ Type = ReferenceType.SecurityScheme,
+ Id = "ServiceBearer"
+ }
});
options.OperationFilter();
diff --git a/src/DfE.ExternalApplications.Api/appsettings.Development.json b/src/DfE.ExternalApplications.Api/appsettings.Development.json
index 44ee7395..58b5a7ae 100644
--- a/src/DfE.ExternalApplications.Api/appsettings.Development.json
+++ b/src/DfE.ExternalApplications.Api/appsettings.Development.json
@@ -19,11 +19,26 @@
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "platform.education.gov.uk",
- "TenantId": "836152b4-78f3-4c6d-825e-6fba2573a41d",
- "ClientId": "35910789-e4ce-47da-a8bb-4ab751eeb044",
- "Audience": "api://6835aa4d-d111-4657-9741-dac15031f013"
+ "TenantId": "9c7d9dd3-840c-4b3f-818e-552865082e16",
+ "ClientId": "65c013ab-cc5d-4fd9-8797-e81c5b16eb3e",
+ "Audience": "api://65c013ab-cc5d-4fd9-8797-e81c5b16eb3e"
},
"Features": {
"PerformanceLoggingEnabled": true
+ },
+ "DfESignIn": {
+ "Authority": "https://test-oidc.signin.education.gov.uk",
+ "ClientId": "RSDExternalApps",
+ "Issuer": "https://test-oidc.signin.education.gov.uk:443",
+ "JwksUri": "https://test-oidc.signin.education.gov.uk/jwks",
+ "DiscoveryEndpoint": "https://test-oidc.signin.education.gov.uk/.well-known/openid-configuration"
+ },
+ "Authorization":{
+ "TokenSettings": {
+ "SecretKey": "iw5/ivfUWaCpj+n3TihlGUzRVna+KKu8IfLP52GdgNXlDcqt3+N2MM45rwQ=",
+ "Issuer": "21f3ed37-8443-4755-9ed2-c68ca86b4398",
+ "Audience": "20dafd6d-79e5-4caf-8b72-d070dcc9716f",
+ "TokenLifetimeMinutes": 60
+ }
}
}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Api/appsettings.json b/src/DfE.ExternalApplications.Api/appsettings.json
index 332e223c..1cf8c7e7 100644
--- a/src/DfE.ExternalApplications.Api/appsettings.json
+++ b/src/DfE.ExternalApplications.Api/appsettings.json
@@ -46,25 +46,19 @@
"Authorization": {
"Policies": [
{
- "Name": "CanRead",
+ "Name": "SvcCanRead",
"Operator": "OR",
"Roles": [ "API.Read" ]
},
{
- "Name": "CanReadWrite",
+ "Name": "SvcCanReadWrite",
"Operator": "AND",
"Roles": [ "API.Read", "API.Write" ]
},
{
- "Name": "CanReadWritePlus",
+ "Name": "SvcCanReadWriteDelete",
"Operator": "AND",
- "Roles": [ "API.Read", "API.Write" ],
- "Claims": [
- {
- "Type": "API.PersonalInfo",
- "Values": [ "true" ]
- }
- ]
+ "Roles": [ "API.Read", "API.Write", "API.Delete" ]
}
]
}
diff --git a/src/DfE.ExternalApplications.Application/ApplicationServiceCollectionExtensions.cs b/src/DfE.ExternalApplications.Application/ApplicationServiceCollectionExtensions.cs
index ca7e6b91..e500d0db 100644
--- a/src/DfE.ExternalApplications.Application/ApplicationServiceCollectionExtensions.cs
+++ b/src/DfE.ExternalApplications.Application/ApplicationServiceCollectionExtensions.cs
@@ -13,7 +13,7 @@ public static IServiceCollection AddApplicationDependencyGroup(
{
var performanceLoggingEnabled = config.GetValue("Features:PerformanceLoggingEnabled");
- services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
+ services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly(), includeInternalTypes: true);
services.AddMediatR(cfg =>
{
diff --git a/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserByExternalProviderIdQueryHandler.cs b/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserByExternalProviderIdQueryHandler.cs
new file mode 100644
index 00000000..8109e946
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserByExternalProviderIdQueryHandler.cs
@@ -0,0 +1,74 @@
+using DfE.CoreLibs.Caching.Helpers;
+using DfE.CoreLibs.Caching.Interfaces;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.ExternalApplications.Application.Applications.QueryObjects;
+using DfE.ExternalApplications.Application.Users.QueryObjects;
+using DfE.ExternalApplications.Domain.Entities;
+using DfE.ExternalApplications.Domain.Interfaces.Repositories;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.Applications.Queries;
+
+public sealed record GetApplicationsForUserByExternalProviderIdQuery(string ExternalProviderId)
+ : IRequest>>;
+
+public sealed class GetApplicationsForUserByExternalProviderIdQueryHandler(
+ IEaRepository userRepo,
+ IEaRepository appRepo,
+ ICacheService cacheService)
+ : IRequestHandler>>
+{
+ public async Task>> Handle(
+ GetApplicationsForUserByExternalProviderIdQuery request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var cacheKey = $"Applications_ForUserExternal_{CacheKeyHelper.GenerateHashedCacheKey(request.ExternalProviderId)}";
+ var methodName = nameof(GetApplicationsForUserByExternalProviderIdQueryHandler);
+
+ return await cacheService.GetOrAddAsync(
+ cacheKey,
+ async () =>
+ {
+ var userWithPerms = await new GetUserWithAllPermissionsByExternalIdQueryObject(request.ExternalProviderId)
+ .Apply(userRepo.Query().AsNoTracking())
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (userWithPerms is null)
+ return Result>.Success(Array.Empty());
+
+ var ids = userWithPerms.Permissions
+ .Where(p => p.ApplicationId != null)
+ .Select(p => p.ApplicationId!)
+ .Distinct()
+ .ToList();
+
+ if (!ids.Any())
+ return Result>.Success(Array.Empty());
+
+ var apps = await new GetApplicationsByIdsQueryObject(ids)
+ .Apply(appRepo.Query().AsNoTracking())
+ .ToListAsync(cancellationToken);
+
+ var dtoList = apps.Select(a => new ApplicationDto
+ {
+ ApplicationId = a.Id!.Value,
+ ApplicationReference = a.ApplicationReference,
+ TemplateVersionId = a.TemplateVersionId.Value,
+ DateCreated = a.CreatedOn,
+ TemplateName = a.TemplateVersion?.Template?.Name ?? string.Empty,
+ Status = a.Status
+ }).ToList().AsReadOnly();
+
+ return Result>.Success(dtoList);
+ },
+ methodName);
+ }
+ catch (Exception e)
+ {
+ return Result>.Failure(e.ToString());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs b/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs
new file mode 100644
index 00000000..d2c24cfb
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserQueryHandler.cs
@@ -0,0 +1,72 @@
+using DfE.CoreLibs.Caching.Helpers;
+using DfE.CoreLibs.Caching.Interfaces;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.ExternalApplications.Application.Applications.QueryObjects;
+using DfE.ExternalApplications.Application.Users.QueryObjects;
+using DfE.ExternalApplications.Domain.Entities;
+using DfE.ExternalApplications.Domain.Interfaces.Repositories;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.Applications.Queries;
+
+public sealed record GetApplicationsForUserQuery(string Email)
+ : IRequest>>;
+
+public sealed class GetApplicationsForUserQueryHandler(
+ IEaRepository userRepo,
+ IEaRepository appRepo,
+ ICacheService cacheService)
+ : IRequestHandler>>
+{
+ public async Task>> Handle(
+ GetApplicationsForUserQuery request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var cacheKey = $"Applications_ForUser_{CacheKeyHelper.GenerateHashedCacheKey(request.Email)}";
+ var methodName = nameof(GetApplicationsForUserQueryHandler);
+
+ return await cacheService.GetOrAddAsync(
+ cacheKey,
+ async () =>
+ {
+ var userWithPerms = await new GetUserWithAllPermissionsQueryObject(request.Email)
+ .Apply(userRepo.Query().AsNoTracking())
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (userWithPerms is null)
+ return Result>.Success(Array.Empty());
+
+ var ids = userWithPerms.Permissions
+ .Where(p => p.ApplicationId != null)
+ .Select(p => p.ApplicationId!)
+ .Distinct()
+ .ToList();
+
+ if (!ids.Any())
+ return Result>.Success(Array.Empty());
+
+ var apps = await new GetApplicationsByIdsQueryObject(ids)
+ .Apply(appRepo.Query().AsNoTracking())
+ .ToListAsync(cancellationToken);
+
+ var dtoList = apps.Select(a => new ApplicationDto
+ {
+ ApplicationId = a.Id!.Value,
+ ApplicationReference = a.ApplicationReference,
+ TemplateVersionId = a.TemplateVersionId.Value,
+ Status = a.Status
+ }).ToList().AsReadOnly();
+
+ return Result>.Success(dtoList);
+ },
+ methodName);
+ }
+ catch (Exception e)
+ {
+ return Result>.Failure(e.ToString());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserQueryValidator.cs b/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserQueryValidator.cs
new file mode 100644
index 00000000..337bdbc2
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Applications/Queries/GetApplicationsForUserQueryValidator.cs
@@ -0,0 +1,15 @@
+using FluentValidation;
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("DfE.ExternalApplications.Application.Tests")]
+namespace DfE.ExternalApplications.Application.Applications.Queries;
+
+internal class GetApplicationsForUserQueryValidator : AbstractValidator
+{
+ public GetApplicationsForUserQueryValidator()
+ {
+ RuleFor(x => x.Email)
+ .NotEmpty()
+ .EmailAddress();
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/Applications/Queries/GetMyApplicationsQueryHandler.cs b/src/DfE.ExternalApplications.Application/Applications/Queries/GetMyApplicationsQueryHandler.cs
new file mode 100644
index 00000000..b4bfe7b3
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Applications/Queries/GetMyApplicationsQueryHandler.cs
@@ -0,0 +1,45 @@
+using System.Net.Http;
+using System.Security.Claims;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.CoreLibs.Security.Interfaces;
+using MediatR;
+using Microsoft.AspNetCore.Http;
+
+namespace DfE.ExternalApplications.Application.Applications.Queries;
+
+public sealed record GetMyApplicationsQuery() : IRequest>>;
+
+public sealed class GetMyApplicationsQueryHandler(
+ IHttpContextAccessor httpContextAccessor,
+ ISender mediator)
+ : IRequestHandler>>
+{
+ public async Task>> Handle(
+ GetMyApplicationsQuery request,
+ CancellationToken cancellationToken)
+ {
+ var user = httpContextAccessor.HttpContext?.User;
+ if (user is null || !user.Identity?.IsAuthenticated == true)
+ return Result>.Failure("Not authenticated");
+
+ var principalId = user.FindFirstValue(ClaimTypes.Email);
+
+ if (string.IsNullOrEmpty(principalId))
+ principalId = user.FindFirstValue("appid") ?? user.FindFirstValue("azp");
+
+ if (string.IsNullOrEmpty(principalId))
+ return Result>.Failure("No user identifier");
+
+ Result> result;
+ if (principalId.Contains('@'))
+ {
+ result = await mediator.Send(new GetApplicationsForUserQuery(principalId), cancellationToken);
+ }
+ else
+ {
+ result = await mediator.Send(new GetApplicationsForUserByExternalProviderIdQuery(principalId), cancellationToken);
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/Applications/QueryObjects/GetApplicationsByIdsQueryObject.cs b/src/DfE.ExternalApplications.Application/Applications/QueryObjects/GetApplicationsByIdsQueryObject.cs
new file mode 100644
index 00000000..caec44af
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Applications/QueryObjects/GetApplicationsByIdsQueryObject.cs
@@ -0,0 +1,16 @@
+using DfE.ExternalApplications.Application.Common.QueriesObjects;
+using Microsoft.EntityFrameworkCore;
+using ApplicationId = DfE.ExternalApplications.Domain.ValueObjects.ApplicationId;
+
+namespace DfE.ExternalApplications.Application.Applications.QueryObjects;
+
+public sealed class GetApplicationsByIdsQueryObject(IEnumerable ids)
+ : IQueryObject
+{
+ private readonly HashSet _ids = ids.ToHashSet();
+
+ public IQueryable Apply(IQueryable query) =>
+ query.Where(a => a.Id != null && _ids.Contains(a.Id))
+ .Include(a => a.TemplateVersion)
+ .ThenInclude(a => a!.Template);
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/Common/Behaviours/PerformanceBehaviour.cs b/src/DfE.ExternalApplications.Application/Common/Behaviours/PerformanceBehaviour.cs
index ec319abd..65d3f00c 100644
--- a/src/DfE.ExternalApplications.Application/Common/Behaviours/PerformanceBehaviour.cs
+++ b/src/DfE.ExternalApplications.Application/Common/Behaviours/PerformanceBehaviour.cs
@@ -1,15 +1,16 @@
+using DfE.CoreLibs.Security.Interfaces;
+using MediatR;
+using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
-using MediatR;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Logging;
namespace DfE.ExternalApplications.Application.Common.Behaviours
{
[ExcludeFromCodeCoverage]
public class PerformanceBehaviour(
ILogger logger,
- IHttpContextAccessor httpContextAccessor)
+ IHttpContextAccessor context)
: IPipelineBehavior
where TRequest : notnull
{
@@ -27,10 +28,8 @@ public async Task Handle(TRequest request, RequestHandlerDelegatenet8.0
+
+
+
+
+
+
+
+
+
-
+
+
+
@@ -21,9 +32,4 @@
-
-
-
-
-
diff --git a/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserByExternalProviderIdQueryHandler.cs b/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserByExternalProviderIdQueryHandler.cs
new file mode 100644
index 00000000..1bec1429
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserByExternalProviderIdQueryHandler.cs
@@ -0,0 +1,63 @@
+using DfE.CoreLibs.Caching.Helpers;
+using DfE.CoreLibs.Caching.Interfaces;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.ExternalApplications.Application.Users.QueryObjects;
+using DfE.ExternalApplications.Domain.Entities;
+using DfE.ExternalApplications.Domain.Interfaces.Repositories;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.TemplatePermissions.Queries
+{
+ public sealed record GetTemplatePermissionsForUserByExternalProviderIdQuery(string ExternalProviderId)
+ : IRequest>>;
+
+ public sealed class GetTemplatePermissionsForUserByExternalProviderIdQueryHandler(
+ IEaRepository userRepo,
+ ICacheService cacheService)
+ : IRequestHandler>>
+ {
+ public async Task>> Handle(
+ GetTemplatePermissionsForUserByExternalProviderIdQuery request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var cacheKey = $"Template_Permissions_ByExternalId_{CacheKeyHelper.GenerateHashedCacheKey(request.ExternalProviderId)}";
+ var methodName = nameof(GetTemplatePermissionsForUserByExternalProviderIdQueryHandler);
+
+ return await cacheService.GetOrAddAsync(
+ cacheKey,
+ async () =>
+ {
+ var userWithTemplatePermissions = await new GetUserWithAllTemplatePermissionsByExternalIdQueryObject(request.ExternalProviderId)
+ .Apply(userRepo.Query().AsNoTracking())
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (userWithTemplatePermissions is null)
+ {
+ return Result>.Success(Array.Empty());
+ }
+
+ var dtoList = userWithTemplatePermissions.TemplatePermissions
+ .Select(p => new TemplatePermissionDto
+ {
+ TemplatePermissionId = p.Id?.Value,
+ UserId = p.UserId.Value,
+ TemplateId = p.TemplateId.Value,
+ AccessType = p.AccessType
+ })
+ .ToList()
+ .AsReadOnly();
+
+ return Result>.Success(dtoList);
+ },
+ methodName);
+ }
+ catch (Exception e)
+ {
+ return Result>.Failure(e.ToString());
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserQueryHandler.cs b/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserQueryHandler.cs
new file mode 100644
index 00000000..f153b09e
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserQueryHandler.cs
@@ -0,0 +1,66 @@
+using DfE.CoreLibs.Caching.Helpers;
+using DfE.CoreLibs.Caching.Interfaces;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.ExternalApplications.Application.TemplatePermissions.QueryObjects;
+using DfE.ExternalApplications.Domain.Entities;
+using DfE.ExternalApplications.Domain.Interfaces.Repositories;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.TemplatePermissions.Queries
+{
+ public sealed record GetTemplatePermissionsForUserQuery(string Email)
+ : IRequest>>;
+
+ public sealed class GetTemplatePermissionsForUserQueryHandler(
+ IEaRepository userRepo,
+ ICacheService cacheService)
+ : IRequestHandler>>
+ {
+ public async Task>> Handle(
+ GetTemplatePermissionsForUserQuery request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var cacheKey = $"Template_Permissions_{CacheKeyHelper.GenerateHashedCacheKey(request.Email)}";
+
+ var methodName = nameof(GetTemplatePermissionsForUserQueryHandler);
+
+ return await cacheService.GetOrAddAsync(
+ cacheKey,
+ async () =>
+ {
+ var userWithTemplatePermissions =
+ await new GetTemplatePermissionsForUserQueryObject(request.Email)
+ .Apply(userRepo.Query().AsNoTracking())
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (userWithTemplatePermissions is null)
+ {
+ return Result>.Success(
+ Array.Empty());
+ }
+
+ var dtoList = userWithTemplatePermissions.TemplatePermissions
+ .Select(p => new TemplatePermissionDto
+ {
+ TemplatePermissionId = p.Id?.Value,
+ UserId = p.UserId.Value,
+ TemplateId = p.TemplateId.Value,
+ AccessType = p.AccessType
+ })
+ .ToList()
+ .AsReadOnly();
+
+ return Result>.Success(dtoList);
+ },
+ methodName);
+ }
+ catch (Exception e)
+ {
+ return Result>.Failure(e.ToString());
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserQueryValidator.cs b/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserQueryValidator.cs
new file mode 100644
index 00000000..ff8c21fb
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/TemplatePermissions/Queries/GetTemplatePermissionsForUserQueryValidator.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Threading.Tasks;
+using FluentValidation;
+
+[assembly: InternalsVisibleTo("DfE.ExternalApplications.Application.Tests")]
+namespace DfE.ExternalApplications.Application.TemplatePermissions.Queries
+{
+ internal class GetTemplatePermissionsForUserQueryValidator : AbstractValidator
+ {
+ public GetTemplatePermissionsForUserQueryValidator()
+ {
+ RuleFor(x => x.Email)
+ .NotEmpty()
+ .EmailAddress();
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/TemplatePermissions/QueryObjects/GetTemplatePermissionsForUserQueryObject.cs b/src/DfE.ExternalApplications.Application/TemplatePermissions/QueryObjects/GetTemplatePermissionsForUserQueryObject.cs
new file mode 100644
index 00000000..41d5b8f2
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/TemplatePermissions/QueryObjects/GetTemplatePermissionsForUserQueryObject.cs
@@ -0,0 +1,27 @@
+using DfE.ExternalApplications.Application.Common.QueriesObjects;
+using DfE.ExternalApplications.Domain.Entities;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.TemplatePermissions.QueryObjects
+{
+ ///
+ /// Filters to one user by normalized email, and includes all Template Permission children.
+ ///
+ public sealed class GetTemplatePermissionsForUserQueryObject(string email)
+ : IQueryObject
+ {
+ private readonly string _normalizedEmail = email.Trim().ToLowerInvariant();
+
+ public IQueryable Apply(IQueryable query)
+ {
+ return query
+ .Where(u => u.Email.ToLower() == _normalizedEmail)
+ .Include(u => u.TemplatePermissions);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Application/Templates/Queries/GetLatestTemplateSchemaQueryHandler.cs b/src/DfE.ExternalApplications.Application/Templates/Queries/GetLatestTemplateSchemaQueryHandler.cs
index bc522e30..e7a23893 100644
--- a/src/DfE.ExternalApplications.Application/Templates/Queries/GetLatestTemplateSchemaQueryHandler.cs
+++ b/src/DfE.ExternalApplications.Application/Templates/Queries/GetLatestTemplateSchemaQueryHandler.cs
@@ -9,11 +9,11 @@
namespace DfE.ExternalApplications.Application.Templates.Queries;
-public sealed record GetLatestTemplateSchemaQuery(string TemplateName, Guid UserId)
+public sealed record GetLatestTemplateSchemaQuery(Guid TemplateId, string Email)
: IRequest>;
public sealed class GetLatestTemplateSchemaQueryHandler(
- IEaRepository accessRepo,
+ IEaRepository accessRepo,
IEaRepository versionRepo,
ICacheService cacheService)
: IRequestHandler>
@@ -24,7 +24,7 @@ public async Task> Handle(
{
try
{
- var cacheKey = $"TemplateSchema_{CacheKeyHelper.GenerateHashedCacheKey(request.TemplateName)}_{request.UserId}";
+ var cacheKey = $"TemplateSchema_{CacheKeyHelper.GenerateHashedCacheKey(request.TemplateId.ToString())}_{request.Email}";
var methodName = nameof(GetLatestTemplateSchemaQueryHandler);
@@ -32,7 +32,7 @@ public async Task> Handle(
cacheKey,
async () =>
{
- var access = await new GetUserTemplateAccessByTemplateNameQueryObject(request.UserId, request.TemplateName)
+ var access = await new GetTemplatePermissionByTemplateNameQueryObject(request.Email, request.TemplateId)
.Apply(accessRepo.Query().AsNoTracking())
.FirstOrDefaultAsync(cancellationToken);
diff --git a/src/DfE.ExternalApplications.Application/Templates/Queries/GetLatestTemplateSchemaQueryValidator.cs b/src/DfE.ExternalApplications.Application/Templates/Queries/GetLatestTemplateSchemaQueryValidator.cs
index 209ee2e8..a4f0352e 100644
--- a/src/DfE.ExternalApplications.Application/Templates/Queries/GetLatestTemplateSchemaQueryValidator.cs
+++ b/src/DfE.ExternalApplications.Application/Templates/Queries/GetLatestTemplateSchemaQueryValidator.cs
@@ -8,9 +8,9 @@ internal class GetLatestTemplateSchemaQueryValidator : AbstractValidator x.TemplateName)
+ RuleFor(x => x.TemplateId)
.NotEmpty();
- RuleFor(x => x.UserId)
+ RuleFor(x => x.Email)
.NotEmpty();
}
}
diff --git a/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetLatestTemplateVersionForTemplateQueryObject.cs b/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetLatestTemplateVersionForTemplateQueryObject.cs
index 46a9cb6c..2d20e0b3 100644
--- a/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetLatestTemplateVersionForTemplateQueryObject.cs
+++ b/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetLatestTemplateVersionForTemplateQueryObject.cs
@@ -10,6 +10,5 @@ public sealed class GetLatestTemplateVersionForTemplateQueryObject(TemplateId te
public IQueryable Apply(IQueryable query) =>
query
.Where(tv => tv.TemplateId == templateId)
- .OrderByDescending(tv => tv.CreatedOn)
- .Take(1);
+ .OrderByDescending(tv => tv.CreatedOn);
}
diff --git a/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetUserTemplateAccessByTemplateNameQueryObject.cs b/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetUserTemplateAccessByTemplateNameQueryObject.cs
index a55b5974..f0579cec 100644
--- a/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetUserTemplateAccessByTemplateNameQueryObject.cs
+++ b/src/DfE.ExternalApplications.Application/Templates/QueryObjects/GetUserTemplateAccessByTemplateNameQueryObject.cs
@@ -5,14 +5,17 @@
namespace DfE.ExternalApplications.Application.Templates.QueryObjects;
-public sealed class GetUserTemplateAccessByTemplateNameQueryObject(Guid userId, string templateName)
- : IQueryObject
+public sealed class GetTemplatePermissionByTemplateNameQueryObject(string email, Guid templateId)
+ : IQueryObject
{
- private readonly UserId _userId = new(userId);
- private readonly string _normalizedName = templateName.Trim().ToLowerInvariant();
+ private readonly string _normalizedEmail = email.Trim().ToLowerInvariant();
- public IQueryable Apply(IQueryable query) =>
+ public IQueryable Apply(IQueryable query) =>
query
.Include(x => x.Template)
- .Where(x => x.UserId == _userId && x.Template!.Name.ToLower() == _normalizedName);
+ .Include(x => x.User)
+ .Where(x =>
+ x.User != null
+ && x.User.Email.ToLower() == _normalizedEmail
+ && x.Template!.Id == new TemplateId(templateId));
}
diff --git a/src/DfE.ExternalApplications.Application/Users/Queries/ExchangeTokenQuery.cs b/src/DfE.ExternalApplications.Application/Users/Queries/ExchangeTokenQuery.cs
new file mode 100644
index 00000000..93cab09e
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Users/Queries/ExchangeTokenQuery.cs
@@ -0,0 +1,67 @@
+using DfE.CoreLibs.Security.Interfaces;
+using DfE.ExternalApplications.Application.Common.Models;
+using DfE.ExternalApplications.Application.Users.QueryObjects;
+using DfE.ExternalApplications.Domain.Entities;
+using DfE.ExternalApplications.Domain.Interfaces.Repositories;
+using MediatR;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.IdentityModel.Tokens;
+using System.Security.Claims;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+
+namespace DfE.ExternalApplications.Application.Users.Queries
+{
+ public record ExchangeTokenQuery(string SubjectToken) : IRequest;
+
+ public class ExchangeTokenQueryHandler(
+ IExternalIdentityValidator externalValidator,
+ IEaRepository userRepo,
+ IUserTokenService tokenSvc,
+ IHttpContextAccessor httpCtxAcc)
+ : IRequestHandler
+ {
+ public async Task Handle(ExchangeTokenQuery req, CancellationToken ct)
+ {
+ var externalUser = await externalValidator
+ .ValidateIdTokenAsync(req.SubjectToken, ct);
+
+ var email = externalUser.FindFirst(ClaimTypes.Email)?.Value
+ ?? throw new SecurityTokenException("Missing email");
+
+ var httpCtx = httpCtxAcc.HttpContext!;
+ var azureAuth = await httpCtx.AuthenticateAsync("AzureEntra");
+ var svcRoles = azureAuth.Succeeded
+ ? azureAuth.Principal.Claims.Where(c => c.Type == ClaimTypes.Role || c.Type == "roles")
+ : Enumerable.Empty();
+
+ var identity = new ClaimsIdentity(externalUser.Identity!);
+ identity.AddClaims(svcRoles);
+
+ var userWithPerms = await new GetUserWithAllPermissionsQueryObject(email)
+ .Apply(userRepo.Query().AsNoTracking())
+ .FirstOrDefaultAsync(ct);
+ var userPerms = userWithPerms?.Permissions;
+
+ var templateWithPerms = await new GetUserWithAllTemplatePermissionsQueryObject(email)
+ .Apply(userRepo.Query().AsNoTracking())
+ .FirstOrDefaultAsync(ct);
+ var templatePerms = templateWithPerms?.TemplatePermissions;
+
+ foreach (var p in userPerms ?? [])
+ identity.AddClaim(new Claim(
+ "permission",
+ $"{p.ResourceType}:{p.ResourceKey}:{p.AccessType}"));
+ foreach (var tp in templatePerms ?? [])
+ identity.AddClaim(new Claim(
+ "permission",
+ $"Template:{tp.TemplateId.Value}:{tp.AccessType.ToString()}"));
+
+ var mergedUser = new ClaimsPrincipal(identity);
+
+ var internalToken = await tokenSvc.GetUserTokenAsync(mergedUser);
+ return new ExchangeTokenDto(internalToken);
+ }
+ }
+}
diff --git a/src/DfE.ExternalApplications.Application/Users/Queries/ExchangeTokenQueryValidator.cs b/src/DfE.ExternalApplications.Application/Users/Queries/ExchangeTokenQueryValidator.cs
new file mode 100644
index 00000000..e0e8baa4
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Users/Queries/ExchangeTokenQueryValidator.cs
@@ -0,0 +1,12 @@
+using FluentValidation;
+
+namespace DfE.ExternalApplications.Application.Users.Queries
+{
+ public class ExchangeTokenQueryValidator : AbstractValidator
+ {
+ public ExchangeTokenQueryValidator()
+ {
+ RuleFor(x => x.SubjectToken).NotEmpty().WithMessage("Subject token must be provided");
+ }
+ }
+}
diff --git a/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsByExternalProviderIdQueryHandler.cs b/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsByExternalProviderIdQueryHandler.cs
new file mode 100644
index 00000000..f11b3669
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsByExternalProviderIdQueryHandler.cs
@@ -0,0 +1,56 @@
+using DfE.CoreLibs.Caching.Helpers;
+using DfE.CoreLibs.Caching.Interfaces;
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.ExternalApplications.Application.Users.QueryObjects;
+using DfE.ExternalApplications.Domain.Entities;
+using DfE.ExternalApplications.Domain.Interfaces.Repositories;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.Users.Queries
+{
+ public sealed record GetAllUserPermissionsByExternalProviderIdQuery(string ExternalProviderId)
+ : IRequest>>;
+
+ public sealed class GetAllUserPermissionsByExternalProviderIdQueryHandler(
+ IEaRepository userRepo,
+ ICacheService cache)
+ : IRequestHandler>>
+ {
+ public async Task>> Handle(
+ GetAllUserPermissionsByExternalProviderIdQuery request,
+ CancellationToken cancellationToken)
+ {
+ var cacheKey = $"Permissions_ByExternalId_{CacheKeyHelper.GenerateHashedCacheKey(request.ExternalProviderId)}";
+
+ return await cache.GetOrAddAsync(
+ cacheKey,
+ async () =>
+ {
+ var userWithPerms = await new GetUserWithAllPermissionsByExternalIdQueryObject(request.ExternalProviderId)
+ .Apply(userRepo.Query().AsNoTracking())
+ .FirstOrDefaultAsync(cancellationToken);
+
+ if (userWithPerms is null)
+ return Result>.Success(
+ Array.Empty());
+
+ var dtoList = userWithPerms.Permissions
+ .Select(p => new UserPermissionDto
+ {
+ ApplicationId = p.ApplicationId?.Value,
+ ResourceType = p.ResourceType,
+ ResourceKey = p.ResourceKey,
+ AccessType = p.AccessType
+ })
+ .ToList()
+ .AsReadOnly();
+
+ return Result>.Success(dtoList);
+ },
+ nameof(GetAllUserPermissionsByExternalProviderIdQueryHandler));
+ }
+ }
+
+}
diff --git a/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsQueryHandler.cs b/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsQueryHandler.cs
index aae1eb1b..f50a03b8 100644
--- a/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsQueryHandler.cs
+++ b/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsQueryHandler.cs
@@ -1,7 +1,6 @@
using DfE.CoreLibs.Caching.Helpers;
using DfE.CoreLibs.Caching.Interfaces;
using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
-using DfE.ExternalApplications.Application.Common.Models;
using DfE.ExternalApplications.Application.Users.QueryObjects;
using DfE.ExternalApplications.Domain.Entities;
using DfE.ExternalApplications.Domain.Interfaces.Repositories;
@@ -44,7 +43,8 @@ public async Task>> Handle(
var dtoList = userWithPermissions.Permissions
.Select(p => new UserPermissionDto
{
- ApplicationId = p.ApplicationId.Value,
+ ApplicationId = p.ApplicationId?.Value,
+ ResourceType = p.ResourceType,
ResourceKey = p.ResourceKey,
AccessType = p.AccessType
})
diff --git a/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsQueryValidator.cs b/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsQueryValidator.cs
index 91615311..59ee4152 100644
--- a/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsQueryValidator.cs
+++ b/src/DfE.ExternalApplications.Application/Users/Queries/GetAllUserPermissionsQueryValidator.cs
@@ -9,8 +9,8 @@ internal class GetAllUserPermissionsQueryValidator : AbstractValidator x.Email)
- .NotEmpty()
- .EmailAddress();
+ .EmailAddress()
+ .NotEmpty();
}
}
}
diff --git a/src/DfE.ExternalApplications.Application/Users/Queries/GetMyPermissionsQueryHandler.cs b/src/DfE.ExternalApplications.Application/Users/Queries/GetMyPermissionsQueryHandler.cs
new file mode 100644
index 00000000..92232006
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Users/Queries/GetMyPermissionsQueryHandler.cs
@@ -0,0 +1,47 @@
+using DfE.CoreLibs.Contracts.ExternalApplications.Models.Response;
+using DfE.CoreLibs.Security.Interfaces;
+using MediatR;
+using Microsoft.AspNetCore.Http;
+using System.Security.Claims;
+
+namespace DfE.ExternalApplications.Application.Users.Queries
+{
+ public sealed record GetMyPermissionsQuery()
+ : IRequest>>;
+
+ public sealed class GetMyPermissionsQueryHandler(
+ IHttpContextAccessor httpContextAccessor,
+ ISender mediator)
+ : IRequestHandler>>
+ {
+ public async Task>> Handle(
+ GetMyPermissionsQuery request,
+ CancellationToken cancellationToken)
+ {
+ var user = httpContextAccessor.HttpContext?.User;
+ if (user is null || !user.Identity?.IsAuthenticated == true)
+ return Result>.Failure("Not authenticated");
+
+ var principalId = user.FindFirstValue(ClaimTypes.Email);
+
+ if (string.IsNullOrEmpty(principalId))
+ principalId = user.FindFirstValue("appid") ?? user.FindFirstValue("azp");
+
+
+ if (string.IsNullOrEmpty(principalId))
+ return Result>.Failure("No user identifier");
+
+ Result> result;
+ if (principalId.Contains('@'))
+ {
+ result = await mediator.Send(new GetAllUserPermissionsQuery(principalId), cancellationToken);
+ }
+ else
+ {
+ result = await mediator.Send(new GetAllUserPermissionsByExternalProviderIdQuery(principalId), cancellationToken);
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllPermissionsByExternalIdQueryObject.cs b/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllPermissionsByExternalIdQueryObject.cs
new file mode 100644
index 00000000..8d8fc5ff
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllPermissionsByExternalIdQueryObject.cs
@@ -0,0 +1,13 @@
+using DfE.ExternalApplications.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.Users.QueryObjects
+{
+ public class GetUserWithAllPermissionsByExternalIdQueryObject(string externalProviderId)
+ {
+ public IQueryable Apply(IQueryable query) =>
+ query
+ .Where(u => u.ExternalProviderId == externalProviderId)
+ .Include(u => u.Permissions);
+ }
+}
diff --git a/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllTemplatePermissionsByExternalIdQueryObject.cs b/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllTemplatePermissionsByExternalIdQueryObject.cs
new file mode 100644
index 00000000..9ee23daf
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllTemplatePermissionsByExternalIdQueryObject.cs
@@ -0,0 +1,13 @@
+using DfE.ExternalApplications.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.Users.QueryObjects
+{
+ public sealed class GetUserWithAllTemplatePermissionsByExternalIdQueryObject(string externalProviderId)
+ {
+ public IQueryable Apply(IQueryable query) =>
+ query
+ .Where(u => u.ExternalProviderId == externalProviderId)
+ .Include(u => u.TemplatePermissions);
+ }
+}
diff --git a/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllTemplatePermissionsQueryObject.cs b/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllTemplatePermissionsQueryObject.cs
new file mode 100644
index 00000000..0c41661f
--- /dev/null
+++ b/src/DfE.ExternalApplications.Application/Users/QueryObjects/GetUserWithAllTemplatePermissionsQueryObject.cs
@@ -0,0 +1,22 @@
+using DfE.ExternalApplications.Application.Common.QueriesObjects;
+using DfE.ExternalApplications.Domain.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace DfE.ExternalApplications.Application.Users.QueryObjects
+{
+ ///
+ /// Filters to one user by normalized email, and includes all Template Permission children.
+ ///
+ public sealed class GetUserWithAllTemplatePermissionsQueryObject(string email)
+ : IQueryObject
+ {
+ private readonly string _normalizedEmail = email.Trim().ToLowerInvariant();
+
+ public IQueryable Apply(IQueryable query)
+ {
+ return query
+ .Where(u => u.Email.ToLower() == _normalizedEmail)
+ .Include(u => u.TemplatePermissions);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj b/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj
index 31cb1b46..78712a0f 100644
--- a/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj
+++ b/src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj
@@ -6,7 +6,7 @@
-
+
diff --git a/src/DfE.ExternalApplications.Domain/Entities/Permission.cs b/src/DfE.ExternalApplications.Domain/Entities/Permission.cs
index 613a3fce..bc4818db 100644
--- a/src/DfE.ExternalApplications.Domain/Entities/Permission.cs
+++ b/src/DfE.ExternalApplications.Domain/Entities/Permission.cs
@@ -10,8 +10,9 @@ public sealed class Permission : IEntity
public PermissionId? Id { get; private set; }
public UserId UserId { get; private set; }
public User? User { get; private set; }
- public ApplicationId ApplicationId { get; private set; }
+ public ApplicationId? ApplicationId { get; private set; }
public Application? Application { get; private set; }
+ public ResourceType ResourceType { get; private set; }
public string ResourceKey { get; private set; } = null!;
public AccessType AccessType { get; private set; }
public DateTime GrantedOn { get; private set; }
@@ -26,6 +27,7 @@ public Permission(
UserId userId,
ApplicationId applicationId,
string resourceKey,
+ ResourceType resourceType,
AccessType accessType,
DateTime grantedOn,
UserId grantedBy)
@@ -34,6 +36,7 @@ public Permission(
UserId = userId;
ApplicationId = applicationId;
ResourceKey = resourceKey ?? throw new ArgumentNullException(nameof(resourceKey));
+ ResourceType = resourceType;
AccessType = accessType;
GrantedOn = grantedOn;
GrantedBy = grantedBy;
diff --git a/src/DfE.ExternalApplications.Domain/Entities/TemplatePermission.cs b/src/DfE.ExternalApplications.Domain/Entities/TemplatePermission.cs
new file mode 100644
index 00000000..1e5179c1
--- /dev/null
+++ b/src/DfE.ExternalApplications.Domain/Entities/TemplatePermission.cs
@@ -0,0 +1,42 @@
+using DfE.CoreLibs.Contracts.ExternalApplications.Enums;
+using DfE.ExternalApplications.Domain.Common;
+using DfE.ExternalApplications.Domain.ValueObjects;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace DfE.ExternalApplications.Domain.Entities
+{
+ public sealed class TemplatePermission : BaseAggregateRoot, IEntity
+ {
+ public TemplatePermissionId? Id { get; private set; }
+ public UserId UserId { get; private set; }
+ public User? User { get; private set; }
+ public TemplateId TemplateId { get; private set; }
+ public Template? Template { get; private set; }
+ public AccessType AccessType { get; private set; }
+ public DateTime GrantedOn { get; private set; }
+ public UserId GrantedBy { get; private set; }
+ public User? GrantedByUser { get; private set; }
+
+ private TemplatePermission() { /* For EF Core */ }
+
+ public TemplatePermission(
+ TemplatePermissionId id,
+ UserId userId,
+ TemplateId templateId,
+ AccessType accessType,
+ DateTime grantedOn,
+ UserId grantedBy)
+ {
+ Id = id ?? throw new ArgumentNullException(nameof(id));
+ UserId = userId ?? throw new ArgumentNullException(nameof(userId));
+ TemplateId = templateId ?? throw new ArgumentNullException(nameof(templateId));
+ AccessType = accessType;
+ GrantedOn = grantedOn;
+ GrantedBy = grantedBy;
+ }
+ }
+}
diff --git a/src/DfE.ExternalApplications.Domain/Entities/User.cs b/src/DfE.ExternalApplications.Domain/Entities/User.cs
index 4fa2304b..843cbb4e 100644
--- a/src/DfE.ExternalApplications.Domain/Entities/User.cs
+++ b/src/DfE.ExternalApplications.Domain/Entities/User.cs
@@ -18,12 +18,17 @@ public sealed class User : BaseAggregateRoot, IEntity
public DateTime? LastModifiedOn { get; private set; }
public UserId? LastModifiedBy { get; private set; }
public User? LastModifiedByUser { get; private set; }
+ public string? ExternalProviderId { get; private set; }
private readonly List _permissions = new();
+ private readonly List _templatePermissions = new();
public IReadOnlyCollection Permissions
=> _permissions.AsReadOnly();
+ public IReadOnlyCollection TemplatePermissions
+ => _templatePermissions.AsReadOnly();
+
private User()
{
// Required by EF Core to materialise the entity.
@@ -42,7 +47,9 @@ public User(
UserId? createdBy,
DateTime? lastModifiedOn,
UserId? lastModifiedBy,
- IEnumerable? initialPermissions = null)
+ string? externalProviderId = null,
+ IEnumerable? initialPermissions = null,
+ IEnumerable? initialTemplatePermissions = null)
{
Id = id ?? throw new ArgumentNullException(nameof(id));
RoleId = roleId ?? throw new ArgumentNullException(nameof(roleId));
@@ -52,11 +59,17 @@ public User(
CreatedBy = createdBy;
LastModifiedOn = lastModifiedOn;
LastModifiedBy = lastModifiedBy;
+ ExternalProviderId = externalProviderId;
if (initialPermissions != null)
{
_permissions.AddRange(initialPermissions);
}
+
+ if (initialTemplatePermissions != null)
+ {
+ _templatePermissions.AddRange(initialTemplatePermissions);
+ }
}
///
@@ -65,6 +78,7 @@ public User(
public Permission AddPermission(
ApplicationId applicationId,
string resourceKey,
+ ResourceType resourceType,
AccessType accessType,
UserId grantedBy,
DateTime? grantedOn = null)
@@ -80,6 +94,7 @@ public Permission AddPermission(
this.Id ?? throw new InvalidOperationException("UserId must be set before adding a permission."),
applicationId,
resourceKey,
+ resourceType,
accessType,
when,
grantedBy);
diff --git a/src/DfE.ExternalApplications.Domain/Entities/UserTemplateAccess.cs b/src/DfE.ExternalApplications.Domain/Entities/UserTemplateAccess.cs
deleted file mode 100644
index 5c25c7fe..00000000
--- a/src/DfE.ExternalApplications.Domain/Entities/UserTemplateAccess.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using DfE.ExternalApplications.Domain.Common;
-using DfE.ExternalApplications.Domain.ValueObjects;
-
-namespace DfE.ExternalApplications.Domain.Entities;
-
-public sealed class UserTemplateAccess : BaseAggregateRoot, IEntity
-{
- public UserTemplateAccessId? Id { get; private set; }
- public UserId UserId { get; private set; }
- public User? User { get; private set; }
- public TemplateId TemplateId { get; private set; }
- public Template? Template { get; private set; }
- public DateTime GrantedOn { get; private set; }
- public UserId GrantedBy { get; private set; }
- public User? GrantedByUser { get; private set; }
-
- private UserTemplateAccess() { /* For EF Core */ }
-
- ///
- /// Constructs a new UserTemplateAccess.
- ///
- public UserTemplateAccess(
- UserTemplateAccessId id,
- UserId userId,
- TemplateId templateId,
- DateTime grantedOn,
- UserId grantedBy)
- {
- Id = id ?? throw new ArgumentNullException(nameof(id));
- UserId = userId ?? throw new ArgumentNullException(nameof(userId));
- TemplateId = templateId ?? throw new ArgumentNullException(nameof(templateId));
- GrantedOn = grantedOn;
- GrantedBy = grantedBy;
- }
-}
\ No newline at end of file
diff --git a/src/DfE.ExternalApplications.Domain/ValueObjects/StronglyTypedIds.cs b/src/DfE.ExternalApplications.Domain/ValueObjects/StronglyTypedIds.cs
index b376187b..a7f5a46c 100644
--- a/src/DfE.ExternalApplications.Domain/ValueObjects/StronglyTypedIds.cs
+++ b/src/DfE.ExternalApplications.Domain/ValueObjects/StronglyTypedIds.cs
@@ -9,6 +9,6 @@ public record TemplateVersionId(Guid Value) : IStronglyTypedId;
public record ApplicationId(Guid Value) : IStronglyTypedId;
public record ResponseId(Guid Value) : IStronglyTypedId;
public record PermissionId(Guid Value) : IStronglyTypedId;
- public record UserTemplateAccessId(Guid Value) : IStronglyTypedId;
public record TaskAssignmentLabelId(Guid Value) : IStronglyTypedId;
+ public record TemplatePermissionId(Guid Value) : IStronglyTypedId;
}
diff --git a/src/DfE.ExternalApplications.Infrastructure/Database/ExternalApplicationsContext.cs b/src/DfE.ExternalApplications.Infrastructure/Database/ExternalApplicationsContext.cs
index 7022406e..218070b2 100644
--- a/src/DfE.ExternalApplications.Infrastructure/Database/ExternalApplicationsContext.cs
+++ b/src/DfE.ExternalApplications.Infrastructure/Database/ExternalApplicationsContext.cs
@@ -1,4 +1,5 @@
using DfE.CoreLibs.Contracts.ExternalApplications.Enums;
+using DfE.ExternalApplications.Domain.Common;
using DfE.ExternalApplications.Domain.Entities;
using DfE.ExternalApplications.Domain.ValueObjects;
using DfE.ExternalApplications.Infrastructure.Database.Interceptors;
@@ -35,8 +36,8 @@ public ExternalApplicationsContext(DbContextOptions
public DbSet Applications { get; set; } = null!;
public DbSet ApplicationResponses { get; set; } = null!;
public DbSet Permissions { get; set; } = null!;
- public DbSet UserTemplateAccesses { get; set; } = null!;
public DbSet TaskAssignmentLabels { get; set; } = null!;
+ public DbSet TemplatePermissions { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -59,7 +60,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity(ConfigureApplication);
modelBuilder.Entity(ConfigureApplicationResponse);
modelBuilder.Entity(ConfigurePermission);
- modelBuilder.Entity(ConfigureUserTemplateAccess);
+ modelBuilder.Entity