diff --git a/Frontend/CO.CDP.OrganisationApp.Tests/AuthorizationTests.cs b/Frontend/CO.CDP.OrganisationApp.Tests/AuthorizationTests.cs index 5f9baa01a..a2908177f 100644 --- a/Frontend/CO.CDP.OrganisationApp.Tests/AuthorizationTests.cs +++ b/Frontend/CO.CDP.OrganisationApp.Tests/AuthorizationTests.cs @@ -21,7 +21,7 @@ public class AuthorizationTests private static Guid personId = new Guid("5b0d3aa8-94cd-4ede-ba03-546937035690"); private static Guid personInviteGuid = new Guid("330fb1d4-26e2-4c69-898f-6197f9321361"); - public HttpClient BuildHttpClient(List userScopes) + public HttpClient BuildHttpClient(List userOrganisationScopes, List userScopes) { var services = new ServiceCollection(); @@ -32,11 +32,11 @@ public HttpClient BuildHttpClient(List userScopes) Tenant.WebApiClient.PartyRole.Supplier, Tenant.WebApiClient.PartyRole.Tenderer ], - userScopes, + userOrganisationScopes, new Uri("http://foo") ); - var person = new Person.WebApiClient.Person("a@b.com", "First name", personId, "Last name"); + var person = new Person.WebApiClient.Person("a@b.com", "First name", personId, "Last name", userScopes); tenantClient.Setup(client => client.LookupTenantAsync()) .ReturnsAsync( @@ -83,8 +83,12 @@ [ organisation ] ) ); + personClient.Setup(client => client.LookupPersonAsync(It.IsAny())).ReturnsAsync(person); + services.AddTransient(sc => organisationClient.Object); + services.AddTransient(sc => personClient.Object); + _mockSession.Setup(s => s.Get(Session.UserDetailsKey)) .Returns(new Models.UserDetails() { Email = "a@b.com", UserUrn = "urn", PersonId = person.Id }); @@ -113,11 +117,31 @@ public static IEnumerable TestCases() yield return new object[] { $"/organisation/{testOrganisationId}/users/{personInviteGuid}/change-role?handler=personInvite", new string[] { "Person invite Last name", "Can add, remove and edit users" } }; } + [Theory] + [MemberData(nameof(TestCases))] + public async Task TestAuthorizationIsSuccessful_WhenUserIsAllowedToAccessResourceAsSupportAdminUser(string url, string[] expectedTexts) + { + var _httpClient = BuildHttpClient([], [PersonScopes.SupportAdmin]); + + var request = new HttpRequestMessage(HttpMethod.Get, url); + + var response = await _httpClient.SendAsync(request); + + var responseBody = await response.Content.ReadAsStringAsync(); + + responseBody.Should().NotBeNull(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + foreach (string expectedText in expectedTexts) + { + responseBody.Should().Contain(expectedText); + } + } + [Theory] [MemberData(nameof(TestCases))] public async Task TestAuthorizationIsSuccessful_WhenUserIsAllowedToAccessResourceAsAdminUser(string url, string[] expectedTexts) { - var _httpClient = BuildHttpClient([OrganisationPersonScopes.Admin]); + var _httpClient = BuildHttpClient([OrganisationPersonScopes.Admin], []); var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -137,7 +161,7 @@ public async Task TestAuthorizationIsSuccessful_WhenUserIsAllowedToAccessResourc [MemberData(nameof(TestCases))] public async Task TestAuthorizationIsUnsuccessful_WhenUserIsNotAllowedToAccessResourceAsEditorUser(string url, string[] _) { - var _httpClient = BuildHttpClient([OrganisationPersonScopes.Editor]); + var _httpClient = BuildHttpClient([OrganisationPersonScopes.Editor], []); var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -154,7 +178,7 @@ public async Task TestAuthorizationIsUnsuccessful_WhenUserIsNotAllowedToAccessRe [MemberData(nameof(TestCases))] public async Task TestAuthorizationIsUnsuccessful_WhenUserIsNotAllowedToAccessResourceAsUserWithoutPermissions(string url, string[] _) { - var _httpClient = BuildHttpClient([]); + var _httpClient = BuildHttpClient([], []); var request = new HttpRequestMessage(HttpMethod.Get, url); @@ -170,7 +194,7 @@ public async Task TestAuthorizationIsUnsuccessful_WhenUserIsNotAllowedToAccessRe [Fact] public async Task TestCanSeeUsersLinkOnOrganisationPage_WhenUserIsAllowedToAccessResourceAsAdminUser() { - var _httpClient = BuildHttpClient([ OrganisationPersonScopes.Admin, OrganisationPersonScopes.Viewer ]); + var _httpClient = BuildHttpClient([ OrganisationPersonScopes.Admin, OrganisationPersonScopes.Viewer ], []); var request = new HttpRequestMessage(HttpMethod.Get, $"/organisation/{testOrganisationId}"); @@ -187,7 +211,7 @@ public async Task TestCanSeeUsersLinkOnOrganisationPage_WhenUserIsAllowedToAcces [Fact] public async Task TestCannotSeeUsersLinkOnOrganisationPage_WhenUserIsNotAllowedToAccessResourceAsEditorUser() { - var _httpClient = BuildHttpClient([ OrganisationPersonScopes.Editor ]); + var _httpClient = BuildHttpClient([ OrganisationPersonScopes.Editor ], []); var request = new HttpRequestMessage(HttpMethod.Get, $"/organisation/{testOrganisationId}"); diff --git a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/OneLoginTest.cs b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/OneLoginTest.cs index 28a4215c6..b3f5ea849 100644 --- a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/OneLoginTest.cs +++ b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/OneLoginTest.cs @@ -229,7 +229,7 @@ public async Task OnGet_UnknownActionParameter_ShouldReturnToIndex() private readonly AuthenticateResult authResultFail = AuthenticateResult.Fail(new Exception("Auth failed")); private readonly Person.WebApiClient.Person dummyPerson - = new("dummy@test.com", "firstdummy", new Guid("0bacf3d1-3b69-4efa-80e9-3623f4b7786e"), "lastdummy"); + = new("dummy@test.com", "firstdummy", new Guid("0bacf3d1-3b69-4efa-80e9-3623f4b7786e"), "lastdummy", new List()); private OneLogin GivenOneLoginCallbackModel() { diff --git a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/Users/ClaimOrganisationInviteModelTests.cs b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/Users/ClaimOrganisationInviteModelTests.cs index 3d025b556..7458d8046 100644 --- a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/Users/ClaimOrganisationInviteModelTests.cs +++ b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/Users/ClaimOrganisationInviteModelTests.cs @@ -18,7 +18,7 @@ public class ClaimOrganisationInviteModelTests public ClaimOrganisationInviteModelTests() { - var person = new Person.WebApiClient.Person("test@test", "F1", PersonId, "L1"); + var person = new Person.WebApiClient.Person("test@test", "F1", PersonId, "L1", new List()); personClientMock = new Mock(); personClientMock.Setup(pc => pc.LookupPersonAsync(UsreUrn)).ReturnsAsync(person); diff --git a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/YourDetailsModelTest.cs b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/YourDetailsModelTest.cs index 7c1dd1396..c38b2283f 100644 --- a/Frontend/CO.CDP.OrganisationApp.Tests/Pages/YourDetailsModelTest.cs +++ b/Frontend/CO.CDP.OrganisationApp.Tests/Pages/YourDetailsModelTest.cs @@ -289,7 +289,7 @@ public async Task OnPost_UnprocessableEntity_AddsModelError() } private readonly Person.WebApiClient.Person dummyPerson - = new("dummy@test.com", "firstdummy", Guid.NewGuid(), "lastdummy"); + = new("dummy@test.com", "firstdummy", Guid.NewGuid(), "lastdummy", new List()); private YourDetailsModel GivenYourDetailsModel() { diff --git a/Frontend/CO.CDP.OrganisationApp/Authorization/CustomAuthorizationPolicyProvider.cs b/Frontend/CO.CDP.OrganisationApp/Authorization/CustomAuthorizationPolicyProvider.cs index 56c53330f..f6cce2978 100644 --- a/Frontend/CO.CDP.OrganisationApp/Authorization/CustomAuthorizationPolicyProvider.cs +++ b/Frontend/CO.CDP.OrganisationApp/Authorization/CustomAuthorizationPolicyProvider.cs @@ -5,7 +5,8 @@ namespace CO.CDP.OrganisationApp.Authorization; public class CustomAuthorizationPolicyProvider : IAuthorizationPolicyProvider { - const string POLICY_PREFIX = "OrgScope_"; + const string ORG_POLICY_PREFIX = "OrgScope_"; + const string POLICY_PREFIX = "PersonScope_"; private readonly DefaultAuthorizationPolicyProvider _fallbackPolicyProvider; public CustomAuthorizationPolicyProvider(IOptions options) @@ -15,13 +16,13 @@ public CustomAuthorizationPolicyProvider(IOptions options) public Task GetPolicyAsync(string policyName) { - if (policyName.StartsWith(POLICY_PREFIX)) - { - var role = policyName.Substring(POLICY_PREFIX.Length); + var role = extractRoleFromPolicyName(policyName); + if (role != null) + { var policy = new AuthorizationPolicyBuilder() .RequireAuthenticatedUser() - .AddRequirements(new OrganizationScopeRequirement(role)) + .AddRequirements(new ScopeRequirement(role)) .Build(); return Task.FromResult(policy); @@ -30,6 +31,21 @@ public CustomAuthorizationPolicyProvider(IOptions options) return _fallbackPolicyProvider.GetPolicyAsync(policyName); } + private static string? extractRoleFromPolicyName(string policyName) + { + if (policyName.StartsWith(ORG_POLICY_PREFIX)) + { + return policyName.Substring(ORG_POLICY_PREFIX.Length); + } + + if (policyName.StartsWith(POLICY_PREFIX)) + { + return policyName.Substring(POLICY_PREFIX.Length); + } + + return null; + } + public Task GetDefaultPolicyAsync() => _fallbackPolicyProvider.GetDefaultPolicyAsync(); public Task GetFallbackPolicyAsync() => _fallbackPolicyProvider.GetFallbackPolicyAsync(); diff --git a/Frontend/CO.CDP.OrganisationApp/Authorization/OrganisationScopeHandler.cs b/Frontend/CO.CDP.OrganisationApp/Authorization/CustomScopeHandler.cs similarity index 66% rename from Frontend/CO.CDP.OrganisationApp/Authorization/OrganisationScopeHandler.cs rename to Frontend/CO.CDP.OrganisationApp/Authorization/CustomScopeHandler.cs index f25b9273f..972b31148 100644 --- a/Frontend/CO.CDP.OrganisationApp/Authorization/OrganisationScopeHandler.cs +++ b/Frontend/CO.CDP.OrganisationApp/Authorization/CustomScopeHandler.cs @@ -3,11 +3,11 @@ namespace CO.CDP.OrganisationApp.Authorization; -public class OrganizationScopeHandler( +public class CustomScopeHandler( ISession session, - IServiceScopeFactory serviceScopeFactory) : AuthorizationHandler + IServiceScopeFactory serviceScopeFactory) : AuthorizationHandler { - protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, OrganizationScopeRequirement requirement) + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, ScopeRequirement requirement) { Models.UserDetails? userDetails = session.Get(Session.UserDetailsKey); @@ -20,23 +20,32 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext { IUserInfoService _userInfo = serviceScope.ServiceProvider.GetRequiredService(); - var scopes = await _userInfo.GetOrganisationUserScopes(); + var userScopes = await _userInfo.GetUserScopes(); + + // SupportAdmin role can do anything within any organisation + if (userScopes.Contains(PersonScopes.SupportAdmin)) + { + context.Succeed(requirement); + return; + } + + var organisationUserScopes = await _userInfo.GetOrganisationUserScopes(); // Admin role can do anything within this organisation - if (scopes.Contains(OrganisationPersonScopes.Admin)) + if (organisationUserScopes.Contains(OrganisationPersonScopes.Admin)) { context.Succeed(requirement); return; } - if (scopes.Contains(requirement.Scope)) + if (organisationUserScopes.Contains(requirement.Scope)) { context.Succeed(requirement); return; } // Editor role implies viewer permissions - if (requirement.Scope == OrganisationPersonScopes.Viewer && scopes.Contains(OrganisationPersonScopes.Editor)) + if (requirement.Scope == OrganisationPersonScopes.Viewer && organisationUserScopes.Contains(OrganisationPersonScopes.Editor)) { context.Succeed(requirement); return; diff --git a/Frontend/CO.CDP.OrganisationApp/Authorization/OrganizationScopeRequirement.cs b/Frontend/CO.CDP.OrganisationApp/Authorization/ScopeRequirement.cs similarity index 56% rename from Frontend/CO.CDP.OrganisationApp/Authorization/OrganizationScopeRequirement.cs rename to Frontend/CO.CDP.OrganisationApp/Authorization/ScopeRequirement.cs index 20be732eb..b6bf35e6c 100644 --- a/Frontend/CO.CDP.OrganisationApp/Authorization/OrganizationScopeRequirement.cs +++ b/Frontend/CO.CDP.OrganisationApp/Authorization/ScopeRequirement.cs @@ -2,11 +2,11 @@ namespace CO.CDP.OrganisationApp.Authorization; -public class OrganizationScopeRequirement : IAuthorizationRequirement +public class ScopeRequirement : IAuthorizationRequirement { public string Scope { get; } - public OrganizationScopeRequirement(string scope) + public ScopeRequirement(string scope) { Scope = scope; } diff --git a/Frontend/CO.CDP.OrganisationApp/Constants/PersonScopes.cs b/Frontend/CO.CDP.OrganisationApp/Constants/PersonScopes.cs new file mode 100644 index 000000000..cca7eb9ca --- /dev/null +++ b/Frontend/CO.CDP.OrganisationApp/Constants/PersonScopes.cs @@ -0,0 +1,11 @@ +namespace CO.CDP.OrganisationApp.Constants; + +public class PersonScopes +{ + public const string SupportAdmin = "SUPPORTADMIN"; +} + +public class PersonScopeRequirement +{ + public const string SupportAdmin = "PersonScope_" + PersonScopes.SupportAdmin; +} \ No newline at end of file diff --git a/Frontend/CO.CDP.OrganisationApp/IUserInfoService.cs b/Frontend/CO.CDP.OrganisationApp/IUserInfoService.cs index d5b610fcd..1163ca6bd 100644 --- a/Frontend/CO.CDP.OrganisationApp/IUserInfoService.cs +++ b/Frontend/CO.CDP.OrganisationApp/IUserInfoService.cs @@ -1,7 +1,7 @@ public interface IUserInfoService { + public Task> GetUserScopes(); public Guid? GetOrganisationId(); public Task> GetOrganisationUserScopes(); - public Task UserHasScope(string scope); } \ No newline at end of file diff --git a/Frontend/CO.CDP.OrganisationApp/Pages/Support/Organisations.cshtml.cs b/Frontend/CO.CDP.OrganisationApp/Pages/Support/Organisations.cshtml.cs index 0b30f56bc..bdc9a09c5 100644 --- a/Frontend/CO.CDP.OrganisationApp/Pages/Support/Organisations.cshtml.cs +++ b/Frontend/CO.CDP.OrganisationApp/Pages/Support/Organisations.cshtml.cs @@ -1,8 +1,10 @@ using CO.CDP.Organisation.WebApiClient; +using CO.CDP.OrganisationApp.Constants; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace CO.CDP.OrganisationApp.Pages.Support; - +[Authorize(Policy = PersonScopeRequirement.SupportAdmin)] public class OrganisationsModel( IOrganisationClient organisationClient, ISession session) : LoggedInUserAwareModel(session) diff --git a/Frontend/CO.CDP.OrganisationApp/Program.cs b/Frontend/CO.CDP.OrganisationApp/Program.cs index 3d9d83272..d0a1c9f86 100644 --- a/Frontend/CO.CDP.OrganisationApp/Program.cs +++ b/Frontend/CO.CDP.OrganisationApp/Program.cs @@ -158,7 +158,7 @@ }); builder.Services.AddSingleton(); -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddAuthorization(); diff --git a/Frontend/CO.CDP.OrganisationApp/UserInfoService.cs b/Frontend/CO.CDP.OrganisationApp/UserInfoService.cs index 21383d9b2..323df8ca8 100644 --- a/Frontend/CO.CDP.OrganisationApp/UserInfoService.cs +++ b/Frontend/CO.CDP.OrganisationApp/UserInfoService.cs @@ -1,9 +1,33 @@ +using CO.CDP.Person.WebApiClient; using CO.CDP.Tenant.WebApiClient; namespace CO.CDP.OrganisationApp; -public class UserInfoService(IHttpContextAccessor httpContextAccessor, ITenantClient tenantClient) : IUserInfoService +public class UserInfoService(IHttpContextAccessor httpContextAccessor, ITenantClient tenantClient, IPersonClient personClient) : IUserInfoService { + public async Task> GetUserScopes() + { + var userUrn = GetUserUrn(); + if (userUrn == null) + { + return new List(); // Return an empty list if no userUrn is found + } + + var person = await personClient.LookupPersonAsync(userUrn); + + if (person == null || person.Scopes == null) + { + return new List(); // Return an empty list if no person or roles are found + } + + return person.Scopes; + } + + private string? GetUserUrn() + { + return httpContextAccessor.HttpContext?.User?.FindFirst("sub")?.Value; + } + public async Task> GetOrganisationUserScopes() { Guid? organisationId = GetOrganisationId(); @@ -14,7 +38,7 @@ public async Task> GetOrganisationUserScopes() if(organisation != null) { return organisation.Scopes; - } + } } return []; diff --git a/Services/CO.CDP.Person.WebApi.Tests/UseCase/GetPersonUseCaseTest.cs b/Services/CO.CDP.Person.WebApi.Tests/UseCase/GetPersonUseCaseTest.cs index a07220778..c9a3dec30 100644 --- a/Services/CO.CDP.Person.WebApi.Tests/UseCase/GetPersonUseCaseTest.cs +++ b/Services/CO.CDP.Person.WebApi.Tests/UseCase/GetPersonUseCaseTest.cs @@ -25,6 +25,7 @@ public async Task ItReturnsNullIfNoPersonIsFound() public async Task ItReturnsTheFoundPerson() { var persontId = Guid.NewGuid(); + var scopes = new List(); var tenant = new OrganisationInformation.Persistence.Person { Id = 42, @@ -32,18 +33,20 @@ public async Task ItReturnsTheFoundPerson() Email = "person@example.com", FirstName = "fn", LastName = "ln", + Scopes = scopes }; _repository.Setup(r => r.Find(persontId)).ReturnsAsync(tenant); var found = await UseCase.Execute(persontId); - found.Should().Be(new Model.Person + found.Should().BeEquivalentTo(new Model.Person { Id = persontId, FirstName = "fn", LastName = "ln", Email = "person@example.com", + Scopes = scopes }); } } \ No newline at end of file diff --git a/Services/CO.CDP.Person.WebApi.Tests/UseCase/LookupPersonUseCaseTest.cs b/Services/CO.CDP.Person.WebApi.Tests/UseCase/LookupPersonUseCaseTest.cs index 0fc323c86..9408eb3ab 100644 --- a/Services/CO.CDP.Person.WebApi.Tests/UseCase/LookupPersonUseCaseTest.cs +++ b/Services/CO.CDP.Person.WebApi.Tests/UseCase/LookupPersonUseCaseTest.cs @@ -24,6 +24,7 @@ public async Task Execute_IfNoPersonIsFound_ReturnsNull() public async Task Execute_IfPersonIsFound_ReturnsPerson() { var personId = Guid.NewGuid(); + var scopes = new List(); var persistencePerson = new OrganisationInformation.Persistence.Person { Id = 1, @@ -31,7 +32,8 @@ public async Task Execute_IfPersonIsFound_ReturnsPerson() FirstName = "fn", LastName = "ln", Email = "email@email.com", - UserUrn = "urn:fdc:gov.uk:2022:7wTqYGMFQxgukTSpSI2GodMwe9" + UserUrn = "urn:fdc:gov.uk:2022:7wTqYGMFQxgukTSpSI2GodMwe9", + Scopes = scopes }; _repository.Setup(r => r.FindByUrn(persistencePerson.UserUrn)).ReturnsAsync(persistencePerson); @@ -44,6 +46,7 @@ public async Task Execute_IfPersonIsFound_ReturnsPerson() Email = "email@email.com", FirstName = "fn", LastName = "ln", + Scopes = scopes }, options => options.ComparingByMembers()); } } \ No newline at end of file diff --git a/Services/CO.CDP.Person.WebApi.Tests/UseCase/RegisterPersonUseCaseTest.cs b/Services/CO.CDP.Person.WebApi.Tests/UseCase/RegisterPersonUseCaseTest.cs index 71f2ea9f1..038e809c3 100644 --- a/Services/CO.CDP.Person.WebApi.Tests/UseCase/RegisterPersonUseCaseTest.cs +++ b/Services/CO.CDP.Person.WebApi.Tests/UseCase/RegisterPersonUseCaseTest.cs @@ -30,7 +30,8 @@ public async Task ItReturnsTheRegisteredPerson() Id = _generatedGuid, FirstName = "ThePerson", LastName = "lastname", - Email = "jon@email.com" + Email = "jon@email.com", + Scopes = new List() }; createdPerson.Should().BeEquivalentTo(expectedPerson, options => options.ComparingByMembers()); diff --git a/Services/CO.CDP.Person.WebApi/Model/Person.cs b/Services/CO.CDP.Person.WebApi/Model/Person.cs index 097113569..243b47885 100644 --- a/Services/CO.CDP.Person.WebApi/Model/Person.cs +++ b/Services/CO.CDP.Person.WebApi/Model/Person.cs @@ -7,4 +7,5 @@ public record Person [Required(AllowEmptyStrings = true)] public required string FirstName { get; init; } [Required(AllowEmptyStrings = true)] public required string LastName { get; init; } [EmailAddress] public required string Email { get; init; } + public List? Scopes { get; init; } } \ No newline at end of file