Skip to content

Commit 3784722

Browse files
authored
feat(designer): endpoint to get UserOrgPermissions (#14389)
1 parent a514966 commit 3784722

File tree

13 files changed

+219
-28
lines changed

13 files changed

+219
-28
lines changed

backend/src/Designer/Controllers/UserController.cs

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
using System.Collections.Generic;
22
using System.Threading.Tasks;
3+
using Altinn.Studio.Designer.Helpers;
4+
using Altinn.Studio.Designer.Models;
5+
using Altinn.Studio.Designer.Models.Dto;
36
using Altinn.Studio.Designer.Services.Interfaces;
47
using Microsoft.AspNetCore.Antiforgery;
58
using Microsoft.AspNetCore.Authorization;
@@ -17,16 +20,18 @@ public class UserController : ControllerBase
1720
{
1821
private readonly IGitea _giteaApi;
1922
private readonly IAntiforgery _antiforgery;
23+
private readonly IUserService _userService;
2024

2125
/// <summary>
2226
/// Initializes a new instance of the <see cref="UserController"/> class.
2327
/// </summary>
2428
/// <param name="giteaWrapper">the gitea wrapper</param>
2529
/// <param name="antiforgery">Access to the antiforgery system in .NET Core</param>
26-
public UserController(IGitea giteaWrapper, IAntiforgery antiforgery)
30+
public UserController(IGitea giteaWrapper, IAntiforgery antiforgery, IUserService userService)
2731
{
2832
_giteaApi = giteaWrapper;
2933
_antiforgery = antiforgery;
34+
_userService = userService;
3035
}
3136

3237
/// <summary>
@@ -80,6 +85,16 @@ public async Task<IActionResult> PutStarred(string org, string repository)
8085
return success ? NoContent() : StatusCode(418);
8186
}
8287

88+
[HttpGet]
89+
[Route("org-permissions/{org}")]
90+
public async Task<IActionResult> HasAccessToCreateRepository(string org)
91+
{
92+
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
93+
AltinnOrgContext editingContext = AltinnOrgContext.FromOrg(org, developer);
94+
UserOrgPermission userOrg = await _userService.GetUserOrgPermission(editingContext);
95+
return Ok(userOrg);
96+
}
97+
8398
/// <summary>
8499
/// Removes the star marking on the specified repository.
85100
/// </summary>

backend/src/Designer/Helpers/Guard.cs

+9
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ public static void AssertArgumentNotNullOrWhiteSpace(string value, string argume
2525
}
2626
}
2727

28+
public static void AssertValidateOrganization(string org)
29+
{
30+
AssertNotNullOrEmpty(org, nameof(org));
31+
if (!AltinnRegexes.AltinnOrganizationNameRegex().IsMatch(org))
32+
{
33+
throw new ArgumentException("Provided organization name is not valid");
34+
}
35+
}
36+
2837
/// <summary>
2938
/// Asserts value not null.
3039
/// </summary>

backend/src/Designer/Infrastructure/ServiceRegistration.cs

+1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public static IServiceCollection RegisterServiceImplementations(this IServiceCol
5454
services.AddScoped<IReleaseRepository, ReleaseRepository>();
5555
services.AddScoped<IDeploymentRepository, DeploymentRepository>();
5656
services.AddScoped<IAppScopesRepository, AppScopesRepository>();
57+
services.AddScoped<IUserService, UserService>();
5758
services.AddTransient<IReleaseService, ReleaseService>();
5859
services.AddTransient<IDeploymentService, DeploymentService>();
5960
services.AddTransient<IAppScopesService, AppScopesService>();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using Altinn.Studio.Designer.Helpers;
2+
3+
namespace Altinn.Studio.Designer.Models;
4+
5+
public class AltinnOrgContext
6+
{
7+
public string Org { get; }
8+
public string DeveloperName { get; }
9+
10+
private AltinnOrgContext(string org, string developerName)
11+
{
12+
Guard.AssertValidateOrganization(org);
13+
Org = org;
14+
15+
Guard.AssertArgumentNotNullOrWhiteSpace(developerName, nameof(developerName));
16+
DeveloperName = developerName;
17+
}
18+
19+
public static AltinnOrgContext FromOrg(string org, string developerName)
20+
{
21+
return new AltinnOrgContext(org, developerName);
22+
}
23+
}

backend/src/Designer/Models/AltinnRepoContext.cs

+2-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
using System;
2-
using Altinn.Studio.Designer.Helpers;
1+
using Altinn.Studio.Designer.Helpers;
32

43
namespace Altinn.Studio.Designer.Models
54
{
@@ -21,21 +20,12 @@ public class AltinnRepoContext
2120

2221
protected AltinnRepoContext(string org, string repo)
2322
{
24-
ValidateOrganization(org);
23+
Guard.AssertValidateOrganization(org);
2524
Guard.AssertValidAppRepoName(repo);
2625
Org = org;
2726
Repo = repo;
2827
}
2928

30-
private void ValidateOrganization(string org)
31-
{
32-
Guard.AssertNotNullOrEmpty(org, nameof(org));
33-
if (!AltinnRegexes.AltinnOrganizationNameRegex().IsMatch(org))
34-
{
35-
throw new ArgumentException("Provided organization name is not valid");
36-
}
37-
}
38-
3929
public static AltinnRepoContext FromOrgRepo(string org, string repo)
4030
{
4131
return new AltinnRepoContext(org, repo);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Altinn.Studio.Designer.Models.Dto;
2+
3+
public class UserOrgPermission
4+
{
5+
public bool CanCreateOrgRepo { get; set; }
6+
}

backend/src/Designer/RepositoryClient/Model/Team.cs

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public class Team
1010
/// </summary>
1111
public string Name { get; set; }
1212

13+
public bool CanCreateOrgRepo { get; set; }
14+
1315
/// <summary>
1416
/// The organization that owns the team
1517
/// </summary>

backend/src/Designer/Services/Implementation/GiteaAPIWrapper/GiteaAPIWrapper.cs

+8-2
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,17 @@ public async Task<List<Team>> GetTeams()
7878
HttpResponseMessage response = await _httpClient.GetAsync(url);
7979
if (response.StatusCode == HttpStatusCode.OK)
8080
{
81-
teams = await response.Content.ReadAsAsync<List<Team>>() ?? new List<Team>();
81+
string jsonString = await response.Content.ReadAsStringAsync();
82+
var deserializeOptions = new JsonSerializerOptions
83+
{
84+
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
85+
};
86+
87+
teams = JsonSerializer.Deserialize<List<Team>>(jsonString, deserializeOptions) ?? new List<Team>();
8288
}
8389
else
8490
{
85-
_logger.LogError("Cold not retrieve teams for user " + AuthenticationHelper.GetDeveloperUserName(_httpContextAccessor.HttpContext) + " GetTeams failed with statuscode " + response.StatusCode);
91+
_logger.LogError("Could not retrieve teams for user " + AuthenticationHelper.GetDeveloperUserName(_httpContextAccessor.HttpContext) + " GetTeams failed with status code " + response.StatusCode);
8692
}
8793

8894
return teams;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using Altinn.Studio.Designer.Models;
5+
using Altinn.Studio.Designer.Models.Dto;
6+
using Altinn.Studio.Designer.RepositoryClient.Model;
7+
using Altinn.Studio.Designer.Services.Interfaces;
8+
9+
namespace Altinn.Studio.Designer.Services.Implementation;
10+
11+
public class UserService : IUserService
12+
{
13+
private readonly IGitea _giteaApi;
14+
15+
public UserService(IGitea giteaApi)
16+
{
17+
_giteaApi = giteaApi;
18+
}
19+
20+
public async Task<UserOrgPermission> GetUserOrgPermission(AltinnOrgContext altinnOrgContext)
21+
{
22+
bool canCreateOrgRepo = await HasPermissionToCreateOrgRepo(altinnOrgContext);
23+
return new UserOrgPermission() { CanCreateOrgRepo = canCreateOrgRepo };
24+
}
25+
26+
private bool IsUserSelfOrg(string developerName, string org)
27+
{
28+
return developerName == org;
29+
}
30+
31+
private async Task<bool> HasPermissionToCreateOrgRepo(AltinnOrgContext altinnOrgContext)
32+
{
33+
List<Team> teams = await _giteaApi.GetTeams();
34+
return IsUserSelfOrg(altinnOrgContext.DeveloperName, altinnOrgContext.Org) ||
35+
teams.Any(team => CheckPermissionToCreateOrgRepo(team, altinnOrgContext.Org));
36+
}
37+
38+
private static bool CheckPermissionToCreateOrgRepo(Team team, string org)
39+
{
40+
return team.CanCreateOrgRepo && team.Organization.Username == org;
41+
}
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using System.Threading.Tasks;
2+
using Altinn.Studio.Designer.Models;
3+
using Altinn.Studio.Designer.Models.Dto;
4+
5+
namespace Altinn.Studio.Designer.Services.Interfaces;
6+
7+
public interface IUserService
8+
{
9+
public Task<UserOrgPermission> GetUserOrgPermission(AltinnOrgContext altinnOrgContext);
10+
}

backend/tests/Designer.Tests/GiteaIntegrationTests/UserControllerGiteaIntegrationTests.cs

+32-9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Net.Http;
55
using System.Text.Json;
66
using System.Threading.Tasks;
7+
using Altinn.Studio.Designer.Models.Dto;
78
using Altinn.Studio.Designer.RepositoryClient.Model;
89
using Designer.Tests.Fixtures;
910
using Designer.Tests.Utils;
@@ -14,8 +15,9 @@ namespace Designer.Tests.GiteaIntegrationTests
1415
{
1516
public class UserControllerGiteaIntegrationTests : GiteaIntegrationTestsBase<UserControllerGiteaIntegrationTests>
1617
{
17-
18-
public UserControllerGiteaIntegrationTests(GiteaWebAppApplicationFactoryFixture<Program> factory, GiteaFixture giteaFixture, SharedDesignerHttpClientProvider sharedDesignerHttpClientProvider) : base(factory, giteaFixture, sharedDesignerHttpClientProvider)
18+
public UserControllerGiteaIntegrationTests(GiteaWebAppApplicationFactoryFixture<Program> factory,
19+
GiteaFixture giteaFixture, SharedDesignerHttpClientProvider sharedDesignerHttpClientProvider) : base(
20+
factory, giteaFixture, sharedDesignerHttpClientProvider)
1921
{
2022
}
2123

@@ -31,10 +33,8 @@ public async Task GetCurrentUser_ShouldReturnOk(string expectedUserName, string
3133
response.StatusCode.Should().Be(HttpStatusCode.OK);
3234
response.Headers.First(h => h.Key == "Set-Cookie").Value.Should().Contain(e => e.Contains("XSRF-TOKEN"));
3335
string content = await response.Content.ReadAsStringAsync();
34-
var user = JsonSerializer.Deserialize<User>(content, new JsonSerializerOptions
35-
{
36-
PropertyNameCaseInsensitive = true
37-
});
36+
var user = JsonSerializer.Deserialize<User>(content,
37+
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
3838

3939
user.Login.Should().Be(expectedUserName);
4040
user.Email.Should().Be(expectedEmail);
@@ -62,16 +62,40 @@ public async Task StarredEndpoints_ShouldBehaveAsExpected(string org)
6262
string targetRepo = TestDataHelper.GenerateTestRepoName();
6363
await CreateAppUsingDesigner(org, targetRepo);
6464

65-
using var putStarredResponse = await HttpClient.PutAsync($"designer/api/user/starred/{org}/{targetRepo}", null);
65+
using var putStarredResponse =
66+
await HttpClient.PutAsync($"designer/api/user/starred/{org}/{targetRepo}", null);
6667
putStarredResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
6768
await GetAndVerifyStarredRepos(targetRepo);
6869

69-
using var deleteStarredResponse = await HttpClient.DeleteAsync($"designer/api/user/starred/{org}/{targetRepo}");
70+
using var deleteStarredResponse =
71+
await HttpClient.DeleteAsync($"designer/api/user/starred/{org}/{targetRepo}");
7072
deleteStarredResponse.StatusCode.Should().Be(HttpStatusCode.NoContent);
7173

7274
await GetAndVerifyStarredRepos();
7375
}
7476

77+
[Theory]
78+
[InlineData(GiteaConstants.TestOrgUsername, true)]
79+
[InlineData("OtherOrg", false)]
80+
public async Task HasAccessToCreateRepository_ShouldReturnCorrectPermissions(string org, bool expectedCanCreate)
81+
{
82+
string requestUrl = $"designer/api/user/org-permissions/{org}";
83+
84+
using var response = await HttpClient.GetAsync(requestUrl);
85+
86+
response.StatusCode.Should().Be(HttpStatusCode.OK);
87+
string content = await response.Content.ReadAsStringAsync();
88+
var deserializeOptions = new JsonSerializerOptions
89+
{
90+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
91+
};
92+
93+
var userOrgPermission = JsonSerializer.Deserialize<Team>(content, deserializeOptions);
94+
95+
userOrgPermission.Should().NotBeNull();
96+
userOrgPermission.CanCreateOrgRepo.Should().Be(expectedCanCreate);
97+
}
98+
7599
private async Task GetAndVerifyStarredRepos(params string[] expectedStarredRepos)
76100
{
77101
using var response = await HttpClient.GetAsync("designer/api/user/starred");
@@ -83,6 +107,5 @@ private async Task GetAndVerifyStarredRepos(params string[] expectedStarredRepos
83107
content.Should().Contain(r => r.Name == expectedStarredRepo);
84108
}
85109
}
86-
87110
}
88111
}

backend/tests/Designer.Tests/Helpers/GuardTests.cs

+22-4
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
using System;
2-
32
using Altinn.Studio.Designer.Helpers;
4-
53
using Xunit;
64

75
namespace Designer.Tests.Helpers
@@ -12,7 +10,8 @@ public class GuardTests
1210
[InlineData("filename.xsd", ".xs")]
1311
[InlineData("path/to/filename.json", "json")]
1412
[InlineData("path/to/filename.schema.json", "jsonschema")]
15-
public void AssertFileExtensionIsOfType_InCorrectType_ShouldThrowException(string file, string incorrectExtension)
13+
public void AssertFileExtensionIsOfType_InCorrectType_ShouldThrowException(string file,
14+
string incorrectExtension)
1615
{
1716
Assert.Throws<ArgumentException>(() => Guard.AssertFileExtensionIsOfType(file, incorrectExtension));
1817
}
@@ -21,7 +20,8 @@ public void AssertFileExtensionIsOfType_InCorrectType_ShouldThrowException(strin
2120
[InlineData("filename.xsd", ".xsd")]
2221
[InlineData("path/to/filename.json", ".json")]
2322
[InlineData("path/to/filename.schema.json", ".json")]
24-
public void AssertFileExtensionIsOfType_CorrectType_ShouldNotThrowException(string file, string correctExtension)
23+
public void AssertFileExtensionIsOfType_CorrectType_ShouldNotThrowException(string file,
24+
string correctExtension)
2525
{
2626
Guard.AssertFileExtensionIsOfType(file, correctExtension);
2727
Assert.True(true);
@@ -43,5 +43,23 @@ public void AssertValidAppRepoName_InvalidName_ShouldThrowException(string name)
4343
{
4444
Assert.Throws<ArgumentException>(() => Guard.AssertValidAppRepoName(name));
4545
}
46+
47+
[Theory]
48+
[InlineData("ValidOrgName")]
49+
public void AssertValidateOrganization_ValidOrg_ShouldNotThrowException(string org)
50+
{
51+
Guard.AssertValidateOrganization(org);
52+
Assert.True(true);
53+
}
54+
55+
[Theory]
56+
[InlineData(null)]
57+
[InlineData("")]
58+
[InlineData(" ")]
59+
[InlineData("Invalid@OrgName")]
60+
public void AssertValidateOrganization_InvalidOrg_ShouldThrowException(string org)
61+
{
62+
Assert.Throws<ArgumentException>(() => Guard.AssertValidateOrganization(org));
63+
}
4664
}
4765
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System.Collections.Generic;
2+
using System.Threading.Tasks;
3+
using Altinn.Studio.Designer.Models;
4+
using Altinn.Studio.Designer.RepositoryClient.Model;
5+
using Altinn.Studio.Designer.Services.Implementation;
6+
using Altinn.Studio.Designer.Services.Interfaces;
7+
using Microsoft.AspNetCore.Http;
8+
using Moq;
9+
using Xunit;
10+
11+
namespace Designer.Tests.Services
12+
{
13+
public class UserServiceTests
14+
{
15+
private readonly Mock<IGitea> _giteaApi;
16+
17+
public UserServiceTests()
18+
{
19+
_giteaApi = new Mock<IGitea>();
20+
}
21+
22+
[Theory]
23+
[InlineData("org1", false)]
24+
[InlineData("org2", true)]
25+
public async Task GetUserOrgPermission_ReturnsCorrectPermission(string org, bool expectedCanCreate)
26+
{
27+
var teams = new List<Team>
28+
{
29+
new()
30+
{
31+
Organization = new Organization { Username = org }, CanCreateOrgRepo = expectedCanCreate
32+
}
33+
};
34+
35+
_giteaApi.Setup(api => api.GetTeams()).ReturnsAsync(teams);
36+
37+
var userService = new UserService(_giteaApi.Object);
38+
39+
AltinnOrgContext altinnOrgContext = AltinnOrgContext.FromOrg(org, "developer");
40+
var result = await userService.GetUserOrgPermission(altinnOrgContext);
41+
42+
Assert.NotNull(result);
43+
Assert.Equal(expectedCanCreate, result.CanCreateOrgRepo);
44+
}
45+
}
46+
}

0 commit comments

Comments
 (0)