Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect;

using System.Net.Http;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.OpenIdConnect;
using Infrastructure.Auth;
using Infrastructure.WebApi;
using NServiceBus.AcceptanceTesting;
using NUnit.Framework;

/// <summary>
/// my/permissions and my/permissions/all
/// These endpoints let a client (e.g. ServicePulse) discover what the current user is allowed to
/// do: the full granular permission list, and a simplified per-area summary used to gate UI
/// sections. Both are governed by the caller's role claims via <see cref="RolePermissions"/>.
/// </summary>
class When_my_permissions_are_requested : AcceptanceTest
{
OpenIdConnectTestConfiguration configuration;
MockOidcServer mockOidcServer;

const string TestAudience = "api://test-audience";

[SetUp]
public void ConfigureAuth()
{
mockOidcServer = new MockOidcServer(audience: TestAudience);
mockOidcServer.Start();

configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
.WithRoleBasedAuthorizationEnabled()
.WithAuthority(mockOidcServer.Authority)
.WithAudience(TestAudience)
.WithRequireHttpsMetadata(false);
}

[TearDown]
public void CleanupAuth()
{
configuration?.Dispose();
mockOidcServer?.Dispose();
}

[Test]
public async Task Should_reject_requests_without_bearer_token()
{
HttpResponseMessage response = null;

_ = await Define<Context>()
.Done(async ctx =>
{
response = await OpenIdConnectAssertions.SendRequestWithoutAuth(
HttpClient,
HttpMethod.Get,
"/api/my/permissions/all");
return response != null;
})
.Run();

OpenIdConnectAssertions.AssertUnauthorized(response);
}

[Test]
public async Task Should_return_only_the_permissions_granted_to_the_reader_role()
{
var descriptor = await GetPermissions(RolePermissions.Reader);

Assert.That(descriptor.Permissions, Is.EquivalentTo(RolePermissions.GetPermissions(RolePermissions.Reader)));
}

[Test]
public async Task Should_return_every_known_permission_for_the_writer_role()
{
var descriptor = await GetPermissions(RolePermissions.Writer);

Assert.That(descriptor.Permissions, Is.EquivalentTo(Permissions.All));
}

[Test]
public async Task Should_summarize_the_reader_role_as_read_only()
{
var summary = await GetSummary(RolePermissions.Reader);

using (Assert.EnterMultipleScope())
{
Assert.That(summary.FailedMessagesRead, Is.True);
Assert.That(summary.FailedMessagesWrite, Is.False);
Assert.That(summary.AuditingRead, Is.True);
Assert.That(summary.MonitoringRead, Is.True);
Assert.That(summary.MonitoringWrite, Is.False);
Assert.That(summary.AdminRead, Is.True);
Assert.That(summary.AdminWrite, Is.False);
}
}

[Test]
public async Task Should_summarize_the_writer_role_as_full_access()
{
var summary = await GetSummary(RolePermissions.Writer);

using (Assert.EnterMultipleScope())
{
Assert.That(summary.FailedMessagesRead, Is.True);
Assert.That(summary.FailedMessagesWrite, Is.True);
Assert.That(summary.AuditingRead, Is.True);
Assert.That(summary.MonitoringRead, Is.True);
Assert.That(summary.MonitoringWrite, Is.True);
Assert.That(summary.AdminRead, Is.True);
Assert.That(summary.AdminWrite, Is.True);
}
}

[Test]
public async Task Should_summarize_a_role_with_no_grants_as_all_false()
{
// A role that exists on the token but isn't recognised by RolePermissions grants nothing.
var summary = await GetSummary("not-a-real-role");

using (Assert.EnterMultipleScope())
{
Assert.That(summary.FailedMessagesRead, Is.False);
Assert.That(summary.FailedMessagesWrite, Is.False);
Assert.That(summary.AuditingRead, Is.False);
Assert.That(summary.MonitoringRead, Is.False);
Assert.That(summary.MonitoringWrite, Is.False);
Assert.That(summary.AdminRead, Is.False);
Assert.That(summary.AdminWrite, Is.False);
}
}

async Task<MeController.PermissionsDescriptor> GetPermissions(string role) =>
await Get<MeController.PermissionsDescriptor>("/api/my/permissions/all", role);

async Task<MeController.PermissionsSummary> GetSummary(string role) =>
await Get<MeController.PermissionsSummary>("/api/my/permissions", role);

async Task<T> Get<T>(string path, string role)
{
HttpResponseMessage response = null;

_ = await Define<Context>()
.Done(async ctx =>
{
var token = mockOidcServer.GenerateToken(additionalClaims: [new Claim("roles", role)]);
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
HttpClient,
HttpMethod.Get,
path,
token);
return response != null;
})
.Run();

OpenIdConnectAssertions.AssertAuthenticated(response);

var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content, SerializerOptions);
}

class Context : ScenarioContext;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
namespace ServiceControl.AcceptanceTests.Security.OpenIdConnect;

using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.OpenIdConnect;
using Infrastructure.Auth;
using Infrastructure.WebApi;
using NServiceBus.AcceptanceTesting;
using NUnit.Framework;

/// <summary>
/// When the caller is authenticated but Authentication.RoleBasedAuthorizationEnabled is false (the
/// default), every permission is implicitly granted (mirrors PermissionPolicyProvider's allow-all
/// policy). my/permissions and my/permissions/all should reflect that even for a token that carries
/// no "roles" claim at all.
/// </summary>
class When_role_based_authorization_is_disabled : AcceptanceTest
{
OpenIdConnectTestConfiguration configuration;
MockOidcServer mockOidcServer;

const string TestAudience = "api://test-audience";

[SetUp]
public void ConfigureAuth()
{
mockOidcServer = new MockOidcServer(audience: TestAudience);
mockOidcServer.Start();

configuration = new OpenIdConnectTestConfiguration(ServiceControlInstanceType.Primary)
.WithConfigurationValidationDisabled()
.WithAuthenticationEnabled()
// Role-based authorization deliberately left disabled (the default).
.WithAuthority(mockOidcServer.Authority)
.WithAudience(TestAudience)
.WithRequireHttpsMetadata(false);
}

[TearDown]
public void CleanupAuth()
{
configuration?.Dispose();
mockOidcServer?.Dispose();
}

[Test]
public async Task Should_grant_every_known_permission_regardless_of_roles()
{
var descriptor = await Get<MeController.PermissionsDescriptor>("/api/my/permissions/all");

Assert.That(descriptor.Permissions, Is.EquivalentTo(Permissions.All));
}

[Test]
public async Task Should_summarize_as_full_access_regardless_of_roles()
{
var summary = await Get<MeController.PermissionsSummary>("/api/my/permissions");

using (Assert.EnterMultipleScope())
{
Assert.That(summary.FailedMessagesRead, Is.True);
Assert.That(summary.FailedMessagesWrite, Is.True);
Assert.That(summary.AuditingRead, Is.True);
Assert.That(summary.MonitoringRead, Is.True);
Assert.That(summary.MonitoringWrite, Is.True);
Assert.That(summary.AdminRead, Is.True);
Assert.That(summary.AdminWrite, Is.True);
}
}

async Task<T> Get<T>(string path)
{
HttpResponseMessage response = null;

_ = await Define<Context>()
.Done(async ctx =>
{
// No "roles" claim at all - allow-all must not depend on the token carrying any role.
var token = mockOidcServer.GenerateToken();
response = await OpenIdConnectAssertions.SendRequestWithBearerToken(
HttpClient,
HttpMethod.Get,
path,
token);
return response != null;
})
.Run();

OpenIdConnectAssertions.AssertAuthenticated(response);

var content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content, SerializerOptions);
}

class Context : ScenarioContext;
}
45 changes: 23 additions & 22 deletions src/ServiceControl.Api/Contracts/RootUrls.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
namespace ServiceControl.Api.Contracts
namespace ServiceControl.Api.Contracts;

public class RootUrls
{
public class RootUrls
{
public string Description { get; set; }
public string EndpointsErrorUrl { get; set; }
public string KnownEndpointsUrl { get; set; }
public string EndpointsMessageSearchUrl { get; set; }
public string EndpointsMessagesUrl { get; set; }
public string AuditCountUrl { get; set; }
public string EndpointsUrl { get; set; }
public string ErrorsUrl { get; set; }
public string Configuration { get; set; }
public string RemoteConfiguration { get; set; }
public string MessageSearchUrl { get; set; }
public string LicenseStatus { get; set; }
public string LicenseDetails { get; set; }
public string Name { get; set; }
public string SagasUrl { get; set; }
public string EventLogItems { get; set; }
public string ArchivedGroupsUrl { get; set; }
public string GetArchiveGroup { get; set; }
}
public string Description { get; set; }
public string EndpointsErrorUrl { get; set; }
public string KnownEndpointsUrl { get; set; }
public string EndpointsMessageSearchUrl { get; set; }
public string EndpointsMessagesUrl { get; set; }
public string AuditCountUrl { get; set; }
public string EndpointsUrl { get; set; }
public string ErrorsUrl { get; set; }
public string Configuration { get; set; }
public string RemoteConfiguration { get; set; }
public string MessageSearchUrl { get; set; }
public string LicenseStatus { get; set; }
public string LicenseDetails { get; set; }
public string Name { get; set; }
public string SagasUrl { get; set; }
public string EventLogItems { get; set; }
public string ArchivedGroupsUrl { get; set; }
public string GetArchiveGroup { get; set; }
public string MypermissionsAll { get; set; }
public string MypermissionsSummary { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ GET /messages/{id}/body => ServiceControl.CompositeViews.Messages.GetMessagesCon
GET /messages/search => ServiceControl.CompositeViews.Messages.GetMessagesController:Search(PagingInfo pagingInfo, SortInfo sortInfo, String q)
GET /messages/search/{keyword} => ServiceControl.CompositeViews.Messages.GetMessagesController:SearchByKeyWord(PagingInfo pagingInfo, SortInfo sortInfo, String keyword)
GET /messages2 => ServiceControl.CompositeViews.Messages.GetMessages2Controller:Messages(SortInfo sortInfo, Int32 pageSize, String endpointName, String from, String to, String q)
GET /my/permissions => ServiceControl.Infrastructure.WebApi.MeController:GetSummaryPermissions()
GET /my/permissions/all => ServiceControl.Infrastructure.WebApi.MeController:GetMyPermissions()
GET /notifications/email => ServiceControl.Notifications.Api.NotificationsController:GetEmailNotificationsSettings()
POST /notifications/email => ServiceControl.Notifications.Api.NotificationsController:UpdateSettings(UpdateEmailNotificationsSettingsRequest request)
POST /notifications/email/test => ServiceControl.Notifications.Api.NotificationsController:SendTestEmail()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@
"SagasUrl": "http://localhost/sagas",
"EventLogItems": "http://localhost/eventlogitems",
"ArchivedGroupsUrl": "http://localhost/errors/groups/{classifier?}",
"GetArchiveGroup": "http://localhost/archive/groups/id/{groupId}"
"GetArchiveGroup": "http://localhost/archive/groups/id/{groupId}",
"MypermissionsAll": "http://localhost/my/permissions/all",
"MypermissionsSummary": "http://localhost/my/permissions"
}
2 changes: 2 additions & 0 deletions src/ServiceControl/Infrastructure/Api/ConfigurationApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public Task<RootUrls> GetUrls(string baseUrl, CancellationToken cancellationToke
EventLogItems = baseUrl + "eventlogitems",
ArchivedGroupsUrl = baseUrl + "errors/groups/{classifier?}",
GetArchiveGroup = baseUrl + "archive/groups/id/{groupId}",
MypermissionsAll = baseUrl + "my/permissions/all",
MypermissionsSummary = baseUrl + "my/permissions"
};

return Task.FromResult(model);
Expand Down
Loading