diff --git a/docs/schema/V1/schema.verified.graphql b/docs/schema/V1/schema.verified.graphql index 1319075c3..8daef535c 100644 --- a/docs/schema/V1/schema.verified.graphql +++ b/docs/schema/V1/schema.verified.graphql @@ -1,5 +1,5 @@ schema { - query: DialogQueries + query: Queries } type Activity { @@ -34,6 +34,18 @@ type ApiActionEndpoint { sunsetAt: DateTime } +type AuthorizedParty { + party: String! + name: String! + partyType: String! + isDeleted: Boolean! + hasKeyRole: Boolean! + isMainAdministrator: Boolean! + isAccessManager: Boolean! + hasOnlyAccessToSubParties: Boolean! + subParties: [AuthorizedParty!] +} + type Content { type: ContentType! value: [Localization!]! @@ -63,11 +75,6 @@ type Dialog { seenSinceLastUpdate: [SeenLog!]! } -type DialogQueries @authorize(policy: "enduser") { - dialogById(dialogId: UUID!): Dialog! - searchDialogs(input: SearchDialogInput!): SearchDialogsPayload! -} - type Element { id: UUID! type: URL @@ -103,6 +110,12 @@ type Localization { cultureCode: String! } +type Queries @authorize(policy: "enduser") { + dialogById(dialogId: UUID!): Dialog! + searchDialogs(input: SearchDialogInput!): SearchDialogsPayload! + parties: [AuthorizedParty!]! +} + type SearchDialog { id: UUID! org: String! @@ -239,4 +252,4 @@ scalar DateTime @specifiedBy(url: "https:\/\/www.graphql-scalars.com\/date-time" scalar URL @specifiedBy(url: "https:\/\/tools.ietf.org\/html\/rfc3986") -scalar UUID @specifiedBy(url: "https:\/\/tools.ietf.org\/html\/rfc4122") \ No newline at end of file +scalar UUID @specifiedBy(url: "https:\/\/tools.ietf.org\/html\/rfc4122") diff --git a/docs/schema/V1/swagger.verified.json b/docs/schema/V1/swagger.verified.json index b15ae29c0..9415f648d 100644 --- a/docs/schema/V1/swagger.verified.json +++ b/docs/schema/V1/swagger.verified.json @@ -1,4 +1,4 @@ -{ +{ "openapi": "3.0.0", "info": { "title": "Dialogporten", @@ -1606,6 +1606,42 @@ ] } }, + "/api/v1/enduser/parties": { + "get": { + "tags": [ + "Enduser" + ], + "summary": "Gets the list of authorized parties for the end user", + "description": "Gets the list of authorized parties for the end user. For more information see the documentation (link TBD).", + "operationId": "GetParties", + "responses": { + "200": { + "description": "The list of authorized parties for the end user", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetPartiesDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + }, + "security": [ + { + "JWTBearerAuth": [] + } + ] + } + }, "/api/v1/enduser/dialogs/{dialogId}/seenlog": { "get": { "tags": [ @@ -3732,6 +3768,55 @@ } } }, + "GetPartiesDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "authorizedParties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthorizedPartyDto" + } + } + } + }, + "AuthorizedPartyDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "party": { + "type": "string" + }, + "name": { + "type": "string" + }, + "partyType": { + "type": "string" + }, + "isDeleted": { + "type": "boolean" + }, + "hasKeyRole": { + "type": "boolean" + }, + "isMainAdministrator": { + "type": "boolean" + }, + "isAccessManager": { + "type": "boolean" + }, + "hasOnlyAccessToSubParties": { + "type": "boolean" + }, + "subParties": { + "type": "array", + "nullable": true, + "items": { + "$ref": "#/components/schemas/AuthorizedPartyDto" + } + } + } + }, "SearchDialogSeenLogDto": { "type": "object", "additionalProperties": false, diff --git a/src/Digdir.Domain.Dialogporten.Application/ApplicationExtensions.cs b/src/Digdir.Domain.Dialogporten.Application/ApplicationExtensions.cs index bdd505b52..d763ea7ba 100644 --- a/src/Digdir.Domain.Dialogporten.Application/ApplicationExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Application/ApplicationExtensions.cs @@ -44,6 +44,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)) diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/IUserParties.cs b/src/Digdir.Domain.Dialogporten.Application/Common/IUserParties.cs new file mode 100644 index 000000000..bd50da870 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Common/IUserParties.cs @@ -0,0 +1,30 @@ +using Digdir.Domain.Dialogporten.Application.Common.Extensions; +using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; +using Digdir.Domain.Dialogporten.Application.Externals.Presentation; +using Digdir.Domain.Dialogporten.Domain.Parties; + +namespace Digdir.Domain.Dialogporten.Application.Common; + +public interface IUserParties +{ + public Task GetUserParties(CancellationToken cancellationToken = default); +} + +public class UserParties : IUserParties +{ + private readonly IUser _user; + private readonly IAltinnAuthorization _altinnAuthorization; + + public UserParties(IUser user, IAltinnAuthorization altinnAuthorization) + { + _user = user ?? throw new ArgumentNullException(nameof(user)); + _altinnAuthorization = altinnAuthorization ?? throw new ArgumentNullException(nameof(altinnAuthorization)); + } + + public Task GetUserParties(CancellationToken cancellationToken = default) => + _user.TryGetPid(out var pid) && + NorwegianPersonIdentifier.TryParse(NorwegianPersonIdentifier.PrefixWithSeparator + pid, + out var partyIdentifier) + ? _altinnAuthorization.GetAuthorizedParties(partyIdentifier, cancellationToken) + : Task.FromResult(new AuthorizedPartiesResult()); +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/AuthorizedPartiesResult.cs b/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/AuthorizedPartiesResult.cs new file mode 100644 index 000000000..e821d7a4e --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/AuthorizedPartiesResult.cs @@ -0,0 +1,28 @@ +using Digdir.Domain.Dialogporten.Domain.Parties.Abstractions; + +namespace Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; + +public class AuthorizedPartiesResult +{ + public List AuthorizedParties { get; init; } = new(); +} + +public class AuthorizedParty +{ + public string Party { get; init; } = null!; + public string Name { get; init; } = null!; + public AuthorizedPartyType PartyType { get; init; } + public bool IsDeleted { get; init; } + public bool HasKeyRole { get; init; } + public bool IsMainAdministrator { get; init; } + public bool IsAccessManager { get; init; } + public bool HasOnlyAccessToSubParties { get; init; } + public List AuthorizedResources { get; init; } = new(); + public List? SubParties { get; init; } +} + +public enum AuthorizedPartyType +{ + Person, + Organization +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs b/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs index 0a4e1fdd8..1c93a5070 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/IAltinnAuthorization.cs @@ -1,4 +1,5 @@ using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; +using Digdir.Domain.Dialogporten.Domain.Parties.Abstractions; namespace Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; @@ -13,4 +14,7 @@ public Task GetAuthorizedResourcesForSearch( List constraintServiceResources, string? endUserId = null, CancellationToken cancellationToken = default); + + public Task GetAuthorizedParties(IPartyIdentifier authenticatedParty, + CancellationToken cancellationToken = default); } diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/GetPartiesDto.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/GetPartiesDto.cs new file mode 100644 index 000000000..ea59ca322 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/GetPartiesDto.cs @@ -0,0 +1,19 @@ +namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Parties.Queries.Get; + +public class GetPartiesDto +{ + public List AuthorizedParties { get; init; } = new(); +} + +public class AuthorizedPartyDto +{ + public string Party { get; init; } = null!; + public string Name { get; init; } = null!; + public string PartyType { get; init; } = null!; + public bool IsDeleted { get; init; } + public bool HasKeyRole { get; init; } + public bool IsMainAdministrator { get; init; } + public bool IsAccessManager { get; init; } + public bool HasOnlyAccessToSubParties { get; init; } + public List? SubParties { get; init; } +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/GetPartiesQuery.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/GetPartiesQuery.cs new file mode 100644 index 000000000..303ae717d --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/GetPartiesQuery.cs @@ -0,0 +1,25 @@ +using AutoMapper; +using Digdir.Domain.Dialogporten.Application.Common; +using MediatR; + +namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Parties.Queries.Get; + +public sealed class GetPartiesQuery : IRequest; + +internal sealed class GetPartiesQueryHandler : IRequestHandler +{ + private readonly IUserParties _userParties; + private readonly IMapper _mapper; + + public GetPartiesQueryHandler(IUserParties userParties, IMapper mapper) + { + _userParties = userParties; + _mapper = mapper; + } + + public async Task Handle(GetPartiesQuery request, CancellationToken cancellationToken) + { + var authorizedPartiesResult = await _userParties.GetUserParties(cancellationToken); + return _mapper.Map(authorizedPartiesResult); + } +} diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/MappingProfile.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/MappingProfile.cs new file mode 100644 index 000000000..c4021ca12 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Parties/Queries/Get/MappingProfile.cs @@ -0,0 +1,13 @@ +using AutoMapper; +using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; + +namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Parties.Queries.Get; + +internal sealed class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap(); + CreateMap(); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogQueries.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogQueries.cs index bb26778c4..c645656c9 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogQueries.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogQueries.cs @@ -1,16 +1,13 @@ using AutoMapper; using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Get; using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Search; -using Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; using Digdir.Domain.Dialogporten.GraphQL.EndUser.DialogById; using Digdir.Domain.Dialogporten.GraphQL.EndUser.SearchDialogs; -using HotChocolate.Authorization; using MediatR; namespace Digdir.Domain.Dialogporten.GraphQL.EndUser; -[Authorize(Policy = AuthorizationPolicy.EndUser)] -public class DialogQueries +public partial class Queries { public async Task GetDialogById( [Service] ISender mediator, diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Parties/MappingProfile.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Parties/MappingProfile.cs new file mode 100644 index 000000000..00784aafc --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Parties/MappingProfile.cs @@ -0,0 +1,12 @@ +using AutoMapper; +using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Parties.Queries.Get; + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.Parties; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap(); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Parties/ObjectTypes.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Parties/ObjectTypes.cs new file mode 100644 index 000000000..61f271ea0 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Parties/ObjectTypes.cs @@ -0,0 +1,14 @@ +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.Parties; + +public class AuthorizedParty +{ + public string Party { get; init; } = null!; + public string Name { get; init; } = null!; + public string PartyType { get; init; } = null!; + public bool IsDeleted { get; init; } + public bool HasKeyRole { get; init; } + public bool IsMainAdministrator { get; init; } + public bool IsAccessManager { get; init; } + public bool HasOnlyAccessToSubParties { get; init; } + public List? SubParties { get; init; } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/PartyQueries.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/PartyQueries.cs new file mode 100644 index 000000000..d2fea28c2 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/PartyQueries.cs @@ -0,0 +1,20 @@ +using AutoMapper; +using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Parties.Queries.Get; +using Digdir.Domain.Dialogporten.GraphQL.EndUser.Parties; +using MediatR; + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser; + +public partial class Queries +{ + public async Task> GetParties( + [Service] ISender mediator, + [Service] IMapper mapper, + CancellationToken cancellationToken) + { + var request = new GetPartiesQuery(); + var result = await mediator.Send(request, cancellationToken); + + return mapper.Map>(result.AuthorizedParties); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Queries.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Queries.cs new file mode 100644 index 000000000..dd65332d1 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Queries.cs @@ -0,0 +1,7 @@ +using Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; +using HotChocolate.Authorization; + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser; + +[Authorize(Policy = AuthorizationPolicy.EndUser)] +public partial class Queries; diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs index 8bbd54fe5..36e04829e 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs @@ -13,7 +13,7 @@ public static IServiceCollection AddDialogportenGraphQl( .AddAuthorization() .RegisterDbContext() .AddDiagnosticEventListener() - .AddQueryType() + .AddQueryType() .Services; } } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs index 9f4b78648..9d5f9ee6b 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs @@ -14,15 +14,23 @@ using ZiggyCreatures.Caching.Fusion; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; - internal sealed class AltinnAuthorizationClient : IAltinnAuthorization { + private const string AuthorizeUrl = "authorization/api/v1/authorize"; + private const string AuthorizedPartiesUrl = "/accessmanagement/api/v1/resourceowner/authorizedparties?includeAltinn2=true"; + private readonly HttpClient _httpClient; private readonly IFusionCache _cache; private readonly IUser _user; private readonly IDialogDbContext _db; private readonly ILogger _logger; + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault + }; + public AltinnAuthorizationClient( HttpClient client, IFusionCacheProvider cacheProvider, @@ -71,7 +79,23 @@ public async Task GetAuthorizedResourcesForSear => await PerformNonScalableDialogSearchAuthorization(request, token), token: cancellationToken); } - private async Task PerformNonScalableDialogSearchAuthorization(DialogSearchAuthorizationRequest request, CancellationToken cancellationToken) + public async Task GetAuthorizedParties(IPartyIdentifier authenticatedParty, + CancellationToken cancellationToken = default) + { + var authorizedPartiesRequest = new AuthorizedPartiesRequest(authenticatedParty); + return await _cache.GetOrSetAsync(authorizedPartiesRequest.GenerateCacheKey(), async token + => await PerformAuthorizedPartiesRequest(authorizedPartiesRequest, token), token: cancellationToken); + } + + private async Task PerformAuthorizedPartiesRequest(AuthorizedPartiesRequest authorizedPartiesRequest, + CancellationToken token) + { + var authorizedPartiesDto = await SendAuthorizedPartiesRequest(authorizedPartiesRequest, token); + return AuthorizedPartiesHelper.CreateAuthorizedPartiesResult(authorizedPartiesDto); + } + + private async Task PerformNonScalableDialogSearchAuthorization( + DialogSearchAuthorizationRequest request, CancellationToken cancellationToken) { /* * This is a preliminary implementation as per https://github.com/digdir/dialogporten/issues/249 @@ -107,14 +131,15 @@ private async Task PerformNonScalableDialogSear } var xacmlJsonRequest = DecisionRequestHelper.NonScalable.CreateDialogSearchRequest(request); - var xamlJsonResponse = await SendRequest(xacmlJsonRequest, cancellationToken); + var xamlJsonResponse = await SendPdpRequest(xacmlJsonRequest, cancellationToken); return DecisionRequestHelper.NonScalable.CreateDialogSearchResponse(xacmlJsonRequest, xamlJsonResponse); } - private async Task PerformDialogDetailsAuthorization(DialogDetailsAuthorizationRequest request, CancellationToken cancellationToken) + private async Task PerformDialogDetailsAuthorization( + DialogDetailsAuthorizationRequest request, CancellationToken cancellationToken) { var xacmlJsonRequest = DecisionRequestHelper.CreateDialogDetailsRequest(request); - var xamlJsonResponse = await SendRequest(xacmlJsonRequest, cancellationToken); + var xamlJsonResponse = await SendPdpRequest(xacmlJsonRequest, cancellationToken); return DecisionRequestHelper.CreateDialogDetailsResponse(request.AltinnActions, xamlJsonResponse); } @@ -133,32 +158,32 @@ private List GetOrCreateClaimsBasedOnEndUserId(string? endUserId) return claims; } - private static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault - }; + private async Task SendPdpRequest( + XacmlJsonRequestRoot xacmlJsonRequest, CancellationToken cancellationToken) => + await SendRequest( + AuthorizeUrl, xacmlJsonRequest, cancellationToken); + + private async Task?> SendAuthorizedPartiesRequest( + AuthorizedPartiesRequest authorizedPartiesRequest, CancellationToken cancellationToken) => + await SendRequest>( + AuthorizedPartiesUrl, authorizedPartiesRequest, cancellationToken); - private async Task SendRequest(XacmlJsonRequestRoot xacmlJsonRequest, CancellationToken cancellationToken) + private async Task SendRequest(string url, object request, CancellationToken cancellationToken) { - const string apiUrl = "authorization/api/v1/authorize"; - var requestJson = JsonSerializer.Serialize(xacmlJsonRequest, SerializerOptions); - _logger.LogDebug("Generated XACML request: {RequestJson}", requestJson); + var requestJson = JsonSerializer.Serialize(request, SerializerOptions); + _logger.LogDebug("Authorization request to {Url}: {RequestJson}", url, requestJson); var httpContent = new StringContent(requestJson, Encoding.UTF8, "application/json"); - - var response = await _httpClient.PostAsync(apiUrl, httpContent, cancellationToken); - + var response = await _httpClient.PostAsync(url, httpContent, cancellationToken); if (response.StatusCode != HttpStatusCode.OK) { var errorResponse = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogInformation( - "AltinnAuthorizationClient.SendRequest failed with non-successful status code: {StatusCode} {Response}", + _logger.LogWarning("AltinnAuthorizationClient.SendRequest failed with non-successful status code: {StatusCode} {Response}", response.StatusCode, errorResponse); - return null; + return default; } var responseData = await response.Content.ReadAsStringAsync(cancellationToken); - return JsonSerializer.Deserialize(responseData, SerializerOptions); + return JsonSerializer.Deserialize(responseData, SerializerOptions); } } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesHelper.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesHelper.cs new file mode 100644 index 000000000..cff240ee1 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesHelper.cs @@ -0,0 +1,64 @@ +using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; +using Digdir.Domain.Dialogporten.Domain.Parties; + +namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; + +internal static class AuthorizedPartiesHelper +{ + private const string PartyTypeOrganization = "Organization"; + private const string PartyTypePerson = "Person"; + private const string AttributeIdResource = "urn:altinn:resource"; + private const string AttributeIdApp = "urn:altinn:app"; + private const string AppIdPrefix = "app_"; + private const string MainAdministratorRoleCode = "HADM"; + private const string AccessManagerRoleCode = "ADMAI"; + private static readonly string[] KeyRoleCodes = ["DAGL", "LEDE", "INNH", "DTPR", "DTSO", "BEST"]; + public static AuthorizedPartiesResult CreateAuthorizedPartiesResult(List? authorizedPartiesDto) + { + var result = new AuthorizedPartiesResult(); + if (authorizedPartiesDto is not null) + { + foreach (var authorizedPartyDto in authorizedPartiesDto) + { + result.AuthorizedParties.Add(MapFromDto(authorizedPartyDto)); + } + } + + return result; + } + + private static AuthorizedParty MapFromDto(AuthorizedPartiesResultDto dto) + { + var party = dto.Type switch + { + PartyTypeOrganization => NorwegianOrganizationIdentifier.PrefixWithSeparator + dto.OrganizationNumber, + PartyTypePerson => NorwegianPersonIdentifier.PrefixWithSeparator + dto.PersonId, + _ => throw new ArgumentOutOfRangeException(nameof(dto)) + }; + + return new AuthorizedParty + { + Party = party, + Name = dto.Name, + PartyType = dto.Type switch + { + PartyTypeOrganization => AuthorizedPartyType.Organization, + PartyTypePerson => AuthorizedPartyType.Person, + _ => throw new ArgumentOutOfRangeException(nameof(dto)) + }, + IsDeleted = dto.IsDeleted, + HasKeyRole = dto.AuthorizedRoles.Exists(role => KeyRoleCodes.Contains(role)), + IsMainAdministrator = dto.AuthorizedRoles.Contains(MainAdministratorRoleCode), + IsAccessManager = dto.AuthorizedRoles.Contains(AccessManagerRoleCode), + HasOnlyAccessToSubParties = dto.OnlyHierarchyElementWithNoAccess, + AuthorizedResources = GetPrefixedResources(dto.AuthorizedResources), + SubParties = dto.Subunits.Count > 0 ? dto.Subunits.Select(MapFromDto).ToList() : null + }; + } + + private static List GetPrefixedResources(List dtoAuthorizedResources) => + dtoAuthorizedResources.Select(resource => resource.StartsWith(AppIdPrefix, StringComparison.Ordinal) + ? AttributeIdApp + ":" + resource + : AttributeIdResource + ":" + resource) + .ToList(); +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesRequest.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesRequest.cs new file mode 100644 index 000000000..d3049ad74 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesRequest.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography; +using System.Text; +using Digdir.Domain.Dialogporten.Domain.Parties.Abstractions; + +namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; + +public sealed class AuthorizedPartiesRequest(IPartyIdentifier partyIdentifier) +{ + public string Type { get; init; } = partyIdentifier.Prefix(); + public string Value { get; init; } = partyIdentifier.Id; +} + +public static class AuthorizedPartiesRequestExtensions +{ + public static string GenerateCacheKey(this AuthorizedPartiesRequest request) + { + var rawKey = $"{request.Type}:{request.Value}"; + + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawKey)); + var hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + + return $"auth:parties:{hashString}"; + } +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesResultDto.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesResultDto.cs new file mode 100644 index 000000000..46994b309 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizedPartiesResultDto.cs @@ -0,0 +1,15 @@ +namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; + +internal sealed class AuthorizedPartiesResultDto +{ + public required string Name { get; set; } + public required string OrganizationNumber { get; set; } + public string? PersonId { get; set; } + public required int PartyId { get; set; } + public required string Type { get; set; } + public required bool IsDeleted { get; set; } + public required bool OnlyHierarchyElementWithNoAccess { get; set; } + public required List AuthorizedResources { get; set; } + public required List AuthorizedRoles { get; set; } + public required List Subunits { get; set; } +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs index 05de0e3ee..dc49a1409 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs @@ -3,6 +3,7 @@ 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.Parties.Abstractions; using Microsoft.EntityFrameworkCore; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; @@ -47,4 +48,7 @@ public async Task GetAuthorizedResourcesForSear return authorizedResources; } + + public async Task GetAuthorizedParties(IPartyIdentifier authenticatedParty, CancellationToken cancellationToken = default) + => await Task.FromResult(new AuthorizedPartiesResult { AuthorizedParties = [new() { Name = "Local Party", Party = authenticatedParty.FullId }] }); } diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/EndUser/Parties/Get/GetPartiesEndpoint.cs b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/EndUser/Parties/Get/GetPartiesEndpoint.cs new file mode 100644 index 000000000..74b742654 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/EndUser/Parties/Get/GetPartiesEndpoint.cs @@ -0,0 +1,31 @@ +using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Parties.Queries.Get; +using Digdir.Domain.Dialogporten.WebApi.Common.Authorization; +using FastEndpoints; +using MediatR; + +namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.EndUser.Parties.Get; + +public class GetPartiesEndpoint : EndpointWithoutRequest +{ + private readonly ISender _sender; + + public GetPartiesEndpoint(ISender sender) + { + _sender = sender ?? throw new ArgumentNullException(nameof(sender)); + } + + public override void Configure() + { + Get("parties"); + Policies(AuthorizationPolicy.EndUser); + Group(); + + Description(d => GetPartiesSwaggerConfig.SetDescription(d)); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var result = await _sender.Send(new GetPartiesQuery(), ct); + await SendOkAsync(result, ct); + } +} diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/EndUser/Parties/Get/GetPartiesSwaggerConfig.cs b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/EndUser/Parties/Get/GetPartiesSwaggerConfig.cs new file mode 100644 index 000000000..bdab83601 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/EndUser/Parties/Get/GetPartiesSwaggerConfig.cs @@ -0,0 +1,30 @@ +using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Parties.Queries.Get; +using Digdir.Domain.Dialogporten.WebApi.Common.Swagger; +using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.Common.Extensions; +using FastEndpoints; + +namespace Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.EndUser.Parties.Get; + +public class GetPartiesSwaggerConfig : ISwaggerConfig +{ + public static string OperationId => "GetParties"; + + public static RouteHandlerBuilder SetDescription(RouteHandlerBuilder builder) + => builder.OperationId(OperationId) + .Produces>(); + + public static object GetExample() => throw new NotImplementedException(); +} + +public sealed class GetPartiesEndpointSummary : Summary +{ + public GetPartiesEndpointSummary() + { + Summary = "Gets the list of authorized parties for the end user"; + Description = """ + Gets the list of authorized parties for the end user. For more information see the documentation (link TBD). + """; + + Responses[StatusCodes.Status200OK] = "The list of authorized parties for the end user"; + } +} diff --git a/tests/k6/tests/enduser/all-tests.js b/tests/k6/tests/enduser/all-tests.js index e97c98028..4c96ee596 100644 --- a/tests/k6/tests/enduser/all-tests.js +++ b/tests/k6/tests/enduser/all-tests.js @@ -1,8 +1,10 @@ // This file is generated, see "scripts" directory import { default as dialogDetails } from './dialogDetails.js'; import { default as dialogSearch } from './dialogSearch.js'; +import { default as parties } from './parties.js'; export default function() { dialogDetails(); dialogSearch(); + parties(); } diff --git a/tests/k6/tests/enduser/parties.js b/tests/k6/tests/enduser/parties.js new file mode 100644 index 000000000..e10c39128 --- /dev/null +++ b/tests/k6/tests/enduser/parties.js @@ -0,0 +1,10 @@ +import { describe, expect, expectStatusFor, getEU } from '../../common/testimports.js' + +export default function () { + describe('Check if we get any parties', () => { + let r = getEU("parties"); + expectStatusFor(r).to.equal(200); + expect(r, 'response').to.have.validJsonBody(); + expect(r.json(), 'response json').to.have.property("authorizedParties").with.lengthOf.at.least(2); + }); +} \ No newline at end of file