diff --git a/.github/workflows/dotnet-migrations-ensure-compatibility.yaml b/.github/workflows/dotnet-migrations-ensure-compatibility.yaml index ce5dc94fa09..1ceea0f0d55 100644 --- a/.github/workflows/dotnet-migrations-ensure-compatibility.yaml +++ b/.github/workflows/dotnet-migrations-ensure-compatibility.yaml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v4 - name: Install dotnet ef # Version should be the same as Migrations docker file and project - run: dotnet tool install --version 8.0.7 --global dotnet-ef + run: dotnet tool install --version 9.0.0 --global dotnet-ef - name: Check if migrations script can be generated run: | diff --git a/backend/Migrations.Dockerfile b/backend/Migrations.Dockerfile index 6523121e42b..09569daacc0 100644 --- a/backend/Migrations.Dockerfile +++ b/backend/Migrations.Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app COPY . . -RUN dotnet tool install --version 8.0.7 --global dotnet-ef +RUN dotnet tool install --version 9.0.0 --global dotnet-ef ENV PATH="$PATH:/root/.dotnet/tools" ENV OidcLoginSettings__FetchClientIdAndSecretFromRootEnvFile=false diff --git a/backend/packagegroups/NuGet.props b/backend/packagegroups/NuGet.props index 389193bbd30..7aa6307c86f 100644 --- a/backend/packagegroups/NuGet.props +++ b/backend/packagegroups/NuGet.props @@ -29,8 +29,8 @@ - - + + diff --git a/backend/src/DataModeling/Converter/Csharp/JsonMetadataToCsharpConverter.cs b/backend/src/DataModeling/Converter/Csharp/JsonMetadataToCsharpConverter.cs index ac2a0672f8f..938e0ada84b 100644 --- a/backend/src/DataModeling/Converter/Csharp/JsonMetadataToCsharpConverter.cs +++ b/backend/src/DataModeling/Converter/Csharp/JsonMetadataToCsharpConverter.cs @@ -182,7 +182,7 @@ private void ParseFieldProperty(ElementMetadata element, StringBuilder classBuil else { elementOrder += 1; - AddXmlElementAnnotation(element, classBuilder, elementOrder); + AddXmlElementAnnotation(element, classBuilder, elementOrder, !isValueType && (element.Nillable ?? false)); // Temporary fix - as long as we use System.Text.Json for serialization and Newtonsoft.Json for // deserialization, we need both JsonProperty and JsonPropertyName annotations. @@ -265,16 +265,21 @@ private void ParseGroupProperty(ElementMetadata element, StringBuilder classBuil } } - private void AddXmlElementAnnotation(ElementMetadata element, StringBuilder classBuilder, int elementOrder) + private void AddXmlElementAnnotation(ElementMetadata element, StringBuilder classBuilder, int elementOrder, bool addNillableAttribute = false) { - if (element.OrderOblivious) + string additionalAttributeParams = string.Empty; + if (!element.OrderOblivious) { - classBuilder.AppendLine($"""{Indent(2)}[XmlElement("{element.XName}")]"""); + additionalAttributeParams += $", Order = {elementOrder}"; } - else + + if (addNillableAttribute) { - classBuilder.AppendLine($"""{Indent(2)}[XmlElement("{element.XName}", Order = {elementOrder})]"""); + additionalAttributeParams += ", IsNullable = true"; } + + + classBuilder.AppendLine($"""{Indent(2)}[XmlElement("{element.XName}"{additionalAttributeParams})]"""); } private void AddShouldSerializeForTagContent(ElementMetadata element, StringBuilder classBuilder, ModelMetadata modelMetadata) diff --git a/backend/src/Designer/Controllers/ResourceAdminController.cs b/backend/src/Designer/Controllers/ResourceAdminController.cs index 10be3387b87..8a68aac10a5 100644 --- a/backend/src/Designer/Controllers/ResourceAdminController.cs +++ b/backend/src/Designer/Controllers/ResourceAdminController.cs @@ -333,7 +333,7 @@ public async Task ImportResource(string org, string serviceCode, i public async Task GetDelegationCount(string org, string serviceCode, int serviceEdition, string env) { ServiceResource resource = await _resourceRegistry.GetServiceResourceFromService(serviceCode, serviceEdition, env.ToLower()); - if (resource?.HasCompetentAuthority == null || !resource.HasCompetentAuthority.Orgcode.Equals(org, StringComparison.InvariantCultureIgnoreCase)) + if (!IsServiceOwner(resource, org)) { return new UnauthorizedResult(); } @@ -348,7 +348,7 @@ public async Task GetDelegationCount(string org, string serviceCod public async Task MigrateDelegations([FromBody] ExportDelegationsRequestBE delegationRequest, string org, string env) { ServiceResource resource = await _resourceRegistry.GetServiceResourceFromService(delegationRequest.ServiceCode, delegationRequest.ServiceEditionCode, env.ToLower()); - if (resource?.HasCompetentAuthority == null || !resource.HasCompetentAuthority.Orgcode.Equals(org, StringComparison.InvariantCultureIgnoreCase)) + if (!IsServiceOwner(resource, org)) { return new UnauthorizedResult(); } @@ -602,6 +602,24 @@ private async Task GetOrgList() return orgList; } + private static bool IsServiceOwner(ServiceResource? resource, string loggedInOrg) + { + if (resource?.HasCompetentAuthority == null) + { + return false; + } + + bool isOwnedByOrg = resource.HasCompetentAuthority.Orgcode.Equals(loggedInOrg, StringComparison.InvariantCultureIgnoreCase); + + if (OrgUtil.IsTestEnv(loggedInOrg)) + { + return isOwnedByOrg || resource.HasCompetentAuthority.Orgcode.Equals("acn", StringComparison.InvariantCultureIgnoreCase); + } + + return isOwnedByOrg; + + } + private async Task AddEnvironmentResourceStatus(string env, string id) { ServiceResource resource = await _resourceRegistry.GetResource(id, env); diff --git a/backend/src/Designer/Designer.csproj b/backend/src/Designer/Designer.csproj index 341117518d1..c52469b77f6 100644 --- a/backend/src/Designer/Designer.csproj +++ b/backend/src/Designer/Designer.csproj @@ -14,10 +14,6 @@ Release;Debug - - 4.5.0 - - @@ -59,8 +55,6 @@ - - diff --git a/backend/src/Designer/Exceptions/Options/InvalidOptionsFormatException.cs b/backend/src/Designer/Exceptions/Options/InvalidOptionsFormatException.cs new file mode 100644 index 00000000000..0dc69bc495d --- /dev/null +++ b/backend/src/Designer/Exceptions/Options/InvalidOptionsFormatException.cs @@ -0,0 +1,25 @@ +using System; + +namespace Altinn.Studio.Designer.Exceptions.Options; + +/// +/// Indicates that an error occurred during json serialization of options. +/// +[Serializable] +public class InvalidOptionsFormatException : Exception +{ + /// + public InvalidOptionsFormatException() + { + } + + /// + public InvalidOptionsFormatException(string message) : base(message) + { + } + + /// + public InvalidOptionsFormatException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/backend/src/Designer/Filters/Options/OptionsErrorCodes.cs b/backend/src/Designer/Filters/Options/OptionsErrorCodes.cs new file mode 100644 index 00000000000..9053087b1b5 --- /dev/null +++ b/backend/src/Designer/Filters/Options/OptionsErrorCodes.cs @@ -0,0 +1,6 @@ +namespace Altinn.Studio.Designer.Filters.Options; + +public class OptionsErrorCodes +{ + public const string InvalidOptionsFormat = nameof(InvalidOptionsFormat); +} diff --git a/backend/src/Designer/Filters/Options/OptionsExceptionFilterAttribute.cs b/backend/src/Designer/Filters/Options/OptionsExceptionFilterAttribute.cs new file mode 100644 index 00000000000..ead5e0a589a --- /dev/null +++ b/backend/src/Designer/Filters/Options/OptionsExceptionFilterAttribute.cs @@ -0,0 +1,26 @@ +using System.Net; +using Altinn.Studio.Designer.Exceptions.Options; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Altinn.Studio.Designer.Filters.Options; + +public class OptionsExceptionFilterAttribute : ExceptionFilterAttribute +{ + public override void OnException(ExceptionContext context) + { + base.OnException(context); + + if (context.ActionDescriptor is not ControllerActionDescriptor) + { + return; + } + + if (context.Exception is InvalidOptionsFormatException) + { + context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, OptionsErrorCodes.InvalidOptionsFormat, HttpStatusCode.BadRequest)) { StatusCode = (int)HttpStatusCode.BadRequest }; + } + } + +} diff --git a/backend/src/Designer/Filters/ProblemDetailsExtensionsCodes.cs b/backend/src/Designer/Filters/ProblemDetailsExtensionsCodes.cs index 4064393c200..a70c0f6f152 100644 --- a/backend/src/Designer/Filters/ProblemDetailsExtensionsCodes.cs +++ b/backend/src/Designer/Filters/ProblemDetailsExtensionsCodes.cs @@ -3,5 +3,5 @@ namespace Altinn.Studio.Designer.Filters; public static class ProblemDetailsExtensionsCodes { public const string ErrorCode = "errorCode"; - + public const string Detail = "detail"; } diff --git a/backend/src/Designer/Helpers/JsonConverterHelpers/NotNullableAttribute.cs b/backend/src/Designer/Helpers/JsonConverterHelpers/NotNullableAttribute.cs new file mode 100644 index 00000000000..c4a556f977c --- /dev/null +++ b/backend/src/Designer/Helpers/JsonConverterHelpers/NotNullableAttribute.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Altinn.Studio.Designer.Helpers.JsonConverterHelpers; + +public class NotNullableAttribute : ValidationAttribute +{ + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (value == null) + { + return new ValidationResult("The field is required."); + } + return ValidationResult.Success; + } +} diff --git a/backend/src/Designer/Helpers/JsonConverterHelpers/OptionConverterHelper.cs b/backend/src/Designer/Helpers/JsonConverterHelpers/OptionConverterHelper.cs index a15ad94deeb..9ba74b172ef 100644 --- a/backend/src/Designer/Helpers/JsonConverterHelpers/OptionConverterHelper.cs +++ b/backend/src/Designer/Helpers/JsonConverterHelpers/OptionConverterHelper.cs @@ -1,10 +1,11 @@ using System; using System.Text.Json; using System.Text.Json.Serialization; +using Altinn.Studio.Designer.Exceptions.Options; namespace Altinn.Studio.Designer.Helpers.JsonConverterHelpers; -public class OptionConverter : JsonConverter +public class OptionValueConverter : JsonConverter { public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -14,7 +15,7 @@ public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonS JsonTokenType.Number when reader.TryGetDouble(out double d) => d, JsonTokenType.True => true, JsonTokenType.False => false, - _ => throw new JsonException($"Unsupported JSON token for Option.Value: {reader.TokenType}") + _ => throw new InvalidOptionsFormatException($"Unsupported JSON token for Value field, {typeToConvert}: {reader.TokenType}.") }; } @@ -32,7 +33,7 @@ public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOp writer.WriteBooleanValue(b); break; default: - throw new JsonException("Unsupported type for Option.Value."); + throw new InvalidOptionsFormatException($"{value} is an unsupported type for Value field. Accepted types are string, double and bool."); } } } diff --git a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs index 7299c3c512b..93ddf76bcc4 100644 --- a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs +++ b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs @@ -425,20 +425,9 @@ public string[] GetLayoutSetNames() public void ChangeLayoutSetFolderName(string oldLayoutSetName, string newLayoutSetName, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (DirectoryExistsByRelativePath(GetPathToLayoutSet(newLayoutSetName))) - { - throw new NonUniqueLayoutSetIdException("Suggested new layout set name already exist"); - } - string destAbsolutePath = GetAbsoluteFileOrDirectoryPathSanitized(GetPathToLayoutSet(newLayoutSetName, true)); - - string sourceRelativePath = GetPathToLayoutSet(oldLayoutSetName, true); - if (!DirectoryExistsByRelativePath(sourceRelativePath)) - { - throw new NotFoundException("Layout set you are trying to change doesn't exist"); - } - - string sourceAbsolutePath = GetAbsoluteFileOrDirectoryPathSanitized(sourceRelativePath); - Directory.Move(sourceAbsolutePath, destAbsolutePath); + string currentDirectoryPath = GetPathToLayoutSet(oldLayoutSetName, true); + string newDirectoryPath = GetPathToLayoutSet(newLayoutSetName, true); + MoveDirectoryByRelativePath(currentDirectoryPath, newDirectoryPath); } /// @@ -575,15 +564,7 @@ public void UpdateFormLayoutName(string layoutSetName, string layoutFileName, st { string currentFilePath = GetPathToLayoutFile(layoutSetName, layoutFileName); string newFilePath = GetPathToLayoutFile(layoutSetName, newFileName); - if (!FileExistsByRelativePath(currentFilePath)) - { - throw new FileNotFoundException("Layout does not exist."); - } - if (FileExistsByRelativePath(newFilePath)) - { - throw new ArgumentException("New layout name must be unique."); - } - File.Move(GetAbsoluteFileOrDirectoryPathSanitized(currentFilePath), GetAbsoluteFileOrDirectoryPathSanitized(newFilePath)); + MoveFileByRelativePath(currentFilePath, newFilePath, newFileName); } public async Task GetLayoutSetsFile(CancellationToken cancellationToken = default) diff --git a/backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs b/backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs index 6b78fe844ba..4b999c8cc03 100644 --- a/backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs +++ b/backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs @@ -254,19 +254,46 @@ public void DeleteFileByAbsolutePath(string absoluteFilePath) /// FileName for the destination file protected void MoveFileByRelativePath(string sourceRelativeFilePath, string destRelativeFilePath, string destinationFileName) { - if (FileExistsByRelativePath(sourceRelativeFilePath)) + if (!FileExistsByRelativePath(sourceRelativeFilePath)) { - Guard.AssertNotNullOrEmpty(sourceRelativeFilePath, nameof(sourceRelativeFilePath)); + throw new FileNotFoundException($"File {sourceRelativeFilePath} does not exist."); + } - string sourceAbsoluteFilePath = GetAbsoluteFileOrDirectoryPathSanitized(sourceRelativeFilePath); - string destAbsoluteFilePath = GetAbsoluteFileOrDirectoryPathSanitized(destRelativeFilePath); - string destAbsoluteParentDirPath = destAbsoluteFilePath.Remove(destAbsoluteFilePath.IndexOf(destinationFileName, StringComparison.Ordinal)); - Directory.CreateDirectory(destAbsoluteParentDirPath); - Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, sourceAbsoluteFilePath); - Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, destAbsoluteFilePath); + if (FileExistsByRelativePath(destRelativeFilePath)) + { + throw new IOException($"Suggested file name {destinationFileName} already exists."); + } + string sourceAbsoluteFilePath = GetAbsoluteFileOrDirectoryPathSanitized(sourceRelativeFilePath); + string destAbsoluteFilePath = GetAbsoluteFileOrDirectoryPathSanitized(destRelativeFilePath); + string destAbsoluteParentDirPath = destAbsoluteFilePath.Remove(destAbsoluteFilePath.IndexOf(destinationFileName, StringComparison.Ordinal)); + Directory.CreateDirectory(destAbsoluteParentDirPath); + Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, sourceAbsoluteFilePath); + Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, destAbsoluteFilePath); + + File.Move(sourceAbsoluteFilePath, destAbsoluteFilePath); + } - File.Move(sourceAbsoluteFilePath, destAbsoluteFilePath); + /// + /// Move the specified folder to specified destination + /// + /// Relative path to folder to be moved. + /// Relative path to destination of moved folder. + protected void MoveDirectoryByRelativePath(string sourceRelativeDirectoryPath, string destRelativeDirectoryPath) + { + if (!DirectoryExistsByRelativePath(sourceRelativeDirectoryPath)) + { + throw new DirectoryNotFoundException($"Directory {sourceRelativeDirectoryPath} does not exist."); } + if (DirectoryExistsByRelativePath(destRelativeDirectoryPath)) + { + throw new IOException($"Suggested directory {destRelativeDirectoryPath} already exists."); + } + string sourceAbsoluteDirectoryPath = GetAbsoluteFileOrDirectoryPathSanitized(sourceRelativeDirectoryPath); + string destAbsoluteDirectoryPath = GetAbsoluteFileOrDirectoryPathSanitized(destRelativeDirectoryPath); + Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, sourceAbsoluteDirectoryPath); + Guard.AssertFilePathWithinParentDirectory(RepositoryDirectory, destAbsoluteDirectoryPath); + + Directory.Move(sourceAbsoluteDirectoryPath, destAbsoluteDirectoryPath); } /// diff --git a/backend/src/Designer/Infrastructure/MvcConfiguration.cs b/backend/src/Designer/Infrastructure/MvcConfiguration.cs index b135b5a35cc..990b05a5498 100644 --- a/backend/src/Designer/Infrastructure/MvcConfiguration.cs +++ b/backend/src/Designer/Infrastructure/MvcConfiguration.cs @@ -2,6 +2,7 @@ using Altinn.Studio.Designer.Filters.DataModeling; using Altinn.Studio.Designer.Filters.Git; using Altinn.Studio.Designer.Filters.IO; +using Altinn.Studio.Designer.Filters.Options; using Altinn.Studio.Designer.ModelBinding; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -26,6 +27,7 @@ public static IServiceCollection ConfigureMvc(this IServiceCollection services) options.Filters.Add(typeof(DataModelingExceptionFilterAttribute)); options.Filters.Add(typeof(GitExceptionFilterAttribute)); options.Filters.Add(typeof(IoExceptionFilterAttribute)); + options.Filters.Add(typeof(OptionsExceptionFilterAttribute)); }) .AddNewtonsoftJson(options => options.SerializerSettings.Converters.Add(new Newtonsoft.Json.Converters.StringEnumConverter())); diff --git a/backend/src/Designer/Infrastructure/ServiceRegistration.cs b/backend/src/Designer/Infrastructure/ServiceRegistration.cs index e91ca200073..8aba45e2e11 100644 --- a/backend/src/Designer/Infrastructure/ServiceRegistration.cs +++ b/backend/src/Designer/Infrastructure/ServiceRegistration.cs @@ -73,7 +73,6 @@ public static IServiceCollection RegisterServiceImplementations(this IServiceCol services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); services.RegisterDatamodeling(configuration); diff --git a/backend/src/Designer/Models/Option.cs b/backend/src/Designer/Models/Option.cs index ddfa6b9559e..b49a499ad28 100644 --- a/backend/src/Designer/Models/Option.cs +++ b/backend/src/Designer/Models/Option.cs @@ -1,4 +1,3 @@ -using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; using Altinn.Studio.Designer.Helpers.JsonConverterHelpers; @@ -12,17 +11,17 @@ public class Option /// /// Value that connects the option to the data model. /// - [Required] + [NotNullable] [JsonPropertyName("value")] - [JsonConverter(typeof(OptionConverter))] - public object Value { get; set; } + [JsonConverter(typeof(OptionValueConverter))] + public required object Value { get; set; } /// /// Label to present to the user. /// - [Required] + [NotNullable] [JsonPropertyName("label")] - public string Label { get; set; } + public required string Label { get; set; } /// /// Description, typically displayed below the label. diff --git a/backend/src/Designer/Program.cs b/backend/src/Designer/Program.cs index e989dacb740..34b5ef65593 100644 --- a/backend/src/Designer/Program.cs +++ b/backend/src/Designer/Program.cs @@ -191,7 +191,7 @@ void ConfigureServices(IServiceCollection services, IConfiguration configuration services.Configure(configuration.GetSection("MaskinportenClientSettings")); var maskinPortenClientName = "MaskinportenClient"; services.RegisterMaskinportenClientDefinition(maskinPortenClientName, configuration.GetSection("MaskinportenClientSettings")); - services.AddHttpClient().AddMaskinportenHttpMessageHandler(maskinPortenClientName); + services.AddHttpClient(); var maskinportenSettings = new MaskinportenClientSettings(); configuration.GetSection("MaskinportenClientSettings").Bind(maskinportenSettings); diff --git a/backend/src/Designer/Services/Implementation/OptionsService.cs b/backend/src/Designer/Services/Implementation/OptionsService.cs index f1834c3156a..137b0cf5c34 100644 --- a/backend/src/Designer/Services/Implementation/OptionsService.cs +++ b/backend/src/Designer/Services/Implementation/OptionsService.cs @@ -1,8 +1,9 @@ using System.Collections.Generic; -using System.Linq; +using System.ComponentModel.DataAnnotations; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Altinn.Studio.Designer.Exceptions.Options; using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Services.Interfaces; using LibGit2Sharp; @@ -50,9 +51,26 @@ public async Task> GetOptionsList(string org, string repo, string d string optionsListString = await altinnAppGitRepository.GetOptionsList(optionsListId, cancellationToken); var optionsList = JsonSerializer.Deserialize>(optionsListString); + + try + { + optionsList.ForEach(ValidateOption); + } + catch (ValidationException) + { + throw new InvalidOptionsFormatException($"One or more of the options have an invalid format in option list: {optionsListId}."); + } + + return optionsList; } + private void ValidateOption(Option option) + { + var validationContext = new ValidationContext(option); + Validator.ValidateObject(option, validationContext, validateAllProperties: true); + } + /// public async Task> CreateOrOverwriteOptionsList(string org, string repo, string developer, string optionsListId, List