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(ConfigureTemplatePermission); modelBuilder.Entity(ConfigureTaskAssignmentLabel); base.OnModelCreating(modelBuilder); @@ -117,6 +118,10 @@ private static void ConfigureUser(EntityTypeBuilder b) .HasColumnName("LastModifiedBy") .HasConversion(v => v!.Value, v => new UserId(v)) .IsRequired(false); + b.Property(u => u.ExternalProviderId) + .HasMaxLength(100) + .IsUnicode(false); + b.HasIndex(u => u.ExternalProviderId).IsUnique(); b.HasIndex(e => e.Email).IsUnique(); b.HasOne(e => e.Role) .WithMany() @@ -133,6 +138,10 @@ private static void ConfigureUser(EntityTypeBuilder b) .WithOne(p => p.User) .HasForeignKey(p => p.UserId) .OnDelete(DeleteBehavior.Cascade); + b.HasMany(u => u.TemplatePermissions) + .WithOne(p => p.User) + .HasForeignKey(p => p.UserId) + .OnDelete(DeleteBehavior.Cascade); } private static void ConfigureTemplate(EntityTypeBuilder