553 lines
20 KiB
C#
553 lines
20 KiB
C#
// -----------------------------------------------------------------------------
|
|
// StellaOps.Notify.WebService.Tests / W1 / NotifyWebServiceAuthTests.cs
|
|
// W1 auth tests for Notify.WebService (deny-by-default, token expiry, tenant isolation).
|
|
// Task: NOTIFY-5100-013
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Security.Claims;
|
|
using System.Text;
|
|
using System.Text.Json.Nodes;
|
|
using System.Threading.Tasks;
|
|
using FluentAssertions;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Notify.WebService.Tests.W1;
|
|
|
|
/// <summary>
|
|
/// W1 authentication and authorization tests for Notify WebService.
|
|
/// Tests verify deny-by-default behavior, token validation, scope enforcement,
|
|
/// and tenant isolation.
|
|
/// </summary>
|
|
public class NotifyWebServiceAuthTests : IClassFixture<WebApplicationFactory<Program>>, IAsyncLifetime
|
|
{
|
|
private const string SigningKey = "super-secret-test-key-for-auth-tests-1234567890";
|
|
private const string Issuer = "test-issuer";
|
|
private const string Audience = "notify";
|
|
private const string TestTenantId = "tenant-auth-test";
|
|
private const string OtherTenantId = "tenant-other";
|
|
|
|
private readonly WebApplicationFactory<Program> _factory;
|
|
|
|
public NotifyWebServiceAuthTests(WebApplicationFactory<Program> factory)
|
|
{
|
|
_factory = factory.WithWebHostBuilder(builder =>
|
|
{
|
|
builder.UseSetting("notify:storage:driver", "memory");
|
|
builder.UseSetting("notify:authority:enabled", "false");
|
|
builder.UseSetting("notify:authority:developmentSigningKey", SigningKey);
|
|
builder.UseSetting("notify:authority:issuer", Issuer);
|
|
builder.UseSetting("notify:authority:audiences:0", Audience);
|
|
builder.UseSetting("notify:authority:allowAnonymousFallback", "false"); // Deny by default
|
|
builder.UseSetting("notify:authority:adminScope", "notify.admin");
|
|
builder.UseSetting("notify:authority:operatorScope", "notify.operator");
|
|
builder.UseSetting("notify:authority:viewerScope", "notify.viewer");
|
|
builder.UseSetting("notify:telemetry:enableRequestLogging", "false");
|
|
});
|
|
}
|
|
|
|
public Task InitializeAsync() => Task.CompletedTask;
|
|
public Task DisposeAsync() => Task.CompletedTask;
|
|
|
|
#region Deny-by-Default
|
|
|
|
[Fact]
|
|
public async Task NoToken_ApiEndpoint_Returns401()
|
|
{
|
|
// Arrange
|
|
var client = _factory.CreateClient();
|
|
// No Authorization header
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoToken_HealthEndpoint_Returns200()
|
|
{
|
|
// Arrange - health endpoints should be public
|
|
var client = _factory.CreateClient();
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/healthz");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task InvalidToken_Returns401()
|
|
{
|
|
// Arrange
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "invalid-token-here");
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MalformedAuthHeader_Returns401()
|
|
{
|
|
// Arrange
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", "NotBearer some-token");
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Token Expiry
|
|
|
|
[Fact]
|
|
public async Task ExpiredToken_Returns401()
|
|
{
|
|
// Arrange
|
|
var expiredToken = CreateToken(
|
|
tenantId: TestTenantId,
|
|
scopes: new[] { "notify.viewer" },
|
|
expiresAt: DateTime.UtcNow.AddHours(-1)); // Already expired
|
|
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", expiredToken);
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotYetValidToken_Returns401()
|
|
{
|
|
// Arrange
|
|
var notYetValidToken = CreateToken(
|
|
tenantId: TestTenantId,
|
|
scopes: new[] { "notify.viewer" },
|
|
notBefore: DateTime.UtcNow.AddHours(1)); // Not valid yet
|
|
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", notYetValidToken);
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidToken_Returns200()
|
|
{
|
|
// Arrange
|
|
var validToken = CreateToken(
|
|
tenantId: TestTenantId,
|
|
scopes: new[] { "notify.viewer" },
|
|
expiresAt: DateTime.UtcNow.AddHours(1));
|
|
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", validToken);
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Scope Enforcement
|
|
|
|
[Fact]
|
|
public async Task ViewerScope_CanRead_Rules()
|
|
{
|
|
// Arrange
|
|
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ViewerScope_CannotCreate_Rules()
|
|
{
|
|
// Arrange
|
|
var viewerToken = CreateToken(TestTenantId, new[] { "notify.viewer" });
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", viewerToken);
|
|
|
|
var payload = CreateRulePayload($"rule-viewer-{Guid.NewGuid():N}");
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
"/api/v1/notify/rules",
|
|
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OperatorScope_CanCreate_Rules()
|
|
{
|
|
// Arrange
|
|
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
|
|
|
|
var payload = CreateRulePayload($"rule-operator-{Guid.NewGuid():N}");
|
|
|
|
// Act
|
|
var response = await client.PostAsync(
|
|
"/api/v1/notify/rules",
|
|
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OperatorScope_CanDelete_Rules()
|
|
{
|
|
// Arrange
|
|
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
|
|
|
|
// First create a rule
|
|
var ruleId = $"rule-delete-{Guid.NewGuid():N}";
|
|
var payload = CreateRulePayload(ruleId);
|
|
await client.PostAsync(
|
|
"/api/v1/notify/rules",
|
|
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Act
|
|
var response = await client.DeleteAsync($"/api/v1/notify/rules/{ruleId}");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoRequiredScope_Returns403()
|
|
{
|
|
// Arrange - token with unrelated scope
|
|
var wrongScopeToken = CreateToken(TestTenantId, new[] { "some.other.scope" });
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongScopeToken);
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdminScope_CanAccessInternalEndpoints()
|
|
{
|
|
// Arrange
|
|
var adminToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator", "notify.admin" });
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", adminToken);
|
|
|
|
var payload = CreateRulePayload("rule-internal-test");
|
|
|
|
// Act - internal normalize endpoint
|
|
var response = await client.PostAsync(
|
|
"/api/v1/notify/_internal/rules/normalize",
|
|
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Tenant Isolation
|
|
|
|
[Fact]
|
|
public async Task CreateRule_UsesTokenTenantId()
|
|
{
|
|
// Arrange
|
|
var operatorToken = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", operatorToken);
|
|
|
|
var ruleId = $"rule-tenant-{Guid.NewGuid():N}";
|
|
var payload = CreateRulePayload(ruleId);
|
|
// Payload has a tenantId, but should be overridden by token
|
|
|
|
// Act
|
|
await client.PostAsync(
|
|
"/api/v1/notify/rules",
|
|
new StringContent(payload.ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Get the rule back
|
|
var response = await client.GetAsync($"/api/v1/notify/rules/{ruleId}");
|
|
var content = await response.Content.ReadAsStringAsync();
|
|
var json = JsonNode.Parse(content);
|
|
|
|
// Assert - tenantId should match token, not payload
|
|
json?["tenantId"]?.GetValue<string>().Should().Be(TestTenantId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ListRules_OnlyReturnsSameTenant()
|
|
{
|
|
// Arrange - create rules with two different tenants
|
|
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
|
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer", "notify.operator" });
|
|
|
|
var client1 = _factory.CreateClient();
|
|
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
|
|
|
|
var client2 = _factory.CreateClient();
|
|
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
|
|
|
|
// Create rule for tenant 1
|
|
var rule1Id = $"rule-t1-{Guid.NewGuid():N}";
|
|
await client1.PostAsync(
|
|
"/api/v1/notify/rules",
|
|
new StringContent(CreateRulePayload(rule1Id).ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Create rule for tenant 2
|
|
var rule2Id = $"rule-t2-{Guid.NewGuid():N}";
|
|
await client2.PostAsync(
|
|
"/api/v1/notify/rules",
|
|
new StringContent(CreateRulePayload(rule2Id).ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Act - tenant 1 lists rules
|
|
var response1 = await client1.GetAsync("/api/v1/notify/rules");
|
|
var content1 = await response1.Content.ReadAsStringAsync();
|
|
var rules1 = JsonNode.Parse(content1)?.AsArray();
|
|
|
|
// Act - tenant 2 lists rules
|
|
var response2 = await client2.GetAsync("/api/v1/notify/rules");
|
|
var content2 = await response2.Content.ReadAsStringAsync();
|
|
var rules2 = JsonNode.Parse(content2)?.AsArray();
|
|
|
|
// Assert - each tenant only sees their own rules
|
|
rules1?.Any(r => r?["ruleId"]?.GetValue<string>() == rule1Id).Should().BeTrue();
|
|
rules1?.Any(r => r?["ruleId"]?.GetValue<string>() == rule2Id).Should().BeFalse();
|
|
|
|
rules2?.Any(r => r?["ruleId"]?.GetValue<string>() == rule2Id).Should().BeTrue();
|
|
rules2?.Any(r => r?["ruleId"]?.GetValue<string>() == rule1Id).Should().BeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetRule_DifferentTenant_Returns404()
|
|
{
|
|
// Arrange - create rule with tenant 1
|
|
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
|
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer" });
|
|
|
|
var client1 = _factory.CreateClient();
|
|
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
|
|
|
|
var ruleId = $"rule-cross-tenant-{Guid.NewGuid():N}";
|
|
await client1.PostAsync(
|
|
"/api/v1/notify/rules",
|
|
new StringContent(CreateRulePayload(ruleId).ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Act - tenant 2 tries to get tenant 1's rule
|
|
var client2 = _factory.CreateClient();
|
|
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
|
|
var response = await client2.GetAsync($"/api/v1/notify/rules/{ruleId}");
|
|
|
|
// Assert - should be 404, not 403 (don't leak existence)
|
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeleteRule_DifferentTenant_Returns404()
|
|
{
|
|
// Arrange - create rule with tenant 1
|
|
var tenant1Token = CreateToken(TestTenantId, new[] { "notify.viewer", "notify.operator" });
|
|
var tenant2Token = CreateToken(OtherTenantId, new[] { "notify.viewer", "notify.operator" });
|
|
|
|
var client1 = _factory.CreateClient();
|
|
client1.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Token);
|
|
|
|
var ruleId = $"rule-cross-delete-{Guid.NewGuid():N}";
|
|
await client1.PostAsync(
|
|
"/api/v1/notify/rules",
|
|
new StringContent(CreateRulePayload(ruleId).ToJsonString(), Encoding.UTF8, "application/json"));
|
|
|
|
// Act - tenant 2 tries to delete tenant 1's rule
|
|
var client2 = _factory.CreateClient();
|
|
client2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Token);
|
|
var response = await client2.DeleteAsync($"/api/v1/notify/rules/{ruleId}");
|
|
|
|
// Assert - should be 404, not 204 (don't leak existence)
|
|
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
|
|
|
// Verify rule still exists for tenant 1
|
|
var verifyResponse = await client1.GetAsync($"/api/v1/notify/rules/{ruleId}");
|
|
verifyResponse.StatusCode.Should().Be(HttpStatusCode.OK);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Wrong Issuer/Audience
|
|
|
|
[Fact]
|
|
public async Task WrongIssuer_Returns401()
|
|
{
|
|
// Arrange
|
|
var wrongIssuerToken = CreateTokenWithConfig(
|
|
tenantId: TestTenantId,
|
|
scopes: new[] { "notify.viewer" },
|
|
issuer: "wrong-issuer",
|
|
audience: Audience,
|
|
signingKey: SigningKey);
|
|
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongIssuerToken);
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WrongAudience_Returns401()
|
|
{
|
|
// Arrange
|
|
var wrongAudienceToken = CreateTokenWithConfig(
|
|
tenantId: TestTenantId,
|
|
scopes: new[] { "notify.viewer" },
|
|
issuer: Issuer,
|
|
audience: "wrong-audience",
|
|
signingKey: SigningKey);
|
|
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongAudienceToken);
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task WrongSigningKey_Returns401()
|
|
{
|
|
// Arrange
|
|
var wrongKeyToken = CreateTokenWithConfig(
|
|
tenantId: TestTenantId,
|
|
scopes: new[] { "notify.viewer" },
|
|
issuer: Issuer,
|
|
audience: Audience,
|
|
signingKey: "different-signing-key-that-is-long-enough-for-hmac");
|
|
|
|
var client = _factory.CreateClient();
|
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", wrongKeyToken);
|
|
|
|
// Act
|
|
var response = await client.GetAsync("/api/v1/notify/rules");
|
|
|
|
// Assert
|
|
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Methods
|
|
|
|
private static string CreateToken(
|
|
string tenantId,
|
|
string[] scopes,
|
|
DateTime? expiresAt = null,
|
|
DateTime? notBefore = null)
|
|
{
|
|
return CreateTokenWithConfig(tenantId, scopes, Issuer, Audience, SigningKey, expiresAt, notBefore);
|
|
}
|
|
|
|
private static string CreateTokenWithConfig(
|
|
string tenantId,
|
|
string[] scopes,
|
|
string issuer,
|
|
string audience,
|
|
string signingKey,
|
|
DateTime? expiresAt = null,
|
|
DateTime? notBefore = null)
|
|
{
|
|
var handler = new JwtSecurityTokenHandler();
|
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
|
|
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtRegisteredClaimNames.Sub, "test-user"),
|
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
|
new("tenant_id", tenantId)
|
|
};
|
|
claims.AddRange(scopes.Select(s => new Claim("scope", s)));
|
|
|
|
var token = new JwtSecurityToken(
|
|
issuer: issuer,
|
|
audience: audience,
|
|
claims: claims,
|
|
notBefore: notBefore,
|
|
expires: expiresAt ?? DateTime.UtcNow.AddHours(1),
|
|
signingCredentials: credentials);
|
|
|
|
return handler.WriteToken(token);
|
|
}
|
|
|
|
private static JsonObject CreateRulePayload(string ruleId)
|
|
{
|
|
return new JsonObject
|
|
{
|
|
["schemaVersion"] = "notify-rule@1",
|
|
["ruleId"] = ruleId,
|
|
["tenantId"] = TestTenantId,
|
|
["name"] = $"Test Rule {ruleId}",
|
|
["description"] = "Auth test rule",
|
|
["enabled"] = true,
|
|
["eventKinds"] = new JsonArray { "scan.completed" },
|
|
["actions"] = new JsonArray
|
|
{
|
|
new JsonObject
|
|
{
|
|
["actionId"] = $"action-{Guid.NewGuid():N}",
|
|
["channel"] = "email:test",
|
|
["templateKey"] = "default"
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|