Bundle G (#23 M7-T15): replace the temporary Admin-only gate on the Audit Log surface with two new permission policies — OperationalAudit (read) and AuditExport (bulk-export) — so the read path and the forensic-export path can be delegated independently. ScadaLink.Security - AuthorizationPolicies: add OperationalAudit + AuditExport policy constants; register them via RequireClaim with an explicit role allow-list (OperationalAuditRoles, AuditExportRoles) so the role-to-permission mapping is documented in one place. - Default mapping: Admin and Audit roles grant both policies; AuditReadOnly grants OperationalAudit only (read access without bulk export); Design and Deployment grant neither. ScadaLink.CentralUI - AuditLogPage: switch the page-level [Authorize] to the OperationalAudit policy and wrap the Export-CSV button in an AuthorizeView gated on AuditExport so an OperationalAudit-only operator still sees the page + filters but cannot trigger the CSV pull. - ConfigurationAuditLog: switch from RequireAdmin to OperationalAudit so both pages under the Audit nav group share the same gate. - NavMenu: the Audit nav group now gates on OperationalAudit so the section header + both child links match the per-page policies. - AuditExportEndpoints: switch RequireAuthorization from RequireAdmin to AuditExport — this is the authoritative gate; the AuthorizeView on the button is just a UX affordance. Tests - New AuditLogPagePermissionTests covers the 5 brief-mandated cases plus defence-in-depth for Admin-alone and AuditReadOnly users on the endpoint. - SecurityTests: add policy-level coverage for the new role→permission matrix (Theory rows pin every role/policy combination). - AuditExportEndpointsTests: switch to AddScadaLinkAuthorization() so the test host exercises the real production wiring under the new gate. - AuditLogPageScaffoldTests: wrap the page render in a CascadingAuthenticationState so the new in-page AuthorizeView resolves the principal.
1238 lines
47 KiB
C#
1238 lines
47 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ScadaLink.Commons.Entities.Security;
|
|
using ScadaLink.Commons.Entities.Sites;
|
|
using ScadaLink.ConfigurationDatabase;
|
|
using ScadaLink.ConfigurationDatabase.Repositories;
|
|
using ScadaLink.Security;
|
|
|
|
namespace ScadaLink.Security.Tests;
|
|
|
|
#region WP-6: LdapAuthService Tests
|
|
|
|
public class LdapAuthServiceTests
|
|
{
|
|
private static SecurityOptions CreateOptions(bool useTls = true, bool allowInsecure = false) => new()
|
|
{
|
|
LdapServer = "ldap.example.com",
|
|
LdapPort = 636,
|
|
LdapUseTls = useTls,
|
|
AllowInsecureLdap = allowInsecure,
|
|
LdapSearchBase = "dc=example,dc=com",
|
|
JwtSigningKey = "test-key-that-is-long-enough-for-hmac-sha256-minimum"
|
|
};
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_EmptyUsername_ReturnsFailed()
|
|
{
|
|
var service = new LdapAuthService(
|
|
Options.Create(CreateOptions()),
|
|
NullLogger<LdapAuthService>.Instance);
|
|
|
|
var result = await service.AuthenticateAsync("", "password");
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Username is required", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_EmptyPassword_ReturnsFailed()
|
|
{
|
|
var service = new LdapAuthService(
|
|
Options.Create(CreateOptions()),
|
|
NullLogger<LdapAuthService>.Instance);
|
|
|
|
var result = await service.AuthenticateAsync("user", "");
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Password is required", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_InsecureLdapNotAllowed_ReturnsFailed()
|
|
{
|
|
var service = new LdapAuthService(
|
|
Options.Create(CreateOptions(useTls: false, allowInsecure: false)),
|
|
NullLogger<LdapAuthService>.Instance);
|
|
|
|
var result = await service.AuthenticateAsync("user", "password");
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Insecure LDAP", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_ConnectionFailure_ReturnsFailed()
|
|
{
|
|
// Point to a non-existent server — connection should fail
|
|
var options = CreateOptions();
|
|
options.LdapServer = "nonexistent.invalid";
|
|
options.LdapPort = 9999;
|
|
|
|
var service = new LdapAuthService(
|
|
Options.Create(options),
|
|
NullLogger<LdapAuthService>.Instance);
|
|
|
|
var result = await service.AuthenticateAsync("user", "password");
|
|
Assert.False(result.Success);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region WP-7: JwtTokenService Tests
|
|
|
|
public class JwtTokenServiceTests
|
|
{
|
|
private static SecurityOptions CreateOptions() => new()
|
|
{
|
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
private static JwtTokenService CreateService(SecurityOptions? options = null)
|
|
{
|
|
return new JwtTokenService(
|
|
Options.Create(options ?? CreateOptions()),
|
|
NullLogger<JwtTokenService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateToken_ContainsCorrectClaims()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken(
|
|
"John Doe", "johnd",
|
|
new[] { "Admin", "Design" },
|
|
new[] { "1", "2" });
|
|
|
|
var principal = service.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
Assert.Equal("John Doe", principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value);
|
|
Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
|
|
|
|
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
|
Assert.Contains("Admin", roles);
|
|
Assert.Contains("Design", roles);
|
|
|
|
var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
|
|
Assert.Contains("1", siteIds);
|
|
Assert.Contains("2", siteIds);
|
|
|
|
Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType));
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateToken_NullSiteIds_NoSiteIdClaims()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
var principal = service.ValidateToken(token);
|
|
|
|
Assert.NotNull(principal);
|
|
Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_InvalidToken_ReturnsNull()
|
|
{
|
|
var service = CreateService();
|
|
var result = service.ValidateToken("invalid.token.here");
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_WrongKey_ReturnsNull()
|
|
{
|
|
var service1 = CreateService();
|
|
var token = service1.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
|
|
var service2 = CreateService(new SecurityOptions
|
|
{
|
|
JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
});
|
|
var result = service2.ValidateToken(token);
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_UsesHmacSha256()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
|
|
// Decode header to verify algorithm
|
|
var parts = token.Split('.');
|
|
var headerJson = System.Text.Encoding.UTF8.GetString(
|
|
Convert.FromBase64String(parts[0].PadRight((parts[0].Length + 3) & ~3, '=')));
|
|
Assert.Contains("HS256", headerJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShouldRefresh_TokenNearExpiry_ReturnsTrue()
|
|
{
|
|
var options = CreateOptions();
|
|
options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min
|
|
var service = CreateService(options);
|
|
|
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
var principal = service.ValidateToken(token);
|
|
|
|
Assert.True(service.ShouldRefresh(principal!));
|
|
}
|
|
|
|
[Fact]
|
|
public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse()
|
|
{
|
|
var service = CreateService(); // 15 min expiry, 5 min threshold
|
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
var principal = service.ValidateToken(token);
|
|
|
|
Assert.False(service.ShouldRefresh(principal!));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsIdleTimedOut_RecentActivity_ReturnsFalse()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
var principal = service.ValidateToken(token);
|
|
|
|
Assert.False(service.IsIdleTimedOut(principal!));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue()
|
|
{
|
|
var service = CreateService();
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User")
|
|
}));
|
|
|
|
Assert.True(service.IsIdleTimedOut(principal));
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_ReturnsNewTokenWithUpdatedClaims()
|
|
{
|
|
var service = CreateService();
|
|
var originalToken = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
var principal = service.ValidateToken(originalToken);
|
|
|
|
var newToken = service.RefreshToken(principal!, new[] { "Admin", "Design" }, new[] { "1" });
|
|
Assert.NotNull(newToken);
|
|
|
|
var newPrincipal = service.ValidateToken(newToken!);
|
|
var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
|
Assert.Contains("Design", roles);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_MissingClaims_ReturnsNull()
|
|
{
|
|
var service = CreateService();
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity());
|
|
|
|
var result = service.RefreshToken(principal, new[] { "Admin" }, null);
|
|
Assert.Null(result);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region WP-8: RoleMapper Tests
|
|
|
|
public class RoleMapperTests : IDisposable
|
|
{
|
|
private readonly ScadaLinkDbContext _context;
|
|
private readonly SecurityRepository _securityRepo;
|
|
private readonly RoleMapper _roleMapper;
|
|
|
|
public RoleMapperTests()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ScadaLinkDbContext>()
|
|
.UseSqlite("DataSource=:memory:")
|
|
.Options;
|
|
|
|
_context = new ScadaLinkDbContext(options);
|
|
_context.Database.OpenConnection();
|
|
_context.Database.EnsureCreated();
|
|
_securityRepo = new SecurityRepository(_context);
|
|
_roleMapper = new RoleMapper(_securityRepo);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_MultiRoleExtraction()
|
|
{
|
|
// Add mappings (note: seed data adds SCADA-Admins -> Admin)
|
|
_context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", "Design"));
|
|
_context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", "Deployment"));
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" });
|
|
|
|
Assert.Contains("Admin", result.Roles);
|
|
Assert.Contains("Design", result.Roles);
|
|
Assert.DoesNotContain("Deployment", result.Roles);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_SiteScopedDeployment()
|
|
{
|
|
var site1 = new Site("Site1", "S-001");
|
|
var site2 = new Site("Site2", "S-002");
|
|
_context.Sites.AddRange(site1, site2);
|
|
_context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", "Deployment"));
|
|
await _context.SaveChangesAsync();
|
|
|
|
var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers");
|
|
_context.SiteScopeRules.AddRange(
|
|
new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id },
|
|
new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site2.Id });
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" });
|
|
|
|
Assert.Contains("Deployment", result.Roles);
|
|
Assert.False(result.IsSystemWideDeployment);
|
|
Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds);
|
|
Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
|
|
{
|
|
_context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", "Deployment"));
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" });
|
|
|
|
Assert.Contains("Deployment", result.Roles);
|
|
Assert.True(result.IsSystemWideDeployment);
|
|
Assert.Empty(result.PermittedSiteIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_UnrecognizedGroups_Ignored()
|
|
{
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "NonExistentGroup", "AnotherRandom" });
|
|
|
|
Assert.Empty(result.Roles);
|
|
Assert.Empty(result.PermittedSiteIds);
|
|
Assert.False(result.IsSystemWideDeployment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_NoMatchingGroups_NoRoles()
|
|
{
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(Array.Empty<string>());
|
|
|
|
Assert.Empty(result.Roles);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch()
|
|
{
|
|
// "SCADA-Admins" is seeded
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" });
|
|
|
|
Assert.Contains("Admin", result.Roles);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-001 (StartTLS dead code), Security-002 (cookie not
|
|
/// marked Secure), and Security-003 (JWT signing key length never validated).
|
|
/// </summary>
|
|
public class SecurityReviewRegressionTests
|
|
{
|
|
// --- Security-003: JWT signing key length validation ---
|
|
|
|
private static SecurityOptions JwtOptions(string signingKey) => new()
|
|
{
|
|
JwtSigningKey = signingKey,
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
[Fact]
|
|
public void JwtTokenService_EmptySigningKey_ThrowsAtConstruction()
|
|
{
|
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
|
new JwtTokenService(Options.Create(JwtOptions("")), NullLogger<JwtTokenService>.Instance));
|
|
Assert.Contains("JwtSigningKey", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void JwtTokenService_ShortSigningKey_ThrowsAtConstruction()
|
|
{
|
|
// 31 bytes — one short of the 256-bit HMAC-SHA256 minimum.
|
|
var shortKey = new string('k', 31);
|
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
|
new JwtTokenService(Options.Create(JwtOptions(shortKey)), NullLogger<JwtTokenService>.Instance));
|
|
Assert.Contains("32", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void JwtTokenService_AdequateSigningKey_ConstructsSuccessfully()
|
|
{
|
|
var key = new string('k', 32);
|
|
var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger<JwtTokenService>.Instance);
|
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
Assert.False(string.IsNullOrEmpty(token));
|
|
}
|
|
|
|
// --- Security-002: authentication cookie must be marked Secure ---
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_IsMarkedSecureAlways()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.Equal(
|
|
Microsoft.AspNetCore.Http.CookieSecurePolicy.Always,
|
|
cookieOptions.Cookie.SecurePolicy);
|
|
Assert.True(cookieOptions.Cookie.HttpOnly);
|
|
}
|
|
|
|
// --- CentralUI-005: cookie auth must use a sliding session window ---
|
|
// Documented policy (CLAUDE.md Security & Auth): sliding refresh with a
|
|
// 30-minute idle timeout. The cookie middleware must enable SlidingExpiration
|
|
// so an active session is renewed on activity and an idle session expires.
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_UsesSlidingExpiration()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.True(cookieOptions.SlidingExpiration);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_ExpireTimeSpanMatchesIdleTimeout()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
// The idle timeout drives the cookie's expiry window.
|
|
services.Configure<SecurityOptions>(o => o.IdleTimeoutMinutes = 30);
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.Equal(TimeSpan.FromMinutes(30), cookieOptions.ExpireTimeSpan);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_ExpireTimeSpanIsConfigurable()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
services.Configure<SecurityOptions>(o => o.IdleTimeoutMinutes = 45);
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.Equal(TimeSpan.FromMinutes(45), cookieOptions.ExpireTimeSpan);
|
|
Assert.True(cookieOptions.SlidingExpiration);
|
|
}
|
|
|
|
// --- Security-001: StartTLS transport must be reachable ---
|
|
|
|
[Fact]
|
|
public void SecurityOptions_LdapTransport_DefaultsToLdaps()
|
|
{
|
|
var options = new SecurityOptions();
|
|
Assert.Equal(LdapTransport.Ldaps, options.LdapTransport);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_StartTlsTransport_AttemptsConnection()
|
|
{
|
|
// With StartTLS selected the service must not be blocked by the insecure-LDAP
|
|
// guard and must reach the connection stage (which fails against a dead host).
|
|
// This proves the StartTLS path is reachable rather than dead code.
|
|
var options = new SecurityOptions
|
|
{
|
|
LdapServer = "nonexistent.invalid",
|
|
LdapPort = 389,
|
|
LdapTransport = LdapTransport.StartTls,
|
|
LdapSearchBase = "dc=example,dc=com"
|
|
};
|
|
var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);
|
|
|
|
var result = await service.AuthenticateAsync("user", "password");
|
|
|
|
// Connection fails (host invalid) — but crucially NOT with the insecure-LDAP message.
|
|
Assert.False(result.Success);
|
|
Assert.DoesNotContain("Insecure LDAP", result.ErrorMessage ?? string.Empty);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_NoTlsTransport_RejectedWithoutAllowInsecure()
|
|
{
|
|
var options = new SecurityOptions
|
|
{
|
|
LdapServer = "ldap.example.com",
|
|
LdapPort = 389,
|
|
LdapTransport = LdapTransport.None,
|
|
AllowInsecureLdap = false
|
|
};
|
|
var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);
|
|
|
|
var result = await service.AuthenticateAsync("user", "password");
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Insecure LDAP", result.ErrorMessage);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-004/005/006/007
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-004 (uid/cn attribute mismatch between search filter
|
|
/// and fallback DN), Security-005 (DN injection in the no-service-account fallback),
|
|
/// Security-006 (JWT issuer/audience checks disabled), and Security-007 (idle-timeout
|
|
/// claim reset on every token refresh).
|
|
/// </summary>
|
|
public class SecurityReviewRegressionTests2
|
|
{
|
|
private static SecurityOptions JwtOptions() => new()
|
|
{
|
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
|
|
new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);
|
|
|
|
// --- Security-004: search filter and fallback DN must use the same attribute ---
|
|
|
|
[Fact]
|
|
public void BuildFallbackUserDn_UsesConfiguredUserIdAttribute()
|
|
{
|
|
// The default user-id attribute is "uid"; the fallback DN must use it,
|
|
// not a hard-coded "cn", so search-then-bind and direct-bind are interchangeable.
|
|
var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "uid");
|
|
Assert.Equal("uid=alice,dc=example,dc=com", dn);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildFallbackUserDn_HonoursNonDefaultUserIdAttribute()
|
|
{
|
|
var dn = LdapAuthService.BuildFallbackUserDn("alice", "dc=example,dc=com", "sAMAccountName");
|
|
Assert.Equal("sAMAccountName=alice,dc=example,dc=com", dn);
|
|
}
|
|
|
|
[Fact]
|
|
public void SecurityOptions_LdapUserIdAttribute_DefaultsToUid()
|
|
{
|
|
Assert.Equal("uid", new SecurityOptions().LdapUserIdAttribute);
|
|
}
|
|
|
|
// --- Security-005: DN-component escaping must be applied to the username ---
|
|
|
|
[Fact]
|
|
public void BuildFallbackUserDn_EscapesDnMetacharacters()
|
|
{
|
|
// A hostile username must not be able to alter the DN structure: the comma
|
|
// that would otherwise start a new RDN ("ou=admins") must be escaped so the
|
|
// whole string remains a single RDN value.
|
|
var dn = LdapAuthService.BuildFallbackUserDn("victim,ou=admins", "dc=example,dc=com", "uid");
|
|
Assert.Equal(@"uid=victim\,ou=admins,dc=example,dc=com", dn);
|
|
// The comma from the username is backslash-escaped, so it does not act as an
|
|
// RDN separator: the only unescaped comma is the one joining RDN and base DN.
|
|
Assert.Contains(@"victim\,ou=admins", dn);
|
|
}
|
|
|
|
[Fact]
|
|
public void EscapeLdapDn_EscapesAllRfc4514Specials()
|
|
{
|
|
var escaped = LdapAuthService.EscapeLdapDn("a,b+c\"d\\e<f>g;h");
|
|
Assert.Equal(@"a\,b\+c\""d\\e\<f\>g\;h", escaped);
|
|
}
|
|
|
|
[Fact]
|
|
public void EscapeLdapDn_EscapesLeadingAndTrailingSpaces()
|
|
{
|
|
Assert.Equal(@"\ x \ ", LdapAuthService.EscapeLdapDn(" x "));
|
|
}
|
|
|
|
// --- Security-006: JWT issuer/audience must be bound and validated ---
|
|
|
|
[Fact]
|
|
public void GenerateToken_SetsIssuerAndAudience()
|
|
{
|
|
var service = CreateJwtService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
|
|
var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token);
|
|
Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer);
|
|
Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_RejectsTokenWithWrongIssuer()
|
|
{
|
|
// A token signed with the same key but a foreign issuer must be rejected.
|
|
var options = JwtOptions();
|
|
var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
|
|
System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey));
|
|
var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials(
|
|
key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256);
|
|
var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
|
|
issuer: "some-other-system",
|
|
audience: JwtTokenService.TokenAudience,
|
|
claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") },
|
|
expires: DateTime.UtcNow.AddMinutes(10),
|
|
signingCredentials: creds);
|
|
var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign);
|
|
|
|
var service = CreateJwtService(options);
|
|
Assert.Null(service.ValidateToken(foreignToken));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_AcceptsOwnIssuerAndAudience()
|
|
{
|
|
var service = CreateJwtService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Admin" }, null);
|
|
Assert.NotNull(service.ValidateToken(token));
|
|
}
|
|
|
|
// --- Security-007: refresh must preserve the original LastActivity timestamp ---
|
|
|
|
[Fact]
|
|
public void RefreshToken_PreservesOriginalLastActivityClaim()
|
|
{
|
|
var service = CreateJwtService();
|
|
|
|
// Mint a token whose LastActivity is 20 minutes in the past (still inside the
|
|
// 30-minute idle window). Refresh must NOT move it forward to "now".
|
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
|
Assert.NotNull(refreshed);
|
|
|
|
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
|
Assert.NotNull(refreshedPrincipal);
|
|
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
|
Assert.NotNull(claim);
|
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
|
|
// The carried timestamp must equal the original, not "now".
|
|
Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2,
|
|
$"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}");
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle()
|
|
{
|
|
// A user idle for 25 of the 30-minute window: a refresh fired by some background
|
|
// request must not make IsIdleTimedOut flip back to false forever.
|
|
var service = CreateJwtService();
|
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
|
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
|
|
|
// Still 25 min idle after refresh — not reset to 0.
|
|
Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid
|
|
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
|
|
Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20,
|
|
"Refresh wrongly reset the idle clock to ~now");
|
|
}
|
|
|
|
[Fact]
|
|
public void RecordActivity_UpdatesLastActivityToNow()
|
|
{
|
|
// Genuine user activity (a real request) — distinct from a token refresh —
|
|
// updates LastActivity to the current time.
|
|
var service = CreateJwtService();
|
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var touched = service.RecordActivity(principal, new[] { "Admin" }, null);
|
|
Assert.NotNull(touched);
|
|
|
|
var touchedPrincipal = service.ValidateToken(touched!);
|
|
var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated));
|
|
Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5,
|
|
"RecordActivity should set LastActivity to ~now");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-009/011
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-009 (no LDAP connection timeout — a hung server can
|
|
/// pin a thread-pool thread indefinitely because <c>ct</c> only guards work-item
|
|
/// scheduling) and the remaining Security-011 coverage gaps.
|
|
/// </summary>
|
|
public class SecurityReviewRegressionTests3
|
|
{
|
|
// --- Security-009: LDAP connection timeout must be configurable and bounded ---
|
|
|
|
[Fact]
|
|
public void SecurityOptions_LdapConnectionTimeout_HasSaneDefault()
|
|
{
|
|
var options = new SecurityOptions();
|
|
// A positive, finite default so a hung LDAP server cannot pin a thread forever.
|
|
Assert.True(options.LdapConnectionTimeoutMs > 0);
|
|
Assert.True(options.LdapConnectionTimeoutMs <= 60_000,
|
|
"Default LDAP connection timeout should be bounded (<= 60s).");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_UnreachableHost_FailsWithinConfiguredTimeout()
|
|
{
|
|
// A routable-but-non-responsive address would otherwise hang for the OS default
|
|
// (often minutes). With LdapConnectionTimeoutMs applied to the LdapConnection the
|
|
// call must give up promptly. 198.51.100.0/24 (TEST-NET-2, RFC 5737) is reserved
|
|
// and not routed, so the connect attempt stalls until the timeout fires.
|
|
var options = new SecurityOptions
|
|
{
|
|
LdapServer = "198.51.100.1",
|
|
LdapPort = 389,
|
|
LdapTransport = LdapTransport.None,
|
|
AllowInsecureLdap = true,
|
|
LdapSearchBase = "dc=example,dc=com",
|
|
LdapConnectionTimeoutMs = 2_000
|
|
};
|
|
var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var result = await service.AuthenticateAsync("user", "password");
|
|
sw.Stop();
|
|
|
|
Assert.False(result.Success);
|
|
// Generous ceiling: the 2s timeout plus scheduling/CI overhead, far below the
|
|
// multi-minute OS default that an unconfigured connection would incur.
|
|
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30),
|
|
$"Authentication did not honour the LDAP connection timeout: took {sw.Elapsed}.");
|
|
}
|
|
|
|
// --- Security-011: additional coverage for the no-service-account / DN paths ---
|
|
|
|
[Fact]
|
|
public void BuildFallbackUserDn_NoSearchBase_ReturnsBareRdn()
|
|
{
|
|
var dn = LdapAuthService.BuildFallbackUserDn("alice", "", "uid");
|
|
Assert.Equal("uid=alice", dn);
|
|
}
|
|
|
|
[Fact]
|
|
public void EscapeLdapDn_LeadingHash_IsEscaped()
|
|
{
|
|
Assert.Equal(@"\#admin", LdapAuthService.EscapeLdapDn("#admin"));
|
|
}
|
|
|
|
[Fact]
|
|
public void EscapeLdapDn_NullOrEmpty_ReturnedUnchanged()
|
|
{
|
|
Assert.Equal("", LdapAuthService.EscapeLdapDn(""));
|
|
Assert.Null(LdapAuthService.EscapeLdapDn(null!));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-012/013/014/015
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-012 (a partial LDAP outage during login — bind OK but
|
|
/// group search failing — silently yields a roleless authenticated session),
|
|
/// Security-013 (<c>ExtractFirstRdnValue</c> mis-parses group DNs containing an escaped
|
|
/// comma), Security-014 (<c>RefreshToken</c> re-issues a token without checking the idle
|
|
/// timeout, so an idle-expired session can be renewed indefinitely), and Security-015
|
|
/// (a username with leading/trailing whitespace is not trimmed before use).
|
|
/// </summary>
|
|
public class SecurityReviewRegressionTests4
|
|
{
|
|
private static SecurityOptions JwtOptions() => new()
|
|
{
|
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
|
|
new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);
|
|
|
|
// --- Security-014: RefreshToken must reject an idle-expired principal ---
|
|
|
|
[Fact]
|
|
public void RefreshToken_IdleExpiredPrincipal_ReturnsNull()
|
|
{
|
|
// A user idle for 31 minutes (past the 30-minute idle window). Even though the
|
|
// DisplayName/Username claims are present, the refresh must be refused so an
|
|
// idle-expired session cannot be renewed by a background request.
|
|
var service = CreateJwtService();
|
|
var idleActivity = DateTimeOffset.UtcNow.AddMinutes(-31);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
|
|
|
Assert.Null(refreshed);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes()
|
|
{
|
|
// A user idle for 10 minutes (well inside the 30-minute window): refresh must
|
|
// still succeed — the idle-timeout guard must not break the normal sliding path.
|
|
var service = CreateJwtService();
|
|
var recentActivity = DateTimeOffset.UtcNow.AddMinutes(-10);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
|
|
|
Assert.NotNull(refreshed);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_MissingLastActivityClaim_ReturnsNull()
|
|
{
|
|
// No LastActivity claim — IsIdleTimedOut treats this as timed out, so the
|
|
// refresh must be refused rather than minting a fresh session out of nothing.
|
|
var service = CreateJwtService();
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user")
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Admin" }, null);
|
|
|
|
Assert.Null(refreshed);
|
|
}
|
|
|
|
// --- Security-013: ExtractFirstRdnValue must honour RFC 4514 escaped commas ---
|
|
|
|
[Fact]
|
|
public void ExtractFirstRdnValue_EscapedComma_KeepsWholeGroupName()
|
|
{
|
|
// A CN that legitimately contains a comma is RFC 4514 backslash-escaped in the
|
|
// memberOf DN. The extracted group name must be the full unescaped CN value,
|
|
// not the fragment before the escaped comma.
|
|
var name = LdapAuthService.ExtractFirstRdnValue(
|
|
@"cn=Acme\, Inc Operators,ou=groups,dc=example,dc=com");
|
|
Assert.Equal("Acme, Inc Operators", name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractFirstRdnValue_PlainDn_ReturnsFirstRdnValue()
|
|
{
|
|
var name = LdapAuthService.ExtractFirstRdnValue("ou=SCADA-Admins,ou=groups,dc=example,dc=com");
|
|
Assert.Equal("SCADA-Admins", name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractFirstRdnValue_SingleRdn_ReturnsValue()
|
|
{
|
|
var name = LdapAuthService.ExtractFirstRdnValue("cn=Operators");
|
|
Assert.Equal("Operators", name);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExtractFirstRdnValue_EscapedSpecials_AreUnescaped()
|
|
{
|
|
// RFC 4514 escape sequences (escaped '+', '"', '\\') must be unescaped in the
|
|
// returned value so it matches the configured LdapGroupName verbatim.
|
|
var name = LdapAuthService.ExtractFirstRdnValue(
|
|
@"cn=A\+B\\C,ou=groups,dc=example,dc=com");
|
|
Assert.Equal(@"A+B\C", name);
|
|
}
|
|
|
|
// --- Security-015: username must be trimmed before use ---
|
|
|
|
[Fact]
|
|
public void BuildFallbackUserDn_TrimmedUsername_NoLeadingTrailingSpace()
|
|
{
|
|
// The whitespace-edge escaping in EscapeLdapDn only fires when whitespace is NOT
|
|
// trimmed. AuthenticateAsync trims first; this asserts the trimmed value yields
|
|
// a clean DN with no escaped edge spaces.
|
|
var dn = LdapAuthService.BuildFallbackUserDn("alice".Trim(), "dc=example,dc=com", "uid");
|
|
Assert.Equal("uid=alice,dc=example,dc=com", dn);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_UsernameWithSurroundingWhitespace_StillRejectedForInsecure()
|
|
{
|
|
// Sanity guard: a padded but otherwise-valid username is not rejected by the
|
|
// IsNullOrWhiteSpace guard — it passes through to the (here, insecure-LDAP) path.
|
|
var options = new SecurityOptions
|
|
{
|
|
LdapServer = "ldap.example.com",
|
|
LdapPort = 389,
|
|
LdapTransport = LdapTransport.None,
|
|
AllowInsecureLdap = false
|
|
};
|
|
var service = new LdapAuthService(Options.Create(options), NullLogger<LdapAuthService>.Instance);
|
|
|
|
var result = await service.AuthenticateAsync(" alice ", "password");
|
|
|
|
// Reaches the insecure-LDAP guard (not the empty-username guard) — proves the
|
|
// padded username is treated as a real, non-empty username.
|
|
Assert.False(result.Success);
|
|
Assert.Contains("Insecure LDAP", result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void NormalizeUsername_TrimsLeadingAndTrailingWhitespace()
|
|
{
|
|
Assert.Equal("alice", LdapAuthService.NormalizeUsername(" alice "));
|
|
Assert.Equal("alice", LdapAuthService.NormalizeUsername("alice"));
|
|
Assert.Equal("alice", LdapAuthService.NormalizeUsername("\talice\n"));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-012 (partial LDAP outage)
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-012: a partial LDAP outage during login (the user bind
|
|
/// succeeds but the subsequent group/attribute search fails) must fail the login per the
|
|
/// design's LDAP-failure rule, rather than returning an authenticated session with zero
|
|
/// roles. These exercise the seam through a stubbed group-lookup so the bind itself can
|
|
/// be treated as successful.
|
|
/// </summary>
|
|
public class Security012GroupLookupFailureTests
|
|
{
|
|
private static SecurityOptions Options() => new()
|
|
{
|
|
LdapServer = "ldap.example.com",
|
|
LdapPort = 636,
|
|
LdapTransport = LdapTransport.Ldaps,
|
|
LdapSearchBase = "dc=example,dc=com"
|
|
};
|
|
|
|
[Fact]
|
|
public void BuildAuthResultFromGroupLookup_LookupFailed_FailsTheLogin()
|
|
{
|
|
// When the group lookup failed (directory partially unavailable mid-login) the
|
|
// result must be a FAILED login — not a success with an empty group list.
|
|
var result = LdapAuthService.BuildAuthResultFromGroupLookup(
|
|
username: "alice",
|
|
displayName: "Alice",
|
|
groups: new List<string>(),
|
|
groupLookupSucceeded: false);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Null(result.Groups);
|
|
Assert.False(string.IsNullOrEmpty(result.ErrorMessage));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildAuthResultFromGroupLookup_LookupSucceededNoGroups_IsAuthenticated()
|
|
{
|
|
// A genuine "user belongs to no mapped groups" outcome must remain a successful
|
|
// login — it must be distinguishable from a failed lookup.
|
|
var result = LdapAuthService.BuildAuthResultFromGroupLookup(
|
|
username: "alice",
|
|
displayName: "Alice",
|
|
groups: new List<string>(),
|
|
groupLookupSucceeded: true);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.NotNull(result.Groups);
|
|
Assert.Empty(result.Groups!);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildAuthResultFromGroupLookup_LookupSucceededWithGroups_CarriesGroups()
|
|
{
|
|
var result = LdapAuthService.BuildAuthResultFromGroupLookup(
|
|
username: "alice",
|
|
displayName: "Alice",
|
|
groups: new List<string> { "SCADA-Admins" },
|
|
groupLookupSucceeded: true);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal(new[] { "SCADA-Admins" }, result.Groups);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region WP-9: Authorization Policy Tests
|
|
|
|
public class AuthorizationPolicyTests
|
|
{
|
|
[Fact]
|
|
public async Task AdminPolicy_AdminRole_Succeeds()
|
|
{
|
|
var principal = CreatePrincipal(new[] { "Admin" });
|
|
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdminPolicy_DesignRole_Fails()
|
|
{
|
|
var principal = CreatePrincipal(new[] { "Design" });
|
|
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DesignPolicy_DesignRole_Succeeds()
|
|
{
|
|
var principal = CreatePrincipal(new[] { "Design" });
|
|
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeploymentPolicy_DeploymentRole_Succeeds()
|
|
{
|
|
var principal = CreatePrincipal(new[] { "Deployment" });
|
|
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoRoles_DeniedAll()
|
|
{
|
|
var principal = CreatePrincipal(Array.Empty<string>());
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15).
|
|
// Default mapping (see AuthorizationPolicies XML doc):
|
|
// Admin → OperationalAudit + AuditExport
|
|
// Audit → OperationalAudit + AuditExport
|
|
// AuditReadOnly → OperationalAudit only
|
|
// Design → neither
|
|
// Deployment → neither
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("Admin")]
|
|
[InlineData("Audit")]
|
|
[InlineData("AuditReadOnly")]
|
|
public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role)
|
|
{
|
|
var principal = CreatePrincipal(new[] { role });
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Design")]
|
|
[InlineData("Deployment")]
|
|
public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role)
|
|
{
|
|
var principal = CreatePrincipal(new[] { role });
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Admin")]
|
|
[InlineData("Audit")]
|
|
public async Task AuditExportPolicy_GrantedRoles_Succeed(string role)
|
|
{
|
|
var principal = CreatePrincipal(new[] { role });
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("AuditReadOnly")]
|
|
[InlineData("Design")]
|
|
[InlineData("Deployment")]
|
|
public async Task AuditExportPolicy_UngrantedRoles_Fail(string role)
|
|
{
|
|
// AuditReadOnly is the load-bearing case: it grants OperationalAudit
|
|
// (read) but NOT AuditExport (bulk export) — the split that lets a
|
|
// triage operator drill in without exfiltrating the table.
|
|
var principal = CreatePrincipal(new[] { role });
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SiteScope_SystemWideDeployer_Succeeds()
|
|
{
|
|
var handler = new SiteScopeAuthorizationHandler();
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtTokenService.RoleClaimType, "Deployment")
|
|
// No SiteId claims = system-wide
|
|
};
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
|
|
var requirement = new SiteScopeRequirement("42");
|
|
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.True(context.HasSucceeded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SiteScope_PermittedSite_Succeeds()
|
|
{
|
|
var handler = new SiteScopeAuthorizationHandler();
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtTokenService.RoleClaimType, "Deployment"),
|
|
new(JwtTokenService.SiteIdClaimType, "1"),
|
|
new(JwtTokenService.SiteIdClaimType, "2")
|
|
};
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
|
|
var requirement = new SiteScopeRequirement("1");
|
|
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.True(context.HasSucceeded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SiteScope_UnpermittedSite_Fails()
|
|
{
|
|
var handler = new SiteScopeAuthorizationHandler();
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtTokenService.RoleClaimType, "Deployment"),
|
|
new(JwtTokenService.SiteIdClaimType, "1")
|
|
};
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
|
|
var requirement = new SiteScopeRequirement("99");
|
|
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.False(context.HasSucceeded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SiteScope_NoDeploymentRole_Fails()
|
|
{
|
|
var handler = new SiteScopeAuthorizationHandler();
|
|
var claims = new List<Claim>
|
|
{
|
|
new(JwtTokenService.RoleClaimType, "Admin")
|
|
};
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
|
|
var requirement = new SiteScopeRequirement("1");
|
|
var context = new AuthorizationHandlerContext(new[] { requirement }, principal, null);
|
|
|
|
await handler.HandleAsync(context);
|
|
|
|
Assert.False(context.HasSucceeded);
|
|
}
|
|
|
|
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
|
|
{
|
|
var claims = new List<Claim>();
|
|
foreach (var role in roles)
|
|
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
|
if (siteIds != null)
|
|
foreach (var siteId in siteIds)
|
|
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
|
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
}
|
|
|
|
private static async Task<bool> EvaluatePolicy(string policyName, ClaimsPrincipal principal)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddScadaLinkAuthorization();
|
|
services.AddLogging();
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var authService = provider.GetRequiredService<IAuthorizationService>();
|
|
|
|
var result = await authService.AuthorizeAsync(principal, null, policyName);
|
|
return result.Succeeded;
|
|
}
|
|
}
|
|
|
|
#endregion
|