diff --git a/Dan.Common/Models/CustomSubjectRequirement.cs b/Dan.Common/Models/CustomSubjectRequirement.cs new file mode 100644 index 0000000..b08fbaf --- /dev/null +++ b/Dan.Common/Models/CustomSubjectRequirement.cs @@ -0,0 +1,21 @@ +namespace Dan.Common.Models; + +/// +/// Requirement for subjects that differ from organisation number or Norwegian SSN +/// +public class CustomSubjectRequirement : Requirement +{ + /// + /// Regex used to validate custom subject. Defaults to \w+ for any letters and digits + /// + [DataMember(Name = "subjectRegex")] + [Required] + public string SubjectRegex { get; set; } = @"\w+"; + + /// + /// Describes the regex in clear text + /// + [DataMember(Name = "subjectRegexDescription")] + [Required] + public string SubjectRegexDescription { get; set; } = "Any string"; +} \ No newline at end of file diff --git a/Dan.Core.UnitTest/AuthorizationRequestValidatorServiceTest.cs b/Dan.Core.UnitTest/Services/AuthorizationRequestValidatorServiceTest.cs similarity index 95% rename from Dan.Core.UnitTest/AuthorizationRequestValidatorServiceTest.cs rename to Dan.Core.UnitTest/Services/AuthorizationRequestValidatorServiceTest.cs index 6c758fa..df08997 100644 --- a/Dan.Core.UnitTest/AuthorizationRequestValidatorServiceTest.cs +++ b/Dan.Core.UnitTest/Services/AuthorizationRequestValidatorServiceTest.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using AwesomeAssertions; using Dan.Common.Enums; using Dan.Common.Models; using Dan.Core.Exceptions; @@ -9,7 +10,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -namespace Dan.Core.UnitTest +namespace Dan.Core.UnitTest.Services { [TestClass] [ExcludeFromCodeCoverage] @@ -140,7 +141,43 @@ await Assert.ThrowsAsync(async () => await arvs.Validate(GetAuthorizationRequest(subject: "123456789")); }); } - + + [TestMethod] + [DataRow("03065001488")] + [DataRow("12345678")] + public async Task ValidateTest_CustomSubjectRequirement_ShouldSetParty(string subject) + { + // Setup + var ae = GetAvailableEvidenceCodes().Take(1).ToList(); + ae.First().AuthorizationRequirements = + [ + new CustomSubjectRequirement + { + SubjectRegex = @"^\d{1,8}$", // digits only 1-8 characters long, + SubjectRegexDescription = "Description", + RequirementType = "CustomSubjectRequirement" + } + ]; + + A.CallTo(() => _mockAvailableEvidenceCodesService.GetAvailableEvidenceCodes(A._)) + .Returns(Task.FromResult(ae)); + + var arvs = new AuthorizationRequestValidatorService( + _loggerFactory, + _mockEntityRegistryService, + _mockAvailableEvidenceCodesService, + _mockRequirementValidatorService, + _mockRequestContextService); + + var request = GetAuthorizationRequest(subject: subject); + + // Act + var action = async () => await arvs.Validate(request); + + // Assert + await action.Should().NotThrowAsync(); + request.SubjectParty.Id.Should().Be(subject); + } [TestMethod] public async Task ValidateTestFailureWithInvalidRequestorOrgNo() diff --git a/Dan.Core.UnitTest/RequirementValidationServiceTest.cs b/Dan.Core.UnitTest/Services/RequirementValidationServiceTest.cs similarity index 88% rename from Dan.Core.UnitTest/RequirementValidationServiceTest.cs rename to Dan.Core.UnitTest/Services/RequirementValidationServiceTest.cs index 7384a16..ce9dfb5 100644 --- a/Dan.Core.UnitTest/RequirementValidationServiceTest.cs +++ b/Dan.Core.UnitTest/Services/RequirementValidationServiceTest.cs @@ -1,4 +1,5 @@ -using Dan.Common.Enums; +using AwesomeAssertions; +using Dan.Common.Enums; using Dan.Common.Models; using Dan.Core.Exceptions; using Dan.Core.Helpers; @@ -10,7 +11,7 @@ using ConsentRequirement = Nadobe.Common.Models.ConsentRequirement; -namespace Dan.Core.UnitTest +namespace Dan.Core.UnitTest.Services { [TestClass] public class RequirementValidationServiceTest @@ -772,6 +773,140 @@ public async Task ReferenceTest_Failed_ConsentReferenceIsNull() Assert.IsTrue(errorList.Count == 1); Assert.IsTrue(errorList[0].Contains("The request requires a valid consent reference but none is provided")); } + + [TestMethod] + [DataRow("12345678", null)] // Custom subject + [DataRow("03065001488", PartyParser.NorwegianIcd)] // Already found subject in pre step + [DataRow("03065001488", PartyParser.SchemeIso6523ActorIdUpis)] // Already found subject in pre step + [DataRow("03065001488", PartyParser.SchemeNorwegianSsn)] // Already found subject in pre step + public async Task CustomSubjectTest_Success(string subject, string scheme) + { + // Arrange + var authRequest = GetAuthRequest(subject, "requestor"); + authRequest.SubjectParty = new Party + { + Id = "12345678", + Scheme = scheme + }; + + var req = new Dictionary> + { + ["ec1"] = + [ + new CustomSubjectRequirement() + { + SubjectRegex = @"^\d{1,8}$" + } + ] + }; + + var svc = new RequirementValidationService(_mockAltinnServiceOwnerApiService, _mockEntityRegistryService, _mockRequestContextService); + + // Act + var errorList = await svc.ValidateRequirements(req, authRequest); + + // Assert + errorList.Should().BeEmpty(); + } + + [TestMethod] + public async Task CustomSubjectTest_MismatchFormat_ShouldThrow() + { + // Arrange + var authRequest = GetAuthRequest("123456789", "requestor"); + authRequest.SubjectParty = new Party + { + Id = "12345678", + Scheme = null + }; + + var req = new Dictionary> + { + ["ec1"] = + [ + new CustomSubjectRequirement() + { + SubjectRegex = @"^\d{1,8}$", + SubjectRegexDescription = "Description" + } + ] + }; + + var svc = new RequirementValidationService(_mockAltinnServiceOwnerApiService, _mockEntityRegistryService, _mockRequestContextService); + + // Act + var errorList = await svc.ValidateRequirements(req, authRequest); + + // Assert + errorList.Should().HaveCount(1); + errorList.Should().Contain("ec1: Subject does not match custom subject format: Description"); + } + + [TestMethod] + public async Task CustomSubjectTest_MissingSubject_ShouldThrow() + { + // Arrange + var authRequest = GetAuthRequest("", "requestor"); + authRequest.SubjectParty = new Party + { + Id = "", + Scheme = null + }; + + var req = new Dictionary> + { + ["ec1"] = + [ + new CustomSubjectRequirement() + { + SubjectRegex = @"^\d{1,8}$", + SubjectRegexDescription = "Description" + } + ] + }; + + var svc = new RequirementValidationService(_mockAltinnServiceOwnerApiService, _mockEntityRegistryService, _mockRequestContextService); + + // Act + var errorList = await svc.ValidateRequirements(req, authRequest); + + // Assert + errorList.Should().HaveCount(1); + errorList.Should().Contain("ec1: Subject is missing"); + } + + [TestMethod] + public async Task CustomSubjectTest_MissingRegex_ShouldThrow() + { + // Arrange + var authRequest = GetAuthRequest("12345", "requestor"); + authRequest.SubjectParty = new Party + { + Id = "12345", + Scheme = null + }; + + var req = new Dictionary> + { + ["ec1"] = + [ + new CustomSubjectRequirement() + { + SubjectRegex = "", + SubjectRegexDescription = "Description" + } + ] + }; + + var svc = new RequirementValidationService(_mockAltinnServiceOwnerApiService, _mockEntityRegistryService, _mockRequestContextService); + + // Act + var errorList = await svc.ValidateRequirements(req, authRequest); + + // Assert + errorList.Should().HaveCount(1); + errorList.Should().Contain("ec1: Missing custom subject format regex"); + } private Requirement GetWhiteListRequirement(List owners, List subjects, List requestors) { diff --git a/Dan.Core/Services/AuthorizationRequestValidatorService.cs b/Dan.Core/Services/AuthorizationRequestValidatorService.cs index d983307..8c443c9 100644 --- a/Dan.Core/Services/AuthorizationRequestValidatorService.cs +++ b/Dan.Core/Services/AuthorizationRequestValidatorService.cs @@ -1,6 +1,5 @@ using Dan.Common; using Dan.Common.Enums; -using Dan.Common.Interfaces; using Dan.Common.Models; using Dan.Core.Config; using Dan.Core.Exceptions; @@ -57,9 +56,22 @@ public async Task Validate(AuthorizationRequest? authorizationRequest) _authRequest = authorizationRequest ?? throw new InvalidAuthorizationRequestException(); _registeredEvidenceCodes = await _availableEvidenceCodesService.GetAvailableEvidenceCodes(); _evidenceCodesFromRequest = _registeredEvidenceCodes.Where(r => _authRequest.EvidenceRequests.Any(x => x.EvidenceCodeName == r.EvidenceCodeName)).ToList(); + + var requirements = _evidenceCodesFromRequest.ToDictionary(es => es.EvidenceCodeName, es => es.AuthorizationRequirements); + if (authorizationRequest.FromEvidenceHarvester) + { + foreach (var requirement in requirements.Values) + { + requirement.RemoveAll(x => x.RequiredOnEvidenceHarvester == false); + } + } + var customSubject = requirements.Values.SelectMany(x => x).Any(req => + req.RequirementType is not null && + req.RequirementType.Equals("CustomSubjectRequirement", StringComparison.InvariantCultureIgnoreCase)); + ValidateAndPopulateRequestor(); - ValidateAndPopulateSubject(); + ValidateAndPopulateSubject(customSubject); ValidateLegalBasisWellFormed(); ValidateEvidenceRequestWellFormed(); ValidateEvidenceCodesAreAvailableForServiceContext(); @@ -78,15 +90,6 @@ public async Task Validate(AuthorizationRequest? authorizationRequest) ValidateLanguageCodes(); - var requirements = _evidenceCodesFromRequest.ToDictionary(es => es.EvidenceCodeName, es => es.AuthorizationRequirements); - if (authorizationRequest.FromEvidenceHarvester) - { - foreach (var requirement in requirements.Values) - { - requirement.RemoveAll(x => x.RequiredOnEvidenceHarvester == false); - } - } - var authorizationErrors = await _requirementValidationService.ValidateRequirements(requirements, _authRequest); if (authorizationErrors.Count > 0) { @@ -185,22 +188,33 @@ private void ValidateAndPopulateRequestor() /// Uses PartyParser on the supplied subject, and populates SubjectParty with it. Overwrites Requestor with norwegian identifier if applicable, else set to null /// /// - private void ValidateAndPopulateSubject() + private void ValidateAndPopulateSubject(bool customSubject) { if (_authRequest.Subject == null) { return; } - - Party? party = PartyParser.GetPartyFromIdentifier(_authRequest.Subject, out string? error); - if (party == null) + + var party = PartyParser.GetPartyFromIdentifier(_authRequest.Subject, out var error); + if (party == null && !customSubject) { throw new InvalidSubjectException($"Invalid subject supplied: {error}"); } - _authRequest.Subject = party.NorwegianOrganizationNumber ?? party.NorwegianSocialSecurityNumber; - _authRequest.SubjectParty = party; + if (party != null) + { + _authRequest.Subject = party.NorwegianOrganizationNumber ?? party.NorwegianSocialSecurityNumber; + _authRequest.SubjectParty = party; + return; + } + + // Custom Subject Validation will be done later in RequirementValidationService + var customParty = new Party + { + Id = _authRequest.Subject + }; + _authRequest.SubjectParty = customParty; } private void ValidateLegalBasisWellFormed() diff --git a/Dan.Core/Services/RequirementValidationService.cs b/Dan.Core/Services/RequirementValidationService.cs index 54344c3..e7db1f9 100644 --- a/Dan.Core/Services/RequirementValidationService.cs +++ b/Dan.Core/Services/RequirementValidationService.cs @@ -7,7 +7,6 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; -using Dan.Common.Interfaces; namespace Dan.Core.Services; @@ -107,6 +106,7 @@ private async Task ValidateSingleRequirement(Requirement req, string evide AccreditationPartyRequirement r => ValidateAccreditationPartyRequirement(r, _authRequest.Subject, _authRequest.Requestor, _owner, evidenceCodeName), ReferenceRequirement r => ValidateReferenceRequirement(r, _authRequest, evidenceCodeName), ProvideOwnTokenRequirement r => ValidateProvideOwnTokenRequirement(r, evidenceCodeName), + CustomSubjectRequirement r => ValidateCustomSubjectRequirement(r, _authRequest, evidenceCodeName), _ => false }; @@ -569,6 +569,37 @@ private bool ValidateProvideOwnTokenRequirement(Requirement req, string evidence return false; } + private bool ValidateCustomSubjectRequirement(CustomSubjectRequirement req, AuthorizationRequest authRequest, string evidenceCodeName) + { + + if (authRequest.SubjectParty.Scheme is PartyParser.SchemeIso6523ActorIdUpis or + PartyParser.SchemeNorwegianSsn or + PartyParser.NorwegianIcd) + { + // Subject already parsed as Organisation number or norwegian ssn + return true; + } + if (string.IsNullOrWhiteSpace(authRequest.Subject)) + { + AddError(req, $"Subject is missing", evidenceCodeName); + return false; + } + + if (string.IsNullOrEmpty(req.SubjectRegex)) + { + AddError(req, $"Missing custom subject format regex", evidenceCodeName); + return false; + } + + var regexMatch = Regex.Match(authRequest.Subject, req.SubjectRegex, RegexOptions.None, TimeSpan.FromSeconds(1)); + if (regexMatch.Success) + { + return true; + } + AddError(req, $"Subject does not match custom subject format: {req.SubjectRegexDescription}", evidenceCodeName); + return false; + } + private async Task GetPartyType(string? identifier) { if (identifier == null) return PartyTypeConstraint.Foreign; diff --git a/Dan.PluginTest/Config/PluginConstants.cs b/Dan.PluginTest/Config/PluginConstants.cs index 5abb96f..aa55968 100644 --- a/Dan.PluginTest/Config/PluginConstants.cs +++ b/Dan.PluginTest/Config/PluginConstants.cs @@ -14,4 +14,5 @@ public static class PluginConstants public const string PluginForward = "PluginForward"; public const string PluginSettingsTest = "PluginSettingsTest"; public const string PluginGenericTest = "PluginGenericTest"; + public const string PluginCustomSubjectTest = "PluginCustomSubjectTest"; } \ No newline at end of file diff --git a/Dan.PluginTest/Metadata.cs b/Dan.PluginTest/Metadata.cs index d5b5afa..7acf233 100644 --- a/Dan.PluginTest/Metadata.cs +++ b/Dan.PluginTest/Metadata.cs @@ -109,6 +109,29 @@ public List GetEvidenceCodes() } ] }, + new EvidenceCode + { + EvidenceCodeName = PluginConstants.PluginCustomSubjectTest, + EvidenceSource = PluginConstants.Source, + BelongsToServiceContexts = _serviceContexts, + Values = + [ + new EvidenceValue + { + EvidenceValueName = "default", + ValueType = EvidenceValueType.JsonSchema, + JsonSchemaDefintion = EvidenceValue.SchemaFromObject() + } + ], + AuthorizationRequirements = + [ + new CustomSubjectRequirement + { + SubjectRegex = @"^\d{1,8}$", + SubjectRegexDescription = "A number 1-8 characters long" + } + ] + }, ]; } } \ No newline at end of file diff --git a/Dan.PluginTest/Plugin.cs b/Dan.PluginTest/Plugin.cs index 5f23a8c..787819c 100644 --- a/Dan.PluginTest/Plugin.cs +++ b/Dan.PluginTest/Plugin.cs @@ -119,6 +119,31 @@ public async Task PluginSettingsTest( () => EvidenceValuesPluginSettings(evidenceHarvesterRequest)); } + [Function(PluginConstants.PluginCustomSubjectTest)] + public async Task PluginCustomSubjectTest( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequestData req, + FunctionContext context) + { + EvidenceHarvesterRequest? evidenceHarvesterRequest; + try + { + evidenceHarvesterRequest = await req.ReadFromJsonAsync(); + } + catch (Exception e) + { + _logger.LogError(e, + "Exception while attempting to parse request into EvidenceHarvesterRequest: {exceptionType}: {exceptionMessage}", + e.GetType().Name, e.Message); + throw new EvidenceSourcePermanentClientException(PluginConstants.ErrorInvalidInput, + "Unable to parse request", e); + } + + var unit = await ccrClientService.IsPublic("923609016", "local"); + + return await EvidenceSourceResponse.CreateResponse(req, + () => GetEvidenceValuesDatasetOne(evidenceHarvesterRequest)); + } + [Function(PluginConstants.PluginGenericTest)] public async Task PluginGenericTest( [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequestData req,