diff --git a/Digdir.Domain.Dialogporten.sln b/Digdir.Domain.Dialogporten.sln index f513035db..9d6ebfded 100644 --- a/Digdir.Domain.Dialogporten.sln +++ b/Digdir.Domain.Dialogporten.sln @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Tool.Dialogporten.Ed EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Domain.Dialogporten.WebApi.Integration.Tests", "tests\Digdir.Domain.Dialogporten.WebApi.Integration.Tests\Digdir.Domain.Dialogporten.WebApi.Integration.Tests.csproj", "{42004236-D45C-4A1F-9FF9-CF12B7388389}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Digdir.Domain.Dialogporten.GraphQL", "src\Digdir.Domain.Dialogporten.GraphQL\Digdir.Domain.Dialogporten.GraphQL.csproj", "{234FE24D-1047-4E29-A625-1EB406C37A2D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -123,6 +125,10 @@ Global {42004236-D45C-4A1F-9FF9-CF12B7388389}.Debug|Any CPU.Build.0 = Debug|Any CPU {42004236-D45C-4A1F-9FF9-CF12B7388389}.Release|Any CPU.ActiveCfg = Release|Any CPU {42004236-D45C-4A1F-9FF9-CF12B7388389}.Release|Any CPU.Build.0 = Release|Any CPU + {234FE24D-1047-4E29-A625-1EB406C37A2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {234FE24D-1047-4E29-A625-1EB406C37A2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {234FE24D-1047-4E29-A625-1EB406C37A2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {234FE24D-1047-4E29-A625-1EB406C37A2D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -147,6 +153,7 @@ Global {B6FE45A3-FB14-4528-9957-295AB2A00A46} = {CADB8189-4AA1-4732-844A-C41DBF3EC8B7} {030909AA-5B61-46B4-9B74-0D2D779478FF} = {3C2C775D-F2D1-42A2-B53F-CC6D5FF59633} {42004236-D45C-4A1F-9FF9-CF12B7388389} = {CADB8189-4AA1-4732-844A-C41DBF3EC8B7} + {234FE24D-1047-4E29-A625-1EB406C37A2D} = {320B47A0-5EB8-4B6E-8C84-90633A1849CA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {B2FE67FF-7622-4AFB-AD8E-961B6A39D888} diff --git a/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/DialogStatus.cs b/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/DialogStatus.cs index 7443da2af..a1d2c0bdd 100644 --- a/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/DialogStatus.cs +++ b/src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/DialogStatus.cs @@ -10,13 +10,13 @@ public DialogStatus(Values id) : base(id) { } public enum Values { /// - /// Dialogen er å regne som ny. Brukes typisk for enkle meldinger som ikke krever noe - /// interaksjon, eller som et initielt steg for dialoger. Dette er default. + /// Dialogen er å regne som ny. Brukes typisk for enkle meldinger som ikke krever noe + /// interaksjon, eller som et initielt steg for dialoger. Dette er default. /// New = 1, /// - /// Under arbeid. Generell status som brukes for dialogtjenester der ytterligere bruker-input er + /// Under arbeid. Generell status som brukes for dialogtjenester der ytterligere bruker-input er /// forventet. /// InProgress = 2, @@ -27,8 +27,8 @@ public enum Values Waiting = 3, /// - /// Dialogen er i en tilstand hvor den venter på signering. Typisk siste steg etter at all - /// utfylling er gjennomført og validert. + /// Dialogen er i en tilstand hvor den venter på signering. Typisk siste steg etter at all + /// utfylling er gjennomført og validert. /// Signing = 4, diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationBuilderExtensions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationBuilderExtensions.cs new file mode 100644 index 000000000..5ddbc6c0f --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationBuilderExtensions.cs @@ -0,0 +1,63 @@ +using Microsoft.IdentityModel.Tokens; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System.Diagnostics; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication; + +internal static class AuthenticationBuilderExtensions +{ + public static IServiceCollection AddDialogportenAuthentication( + this IServiceCollection services, + IConfiguration configuration) + { + var jwtTokenSchemas = configuration + .GetSection(GraphQlSettings.SectionName) + .Get() + ?.Authentication + ?.JwtBearerTokenSchemas; + + if (jwtTokenSchemas is null || jwtTokenSchemas.Count == 0) + // Validation should have caught this. + throw new UnreachableException(); + + services.AddSingleton(); + + var authenticationBuilder = services.AddAuthentication(); + + foreach (var schema in jwtTokenSchemas) + { + authenticationBuilder.AddJwtBearer(schema.Name, options => + { + options.MetadataAddress = schema.WellKnown; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + ValidateIssuer = false, + ValidateAudience = false, + RequireExpirationTime = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(2) + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = async context => + { + var expectedIssuer = await context.HttpContext + .RequestServices + .GetRequiredService() + .GetIssuerForScheme(schema.Name); + + if (context.HttpContext.Items.TryGetValue(Constants.CurrentTokenIssuer, out var tokenIssuer) + && (string?)tokenIssuer != expectedIssuer) + { + context.NoResult(); + } + } + }; + }); + } + + return services; + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationOptions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationOptions.cs new file mode 100644 index 000000000..7c17dd258 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationOptions.cs @@ -0,0 +1,12 @@ +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication; + +public sealed class AuthenticationOptions +{ + public required List JwtBearerTokenSchemas { get; init; } +} + +public sealed class JwtBearerTokenSchemasOptions +{ + public required string Name { get; init; } + public required string WellKnown { get; init; } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationOptionsValidator.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationOptionsValidator.cs new file mode 100644 index 000000000..cb3b95620 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/AuthenticationOptionsValidator.cs @@ -0,0 +1,25 @@ +using FluentValidation; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication; + +internal sealed class AuthenticationOptionsValidator : AbstractValidator +{ + public AuthenticationOptionsValidator( + IValidator jwtTokenSchemaValidator) + { + RuleFor(x => x.JwtBearerTokenSchemas) + .NotEmpty() + .WithMessage("At least one JwtBearerTokenSchema must be configured"); + RuleForEach(x => x.JwtBearerTokenSchemas) + .SetValidator(jwtTokenSchemaValidator); + } +} + +internal sealed class JwtBearerTokenSchemasOptionsValidator : AbstractValidator +{ + public JwtBearerTokenSchemasOptionsValidator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.WellKnown).NotEmpty(); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/JwtSchemeSelectorMiddleware.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/JwtSchemeSelectorMiddleware.cs new file mode 100644 index 000000000..4c73edb27 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/JwtSchemeSelectorMiddleware.cs @@ -0,0 +1,40 @@ +using System.IdentityModel.Tokens.Jwt; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication; + +public class JwtSchemeSelectorMiddleware +{ + private readonly RequestDelegate _next; + + public JwtSchemeSelectorMiddleware(RequestDelegate next) + { + _next = next; + } + + public Task InvokeAsync(HttpContext context) + { + if (!context.Request.Headers.TryGetValue(Constants.Authorization, out var authorizationHeader)) + return _next(context); + + var token = authorizationHeader.ToString() + .Split(' ') + .LastOrDefault(); + + if (string.IsNullOrEmpty(token)) + return _next(context); + + var handler = new JwtSecurityTokenHandler(); + if (!handler.CanReadToken(token)) + return _next(context); + + var jwtToken = handler.ReadJwtToken(token); + context.Items[Constants.CurrentTokenIssuer] = jwtToken.Issuer; + return _next(context); + } +} + +public static class JwtSchemeSelectorMiddlewareExtensions +{ + public static IApplicationBuilder UseJwtSchemeSelector(this IApplicationBuilder app) + => app.UseMiddleware(); +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/LocalDevelopmentUser.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/LocalDevelopmentUser.cs new file mode 100644 index 000000000..499f1b16f --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/LocalDevelopmentUser.cs @@ -0,0 +1,44 @@ +using System.Collections.ObjectModel; +using System.Security.Claims; +using Digdir.Domain.Dialogporten.Application.Externals.Presentation; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication; + +internal sealed class LocalDevelopmentUser : IUser +{ + private readonly ClaimsPrincipal _principal = new(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Name, "Local Development User"), + new Claim(ClaimTypes.NameIdentifier, "local-development-user"), + new Claim("pid", "03886595947"), + new Claim("scope", string.Join(" ", AuthorizationScope.AllScopes.Value)), + new Claim("consumer", + """ + { + "authority": "iso6523-actorid-upis", + "ID": "0192:991825827" + } + """) + })); + + public ClaimsPrincipal GetPrincipal() => _principal; +} + + +internal static class AuthorizationScope +{ + public const string EndUser = "digdir:dialogporten"; + public const string ServiceProvider = "digdir:dialogporten.serviceprovider"; + public const string ServiceProviderSearch = "digdir:dialogporten.serviceprovider.search"; + public const string Testing = "digdir:dialogporten.developer.test"; + + internal static readonly Lazy> AllScopes = new(GetAll); + + private static ReadOnlyCollection GetAll() => + typeof(AuthorizationScope) + .GetFields() + .Where(x => x.IsLiteral && !x.IsInitOnly && x.DeclaringType == typeof(string)) + .Select(x => (string)x.GetRawConstantValue()!) + .ToList() + .AsReadOnly(); +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/TokenIssuerCache.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/TokenIssuerCache.cs new file mode 100644 index 000000000..f2ffe53c5 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authentication/TokenIssuerCache.cs @@ -0,0 +1,64 @@ +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Protocols; +using Microsoft.Extensions.Options; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authentication; + +public interface ITokenIssuerCache +{ + public Task GetIssuerForScheme(string schemeName); +} + +public sealed class TokenIssuerCache : ITokenIssuerCache, IDisposable +{ + private readonly Dictionary _issuerMappings = new(); + private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); + private bool _initialized; + private readonly IReadOnlyCollection _jwtTokenSchemas; + + public TokenIssuerCache(IOptions apiSettings) + { + _jwtTokenSchemas = apiSettings + .Value + .Authentication + .JwtBearerTokenSchemas + ?? throw new ArgumentException("JwtBearerTokenSchemas is required."); + } + + public async Task GetIssuerForScheme(string schemeName) + { + await EnsureInitializedAsync(); + + return _issuerMappings.TryGetValue(schemeName, out var issuer) + ? issuer : null; + } + + private async Task EnsureInitializedAsync() + { + if (_initialized) return; + await _initializationSemaphore.WaitAsync(); + if (_initialized) return; + + try + { + foreach (var schema in _jwtTokenSchemas) + { + var configManager = new ConfigurationManager( + schema.WellKnown, new OpenIdConnectConfigurationRetriever()); + var config = await configManager.GetConfigurationAsync(); + _issuerMappings[schema.Name] = config.Issuer; + } + + _initialized = true; + } + finally + { + _initializationSemaphore.Release(); + } + } + + public void Dispose() + { + _initializationSemaphore.Dispose(); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AllowAnonymousHandler.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AllowAnonymousHandler.cs new file mode 100644 index 000000000..59478a730 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AllowAnonymousHandler.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authorization; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; + +/// +/// This authorisation handler will bypass all requirements +/// +public class AllowAnonymousHandler : IAuthorizationHandler +{ + public Task HandleAsync(AuthorizationHandlerContext context) + { + foreach (var requirement in context.PendingRequirements) + { + //Simply pass all requirements + context.Succeed(requirement); + } + + return Task.CompletedTask; + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationOptionsSetup.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationOptionsSetup.cs new file mode 100644 index 000000000..3af13c46e --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationOptionsSetup.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; + +internal sealed class AuthorizationOptionsSetup : IConfigureOptions +{ + private readonly GraphQlSettings _options; + + public AuthorizationOptionsSetup(IOptions options) + { + _options = options.Value; + } + + public void Configure(AuthorizationOptions options) + { + var authenticationSchemas = _options + .Authentication + .JwtBearerTokenSchemas + .Select(x => x.Name) + .ToArray(); + + options.DefaultPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .AddAuthenticationSchemes(authenticationSchemas) + .RequireValidConsumerClaim() + .Build(); + + options.AddPolicy(AuthorizationPolicy.EndUser, builder => builder + .Combine(options.DefaultPolicy) + .RequireScope(AuthorizationScope.EndUser)); + + options.AddPolicy(AuthorizationPolicy.ServiceProvider, builder => builder + .Combine(options.DefaultPolicy) + .RequireScope(AuthorizationScope.ServiceProvider)); + + options.AddPolicy(AuthorizationPolicy.ServiceProviderSearch, builder => builder + .Combine(options.DefaultPolicy) + .RequireScope(AuthorizationScope.ServiceProviderSearch)); + + options.AddPolicy(AuthorizationPolicy.Testing, builder => builder + .Combine(options.DefaultPolicy) + .RequireScope(AuthorizationScope.Testing)); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicy.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicy.cs new file mode 100644 index 000000000..9a227445a --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicy.cs @@ -0,0 +1,29 @@ +using System.Collections.ObjectModel; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; + +internal static class AuthorizationPolicy +{ + public const string EndUser = "enduser"; + public const string ServiceProvider = "serviceprovider"; + public const string ServiceProviderSearch = "serviceproviderSearch"; + public const string Testing = "testing"; +} + +internal static class AuthorizationScope +{ + public const string EndUser = "digdir:dialogporten"; + public const string ServiceProvider = "digdir:dialogporten.serviceprovider"; + public const string ServiceProviderSearch = "digdir:dialogporten.serviceprovider.search"; + public const string Testing = "digdir:dialogporten.developer.test"; + + internal static readonly Lazy> AllScopes = new(GetAll); + + private static ReadOnlyCollection GetAll() => + typeof(AuthorizationScope) + .GetFields() + .Where(x => x.IsLiteral && !x.IsInitOnly && x.DeclaringType == typeof(string)) + .Select(x => (string)x.GetRawConstantValue()!) + .ToList() + .AsReadOnly(); +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicyBuilderExtensions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicyBuilderExtensions.cs new file mode 100644 index 000000000..d1d2e2395 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Authorization/AuthorizationPolicyBuilderExtensions.cs @@ -0,0 +1,21 @@ +using Digdir.Domain.Dialogporten.Application.Common.Extensions; +using Microsoft.AspNetCore.Authorization; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; + +internal static class AuthorizationPolicyBuilderExtensions +{ + private const string ScopeClaim = "scope"; + private const char ScopeClaimSeparator = ' '; + + public static AuthorizationPolicyBuilder RequireScope(this AuthorizationPolicyBuilder builder, string scope) => + builder.RequireAssertion(ctx => ctx.User.Claims + .Where(x => x.Type == ScopeClaim) + .Select(x => x.Value) + .Any(scopeValue => scopeValue == scope || scopeValue + .Split(ScopeClaimSeparator, StringSplitOptions.RemoveEmptyEntries) + .Contains(scope))); + + public static AuthorizationPolicyBuilder RequireValidConsumerClaim(this AuthorizationPolicyBuilder builder) => + builder.RequireAssertion(ctx => ctx.User.TryGetOrgNumber(out _)); +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/Constants.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Constants.cs new file mode 100644 index 000000000..7d054cc36 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/Constants.cs @@ -0,0 +1,8 @@ +namespace Digdir.Domain.Dialogporten.GraphQL.Common; + +internal static class Constants +{ + internal const string Authorization = "Authorization"; + internal const string CurrentTokenIssuer = "CurrentIssuer"; +} + diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Common/GraphQlSettings.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Common/GraphQlSettings.cs new file mode 100644 index 000000000..e65004c58 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Common/GraphQlSettings.cs @@ -0,0 +1,21 @@ +using Digdir.Domain.Dialogporten.GraphQL.Common.Authentication; +using FluentValidation; + +namespace Digdir.Domain.Dialogporten.GraphQL.Common; + +public sealed class GraphQlSettings +{ + public const string SectionName = "GraphQl"; + + public required AuthenticationOptions Authentication { get; init; } +} + +internal sealed class GraphQlOptionsValidator : AbstractValidator +{ + public GraphQlOptionsValidator( + IValidator authenticationOptionsValidator) + { + RuleFor(x => x.Authentication) + .SetValidator(authenticationOptionsValidator); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Digdir.Domain.Dialogporten.GraphQL.csproj b/src/Digdir.Domain.Dialogporten.GraphQL/Digdir.Domain.Dialogporten.GraphQL.csproj new file mode 100644 index 000000000..b22356814 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Digdir.Domain.Dialogporten.GraphQL.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + 750256a4-f332-4783-8802-8a7d9566f9ca + + + + + + + + + + + + + + + + + + diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Common/MappingProfile.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Common/MappingProfile.cs new file mode 100644 index 000000000..8924c36d8 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Common/MappingProfile.cs @@ -0,0 +1,27 @@ +using AutoMapper; +using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; +using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Get; +using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Search; + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.Common; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap(); + + CreateMap(); + CreateMap(); + + CreateMap() + .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type)); + CreateMap() + .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type)); + + CreateMap() + .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type)); + CreateMap() + .ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.Type)); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Common/ObjectTypes.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Common/ObjectTypes.cs new file mode 100644 index 000000000..f97895552 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/Common/ObjectTypes.cs @@ -0,0 +1,90 @@ +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.Common; + +public sealed class Localization +{ + public string Value { get; set; } = null!; + public string CultureCode { get; set; } = null!; +} + +public enum ContentType +{ + Title = 1, + SenderName = 2, + Summary = 3, + AdditionalInfo = 4 +} + +public sealed class Content +{ + public ContentType Type { get; set; } + public List Value { get; set; } = []; +} + +public sealed class SeenLog +{ + public Guid Id { get; set; } + public DateTimeOffset SeenAt { get; set; } + + public string EndUserIdHash { get; set; } = null!; + + public string? EndUserName { get; set; } + + public bool IsCurrentEndUser { get; set; } +} + +public sealed class Activity +{ + public Guid Id { get; set; } + public DateTimeOffset? CreatedAt { get; set; } + public Uri? ExtendedType { get; set; } + + public ActivityType Type { get; set; } + + public Guid? RelatedActivityId { get; set; } + public Guid? DialogElementId { get; set; } + + public List? PerformedBy { get; set; } = []; + public List Description { get; set; } = []; +} + +public enum ActivityType +{ + [GraphQLDescription("Refers to a submission made by a party that has been received by the service provider.")] + Submission = 1, + + [GraphQLDescription("Indicates feedback from the service provider on a submission. Contains a reference to the current submission.")] + Feedback = 2, + + [GraphQLDescription("Information from the service provider, not (directly) related to any submission.")] + Information = 3, + + [GraphQLDescription("Used to indicate an error situation, typically on a submission. Contains a service-specific activityErrorCode.")] + Error = 4, + + [GraphQLDescription("Indicates that the dialog is closed for further changes. This typically happens when the dialog is completed or deleted.")] + Closed = 5, + + [GraphQLDescription("When the dialog is forwarded (delegated access) by someone with access to others.")] + Forwarded = 7 +} + +public enum DialogStatus +{ + [GraphQLDescription("New")] + New = 1, + + [GraphQLDescription("In progress. General status used for dialog services where further user input is expected.")] + InProgress = 2, + + [GraphQLDescription("Waiting for feedback from the service provider")] + Waiting = 3, + + [GraphQLDescription("The dialog is in a state where it is waiting for signing. Typically the last step after all completion is carried out and validated.")] + Signing = 4, + + [GraphQLDescription("The dialog was cancelled. This typically removes the dialog from normal GUI views.")] + Cancelled = 5, + + [GraphQLDescription("The dialog was completed. This typically moves the dialog to a GUI archive or similar.")] + Completed = 6 +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/MappingProfile.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/MappingProfile.cs new file mode 100644 index 000000000..c0c5c8ec3 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/MappingProfile.cs @@ -0,0 +1,28 @@ +using AutoMapper; +using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; +using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Get; +using Digdir.Domain.Dialogporten.GraphQL.EndUser.Common; + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.DialogById; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap() + .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status)); + + CreateMap(); + CreateMap() + .ForMember(dest => dest.ConsumerType, opt => opt.MapFrom(src => src.ConsumerType)); + + CreateMap() + .ForMember(dest => dest.Priority, opt => opt.MapFrom(src => src.Priority)); + + CreateMap(); + CreateMap() + .ForMember(dest => dest.HttpMethod, opt => opt.MapFrom(src => src.HttpMethod)); + + CreateMap(); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/ObjectTypes.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/ObjectTypes.cs new file mode 100644 index 000000000..46a905d1f --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/ObjectTypes.cs @@ -0,0 +1,121 @@ +using Digdir.Domain.Dialogporten.GraphQL.EndUser.Common; + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.DialogById; + +public sealed class Dialog +{ + public Guid Id { get; set; } + public Guid Revision { get; set; } + public string Org { get; set; } = null!; + public string ServiceResource { get; set; } = null!; + public string Party { get; set; } = null!; + public int? Progress { get; set; } + public string? ExtendedStatus { get; set; } + public string? ExternalReference { get; set; } + public DateTimeOffset? VisibleFrom { get; set; } + public DateTimeOffset? DueAt { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + + public string? DialogToken { get; set; } + + public DialogStatus Status { get; set; } + + public List Content { get; set; } = []; + public List Elements { get; set; } = []; + public List GuiActions { get; set; } = []; + public List ApiActions { get; set; } = []; + public List Activities { get; set; } = []; + public List SeenSinceLastUpdate { get; set; } = []; +} + +public sealed class ApiAction +{ + public Guid Id { get; set; } + public string Action { get; set; } = null!; + public string? AuthorizationAttribute { get; set; } + public bool IsAuthorized { get; set; } + + public Guid? DialogElementId { get; set; } + + public List Endpoints { get; set; } = []; +} + +// ReSharper disable InconsistentNaming +public enum HttpVerb +{ + GET = 1, + POST = 2, + PUT = 3, + PATCH = 4, + DELETE = 5, + HEAD = 6, + OPTIONS = 7, + TRACE = 8, + CONNECT = 9 +} + +public sealed class ApiActionEndpoint +{ + public Guid Id { get; set; } + public string? Version { get; set; } + public Uri Url { get; set; } = null!; + public HttpVerb HttpMethod { get; set; } + public Uri? DocumentationUrl { get; set; } + public Uri? RequestSchema { get; set; } + public Uri? ResponseSchema { get; set; } + public bool Deprecated { get; set; } + public DateTimeOffset? SunsetAt { get; set; } +} + +public sealed class GuiAction +{ + public Guid Id { get; set; } + public string Action { get; set; } = null!; + public Uri Url { get; set; } = null!; + public string? AuthorizationAttribute { get; set; } + public bool IsAuthorized { get; set; } + public bool IsBackChannel { get; set; } + public bool IsDeleteAction { get; set; } + + public GuiActionPriority Priority { get; set; } + + public List Title { get; set; } = []; +} + +public enum GuiActionPriority +{ + Primary = 1, + Secondary = 2, + Tertiary = 3 +} + +public sealed class Element +{ + public Guid Id { get; set; } + public Uri? Type { get; set; } + public string? ExternalReference { get; set; } + public string? AuthorizationAttribute { get; set; } + public bool IsAuthorized { get; set; } + + public Guid? RelatedDialogElementId { get; set; } + + public List DisplayName { get; set; } = []; + public List Urls { get; set; } = []; +} + +public sealed class ElementUrl +{ + public Guid Id { get; set; } + public Uri Url { get; set; } = null!; + public string? MimeType { get; set; } + + public ElementUrlConsumer ConsumerType { get; set; } +} + +public enum ElementUrlConsumer +{ + Gui = 1, + Api = 2 +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogQueries.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogQueries.cs new file mode 100644 index 000000000..bb26778c4 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogQueries.cs @@ -0,0 +1,56 @@ +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 async Task GetDialogById( + [Service] ISender mediator, + [Service] IMapper mapper, + [Argument] Guid dialogId, + CancellationToken cancellationToken) + { + var request = new GetDialogQuery { DialogId = dialogId }; + var result = await mediator.Send(request, cancellationToken); + var getDialogResult = result.Match( + dialog => dialog, + // TODO: Error handling + notFound => throw new NotImplementedException("Not found"), + deleted => throw new NotImplementedException("Deleted"), + forbidden => throw new NotImplementedException("Forbidden")); + + var dialog = mapper.Map(getDialogResult); + + return dialog; + } + + public async Task SearchDialogs( + [Service] ISender mediator, + [Service] IMapper mapper, + SearchDialogInput input, + CancellationToken cancellationToken) + { + + var searchDialogQuery = mapper.Map(input); + + var result = await mediator.Send(searchDialogQuery, cancellationToken); + + var searchResultOneOf = result.Match( + paginatedList => paginatedList, + // TODO: Error handling + validationError => throw new NotImplementedException("Validation error"), + forbidden => throw new NotImplementedException("Forbidden")); + + var dialogSearchResult = mapper.Map(searchResultOneOf); + + return dialogSearchResult; + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/SearchDialogs/MappingProfile.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/SearchDialogs/MappingProfile.cs new file mode 100644 index 000000000..a04aa829f --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/SearchDialogs/MappingProfile.cs @@ -0,0 +1,21 @@ +using AutoMapper; +using Digdir.Domain.Dialogporten.Application.Common.Pagination; +using Digdir.Domain.Dialogporten.Application.Features.V1.Common.Localizations; +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.EndUser.Common; + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.SearchDialogs; + +public class MappingProfile : Profile +{ + public MappingProfile() + { + CreateMap() + .ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.Status)); + + CreateMap, SearchDialogsPayload>(); + + CreateMap(); + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/SearchDialogs/ObjectTypes.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/SearchDialogs/ObjectTypes.cs new file mode 100644 index 000000000..1129a43dc --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/SearchDialogs/ObjectTypes.cs @@ -0,0 +1,77 @@ +using Digdir.Domain.Dialogporten.GraphQL.EndUser.Common; + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.SearchDialogs; + +public sealed class SearchDialogsPayload +{ + public List Items { get; } = []; + public bool HasNextPage { get; } + public string? ContinuationToken { get; } + public string OrderBy { get; } = null!; +} + +public sealed class SearchDialog +{ + public Guid Id { get; set; } + public string Org { get; set; } = null!; + public string ServiceResource { get; set; } = null!; + public string Party { get; set; } = null!; + public int? Progress { get; set; } + public int? GuiAttachmentCount { get; set; } + public string? ExtendedStatus { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? DueAt { get; set; } + + public DialogStatus Status { get; set; } + + public Activity? LatestActivity { get; set; } + + public List Content { get; set; } = []; + public List SeenSinceLastUpdate { get; set; } = []; +} + +public sealed class SearchDialogInput +{ + [GraphQLDescription("Filter by one or more service owner codes")] + public List? Org { get; init; } + + [GraphQLDescription("Filter by one or more service resources")] + public List? ServiceResource { get; init; } + + [GraphQLDescription("Filter by one or more owning parties")] + public List? Party { get; init; } + + [GraphQLDescription("Filter by one or more extended statuses")] + public List? ExtendedStatus { get; init; } + + [GraphQLDescription("Filter by external reference")] + public string? ExternalReference { get; init; } + + [GraphQLDescription("Filter by status")] + public List? Status { get; init; } + + [GraphQLDescription("Only return dialogs created after this date")] + public DateTimeOffset? CreatedAfter { get; init; } + + [GraphQLDescription("Only return dialogs created before this date")] + public DateTimeOffset? CreatedBefore { get; init; } + + [GraphQLDescription("Only return dialogs updated after this date")] + public DateTimeOffset? UpdatedAfter { get; init; } + + [GraphQLDescription("Only return dialogs updated before this date")] + public DateTimeOffset? UpdatedBefore { get; init; } + + [GraphQLDescription("Only return dialogs with due date after this date")] + public DateTimeOffset? DueAfter { get; init; } + + [GraphQLDescription("Only return dialogs with due date before this date")] + public DateTimeOffset? DueBefore { get; init; } + + [GraphQLDescription("Search string for free text search. Will attempt to fuzzily match in all free text fields in the aggregate")] + public string? Search { get; init; } + + [GraphQLDescription("Limit free text search to texts with this culture code, e.g. \"nb-NO\". Default: search all culture codes")] + public string? SearchCultureCode { get; init; } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs new file mode 100644 index 000000000..abe7f33e5 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using System.Reflection; +using Digdir.Domain.Dialogporten.Application; +using Digdir.Domain.Dialogporten.Application.Common.Extensions; +using Digdir.Domain.Dialogporten.Application.Externals.Presentation; +using Digdir.Domain.Dialogporten.GraphQL.Common; +using Digdir.Domain.Dialogporten.GraphQL.Common.Authentication; +using Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; +using Digdir.Domain.Dialogporten.Infrastructure; +using Digdir.Domain.Dialogporten.Infrastructure.Persistence; +using Microsoft.ApplicationInsights.Extensibility; +using Digdir.Domain.Dialogporten.Application.Common.Extensions.OptionExtensions; +using Digdir.Domain.Dialogporten.GraphQL.EndUser; +using Serilog; +using FluentValidation; +using HotChocolate.AspNetCore; +using Microsoft.AspNetCore.Authorization; + +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Warning() + .Enrich.FromLogContext() + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) + .WriteTo.ApplicationInsights( + TelemetryConfiguration.CreateDefault(), + TelemetryConverter.Traces) + .CreateBootstrapLogger(); + +try +{ + BuildAndRun(args); +} +catch (Exception ex) when (ex is not OperationCanceledException) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); + throw; +} +finally +{ + Log.CloseAndFlush(); +} + +static void BuildAndRun(string[] args) +{ + var builder = WebApplication.CreateBuilder(args); + + builder.Host.UseSerilog((context, services, configuration) => configuration + .MinimumLevel.Warning() + .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Fatal) + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) + .WriteTo.ApplicationInsights( + services.GetRequiredService(), + TelemetryConverter.Traces)); + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(GraphQlSettings.SectionName)) + .ValidateFluently() + .ValidateOnStart(); + + if (builder.Environment.IsDevelopment()) + { + var localDevelopmentSettings = builder.Configuration.GetLocalDevelopmentSettings(); + builder.Services + .ReplaceSingleton(predicate: localDevelopmentSettings.UseLocalDevelopmentUser) + .ReplaceSingleton( + predicate: localDevelopmentSettings.DisableAuth); + } + + var thisAssembly = Assembly.GetExecutingAssembly(); + + builder.Services + // Options setup + .ConfigureOptions() + + // Clean architecture projects + .AddApplication(builder.Configuration, builder.Environment) + .AddInfrastructure(builder.Configuration, builder.Environment) + + .AddAutoMapper(Assembly.GetExecutingAssembly()) + .AddApplicationInsightsTelemetry() + .AddScoped() + .AddValidatorsFromAssembly(thisAssembly, ServiceLifetime.Transient, includeInternalTypes: true) + + // Graph QL + .AddGraphQLServer() + .AddAuthorization() + .AddProjections() + .AddFiltering() + .AddSorting() + .RegisterDbContext() + .AddQueryType() + .Services + + // Auth + .AddDialogportenAuthentication(builder.Configuration) + .AddAuthorization(); + + var app = builder.Build(); + + app.UseJwtSchemeSelector(); + app.UseAuthentication(); + app.UseAuthorization(); + + if (app.Environment.IsDevelopment()) + { + // GUI endpoint + app.MapBananaCakePop("/bcp"); + } + + app.MapGraphQL() + .RequireAuthorization() + .WithOptions(new GraphQLServerOptions + { + EnableSchemaRequests = true, + Tool = + { + Enable = false + } + }); + + app.Run(); +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Properties/launchSettings.json b/src/Digdir.Domain.Dialogporten.GraphQL/Properties/launchSettings.json new file mode 100644 index 000000000..fa20d39ed --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "graphql", + "applicationUrl": "http://localhost:5181", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.Development.json b/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.Development.json new file mode 100644 index 000000000..8521c47ec --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.Development.json @@ -0,0 +1,76 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Infrastructure": { + "Redis": { + "Enabled": true, + "ConnectionString": "localhost:6379" + }, + "DialogDbConnectionString": "TODO: Add to local secrets", + // Settings from appsettings.json, environment variables or other configuration providers. + // The first three are always mandatory for all client definitions types + "Maskinporten": { + // 1. Valid values are test and prod + "Environment": "test", + + // 2. Client Id/integration as configured in Maskinporten + "ClientId": "TODO: Add to local secrets", + + // 3. Scope(s) requested, space seperated. Must be provisioned on supplied client id. + "Scope": "altinn:events.publish altinn:events.publish.admin altinn:register/partylookup.admin altinn:authorization:pdp", + + // -------------------------- + // Any additional settings are specific for the selected client definition type. + // See below for examples using other types. + "EncodedJwk": "TODO: Add to local secrets" + }, + "Altinn": { + "BaseUri": "https://platform.tt02.altinn.no/", + "SubscriptionKey": "TODO: Add to local secrets" + }, + "AltinnCdn": { + "BaseUri": "https://altinncdn.no/" + } + }, + "Application": { + "Dialogporten": { + "BaseUri": "https://localhost:7214" + } + }, + "GraphQl": { + "Authentication": { + "JwtBearerTokenSchemas": [ + { + "Name": "Maskinporten", + "WellKnown": "https://test.maskinporten.no/.well-known/oauth-authorization-server/" + }, + { + "Name": "MaskinportenAuxiliary", + "WellKnown": "https://ver2.maskinporten.no/.well-known/oauth-authorization-server/" + }, + { + "Name": "Altinn", + "WellKnown": "https://platform.tt02.altinn.no/authentication/api/v1/openid/.well-known/openid-configuration" + }, + { + "Name": "Idporten", + "WellKnown": "https://test.idporten.no/.well-known/openid-configuration" + } + ] + } + }, + "LocalDevelopment": { + "UseLocalDevelopmentUser": true, + "UseLocalDevelopmentResourceRegister": true, + "UseLocalDevelopmentOrganizationRegister": true, + "UseLocalDevelopmentNameRegister": true, + "UseLocalDevelopmentAltinnAuthorization": true, + "UseLocalDevelopmentCloudEventBus": true, + "DisableShortCircuitOutboxDispatcher": true, + "DisableAuth": true + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.json b/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.json new file mode 100644 index 000000000..d51dc7502 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "None", + "System.Net.Http.HttpClient": "Information" + } + }, + "Infrastructure": { + "AltinnCdn": { + "BaseUri": "https://altinncdn.no/" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj index 1036b5a8e..a8b403935 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj @@ -35,6 +35,7 @@ + - \ No newline at end of file +