Skip to content

Commit 10cd669

Browse files
knuhauelsand
andauthoredJan 22, 2024
Filter SO dialog search using EndUserId (#333)
Issue #322 --------- Co-authored-by: Bjørn Dybvik Langfors <bdl@digdir.no>
1 parent 17700e6 commit 10cd669

File tree

13 files changed

+130
-18
lines changed

13 files changed

+130
-18
lines changed
 
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Globalization;
2+
using System.Text.RegularExpressions;
3+
4+
namespace Digdir.Domain.Dialogporten.Application.Common.Numbers;
5+
6+
internal static partial class EndUserIdentifier
7+
{
8+
private static readonly int[] NorwegianIdentifierNumberWeights1 = [3, 7, 6, 1, 8, 9, 4, 5, 2, 1];
9+
private static readonly int[] NorwegianIdentifierNumberWeights2 = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2, 1];
10+
11+
[GeneratedRegex(@"urn:altinn:([\w-]{5,20}):([\w-]{4,20})::([\w-]{5,36})", RegexOptions.None, matchTimeoutMilliseconds: 100)]
12+
private static partial Regex IdRegex();
13+
14+
public static bool IsValid(string identifier)
15+
{
16+
var match = IdRegex().Match(identifier);
17+
18+
var namespacePart = match.Groups[1].Value;
19+
var type = match.Groups[2].Value;
20+
var value = match.Groups[3].Value;
21+
22+
return namespacePart switch
23+
{
24+
"person" => ValidatePerson(type, value),
25+
"systemuser" => ValidateSystemUser(type, value),
26+
_ => false
27+
};
28+
}
29+
30+
private static bool ValidatePerson(string type, string value)
31+
{
32+
return type switch
33+
{
34+
"identifier-no" => ValidateNorwegianIdentifier(value),
35+
_ => false,
36+
};
37+
}
38+
39+
private static bool ValidateSystemUser(string type, string value)
40+
{
41+
return type == "uuid" && Guid.TryParse(value, out _);
42+
}
43+
44+
private static bool ValidateNorwegianIdentifier(ReadOnlySpan<char> norwegianIdentifier)
45+
{
46+
return norwegianIdentifier.Length == 11
47+
&& Mod11.TryCalculateControlDigit(norwegianIdentifier[..9], NorwegianIdentifierNumberWeights1, out var control1)
48+
&& Mod11.TryCalculateControlDigit(norwegianIdentifier[..10], NorwegianIdentifierNumberWeights2, out var control2)
49+
&& control1 == int.Parse(norwegianIdentifier[9..10], CultureInfo.InvariantCulture)
50+
&& control2 == int.Parse(norwegianIdentifier[10..11], CultureInfo.InvariantCulture);
51+
}
52+
}

‎src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs

+1
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ public Task<DialogDetailsAuthorizationResult> GetDialogDetailsAuthorization(
1111
public Task<DialogSearchAuthorizationResult> GetAuthorizedResourcesForSearch(
1212
List<string> constraintParties,
1313
List<string> constraintServiceResources,
14+
string? endUserId = null,
1415
CancellationToken cancellationToken = default);
1516
}

‎src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Get/GetDialogActivityQuery.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public async Task<GetDialogActivityResult> Handle(GetDialogActivityQuery request
5353

5454
var authorizationResult = await _altinnAuthorization.GetDialogDetailsAuthorization(
5555
dialog,
56-
cancellationToken);
56+
cancellationToken: cancellationToken);
5757

5858
// If we cannot read the dialog at all, we don't allow access to any of the activity history
5959
if (!authorizationResult.HasReadAccessToMainResource())

‎src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ public async Task<SearchDialogResult> Handle(SearchDialogQuery request, Cancella
134134
var authorizedResources = await _altinnAuthorization.GetAuthorizedResourcesForSearch(
135135
request.Party ?? new List<string>(),
136136
request.ServiceResource ?? new List<string>(),
137-
cancellationToken);
137+
cancellationToken: cancellationToken);
138138

139139
if (authorizedResources.HasNoAuthorizations)
140140
{

‎src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogDto.cs

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public sealed class SearchDialogDto
1010
public string Org { get; set; } = null!;
1111
public string ServiceResource { get; set; } = null!;
1212
public string Party { get; set; } = null!;
13+
public string? EndUserId { get; set; } = null!;
1314
public int? Progress { get; set; }
1415
public string? ExtendedStatus { get; set; }
1516
public DateTimeOffset CreatedAt { get; set; }

‎src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQuery.cs

+30-5
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
using Digdir.Domain.Dialogporten.Application.Common;
44
using Digdir.Domain.Dialogporten.Application.Common.Extensions;
55
using Digdir.Domain.Dialogporten.Application.Common.Extensions.Enumerables;
6+
using Digdir.Domain.Dialogporten.Application.Common.Numbers;
67
using Digdir.Domain.Dialogporten.Application.Common.Pagination;
78
using Digdir.Domain.Dialogporten.Application.Common.Pagination.OrderOption;
89
using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes;
910
using Digdir.Domain.Dialogporten.Application.Externals;
11+
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
1012
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities;
1113
using Digdir.Domain.Dialogporten.Domain.Localizations;
1214
using MediatR;
@@ -29,6 +31,11 @@ public sealed class SearchDialogQuery : SortablePaginationParameter<SearchDialog
2931
/// </summary>
3032
public List<string>? Party { get; init; }
3133

34+
/// <summary>
35+
/// Filter by end user id
36+
/// </summary>
37+
public string? EndUserId { get; init; }
38+
3239
/// <summary>
3340
/// Filter by one or more extended statuses
3441
/// </summary>
@@ -116,25 +123,31 @@ internal sealed class SearchDialogQueryHandler : IRequestHandler<SearchDialogQue
116123
private readonly IDialogDbContext _db;
117124
private readonly IMapper _mapper;
118125
private readonly IUserService _userService;
126+
private readonly IAltinnAuthorization _altinnAuthorization;
119127

120128
public SearchDialogQueryHandler(
121129
IDialogDbContext db,
122130
IMapper mapper,
123-
IUserService userService)
131+
IUserService userService,
132+
IAltinnAuthorization altinnAuthorization)
124133
{
125134
_db = db ?? throw new ArgumentNullException(nameof(db));
126135
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
127136
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
137+
_altinnAuthorization = altinnAuthorization;
128138
}
129139

130140
public async Task<SearchDialogResult> Handle(SearchDialogQuery request, CancellationToken cancellationToken)
131141
{
132142
var resourceIds = await _userService.GetCurrentUserResourceIds(cancellationToken);
133143
var searchExpression = Expressions.LocalizedSearchExpression(request.Search, request.SearchCultureCode);
134-
return await _db.Dialogs
135-
.WhereIf(!request.ServiceResource.IsNullOrEmpty(), x => request.ServiceResource!.Contains(x.ServiceResource))
144+
145+
var query = _db.Dialogs
146+
.WhereIf(!request.ServiceResource.IsNullOrEmpty(),
147+
x => request.ServiceResource!.Contains(x.ServiceResource))
136148
.WhereIf(!request.Party.IsNullOrEmpty(), x => request.Party!.Contains(x.Party))
137-
.WhereIf(!request.ExtendedStatus.IsNullOrEmpty(), x => x.ExtendedStatus != null && request.ExtendedStatus!.Contains(x.ExtendedStatus))
149+
.WhereIf(!request.ExtendedStatus.IsNullOrEmpty(),
150+
x => x.ExtendedStatus != null && request.ExtendedStatus!.Contains(x.ExtendedStatus))
138151
.WhereIf(!string.IsNullOrWhiteSpace(request.ExternalReference),
139152
x => x.ExternalReference != null && request.ExternalReference == x.ExternalReference)
140153
.WhereIf(!request.Status.IsNullOrEmpty(), x => request.Status!.Contains(x.StatusId))
@@ -150,7 +163,19 @@ public async Task<SearchDialogResult> Handle(SearchDialogQuery request, Cancella
150163
x.Content.Any(x => x.Value.Localizations.AsQueryable().Any(searchExpression)) ||
151164
x.SearchTags.Any(x => EF.Functions.ILike(x.Value, request.Search!))
152165
)
153-
.Where(x => resourceIds.Contains(x.ServiceResource))
166+
.Where(x => resourceIds.Contains(x.ServiceResource));
167+
168+
if (request.EndUserId is not null)
169+
{
170+
var authorizedResources = await _altinnAuthorization.GetAuthorizedResourcesForSearch(
171+
request.Party ?? new List<string>(),
172+
request.ServiceResource ?? new List<string>(),
173+
request.EndUserId,
174+
cancellationToken);
175+
query = query.WhereUserIsAuthorizedFor(authorizedResources);
176+
}
177+
178+
return await query
154179
.ProjectTo<SearchDialogDto>(_mapper.ConfigurationProvider)
155180
.ToPaginatedListAsync(request, cancellationToken: cancellationToken);
156181
}

‎src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Queries/Search/SearchDialogQueryValidator.cs

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using Digdir.Domain.Dialogporten.Application.Common.Pagination;
1+
using Digdir.Domain.Dialogporten.Application.Common.Extensions.Enumerables;
2+
using Digdir.Domain.Dialogporten.Application.Common.Numbers;
3+
using Digdir.Domain.Dialogporten.Application.Common.Pagination;
24
using Digdir.Domain.Dialogporten.Domain.Localizations;
35
using FluentValidation;
46

@@ -17,6 +19,13 @@ public SearchDialogQueryValidator()
1719
.Must(x => x is null || Localization.IsValidCultureCode(x))
1820
.WithMessage("'{PropertyName}' must be a valid culture code.");
1921

22+
RuleFor(x => x)
23+
.Must(x => EndUserIdentifier.IsValid(x.EndUserId!))
24+
.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}}\"")
25+
.Must(x => !x.ServiceResource.IsNullOrEmpty() || !x.Party.IsNullOrEmpty())
26+
.WithMessage($"Either '{nameof(SearchDialogQuery.ServiceResource)}' or '{nameof(SearchDialogQuery.Party)}' must be specified if '{nameof(SearchDialogQuery.EndUserId)}' is provided.")
27+
.When(x => x.EndUserId is not null);
28+
2029
RuleFor(x => x.ServiceResource!.Count)
2130
.LessThanOrEqualTo(20)
2231
.When(x => x.ServiceResource is not null);

‎src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs

+28-4
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Net;
2+
using System.Security.Claims;
23
using System.Text;
34
using System.Text.Json;
45
using System.Text.Json.Serialization;
@@ -14,6 +15,8 @@ namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization;
1415

1516
internal sealed class AltinnAuthorizationClient : IAltinnAuthorization
1617
{
18+
private const string AttributePidClaim = "urn:altinn:ssn";
19+
1720
private readonly HttpClient _httpClient;
1821
private readonly IUser _user;
1922
private readonly IDialogDbContext _db;
@@ -35,7 +38,7 @@ public async Task<DialogDetailsAuthorizationResult> GetDialogDetailsAuthorizatio
3538
CancellationToken cancellationToken = default) =>
3639
await PerformDialogDetailsAuthorization(new DialogDetailsAuthorizationRequest
3740
{
38-
ClaimsPrincipal = _user.GetPrincipal(),
41+
Claims = _user.GetPrincipal().Claims.ToList(),
3942
ServiceResource = dialogEntity.ServiceResource,
4043
DialogId = dialogEntity.Id,
4144
Party = dialogEntity.Party,
@@ -45,13 +48,17 @@ await PerformDialogDetailsAuthorization(new DialogDetailsAuthorizationRequest
4548
public async Task<DialogSearchAuthorizationResult> GetAuthorizedResourcesForSearch(
4649
List<string> constraintParties,
4750
List<string> serviceResources,
48-
CancellationToken cancellationToken = default) =>
49-
await PerformNonScalableDialogSearchAuthorization(new DialogSearchAuthorizationRequest
51+
string? endUserId,
52+
CancellationToken cancellationToken = default)
53+
{
54+
var claims = GetOrCreateClaimsBasedOnEndUserId(endUserId);
55+
return await PerformNonScalableDialogSearchAuthorization(new DialogSearchAuthorizationRequest
5056
{
51-
ClaimsPrincipal = _user.GetPrincipal(),
57+
Claims = claims,
5258
ConstraintParties = constraintParties,
5359
ConstraintServiceResources = serviceResources
5460
}, cancellationToken);
61+
}
5562

5663
private async Task<DialogSearchAuthorizationResult> PerformNonScalableDialogSearchAuthorization(DialogSearchAuthorizationRequest request, CancellationToken cancellationToken)
5764
{
@@ -97,6 +104,23 @@ private async Task<DialogDetailsAuthorizationResult> PerformDialogDetailsAuthori
97104
return DecisionRequestHelper.CreateDialogDetailsResponse(request.AltinnActions, xamlJsonResponse);
98105
}
99106

107+
private List<Claim> GetOrCreateClaimsBasedOnEndUserId(string? endUserId)
108+
{
109+
List<Claim> claims = new();
110+
if (endUserId is not null)
111+
{
112+
claims.Add(new Claim(AttributePidClaim, ExtractEndUserIdNumber(endUserId)!));
113+
}
114+
else
115+
{
116+
claims.AddRange(_user.GetPrincipal().Claims);
117+
}
118+
return claims;
119+
}
120+
121+
private static string ExtractEndUserIdNumber(string endUserId) =>
122+
endUserId.Split("::").LastOrDefault() ?? string.Empty;
123+
100124
private static readonly JsonSerializerOptions _serializerOptions = new()
101125
{
102126
PropertyNameCaseInsensitive = true,

‎src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DecisionRequestHelper.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ internal static class DecisionRequestHelper
2424

2525
public static XacmlJsonRequestRoot CreateDialogDetailsRequest(DialogDetailsAuthorizationRequest request)
2626
{
27-
var accessSubject = CreateAccessSubjectCategory(request.ClaimsPrincipal.Claims);
27+
var accessSubject = CreateAccessSubjectCategory(request.Claims);
2828
var actions = CreateActionCategories(request.AltinnActions, out var actionIdByName);
2929
var resources = CreateResourceCategories(request.ServiceResource, request.DialogId, request.Party, request.AltinnActions, out var resourceIdByName);
3030

@@ -223,7 +223,7 @@ public static XacmlJsonRequestRoot CreateDialogSearchRequest(DialogSearchAuthori
223223
new (Constants.ReadAction, Constants.MainResource)
224224
};
225225

226-
var accessSubject = CreateAccessSubjectCategory(request.ClaimsPrincipal.Claims);
226+
var accessSubject = CreateAccessSubjectCategory(request.Claims);
227227
var actions = CreateActionCategories(requestActions, out _);
228228
var resources = CreateResourceCategoriesForSearch(request.ConstraintServiceResources, request.ConstraintParties);
229229

‎src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization;
55

66
public sealed class DialogDetailsAuthorizationRequest
77
{
8-
public required ClaimsPrincipal ClaimsPrincipal { get; init; }
8+
public required List<Claim> Claims { get; init; }
99
public required string ServiceResource { get; init; }
1010
public required Guid DialogId { get; init; }
1111
public required string Party { get; init; }

‎src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogSearchAuthorizationRequest.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization;
44

55
public sealed class DialogSearchAuthorizationRequest
66
{
7-
public required ClaimsPrincipal ClaimsPrincipal { get; init; }
7+
public required List<Claim> Claims { get; init; }
88
public List<string> ConstraintParties { get; set; } = new();
99
public List<string> ConstraintServiceResources { get; set; } = new();
1010
}

‎src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public Task<DialogDetailsAuthorizationResult> GetDialogDetailsAuthorization(Dial
2121
// Just allow everything
2222
Task.FromResult(new DialogDetailsAuthorizationResult { AuthorizedAltinnActions = dialogEntity.GetAltinnActions() });
2323

24-
public async Task<DialogSearchAuthorizationResult> GetAuthorizedResourcesForSearch(List<string> constraintParties, List<string> serviceResources,
24+
public async Task<DialogSearchAuthorizationResult> GetAuthorizedResourcesForSearch(List<string> constraintParties, List<string> serviceResources, string? endUserId,
2525
CancellationToken cancellationToken = default)
2626
{
2727
// Allow all resources for all parties

‎tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/DecisionRequestHelperTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ private static DialogDetailsAuthorizationRequest CreateDialogDetailsAuthorizatio
138138
allClaims.AddRange(principalClaims);
139139
return new DialogDetailsAuthorizationRequest
140140
{
141-
ClaimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(allClaims, "test")),
141+
Claims = allClaims,
142142
ServiceResource = "urn:altinn:resource:some-service",
143143
DialogId = Guid.NewGuid(),
144144

0 commit comments

Comments
 (0)
Please sign in to comment.