diff --git a/backend/src/Designer/Controllers/UserController.cs b/backend/src/Designer/Controllers/UserController.cs index 5173cdb193d..5e81574fd1f 100644 --- a/backend/src/Designer/Controllers/UserController.cs +++ b/backend/src/Designer/Controllers/UserController.cs @@ -1,5 +1,8 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Altinn.Studio.Designer.Helpers; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Models.Dto; using Altinn.Studio.Designer.Services.Interfaces; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authorization; @@ -17,16 +20,18 @@ public class UserController : ControllerBase { private readonly IGitea _giteaApi; private readonly IAntiforgery _antiforgery; + private readonly IUserService _userService; /// /// Initializes a new instance of the class. /// /// the gitea wrapper /// Access to the antiforgery system in .NET Core - public UserController(IGitea giteaWrapper, IAntiforgery antiforgery) + public UserController(IGitea giteaWrapper, IAntiforgery antiforgery, IUserService userService) { _giteaApi = giteaWrapper; _antiforgery = antiforgery; + _userService = userService; } /// @@ -80,6 +85,16 @@ public async Task PutStarred(string org, string repository) return success ? NoContent() : StatusCode(418); } + [HttpGet] + [Route("org-permissions/{org}")] + public async Task HasAccessToCreateRepository(string org) + { + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + AltinnOrgContext editingContext = AltinnOrgContext.FromOrg(org, developer); + UserOrgPermission userOrg = await _userService.GetUserOrgPermission(editingContext); + return Ok(userOrg); + } + /// /// Removes the star marking on the specified repository. /// diff --git a/backend/src/Designer/Helpers/Guard.cs b/backend/src/Designer/Helpers/Guard.cs index 493ed15edd8..7a9894e78b5 100644 --- a/backend/src/Designer/Helpers/Guard.cs +++ b/backend/src/Designer/Helpers/Guard.cs @@ -25,6 +25,15 @@ public static void AssertArgumentNotNullOrWhiteSpace(string value, string argume } } + public static void AssertValidateOrganization(string org) + { + AssertNotNullOrEmpty(org, nameof(org)); + if (!AltinnRegexes.AltinnOrganizationNameRegex().IsMatch(org)) + { + throw new ArgumentException("Provided organization name is not valid"); + } + } + /// /// Asserts value not null. /// diff --git a/backend/src/Designer/Infrastructure/ServiceRegistration.cs b/backend/src/Designer/Infrastructure/ServiceRegistration.cs index 104b7d7ae9b..599a8bc1d47 100644 --- a/backend/src/Designer/Infrastructure/ServiceRegistration.cs +++ b/backend/src/Designer/Infrastructure/ServiceRegistration.cs @@ -54,6 +54,7 @@ public static IServiceCollection RegisterServiceImplementations(this IServiceCol services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/backend/src/Designer/Models/AltinnOrgContext.cs b/backend/src/Designer/Models/AltinnOrgContext.cs new file mode 100644 index 00000000000..a27281b615f --- /dev/null +++ b/backend/src/Designer/Models/AltinnOrgContext.cs @@ -0,0 +1,23 @@ +using Altinn.Studio.Designer.Helpers; + +namespace Altinn.Studio.Designer.Models; + +public class AltinnOrgContext +{ + public string Org { get; } + public string DeveloperName { get; } + + private AltinnOrgContext(string org, string developerName) + { + Guard.AssertValidateOrganization(org); + Org = org; + + Guard.AssertArgumentNotNullOrWhiteSpace(developerName, nameof(developerName)); + DeveloperName = developerName; + } + + public static AltinnOrgContext FromOrg(string org, string developerName) + { + return new AltinnOrgContext(org, developerName); + } +} diff --git a/backend/src/Designer/Models/AltinnRepoContext.cs b/backend/src/Designer/Models/AltinnRepoContext.cs index c42e5a40ad7..8a3cbb3b829 100644 --- a/backend/src/Designer/Models/AltinnRepoContext.cs +++ b/backend/src/Designer/Models/AltinnRepoContext.cs @@ -1,5 +1,4 @@ -using System; -using Altinn.Studio.Designer.Helpers; +using Altinn.Studio.Designer.Helpers; namespace Altinn.Studio.Designer.Models { @@ -21,21 +20,12 @@ public class AltinnRepoContext protected AltinnRepoContext(string org, string repo) { - ValidateOrganization(org); + Guard.AssertValidateOrganization(org); Guard.AssertValidAppRepoName(repo); Org = org; Repo = repo; } - private void ValidateOrganization(string org) - { - Guard.AssertNotNullOrEmpty(org, nameof(org)); - if (!AltinnRegexes.AltinnOrganizationNameRegex().IsMatch(org)) - { - throw new ArgumentException("Provided organization name is not valid"); - } - } - public static AltinnRepoContext FromOrgRepo(string org, string repo) { return new AltinnRepoContext(org, repo); diff --git a/backend/src/Designer/Models/Dto/UserOrgPermission.cs b/backend/src/Designer/Models/Dto/UserOrgPermission.cs new file mode 100644 index 00000000000..07096438d0d --- /dev/null +++ b/backend/src/Designer/Models/Dto/UserOrgPermission.cs @@ -0,0 +1,6 @@ +namespace Altinn.Studio.Designer.Models.Dto; + +public class UserOrgPermission +{ + public bool CanCreateOrgRepo { get; set; } +} diff --git a/backend/src/Designer/RepositoryClient/Model/Team.cs b/backend/src/Designer/RepositoryClient/Model/Team.cs index 17711303a0b..3b20f79adfe 100644 --- a/backend/src/Designer/RepositoryClient/Model/Team.cs +++ b/backend/src/Designer/RepositoryClient/Model/Team.cs @@ -10,6 +10,8 @@ public class Team /// public string Name { get; set; } + public bool CanCreateOrgRepo { get; set; } + /// /// The organization that owns the team /// diff --git a/backend/src/Designer/Services/Implementation/GiteaAPIWrapper/GiteaAPIWrapper.cs b/backend/src/Designer/Services/Implementation/GiteaAPIWrapper/GiteaAPIWrapper.cs index 9f37754b8c8..6af6bcef16b 100644 --- a/backend/src/Designer/Services/Implementation/GiteaAPIWrapper/GiteaAPIWrapper.cs +++ b/backend/src/Designer/Services/Implementation/GiteaAPIWrapper/GiteaAPIWrapper.cs @@ -78,11 +78,17 @@ public async Task> GetTeams() HttpResponseMessage response = await _httpClient.GetAsync(url); if (response.StatusCode == HttpStatusCode.OK) { - teams = await response.Content.ReadAsAsync>() ?? new List(); + string jsonString = await response.Content.ReadAsStringAsync(); + var deserializeOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + teams = JsonSerializer.Deserialize>(jsonString, deserializeOptions) ?? new List(); } else { - _logger.LogError("Cold not retrieve teams for user " + AuthenticationHelper.GetDeveloperUserName(_httpContextAccessor.HttpContext) + " GetTeams failed with statuscode " + response.StatusCode); + _logger.LogError("Could not retrieve teams for user " + AuthenticationHelper.GetDeveloperUserName(_httpContextAccessor.HttpContext) + " GetTeams failed with status code " + response.StatusCode); } return teams; diff --git a/backend/src/Designer/Services/Implementation/UserService.cs b/backend/src/Designer/Services/Implementation/UserService.cs new file mode 100644 index 00000000000..c2e17006611 --- /dev/null +++ b/backend/src/Designer/Services/Implementation/UserService.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Models.Dto; +using Altinn.Studio.Designer.RepositoryClient.Model; +using Altinn.Studio.Designer.Services.Interfaces; + +namespace Altinn.Studio.Designer.Services.Implementation; + +public class UserService : IUserService +{ + private readonly IGitea _giteaApi; + + public UserService(IGitea giteaApi) + { + _giteaApi = giteaApi; + } + + public async Task GetUserOrgPermission(AltinnOrgContext altinnOrgContext) + { + bool canCreateOrgRepo = await HasPermissionToCreateOrgRepo(altinnOrgContext); + return new UserOrgPermission() { CanCreateOrgRepo = canCreateOrgRepo }; + } + + private bool IsUserSelfOrg(string developerName, string org) + { + return developerName == org; + } + + private async Task HasPermissionToCreateOrgRepo(AltinnOrgContext altinnOrgContext) + { + List teams = await _giteaApi.GetTeams(); + return IsUserSelfOrg(altinnOrgContext.DeveloperName, altinnOrgContext.Org) || + teams.Any(team => CheckPermissionToCreateOrgRepo(team, altinnOrgContext.Org)); + } + + private static bool CheckPermissionToCreateOrgRepo(Team team, string org) + { + return team.CanCreateOrgRepo && team.Organization.Username == org; + } +} diff --git a/backend/src/Designer/Services/Interfaces/IUserService.cs b/backend/src/Designer/Services/Interfaces/IUserService.cs new file mode 100644 index 00000000000..c77418b52d1 --- /dev/null +++ b/backend/src/Designer/Services/Interfaces/IUserService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Models.Dto; + +namespace Altinn.Studio.Designer.Services.Interfaces; + +public interface IUserService +{ + public Task GetUserOrgPermission(AltinnOrgContext altinnOrgContext); +} diff --git a/backend/src/Designer/TypedHttpClients/DelegatingHandlers/AzureDevOpsTokenDelegatingHandler.cs b/backend/src/Designer/TypedHttpClients/DelegatingHandlers/AzureDevOpsTokenDelegatingHandler.cs new file mode 100644 index 00000000000..6832d5d11a6 --- /dev/null +++ b/backend/src/Designer/TypedHttpClients/DelegatingHandlers/AzureDevOpsTokenDelegatingHandler.cs @@ -0,0 +1,24 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Altinn.Studio.Designer.TypedHttpClients.DelegatingHandlers; + +public class AzureDevOpsTokenDelegatingHandler : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("Failed to interact with Azure DevOps. Contact system support.") + }; + } + + return response; + } +} diff --git a/backend/src/Designer/TypedHttpClients/TypedHttpClientRegistration.cs b/backend/src/Designer/TypedHttpClients/TypedHttpClientRegistration.cs index 7b387507409..b20fe66cad4 100644 --- a/backend/src/Designer/TypedHttpClients/TypedHttpClientRegistration.cs +++ b/backend/src/Designer/TypedHttpClients/TypedHttpClientRegistration.cs @@ -38,6 +38,7 @@ public static class TypedHttpClientRegistration public static IServiceCollection RegisterTypedHttpClients(this IServiceCollection services, IConfiguration config) { services.AddHttpClient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddAzureDevOpsTypedHttpClient(config); @@ -70,7 +71,7 @@ private static IHttpClientBuilder AddAzureDevOpsTypedHttpClient(this IServiceCol client.BaseAddress = new Uri($"{azureDevOpsSettings.BaseUri}build/builds/"); client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", token); - }).AddHttpMessageHandler(); + }).AddHttpMessageHandler().AddHttpMessageHandler(); } private static IHttpClientBuilder AddKubernetesWrapperTypedHttpClient(this IServiceCollection services) diff --git a/backend/tests/Designer.Tests/GiteaIntegrationTests/UserControllerGiteaIntegrationTests.cs b/backend/tests/Designer.Tests/GiteaIntegrationTests/UserControllerGiteaIntegrationTests.cs index e2576cb7adf..88b75a6ae6c 100644 --- a/backend/tests/Designer.Tests/GiteaIntegrationTests/UserControllerGiteaIntegrationTests.cs +++ b/backend/tests/Designer.Tests/GiteaIntegrationTests/UserControllerGiteaIntegrationTests.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; +using Altinn.Studio.Designer.Models.Dto; using Altinn.Studio.Designer.RepositoryClient.Model; using Designer.Tests.Fixtures; using Designer.Tests.Utils; @@ -14,8 +15,9 @@ namespace Designer.Tests.GiteaIntegrationTests { public class UserControllerGiteaIntegrationTests : GiteaIntegrationTestsBase { - - public UserControllerGiteaIntegrationTests(GiteaWebAppApplicationFactoryFixture factory, GiteaFixture giteaFixture, SharedDesignerHttpClientProvider sharedDesignerHttpClientProvider) : base(factory, giteaFixture, sharedDesignerHttpClientProvider) + public UserControllerGiteaIntegrationTests(GiteaWebAppApplicationFactoryFixture factory, + GiteaFixture giteaFixture, SharedDesignerHttpClientProvider sharedDesignerHttpClientProvider) : base( + factory, giteaFixture, sharedDesignerHttpClientProvider) { } @@ -31,10 +33,8 @@ public async Task GetCurrentUser_ShouldReturnOk(string expectedUserName, string response.StatusCode.Should().Be(HttpStatusCode.OK); response.Headers.First(h => h.Key == "Set-Cookie").Value.Should().Contain(e => e.Contains("XSRF-TOKEN")); string content = await response.Content.ReadAsStringAsync(); - var user = JsonSerializer.Deserialize(content, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); + var user = JsonSerializer.Deserialize(content, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); user.Login.Should().Be(expectedUserName); user.Email.Should().Be(expectedEmail); @@ -62,16 +62,40 @@ public async Task StarredEndpoints_ShouldBehaveAsExpected(string org) string targetRepo = TestDataHelper.GenerateTestRepoName(); await CreateAppUsingDesigner(org, targetRepo); - using var putStarredResponse = await HttpClient.PutAsync($"designer/api/user/starred/{org}/{targetRepo}", null); + using var putStarredResponse = + await HttpClient.PutAsync($"designer/api/user/starred/{org}/{targetRepo}", null); putStarredResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); await GetAndVerifyStarredRepos(targetRepo); - using var deleteStarredResponse = await HttpClient.DeleteAsync($"designer/api/user/starred/{org}/{targetRepo}"); + using var deleteStarredResponse = + await HttpClient.DeleteAsync($"designer/api/user/starred/{org}/{targetRepo}"); deleteStarredResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); await GetAndVerifyStarredRepos(); } + [Theory] + [InlineData(GiteaConstants.TestOrgUsername, true)] + [InlineData("OtherOrg", false)] + public async Task HasAccessToCreateRepository_ShouldReturnCorrectPermissions(string org, bool expectedCanCreate) + { + string requestUrl = $"designer/api/user/org-permissions/{org}"; + + using var response = await HttpClient.GetAsync(requestUrl); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + string content = await response.Content.ReadAsStringAsync(); + var deserializeOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + var userOrgPermission = JsonSerializer.Deserialize(content, deserializeOptions); + + userOrgPermission.Should().NotBeNull(); + userOrgPermission.CanCreateOrgRepo.Should().Be(expectedCanCreate); + } + private async Task GetAndVerifyStarredRepos(params string[] expectedStarredRepos) { using var response = await HttpClient.GetAsync("designer/api/user/starred"); @@ -83,6 +107,5 @@ private async Task GetAndVerifyStarredRepos(params string[] expectedStarredRepos content.Should().Contain(r => r.Name == expectedStarredRepo); } } - } } diff --git a/backend/tests/Designer.Tests/Helpers/GuardTests.cs b/backend/tests/Designer.Tests/Helpers/GuardTests.cs index 4966c45e948..da90664720c 100644 --- a/backend/tests/Designer.Tests/Helpers/GuardTests.cs +++ b/backend/tests/Designer.Tests/Helpers/GuardTests.cs @@ -1,7 +1,5 @@ using System; - using Altinn.Studio.Designer.Helpers; - using Xunit; namespace Designer.Tests.Helpers @@ -12,7 +10,8 @@ public class GuardTests [InlineData("filename.xsd", ".xs")] [InlineData("path/to/filename.json", "json")] [InlineData("path/to/filename.schema.json", "jsonschema")] - public void AssertFileExtensionIsOfType_InCorrectType_ShouldThrowException(string file, string incorrectExtension) + public void AssertFileExtensionIsOfType_InCorrectType_ShouldThrowException(string file, + string incorrectExtension) { Assert.Throws(() => Guard.AssertFileExtensionIsOfType(file, incorrectExtension)); } @@ -21,7 +20,8 @@ public void AssertFileExtensionIsOfType_InCorrectType_ShouldThrowException(strin [InlineData("filename.xsd", ".xsd")] [InlineData("path/to/filename.json", ".json")] [InlineData("path/to/filename.schema.json", ".json")] - public void AssertFileExtensionIsOfType_CorrectType_ShouldNotThrowException(string file, string correctExtension) + public void AssertFileExtensionIsOfType_CorrectType_ShouldNotThrowException(string file, + string correctExtension) { Guard.AssertFileExtensionIsOfType(file, correctExtension); Assert.True(true); @@ -43,5 +43,23 @@ public void AssertValidAppRepoName_InvalidName_ShouldThrowException(string name) { Assert.Throws(() => Guard.AssertValidAppRepoName(name)); } + + [Theory] + [InlineData("ValidOrgName")] + public void AssertValidateOrganization_ValidOrg_ShouldNotThrowException(string org) + { + Guard.AssertValidateOrganization(org); + Assert.True(true); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Invalid@OrgName")] + public void AssertValidateOrganization_InvalidOrg_ShouldThrowException(string org) + { + Assert.Throws(() => Guard.AssertValidateOrganization(org)); + } } } diff --git a/backend/tests/Designer.Tests/Services/UserServiceTests.cs b/backend/tests/Designer.Tests/Services/UserServiceTests.cs new file mode 100644 index 00000000000..e55efeae404 --- /dev/null +++ b/backend/tests/Designer.Tests/Services/UserServiceTests.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.RepositoryClient.Model; +using Altinn.Studio.Designer.Services.Implementation; +using Altinn.Studio.Designer.Services.Interfaces; +using Microsoft.AspNetCore.Http; +using Moq; +using Xunit; + +namespace Designer.Tests.Services +{ + public class UserServiceTests + { + private readonly Mock _giteaApi; + + public UserServiceTests() + { + _giteaApi = new Mock(); + } + + [Theory] + [InlineData("org1", false)] + [InlineData("org2", true)] + public async Task GetUserOrgPermission_ReturnsCorrectPermission(string org, bool expectedCanCreate) + { + var teams = new List + { + new() + { + Organization = new Organization { Username = org }, CanCreateOrgRepo = expectedCanCreate + } + }; + + _giteaApi.Setup(api => api.GetTeams()).ReturnsAsync(teams); + + var userService = new UserService(_giteaApi.Object); + + AltinnOrgContext altinnOrgContext = AltinnOrgContext.FromOrg(org, "developer"); + var result = await userService.GetUserOrgPermission(altinnOrgContext); + + Assert.NotNull(result); + Assert.Equal(expectedCanCreate, result.CanCreateOrgRepo); + } + } +} diff --git a/backend/tests/Designer.Tests/TypedHttpClients/DelegatingHandlers/AzureDevOpsTokenDelegatingHandlerTests.cs b/backend/tests/Designer.Tests/TypedHttpClients/DelegatingHandlers/AzureDevOpsTokenDelegatingHandlerTests.cs new file mode 100644 index 00000000000..ee54efa5b68 --- /dev/null +++ b/backend/tests/Designer.Tests/TypedHttpClients/DelegatingHandlers/AzureDevOpsTokenDelegatingHandlerTests.cs @@ -0,0 +1,70 @@ +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.TypedHttpClients.DelegatingHandlers; +using Moq; +using Moq.Protected; +using Xunit; + +public class AzureDevOpsTokenDelegatingHandlerTests +{ + [Fact] + public async Task SendAsync_WhenUnauthorized_ThrowsHttpRequestException() + { + // Arrange + var mockInnerHandler = new Mock(); + + mockInnerHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.Unauthorized + }); + + var handler = new AzureDevOpsTokenDelegatingHandler + { + InnerHandler = mockInnerHandler.Object + }; + + HttpClient httpClient = new HttpClient(handler); + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "https://dev.azure.com"); + + HttpResponseMessage response = await httpClient.SendAsync(request); + string content = await response.Content.ReadAsStringAsync(); + + Assert.Equal("Failed to interact with Azure DevOps. Contact system support.", content); + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + } + + [Fact] + public async Task SendAsync_WhenSuccessful_ReturnsResponse() + { + var mockInnerHandler = new Mock(); + + mockInnerHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("Success") + }); + + var handler = new AzureDevOpsTokenDelegatingHandler + { + InnerHandler = mockInnerHandler.Object + }; + + HttpClient httpClient = new HttpClient(handler); + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "https://dev.azure.com"); + + HttpResponseMessage response = await httpClient.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Success", await response.Content.ReadAsStringAsync()); + } +}