diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/Numbers/EndUserIdentifier.cs b/src/Digdir.Domain.Dialogporten.Application/Common/Numbers/EndUserIdentifier.cs new file mode 100644 index 000000000..3d87ec590 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Common/Numbers/EndUserIdentifier.cs @@ -0,0 +1,52 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Digdir.Domain.Dialogporten.Application.Common.Numbers; + +internal static partial class EndUserIdentifier +{ + private static readonly int[] NorwegianIdentifierNumberWeights1 = [3, 7, 6, 1, 8, 9, 4, 5, 2, 1]; + private static readonly int[] NorwegianIdentifierNumberWeights2 = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2, 1]; + + [GeneratedRegex(@"urn:altinn:([\w-]{5,20}):([\w-]{4,20})::([\w-]{5,36})", RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex IdRegex(); + + public static bool IsValid(string identifier) + { + var match = IdRegex().Match(identifier); + + var namespacePart = match.Groups[1].Value; + var type = match.Groups[2].Value; + var value = match.Groups[3].Value; + + return namespacePart switch + { + "person" => ValidatePerson(type, value), + "systemuser" => ValidateSystemUser(type, value), + _ => false + }; + } + + private static bool ValidatePerson(string type, string value) + { + return type switch + { + "identifier-no" => ValidateNorwegianIdentifier(value), + _ => false, + }; + } + + private static bool ValidateSystemUser(string type, string value) + { + return type == "uuid" && Guid.TryParse(value, out _); + } + + private static bool ValidateNorwegianIdentifier(ReadOnlySpan norwegianIdentifier) + { + return norwegianIdentifier.Length == 11 + && Mod11.TryCalculateControlDigit(norwegianIdentifier[..9], NorwegianIdentifierNumberWeights1, out var control1) + && Mod11.TryCalculateControlDigit(norwegianIdentifier[..10], NorwegianIdentifierNumberWeights2, out var control2) + && control1 == int.Parse(norwegianIdentifier[9..10], CultureInfo.InvariantCulture) + && control2 == int.Parse(norwegianIdentifier[10..11], CultureInfo.InvariantCulture); + } +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs b/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs index 95dd94c5b..0a4e1fdd8 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs @@ -11,5 +11,6 @@ public Task GetDialogDetailsAuthorization( public Task GetAuthorizedResourcesForSearch( List constraintParties, List constraintServiceResources, + string? endUserId = null, CancellationToken cancellationToken = default); } diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Get/GetDialogActivityQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Get/GetDialogActivityQuery.cs index 7c1f2140e..589d9b0c7 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Get/GetDialogActivityQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Get/GetDialogActivityQuery.cs @@ -53,7 +53,7 @@ public async Task Handle(GetDialogActivityQuery request var authorizationResult = await _altinnAuthorization.GetDialogDetailsAuthorization( dialog, - cancellationToken); + cancellationToken: cancellationToken); // If we cannot read the dialog at all, we don't allow access to any of the activity history if (!authorizationResult.HasReadAccessToMainResource()) diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs index 51265c60e..855a4d17f 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs @@ -134,7 +134,7 @@ public async Task Handle(SearchDialogQuery request, Cancella var authorizedResources = await _altinnAuthorization.GetAuthorizedResourcesForSearch( request.Party ?? new List(), request.ServiceResource ?? new List(), - cancellationToken); + cancellationToken: cancellationToken); if (authorizedResources.HasNoAuthorizations) { diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogDto.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogDto.cs index e4c8b46b8..21d3b82cb 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogDto.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogDto.cs @@ -10,6 +10,7 @@ public sealed class SearchDialogDto public string Org { get; set; } = null!; public string ServiceResource { get; set; } = null!; public string Party { get; set; } = null!; + public string? EndUserId { get; set; } = null!; public int? Progress { get; set; } public string? ExtendedStatus { get; set; } public DateTimeOffset CreatedAt { get; set; } diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQuery.cs index 53e42c2e3..16fd2db88 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQuery.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQuery.cs @@ -3,10 +3,12 @@ using Digdir.Domain.Dialogporten.Application.Common; using Digdir.Domain.Dialogporten.Application.Common.Extensions; using Digdir.Domain.Dialogporten.Application.Common.Extensions.Enumerables; +using Digdir.Domain.Dialogporten.Application.Common.Numbers; using Digdir.Domain.Dialogporten.Application.Common.Pagination; using Digdir.Domain.Dialogporten.Application.Common.Pagination.OrderOption; using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes; using Digdir.Domain.Dialogporten.Application.Externals; +using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; using Digdir.Domain.Dialogporten.Domain.Localizations; using MediatR; @@ -29,6 +31,11 @@ public sealed class SearchDialogQuery : SortablePaginationParameter public List? Party { get; init; } + /// + /// Filter by end user id + /// + public string? EndUserId { get; init; } + /// /// Filter by one or more extended statuses /// @@ -116,25 +123,31 @@ internal sealed class SearchDialogQueryHandler : IRequestHandler Handle(SearchDialogQuery request, CancellationToken cancellationToken) { var resourceIds = await _userService.GetCurrentUserResourceIds(cancellationToken); var searchExpression = Expressions.LocalizedSearchExpression(request.Search, request.SearchCultureCode); - return await _db.Dialogs - .WhereIf(!request.ServiceResource.IsNullOrEmpty(), x => request.ServiceResource!.Contains(x.ServiceResource)) + + var query = _db.Dialogs + .WhereIf(!request.ServiceResource.IsNullOrEmpty(), + x => request.ServiceResource!.Contains(x.ServiceResource)) .WhereIf(!request.Party.IsNullOrEmpty(), x => request.Party!.Contains(x.Party)) - .WhereIf(!request.ExtendedStatus.IsNullOrEmpty(), x => x.ExtendedStatus != null && request.ExtendedStatus!.Contains(x.ExtendedStatus)) + .WhereIf(!request.ExtendedStatus.IsNullOrEmpty(), + x => x.ExtendedStatus != null && request.ExtendedStatus!.Contains(x.ExtendedStatus)) .WhereIf(!string.IsNullOrWhiteSpace(request.ExternalReference), x => x.ExternalReference != null && request.ExternalReference == x.ExternalReference) .WhereIf(!request.Status.IsNullOrEmpty(), x => request.Status!.Contains(x.StatusId)) @@ -150,7 +163,19 @@ public async Task Handle(SearchDialogQuery request, Cancella x.Content.Any(x => x.Value.Localizations.AsQueryable().Any(searchExpression)) || x.SearchTags.Any(x => EF.Functions.ILike(x.Value, request.Search!)) ) - .Where(x => resourceIds.Contains(x.ServiceResource)) + .Where(x => resourceIds.Contains(x.ServiceResource)); + + if (request.EndUserId is not null) + { + var authorizedResources = await _altinnAuthorization.GetAuthorizedResourcesForSearch( + request.Party ?? new List(), + request.ServiceResource ?? new List(), + request.EndUserId, + cancellationToken); + query = query.WhereUserIsAuthorizedFor(authorizedResources); + } + + return await query .ProjectTo(_mapper.ConfigurationProvider) .ToPaginatedListAsync(request, cancellationToken: cancellationToken); } diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQueryValidator.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQueryValidator.cs index 287e0b4fb..a5144755d 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQueryValidator.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQueryValidator.cs @@ -1,4 +1,6 @@ -using Digdir.Domain.Dialogporten.Application.Common.Pagination; +using Digdir.Domain.Dialogporten.Application.Common.Extensions.Enumerables; +using Digdir.Domain.Dialogporten.Application.Common.Numbers; +using Digdir.Domain.Dialogporten.Application.Common.Pagination; using Digdir.Domain.Dialogporten.Domain.Localizations; using FluentValidation; @@ -17,6 +19,13 @@ public SearchDialogQueryValidator() .Must(x => x is null || Localization.IsValidCultureCode(x)) .WithMessage("'{PropertyName}' must be a valid culture code."); + RuleFor(x => x) + .Must(x => EndUserIdentifier.IsValid(x.EndUserId!)) + .WithMessage($"'{nameof(SearchDialogQuery.EndUserId)}' must be a valid end user identifier. It should match the format 'urn:altinn:person:identifier-no::{{norwegian f-nr/d-nr}} or 'urn:altinn:systemuser:{{uuid}}\"") + .Must(x => !x.ServiceResource.IsNullOrEmpty() || !x.Party.IsNullOrEmpty()) + .WithMessage($"Either '{nameof(SearchDialogQuery.ServiceResource)}' or '{nameof(SearchDialogQuery.Party)}' must be specified if '{nameof(SearchDialogQuery.EndUserId)}' is provided.") + .When(x => x.EndUserId is not null); + RuleFor(x => x.ServiceResource!.Count) .LessThanOrEqualTo(20) .When(x => x.ServiceResource is not null); diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs index 63e177577..d714e3c20 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Security.Claims; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -14,6 +15,8 @@ namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; internal sealed class AltinnAuthorizationClient : IAltinnAuthorization { + private const string AttributePidClaim = "urn:altinn:ssn"; + private readonly HttpClient _httpClient; private readonly IUser _user; private readonly IDialogDbContext _db; @@ -35,7 +38,7 @@ public async Task GetDialogDetailsAuthorizatio CancellationToken cancellationToken = default) => await PerformDialogDetailsAuthorization(new DialogDetailsAuthorizationRequest { - ClaimsPrincipal = _user.GetPrincipal(), + Claims = _user.GetPrincipal().Claims.ToList(), ServiceResource = dialogEntity.ServiceResource, DialogId = dialogEntity.Id, Party = dialogEntity.Party, @@ -45,13 +48,17 @@ await PerformDialogDetailsAuthorization(new DialogDetailsAuthorizationRequest public async Task GetAuthorizedResourcesForSearch( List constraintParties, List serviceResources, - CancellationToken cancellationToken = default) => - await PerformNonScalableDialogSearchAuthorization(new DialogSearchAuthorizationRequest + string? endUserId, + CancellationToken cancellationToken = default) + { + var claims = GetOrCreateClaimsBasedOnEndUserId(endUserId); + return await PerformNonScalableDialogSearchAuthorization(new DialogSearchAuthorizationRequest { - ClaimsPrincipal = _user.GetPrincipal(), + Claims = claims, ConstraintParties = constraintParties, ConstraintServiceResources = serviceResources }, cancellationToken); + } private async Task PerformNonScalableDialogSearchAuthorization(DialogSearchAuthorizationRequest request, CancellationToken cancellationToken) { @@ -97,6 +104,23 @@ private async Task PerformDialogDetailsAuthori return DecisionRequestHelper.CreateDialogDetailsResponse(request.AltinnActions, xamlJsonResponse); } + private List GetOrCreateClaimsBasedOnEndUserId(string? endUserId) + { + List claims = new(); + if (endUserId is not null) + { + claims.Add(new Claim(AttributePidClaim, ExtractEndUserIdNumber(endUserId)!)); + } + else + { + claims.AddRange(_user.GetPrincipal().Claims); + } + return claims; + } + + private static string ExtractEndUserIdNumber(string endUserId) => + endUserId.Split("::").LastOrDefault() ?? string.Empty; + private static readonly JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true, diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DecisionRequestHelper.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DecisionRequestHelper.cs index 9409b8102..6bcc0f35a 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DecisionRequestHelper.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DecisionRequestHelper.cs @@ -24,7 +24,7 @@ internal static class DecisionRequestHelper public static XacmlJsonRequestRoot CreateDialogDetailsRequest(DialogDetailsAuthorizationRequest request) { - var accessSubject = CreateAccessSubjectCategory(request.ClaimsPrincipal.Claims); + var accessSubject = CreateAccessSubjectCategory(request.Claims); var actions = CreateActionCategories(request.AltinnActions, out var actionIdByName); var resources = CreateResourceCategories(request.ServiceResource, request.DialogId, request.Party, request.AltinnActions, out var resourceIdByName); @@ -223,7 +223,7 @@ public static XacmlJsonRequestRoot CreateDialogSearchRequest(DialogSearchAuthori new (Constants.ReadAction, Constants.MainResource) }; - var accessSubject = CreateAccessSubjectCategory(request.ClaimsPrincipal.Claims); + var accessSubject = CreateAccessSubjectCategory(request.Claims); var actions = CreateActionCategories(requestActions, out _); var resources = CreateResourceCategoriesForSearch(request.ConstraintServiceResources, request.ConstraintParties); diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs index 50c7adcf2..845eda79a 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs @@ -5,7 +5,7 @@ namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; public sealed class DialogDetailsAuthorizationRequest { - public required ClaimsPrincipal ClaimsPrincipal { get; init; } + public required List Claims { get; init; } public required string ServiceResource { get; init; } public required Guid DialogId { get; init; } public required string Party { get; init; } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs index 8652f3c74..b0846b56e 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs @@ -4,7 +4,7 @@ namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; public sealed class DialogSearchAuthorizationRequest { - public required ClaimsPrincipal ClaimsPrincipal { get; init; } + public required List Claims { get; init; } public List ConstraintParties { get; set; } = new(); public List ConstraintServiceResources { get; set; } = new(); } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs index 9bb92d92e..9f1d493d1 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs @@ -21,7 +21,7 @@ public Task GetDialogDetailsAuthorization(Dial // Just allow everything Task.FromResult(new DialogDetailsAuthorizationResult { AuthorizedAltinnActions = dialogEntity.GetAltinnActions() }); - public async Task GetAuthorizedResourcesForSearch(List constraintParties, List serviceResources, + public async Task GetAuthorizedResourcesForSearch(List constraintParties, List serviceResources, string? endUserId, CancellationToken cancellationToken = default) { // Allow all resources for all parties diff --git a/tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/DecisionRequestHelperTests.cs b/tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/DecisionRequestHelperTests.cs index bd42b0109..4fb5fb489 100644 --- a/tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/DecisionRequestHelperTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/DecisionRequestHelperTests.cs @@ -138,7 +138,7 @@ private static DialogDetailsAuthorizationRequest CreateDialogDetailsAuthorizatio allClaims.AddRange(principalClaims); return new DialogDetailsAuthorizationRequest { - ClaimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(allClaims, "test")), + Claims = allClaims, ServiceResource = "urn:altinn:resource:some-service", DialogId = Guid.NewGuid(),