Skip to content

Commit

Permalink
feat: Implement scalable dialog search authorization (#875)
Browse files Browse the repository at this point in the history
## Description
This implements a authorized-parties based approach to list/search
authorization, which should scale better than the current approach.

This builds on the syncronization of data from RR, which contains a map
of subjects (role codes and eventually access packages) and resources.
This data is persisted in Dialogporten DB, and used as a cache.

A new predicate builder `PrefilterAuthorizedDialogs`
replaces`WhereUserIsAuthorizedFor`, and constructs a SQL manually in
order to propertly handle the new property `SubjectsByParties` in
`DialogSearchAuthorizationResult`, which is a dict of party->subjects.
Each of the roles grant access to a list of resources.

This also removes legacy system users, as they cannot be authorized this
way (not possible to get a list of parties from Authorization APIs for a
legacy system user).

## Related Issue(s)

- #42 

## Verification

- [x] **Your** code builds clean without any errors or warnings
- [x] Manual testing done (required)
- [ ] Relevant automated test added (if you find this hard, leave it and
we'll help out)

## Documentation

- [ ] Documentation is updated (either in `docs`-directory, Altinnpedia
or a separate linked PR in
[altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs), if
applicable)

---------

Co-authored-by: Magnus Sandgren <5285192+MagnusSandgren@users.noreply.github.com>
  • Loading branch information
elsand and MagnusSandgren authored Sep 6, 2024
1 parent b408d41 commit aa8f84d
Show file tree
Hide file tree
Showing 30 changed files with 6,076 additions and 495 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities;
using Digdir.Domain.Dialogporten.Domain.Parties;
using Digdir.Domain.Dialogporten.Domain.Parties.Abstractions;
using UserIdType = Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.DialogUserType.Values;

namespace Digdir.Domain.Dialogporten.Application.Common.Extensions;
Expand All @@ -23,10 +25,6 @@ public static class ClaimsPrincipalExtensions
private const string AltinnAutorizationDetailsClaim = "authorization_details";
private const string AttributeIdSystemUser = "urn:altinn:systemuser";
private const string AltinnAuthLevelClaim = "urn:altinn:authlevel";
private const string AltinnAuthenticationMethodClaim = "urn:altinn:authenticatemethod";
private const string AltinnAuthenticationEnterpriseUserMethod = "virksomhetsbruker";
private const string AltinnUserIdClaim = "urn:altinn:userid";
private const string AltinnUserNameClaim = "urn:altinn:username";
private const string ScopeClaim = "scope";
private const char ScopeClaimSeparator = ' ';
private const string PidClaim = "pid";
Expand Down Expand Up @@ -140,33 +138,6 @@ public static bool TryGetSystemUserId(this ClaimsPrincipal claimsPrincipal,
return systemUserId is not null;
}

// This is used for legacy systems using Altinn 2 enterprise users with Maskinporten authentication + token exchange
// as described in https://altinn.github.io/docs/api/rest/kom-i-gang/virksomhet/#autentisering-med-virksomhetsbruker-og-maskinporten
public static bool TryGetLegacySystemUserId(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? systemUserId)
{
systemUserId = null;
if (claimsPrincipal.TryGetClaimValue(AltinnAuthenticationMethodClaim, out var authMethod) &&
authMethod == AltinnAuthenticationEnterpriseUserMethod &&
claimsPrincipal.TryGetClaimValue(AltinnUserIdClaim, out var userId))
{
systemUserId = userId;
}

return systemUserId is not null;
}

public static bool TryGetLegacySystemUserName(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? systemUserName)
{
systemUserName = null;
if (claimsPrincipal.TryGetLegacySystemUserId(out _) &&
claimsPrincipal.TryGetClaimValue(AltinnUserNameClaim, out var claimValue))
{
systemUserName = claimValue;
}

return systemUserName is not null;
}

public static bool TryGetOrganizationNumber(this Claim? consumerClaim, [NotNullWhen(true)] out string? orgNumber)
{
orgNumber = null;
Expand Down Expand Up @@ -237,11 +208,6 @@ public static (UserIdType, string externalId) GetUserType(this ClaimsPrincipal c
: UserIdType.Person, externalId);
}

if (claimsPrincipal.TryGetLegacySystemUserId(out externalId))
{
return (UserIdType.LegacySystemUser, externalId);
}

// https://docs.altinn.studio/authentication/systemauthentication/
if (claimsPrincipal.TryGetSystemUserId(out externalId))
{
Expand All @@ -257,15 +223,29 @@ public static (UserIdType, string externalId) GetUserType(this ClaimsPrincipal c
return (UserIdType.Unknown, string.Empty);
}

public static IPartyIdentifier? GetEndUserPartyIdentifier(this List<Claim> claims)
=> new ClaimsPrincipal(new ClaimsIdentity(claims)).GetEndUserPartyIdentifier();

public static IPartyIdentifier? GetEndUserPartyIdentifier(this ClaimsPrincipal claimsPrincipal)
{
var (userType, externalId) = claimsPrincipal.GetUserType();
return userType switch
{
UserIdType.ServiceOwnerOnBehalfOfPerson or UserIdType.Person => NorwegianPersonIdentifier.TryParse(externalId, out var personId)
? personId
: null,
UserIdType.SystemUser => SystemUserIdentifier.TryParse(externalId, out var systemUserId)
? systemUserId
: null,
UserIdType.Unknown => null,
UserIdType.ServiceOwner => null,
_ => null,
};
}

internal static bool TryGetOrganizationNumber(this IUser user, [NotNullWhen(true)] out string? orgNumber) =>
user.GetPrincipal().TryGetOrganizationNumber(out orgNumber);

internal static bool TryGetPid(this IUser user, [NotNullWhen(true)] out string? pid) =>
user.GetPrincipal().TryGetPid(out pid);

internal static bool TryGetLegacySystemUserId(this IUser user, [NotNullWhen(true)] out string? systemUserId) =>
user.GetPrincipal().TryGetLegacySystemUserId(out systemUserId);

internal static bool TryGetLegacySystemUserName(this IUser user, [NotNullWhen(true)] out string? systemUserName) =>
user.GetPrincipal().TryGetLegacySystemUserName(out systemUserName);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using System.Globalization;
using System.Text;
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities;
using Digdir.Domain.Dialogporten.Domain.SubjectResources;
using Microsoft.EntityFrameworkCore;

namespace Digdir.Domain.Dialogporten.Application.Common.Extensions;

public static class DbSetExtensions
{
public static IQueryable<DialogEntity> PrefilterAuthorizedDialogs(this DbSet<DialogEntity> dialogs, DialogSearchAuthorizationResult authorizedResources)
{
var parameters = new List<object>();

// lang=sql
var sb = new StringBuilder()
.AppendLine(CultureInfo.InvariantCulture, $"""
SELECT *
FROM "Dialog"
WHERE "Id" = ANY(@p{parameters.Count})
""");
parameters.Add(authorizedResources.DialogIds);

foreach (var (party, resources) in authorizedResources.ResourcesByParties)
{
// lang=sql
sb.AppendLine(CultureInfo.InvariantCulture, $"""
OR (
"{nameof(DialogEntity.Party)}" = @p{parameters.Count}
AND "{nameof(DialogEntity.ServiceResource)}" = ANY(@p{parameters.Count + 1})
)
""");
parameters.Add(party);
parameters.Add(resources);
}

foreach (var (party, subjects) in authorizedResources.SubjectsByParties)
{
// lang=sql
sb.AppendLine(CultureInfo.InvariantCulture, $"""
OR (
"{nameof(DialogEntity.Party)}" = @p{parameters.Count}
AND "{nameof(DialogEntity.ServiceResource)}" = ANY(
SELECT "{nameof(SubjectResource.Resource)}"
FROM "{nameof(SubjectResource)}"
WHERE "{nameof(SubjectResource.Subject)}" = ANY(@p{parameters.Count + 1})
)
)
""");
parameters.Add(party);
parameters.Add(subjects);
}

return dialogs.FromSqlRaw(sb.ToString(), parameters.ToArray());
}
}
Original file line number Diff line number Diff line change
@@ -1,78 +1,9 @@
using System.Linq.Expressions;
using System.Reflection;
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities;

namespace Digdir.Domain.Dialogporten.Application.Common.Extensions;

public static class QueryableExtensions
{
public static IQueryable<TSource> WhereIf<TSource>(this IQueryable<TSource> source, bool predicate, Expression<Func<TSource, bool>> queryPredicate)
=> predicate ? source.Where(queryPredicate) : source;

private static readonly Type DialogType = typeof(DialogEntity);
private static readonly PropertyInfo DialogIdPropertyInfo = DialogType.GetProperty(nameof(DialogEntity.Id))!;
private static readonly PropertyInfo DialogPartyPropertyInfo = DialogType.GetProperty(nameof(DialogEntity.Party))!;
private static readonly PropertyInfo DialogServiceResourcePropertyInfo = DialogType.GetProperty(nameof(DialogEntity.ServiceResource))!;

private static readonly MethodInfo StringListContainsMethodInfo = typeof(List<string>).GetMethod(nameof(List<object>.Contains))!;
private static readonly MethodInfo GuidListContainsMethodInfo = typeof(List<Guid>).GetMethod(nameof(List<object>.Contains))!;

private static readonly Type KeyValueType = typeof(KeyValuePair<string, List<string>>);
private static readonly PropertyInfo KeyPropertyInfo = KeyValueType.GetProperty(nameof(KeyValuePair<object, object>.Key))!;
private static readonly PropertyInfo ValuePropertyInfo = KeyValueType.GetProperty(nameof(KeyValuePair<object, object>.Value))!;

private static readonly Type DialogSearchAuthorizationResultType = typeof(DialogSearchAuthorizationResult);
private static readonly PropertyInfo DialogIdsPropertyInfo = DialogSearchAuthorizationResultType.GetProperty(nameof(DialogSearchAuthorizationResult.DialogIds))!;

public static IQueryable<DialogEntity> WhereUserIsAuthorizedFor(
this IQueryable<DialogEntity> source,
DialogSearchAuthorizationResult authorizedResources)
{
if (authorizedResources.HasNoAuthorizations)
{
return source.Where(x => false);
}

var dialogParameter = Expression.Parameter(DialogType);
var id = Expression.MakeMemberAccess(dialogParameter, DialogIdPropertyInfo);
var party = Expression.MakeMemberAccess(dialogParameter, DialogPartyPropertyInfo);
var serviceResource = Expression.MakeMemberAccess(dialogParameter, DialogServiceResourcePropertyInfo);

var partyResourceExpressions = new List<Expression>();

foreach (var item in authorizedResources.ResourcesByParties)
{
var itemArg = Expression.Constant(item, KeyValueType);
var partyAccess = Expression.MakeMemberAccess(itemArg, KeyPropertyInfo);
var resourcesAccess = Expression.MakeMemberAccess(itemArg, ValuePropertyInfo);
var partyEquals = Expression.Equal(partyAccess, party);
var resourceContains = Expression.Call(resourcesAccess, StringListContainsMethodInfo, serviceResource);
partyResourceExpressions.Add(Expression.AndAlso(partyEquals, resourceContains));
}

foreach (var item in authorizedResources.PartiesByResources)
{
var itemArg = Expression.Constant(item, KeyValueType);
var resourceAccess = Expression.MakeMemberAccess(itemArg, KeyPropertyInfo);
var partiesAccess = Expression.MakeMemberAccess(itemArg, ValuePropertyInfo);
var resourceEquals = Expression.Equal(resourceAccess, serviceResource);
var partiesContains = Expression.Call(partiesAccess, StringListContainsMethodInfo, party);
partyResourceExpressions.Add(Expression.AndAlso(resourceEquals, partiesContains));
}

if (authorizedResources.DialogIds.Count > 0)
{
var itemArg = Expression.Constant(authorizedResources, DialogSearchAuthorizationResultType);
var dialogIdsAccess = Expression.MakeMemberAccess(itemArg, DialogIdsPropertyInfo);
var dialogIdsContains = Expression.Call(dialogIdsAccess, GuidListContainsMethodInfo, id);
partyResourceExpressions.Add(dialogIdsContains);
}

var predicate = partyResourceExpressions
.DefaultIfEmpty(Expression.Constant(false))
.Aggregate(Expression.OrElse);

return source.Where(Expression.Lambda<Func<DialogEntity, bool>>(predicate, dialogParameter));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ public Task<AuthorizedPartiesResult> GetUserParties(CancellationToken cancellati
_user.TryGetPid(out var pid) &&
NorwegianPersonIdentifier.TryParse(NorwegianPersonIdentifier.PrefixWithSeparator + pid,
out var partyIdentifier)
? _altinnAuthorization.GetAuthorizedParties(partyIdentifier, cancellationToken)
? _altinnAuthorization.GetAuthorizedParties(partyIdentifier, cancellationToken: cancellationToken)
: Task.FromResult(new AuthorizedPartiesResult());
}
33 changes: 8 additions & 25 deletions src/Digdir.Domain.Dialogporten.Application/Common/IUserRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public sealed class UserId
UserIdType.Person or UserIdType.ServiceOwnerOnBehalfOfPerson => NorwegianPersonIdentifier.PrefixWithSeparator,
UserIdType.SystemUser => SystemUserIdentifier.PrefixWithSeparator,
UserIdType.ServiceOwner => NorwegianOrganizationIdentifier.PrefixWithSeparator,
UserIdType.Unknown or UserIdType.LegacySystemUser => string.Empty,
UserIdType.Unknown => string.Empty,
_ => throw new UnreachableException("Unknown UserIdType")
}) + ExternalId;
}
Expand Down Expand Up @@ -60,31 +60,14 @@ public UserId GetCurrentUserId()
public async Task<UserInformation> GetCurrentUserInformation(CancellationToken cancellationToken)
{
var userId = GetCurrentUserId();
string? name;

switch (userId.Type)
var name = userId.Type switch
{
case UserIdType.Person:
case UserIdType.ServiceOwnerOnBehalfOfPerson:
name = await _partyNameRegistry.GetName(userId.ExternalIdWithPrefix, cancellationToken);
break;

case UserIdType.LegacySystemUser:
_user.TryGetLegacySystemUserName(out var legacyUserName);
name = legacyUserName;
break;

case UserIdType.SystemUser:
// TODO: Implement when SystemUsers are introduced?
name = "System User";
break;

case UserIdType.ServiceOwner:
case UserIdType.Unknown:
default:
throw new UnreachableException();
}

UserIdType.Person or UserIdType.ServiceOwnerOnBehalfOfPerson => await _partyNameRegistry.GetName(userId.ExternalIdWithPrefix, cancellationToken),
UserIdType.SystemUser => "System User",// TODO: Implement when SystemUsers are introduced?
UserIdType.Unknown => throw new UnreachableException(),
UserIdType.ServiceOwner => throw new UnreachableException(),
_ => throw new UnreachableException(),
};
return new()
{
UserId = userId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
namespace Digdir.Domain.Dialogporten.Application.Common.Pagination;
using Digdir.Domain.Dialogporten.Application.Common.Pagination.Extensions;
using Digdir.Domain.Dialogporten.Application.Common.Pagination.OrderOption;

namespace Digdir.Domain.Dialogporten.Application.Common.Pagination;

public sealed class PaginatedList<T>
{
Expand Down Expand Up @@ -31,4 +34,34 @@ public PaginatedList(IEnumerable<T> items, bool hasNextPage, string? @continue,
OrderBy = orderBy;
Items = items?.ToList() ?? throw new ArgumentNullException(nameof(items));
}

/// <summary>
/// Converts the items in the paginated list to a different type.
/// </summary>
/// <typeparam name="TDestination">The type to convert the items to.</typeparam>
/// <param name="map">A function to convert each item to the new type.</param>
/// <returns>A new <see cref="PaginatedList{TDestination}"/> with the converted items.</returns>
public PaginatedList<TDestination> ConvertTo<TDestination>(Func<T, TDestination> map)
{
return new PaginatedList<TDestination>(
Items.Select(map),
HasNextPage,
ContinuationToken,
OrderBy);
}

/// <summary>
/// Creates an empty paginated list based on the provided pagination parameters.
/// </summary>
/// <typeparam name="TOrderDefinition">The type of the order definition.</typeparam>
/// <typeparam name="TTarget">The type of the target.</typeparam>
/// <param name="parameter">The sortable pagination parameter.</param>
/// <returns>An empty <see cref="PaginatedList{T}"/>.</returns>
public static PaginatedList<T> CreateEmpty<TOrderDefinition, TTarget>(SortablePaginationParameter<TOrderDefinition, TTarget> parameter)
where TOrderDefinition : IOrderDefinition<TTarget> =>
new(
items: [],
hasNextPage: false,
@continue: parameter.ContinuationToken?.Raw,
orderBy: parameter.OrderBy.DefaultIfNull().GetOrderString());
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;

public class AuthorizedPartiesResult
{
public List<AuthorizedParty> AuthorizedParties { get; init; } = [];
public List<AuthorizedParty> AuthorizedParties { get; set; } = [];
}

public class AuthorizedParty
Expand All @@ -17,7 +17,13 @@ public class AuthorizedParty
public bool IsAccessManager { get; init; }
public bool HasOnlyAccessToSubParties { get; init; }
public List<string> AuthorizedResources { get; init; } = [];
public List<AuthorizedParty>? SubParties { get; init; }
public List<string> AuthorizedRoles { get; init; } = [];

// Only populated in case of flatten = false
public List<AuthorizedParty>? SubParties { get; set; }

// Only populated in case of flatten = true
public string? ParentParty { get; set; }
}

public enum AuthorizedPartyType
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ public sealed class DialogSearchAuthorizationResult
// Resources here are "main" resources, eg. something that represents an entry in the Resource Registry
// eg. "urn:altinn:resource:some-service" and referred to by "ServiceResource" in DialogEntity
public Dictionary<string, List<string>> ResourcesByParties { get; init; } = new();
public Dictionary<string, List<string>> PartiesByResources { get; init; } = new();
public Dictionary<string, List<string>> SubjectsByParties { get; init; } = new();
public List<Guid> DialogIds { get; init; } = [];

public bool HasNoAuthorizations =>
ResourcesByParties.Count == 0
&& PartiesByResources.Count == 0
&& DialogIds.Count == 0;
&& DialogIds.Count == 0
&& SubjectsByParties.Count == 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ public Task<DialogSearchAuthorizationResult> GetAuthorizedResourcesForSearch(
string? endUserId = null,
CancellationToken cancellationToken = default);

public Task<AuthorizedPartiesResult> GetAuthorizedParties(IPartyIdentifier authenticatedParty,
public Task<AuthorizedPartiesResult> GetAuthorizedParties(IPartyIdentifier authenticatedParty, bool flatten = false,
CancellationToken cancellationToken = default);
}
Loading

0 comments on commit aa8f84d

Please sign in to comment.