diff --git a/backend/LexBoxApi/Services/PermissionService.cs b/backend/LexBoxApi/Services/PermissionService.cs index 83b1d158c..140b783a4 100644 --- a/backend/LexBoxApi/Services/PermissionService.cs +++ b/backend/LexBoxApi/Services/PermissionService.cs @@ -99,6 +99,8 @@ public async ValueTask CanViewProjectMembers(Guid projectId) if (User is not null && User.Role == UserRole.admin) return true; // Project managers can view members of their own projects, even confidential ones if (await CanManageProject(projectId)) return true; + if (User is null || !User.Projects.Any(p => p.ProjectId == projectId)) return false; + var isConfidential = await projectService.LookupProjectConfidentiality(projectId); // In this specific case (only), we assume public unless explicitly set to private return !(isConfidential ?? false); diff --git a/backend/Testing/ApiTests/ApiTestBase.cs b/backend/Testing/ApiTests/ApiTestBase.cs index bf5f8cb0b..25803a0c7 100644 --- a/backend/Testing/ApiTests/ApiTestBase.cs +++ b/backend/Testing/ApiTests/ApiTestBase.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Http.Json; using System.Text.Json.Nodes; +using LexCore.Auth; using Microsoft.Extensions.Http.Resilience; using Polly; using Shouldly; @@ -15,6 +16,7 @@ public class ApiTestBase private readonly SocketsHttpHandler _httpClientHandler; public readonly HttpClient HttpClient; public string? CurrJwt { get; private set; } + public LexAuthUser CurrentUser => JwtHelper.ToLexAuthUser(CurrJwt!); public ApiTestBase() { diff --git a/backend/Testing/ApiTests/FlexJwtTests.cs b/backend/Testing/ApiTests/FlexJwtTests.cs index 51328ac7c..4594590a1 100644 --- a/backend/Testing/ApiTests/FlexJwtTests.cs +++ b/backend/Testing/ApiTests/FlexJwtTests.cs @@ -11,8 +11,6 @@ namespace Testing.ApiTests; [Trait("Category", "Integration")] public class FlexJwtTests : ApiTestBase { - private static readonly JwtSecurityTokenHandler TokenHandler = new(); - private async Task GetFlexJwt() { var userJwt = await JwtHelper.GetJwtForUser(new SendReceiveAuth("manager", @@ -23,9 +21,7 @@ private async Task GetFlexJwt() private LexAuthUser ParseUserToken(string jwt) { - var outputJwt = TokenHandler.ReadJwtToken(jwt); - var principal = new ClaimsPrincipal(new ClaimsIdentity(outputJwt.Claims, "Testing")); - return LexAuthUser.FromClaimsPrincipal(principal) ?? throw new NullReferenceException("User was null"); + return JwtHelper.ToLexAuthUser(jwt); } [Fact] diff --git a/backend/Testing/ApiTests/GqlMiddlewareTests.cs b/backend/Testing/ApiTests/GqlMiddlewareTests.cs index 7703d2da6..766595775 100644 --- a/backend/Testing/ApiTests/GqlMiddlewareTests.cs +++ b/backend/Testing/ApiTests/GqlMiddlewareTests.cs @@ -69,6 +69,6 @@ await Task.WhenAll( var myProjects = json["data"]!["myProjects"]!.AsArray(); var ids = myProjects.Select(p => p!["id"]!.GetValue()); - projects.Select(p => p.id).ShouldBeSubsetOf(ids); + projects.Select(p => p.Id).ShouldBeSubsetOf(ids); } } diff --git a/backend/Testing/ApiTests/ProjectPermissionTests.cs b/backend/Testing/ApiTests/ProjectPermissionTests.cs new file mode 100644 index 000000000..36f781ffc --- /dev/null +++ b/backend/Testing/ApiTests/ProjectPermissionTests.cs @@ -0,0 +1,138 @@ +using System.Text.Json.Nodes; +using Shouldly; +using Testing.Services; + +namespace Testing.ApiTests; + +[Trait("Category", "Integration")] +public class ProjectPermissionTests : ApiTestBase +{ + private async Task QueryProject(string projectCode, bool expectGqlError = false) + { + var json = await ExecuteGql( + $$""" + query { + projectByCode(code: "{{projectCode}}") { + id + name + users { + user { + id + name + } + } + } + } + """, + expectGqlError); + return json; + } + + private async Task AddUserToProject(Guid projectId, string username) + { + await ExecuteGql( + $$""" + mutation { + addProjectMember(input: { + projectId: "{{projectId}}", + usernameOrEmail: "{{username}}", + role: EDITOR, + canInvite: false + }) { + project { + id + } + errors { + __typename + ... on Error { + message + } + } + } + } + """); + } + + private JsonObject GetProject(JsonObject json) + { + var project = json["data"]!["projectByCode"]?.AsObject(); + project.ShouldNotBeNull(); + return project; + } + + private void MustHaveMembers(JsonObject project, int? count = null) + { + var members = project["users"]!.AsArray(); + members.ShouldNotBeNull().ShouldNotBeEmpty(); + if (count is not null) members.Count.ShouldBe(count.Value); + } + + private void MustNotHaveMembers(JsonObject project) + { + var users = project["users"]!.AsArray(); + users.ShouldBeEmpty(); + } + + private void MustHaveOnlyUserAsMember(JsonObject project, Guid userId) + { + var users = project["users"]!.AsArray(); + users.ShouldContain(node => node!["user"]!["id"]!.GetValue() == userId, + "user list " + users.ToJsonString()); + } + + [Fact] + public async Task MemberCanSeeProjectMembers() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig()); + //refresh jwt + await LoginAs("manager"); + var json = GetProject(await QueryProject(project.Code)); + MustHaveMembers(json); + } + + [Fact] + public async Task NonMemberCannotSeeProjectMembers() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig()); + await LoginAs("user"); + var json = GetProject(await QueryProject(project.Code)); + MustNotHaveMembers(json); + } + + [Fact] + public async Task ConfidentialProject_ManagerCanSeeProjectMembers() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true)); + await LoginAs("manager"); + var json = GetProject(await QueryProject(project.Code)); + MustHaveMembers(json); + } + + [Fact] + public async Task ConfidentialProject_NonManagerCannotSeeProjectMembers() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true)); + await LoginAs("manager"); + await AddUserToProject(project.Id, "editor"); + MustHaveMembers(GetProject(await QueryProject(project.Code)), count: 2); + await LoginAs("editor"); + var json = GetProject(await QueryProject(project.Code)); + MustHaveOnlyUserAsMember(json, CurrentUser.Id); + } + + [Fact] + public async Task ConfidentialProject_NonMemberCannotSeeProject() + { + await LoginAs("manager"); + await using var project = await this.RegisterProjectInLexBox(Utils.GetNewProjectConfig(isConfidential: true)); + await LoginAs("user"); + var json = await QueryProject(project.Code, expectGqlError: true); + var error = json["errors"]!.AsArray().First()?.AsObject(); + error.ShouldNotBeNull(); + error["extensions"]?["code"]?.GetValue().ShouldBe("AUTH_NOT_AUTHORIZED"); + } +} diff --git a/backend/Testing/Services/JwtHelper.cs b/backend/Testing/Services/JwtHelper.cs index 10c3fc3bb..63b4d157b 100644 --- a/backend/Testing/Services/JwtHelper.cs +++ b/backend/Testing/Services/JwtHelper.cs @@ -1,8 +1,12 @@ +using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Net.Http.Json; +using System.Security.Claims; using System.Text.Json; using LexBoxApi.Auth; +using LexCore.Auth; using Microsoft.Extensions.Http.Resilience; +using Mono.Unix.Native; using Polly; using Shouldly; using Testing.ApiTests; @@ -72,4 +76,12 @@ public static void ClearCookies(SocketsHttpHandler httpClientHandler) cookie.Expired = true; } } + + private static readonly JwtSecurityTokenHandler TokenHandler = new(); + public static LexAuthUser ToLexAuthUser(string jwt) + { + var outputJwt = TokenHandler.ReadJwtToken(jwt); + var principal = new ClaimsPrincipal(new ClaimsIdentity(outputJwt.Claims, "Testing")); + return LexAuthUser.FromClaimsPrincipal(principal) ?? throw new NullReferenceException("User was null"); + } } diff --git a/backend/Testing/Services/Utils.cs b/backend/Testing/Services/Utils.cs index 3c8c46310..eda0b41a5 100644 --- a/backend/Testing/Services/Utils.cs +++ b/backend/Testing/Services/Utils.cs @@ -32,6 +32,14 @@ public static ProjectConfig GetNewProjectConfig(HgProtocol? protocol = null, boo return new ProjectConfig(id, projectName, projectCode, dir, isConfidential, owningOrgId); } + public static async Task RegisterProjectInLexBox( + this ApiTestBase apiTester, + ProjectConfig config, + bool waitForRepoReady = false) + { + return await RegisterProjectInLexBox(config, apiTester, waitForRepoReady); + } + public static async Task RegisterProjectInLexBox( ProjectConfig config, ApiTestBase apiTester, @@ -65,7 +73,7 @@ ... on DbError { } """); if (waitForRepoReady) await WaitForHgRefreshIntervalAsync(); - return new LexboxProject(apiTester, config.Id); + return new LexboxProject(apiTester, config); } public static async Task AddMemberToProject( @@ -135,20 +143,22 @@ private static string GetNewProjectDir(string projectCode, public record LexboxProject : IAsyncDisposable { - public readonly Guid id; + private static string? _jwt; + public Guid Id => _config.Id; + public string Code => _config.Code; + private readonly ProjectConfig _config; private readonly ApiTestBase _apiTester; - private readonly string _jwt; - public LexboxProject(ApiTestBase apiTester, Guid id) + public LexboxProject(ApiTestBase apiTester, ProjectConfig config) { - this.id = id; + _config = config; _apiTester = apiTester; - _jwt = apiTester.CurrJwt ?? throw new InvalidOperationException("No JWT found"); } public async ValueTask DisposeAsync() { - var response = await _apiTester.HttpClient.DeleteAsync($"api/project/{id}?jwt={_jwt}"); + _jwt ??= await JwtHelper.GetJwtForUser(AdminAuth); + var response = await _apiTester.HttpClient.DeleteAsync($"api/project/{Id}?jwt={_jwt}"); response.EnsureSuccessStatusCode(); } }