feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Extensions;
|
||||
using Nexus.Api.Middleware;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class SecurityBoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Authorization_UsesAuthenticatedFallbackPolicy()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:Key"] = new string('k', 48),
|
||||
["Jwt:Issuer"] = "nexus-test",
|
||||
["Jwt:Audience"] = "nexus-test"
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddNexusAuth(configuration);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<AuthorizationOptions>>().Value;
|
||||
Assert.NotNull(options.FallbackPolicy);
|
||||
Assert.Contains(
|
||||
options.FallbackPolicy!.Requirements,
|
||||
requirement => requirement is DenyAnonymousAuthorizationRequirement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DomainControllers_DoNotOptOutOfAuthentication()
|
||||
{
|
||||
Type[] domainControllers =
|
||||
[
|
||||
typeof(ActivityController),
|
||||
typeof(AgentsController),
|
||||
typeof(CalendarController),
|
||||
typeof(DocsController),
|
||||
typeof(GatewayBridgeController),
|
||||
typeof(IncidentsController),
|
||||
typeof(MemoryController),
|
||||
typeof(RoutingController),
|
||||
typeof(TeamController)
|
||||
];
|
||||
|
||||
foreach (var controller in domainControllers)
|
||||
{
|
||||
Assert.Empty(controller.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
||||
Assert.DoesNotContain(
|
||||
controller.GetMethods(),
|
||||
method => method.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true).Length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskAutomationEndpoints_DoNotOptOutOfAuthentication()
|
||||
{
|
||||
var board = typeof(TasksController).GetMethod(nameof(TasksController.GetBoard));
|
||||
var reset = typeof(TasksController).GetMethod(nameof(TasksController.ResetStale));
|
||||
|
||||
Assert.NotNull(board);
|
||||
Assert.NotNull(reset);
|
||||
Assert.Empty(board!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
||||
Assert.Empty(reset!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentCommand_RequiresOwnerRole()
|
||||
{
|
||||
var command = typeof(AgentsController).GetMethod(nameof(AgentsController.SendCommand));
|
||||
Assert.NotNull(command);
|
||||
|
||||
var authorize = Assert.Single(
|
||||
command!.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
||||
.OfType<AuthorizeAttribute>());
|
||||
Assert.Equal("owner", authorize.Roles);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.GetCsrfToken))]
|
||||
[InlineData(nameof(AuthController.Login))]
|
||||
[InlineData(nameof(AuthController.Refresh))]
|
||||
[InlineData(nameof(AuthController.Logout))]
|
||||
[InlineData(nameof(AuthController.AdminResetPassword))]
|
||||
public void PublicAuthBootstrapEndpoints_AreExplicitlyAnonymous(string methodName)
|
||||
{
|
||||
var method = typeof(AuthController).GetMethod(methodName);
|
||||
Assert.NotNull(method);
|
||||
Assert.NotEmpty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(HealthController.Live))]
|
||||
[InlineData(nameof(HealthController.Get))]
|
||||
public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName)
|
||||
{
|
||||
var method = typeof(HealthController).GetMethod(methodName);
|
||||
Assert.NotNull(method);
|
||||
Assert.NotEmpty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(AuthController.GetMe))]
|
||||
[InlineData(nameof(AuthController.UpdateProfile))]
|
||||
[InlineData(nameof(AuthController.ChangePassword))]
|
||||
public void AccountEndpoints_InheritAuthenticatedFallback(string methodName)
|
||||
{
|
||||
var method = typeof(AuthController).GetMethod(methodName);
|
||||
Assert.NotNull(method);
|
||||
Assert.Empty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var context = TaskWorkflowFixture.CreateHttpContext(agentId: "iris");
|
||||
|
||||
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
|
||||
context,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(resolution.AgentId);
|
||||
Assert.True(resolution.HeaderProvided);
|
||||
Assert.True(resolution.IsRecognized);
|
||||
Assert.False(resolution.CredentialVerified);
|
||||
Assert.False(resolution.IdentityHintAuthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentIdentityHeader_IsAcceptedAsHintAfterVerifiedServiceKey()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var context = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Agent-Id"] = "iris",
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
});
|
||||
|
||||
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
|
||||
context,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("iris", resolution.AgentId);
|
||||
Assert.True(resolution.CredentialVerified);
|
||||
Assert.True(resolution.IdentityHintAuthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentIdentityHeader_CannotEscalateAnOrdinaryJwtUser()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var context = TaskWorkflowFixture.CreateHttpContext(
|
||||
agentId: "iris",
|
||||
user: TaskWorkflowFixture.CreateUser("ordinary-user", "user"));
|
||||
|
||||
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
|
||||
context,
|
||||
fixture.AgentService,
|
||||
fixture.Configuration,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Null(resolution.AgentId);
|
||||
Assert.True(resolution.CredentialVerified);
|
||||
Assert.False(resolution.IdentityHintAuthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiKeyMiddleware_AuthenticatesMcpRequests()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["NexusApiKey"] = "service-secret"
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var context = new DefaultHttpContext { RequestServices = provider };
|
||||
context.Request.Path = "/mcp";
|
||||
context.Request.Headers["X-Nexus-Api-Key"] = "service-secret";
|
||||
var nextWasCalled = false;
|
||||
var middleware = new ApiKeyMiddleware(nextContext =>
|
||||
{
|
||||
nextWasCalled = true;
|
||||
Assert.True(nextContext.User.Identity?.IsAuthenticated);
|
||||
Assert.True(nextContext.User.IsInRole("Service"));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
|
||||
Assert.True(nextWasCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApiKeyMiddleware_DoesNotAuthenticateMcpFromAgentHeaderAlone()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["NexusApiKey"] = "service-secret"
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var context = new DefaultHttpContext { RequestServices = provider };
|
||||
context.Request.Path = "/mcp";
|
||||
context.Request.Headers["X-Agent-Id"] = "iris";
|
||||
var middleware = new ApiKeyMiddleware(nextContext =>
|
||||
{
|
||||
Assert.False(nextContext.User.Identity?.IsAuthenticated);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
await middleware.InvokeAsync(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForwardedHeaders_TrustOnlyDefaultsAndConfiguredProxyRanges()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ForwardedHeaders:ForwardLimit"] = "2",
|
||||
["ForwardedHeaders:KnownProxies:0"] = "10.10.0.12",
|
||||
["ForwardedHeaders:KnownNetworks:0"] = "10.20.0.0/24"
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddNexusForwardedHeaders(configuration);
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<ForwardedHeadersOptions>>().Value;
|
||||
|
||||
Assert.Equal(2, options.ForwardLimit);
|
||||
Assert.Contains(IPAddress.Parse("10.10.0.12"), options.KnownProxies);
|
||||
Assert.Contains(options.KnownIPNetworks, network => network.ToString() == "10.20.0.0/24");
|
||||
Assert.DoesNotContain(options.KnownIPNetworks, network => network.ToString() == "0.0.0.0/0");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user