1b3928a0a2
- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on /management, the audit REST endpoints and the debug-stream hub. Tokens are validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with 30 s skew); roles and site scope inside a valid token are authoritative for its lifetime, so a bearer call costs no LDAP bind and no role re-map. - /auth/token returns expires_in + management_bearer:true, carries username, display name, roles and permitted sites, and answers 404 under DisableLogin. - CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer, re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are Basic-only; bad credentials are not retried with Basic. Debug stream uses the token via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out. - StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes; fixed the false 'fail-fast at construction' comment. Removed the dead RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime (validated > 0). - RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds, default 30 s): one refresh loads the table plus every Deployer mapping's scope rules, Invalidate() drops it (generation-guarded against in-flight refreshes). - Component-Security.md / Component-CLI.md / CLI README describe the real model (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
149 lines
5.8 KiB
C#
149 lines
5.8 KiB
C#
using System.Net;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Full-system failover testing — Central.
|
|
/// Verifies that JWT tokens and deployment state survive central node failover.
|
|
/// Multi-process failover tests are marked with Integration trait for separate runs.
|
|
/// </summary>
|
|
public class CentralFailoverTests
|
|
{
|
|
private static JwtTokenService CreateJwtService(string signingKey = "integration-test-signing-key-must-be-at-least-32-chars-long")
|
|
{
|
|
var options = Options.Create(new SecurityOptions
|
|
{
|
|
JwtSigningKey = signingKey,
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30
|
|
});
|
|
return new JwtTokenService(options, NullLogger<JwtTokenService>.Instance);
|
|
}
|
|
|
|
/// <summary>Verifies that a JWT generated before failover validates after failover.</summary>
|
|
[Fact]
|
|
public void JwtToken_GeneratedBeforeFailover_ValidatesAfterFailover()
|
|
{
|
|
// Simulates: generate token on node A, validate on node B (shared signing key).
|
|
var jwtServiceA = CreateJwtService();
|
|
|
|
var token = jwtServiceA.GenerateToken(
|
|
displayName: "Failover User",
|
|
username: "failover_test",
|
|
roles: new[] { "Administrator" },
|
|
permittedSiteIds: null);
|
|
|
|
// Validate with a second instance (same signing key = simulated failover)
|
|
var jwtServiceB = CreateJwtService();
|
|
|
|
var principal = jwtServiceB.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
var username = principal!.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
|
|
Assert.Equal("failover_test", username);
|
|
}
|
|
|
|
/// <summary>Verifies that a JWT with site scopes survives revalidation.</summary>
|
|
[Fact]
|
|
public void JwtToken_WithSiteScopes_SurvivesRevalidation()
|
|
{
|
|
var jwtService = CreateJwtService();
|
|
|
|
var token = jwtService.GenerateToken(
|
|
displayName: "Scoped User",
|
|
username: "scoped_user",
|
|
roles: new[] { "Deployer" },
|
|
permittedSiteIds: new[] { "site-1", "site-2", "site-5" });
|
|
|
|
var principal = jwtService.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
var siteIds = principal!.FindAll(JwtTokenService.SiteIdClaimType)
|
|
.Select(c => c.Value).ToList();
|
|
Assert.Equal(3, siteIds.Count);
|
|
Assert.Contains("site-1", siteIds);
|
|
Assert.Contains("site-5", siteIds);
|
|
}
|
|
|
|
/// <summary>Verifies that a JWT validated with a different signing key fails validation.</summary>
|
|
[Fact]
|
|
public void JwtToken_DifferentSigningKeys_FailsValidation()
|
|
{
|
|
// If two nodes have different signing keys, tokens from one won't validate on the other.
|
|
var jwtServiceA = CreateJwtService("node-a-signing-key-that-is-long-enough-32chars");
|
|
var jwtServiceB = CreateJwtService("node-b-signing-key-that-is-long-enough-32chars");
|
|
|
|
var token = jwtServiceA.GenerateToken(
|
|
displayName: "User",
|
|
username: "user",
|
|
roles: new[] { "Administrator" },
|
|
permittedSiteIds: null);
|
|
|
|
// Token from A should NOT validate on B (different key)
|
|
var principal = jwtServiceB.ValidateToken(token);
|
|
Assert.Null(principal);
|
|
}
|
|
|
|
/// <summary>Verifies that optimistic concurrency on deployment status detects stale writes.</summary>
|
|
[Trait("Category", "Integration")]
|
|
[Fact]
|
|
public void DeploymentStatus_OptimisticConcurrency_DetectsStaleWrites()
|
|
{
|
|
var status1 = new Commons.Messages.Deployment.DeploymentStatusResponse(
|
|
"dep-1", "instance-1", Commons.Types.Enums.DeploymentStatus.InProgress,
|
|
null, DateTimeOffset.UtcNow);
|
|
|
|
var status2 = new Commons.Messages.Deployment.DeploymentStatusResponse(
|
|
"dep-1", "instance-1", Commons.Types.Enums.DeploymentStatus.Success,
|
|
null, DateTimeOffset.UtcNow.AddSeconds(1));
|
|
|
|
Assert.True(status2.Timestamp > status1.Timestamp);
|
|
Assert.Equal(Commons.Types.Enums.DeploymentStatus.Success, status2.Status);
|
|
}
|
|
|
|
/// <summary>Verifies that a JWT that expired before failover is rejected after failover.</summary>
|
|
[Fact]
|
|
public void JwtToken_ExpiredBeforeFailover_RejectedAfterFailover()
|
|
{
|
|
var jwtService = CreateJwtService();
|
|
|
|
var token = jwtService.GenerateToken(
|
|
displayName: "Expired User",
|
|
username: "expired_user",
|
|
roles: new[] { "Administrator" },
|
|
permittedSiteIds: null);
|
|
|
|
var principal = jwtService.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
var expClaim = principal!.FindFirst("exp");
|
|
Assert.NotNull(expClaim);
|
|
}
|
|
|
|
/// <summary>Verifies that deployment status for multiple instances is tracked independently.</summary>
|
|
[Trait("Category", "Integration")]
|
|
[Fact]
|
|
public void DeploymentStatus_MultipleInstances_IndependentTracking()
|
|
{
|
|
var statuses = new[]
|
|
{
|
|
new Commons.Messages.Deployment.DeploymentStatusResponse(
|
|
"dep-1", "instance-1", Commons.Types.Enums.DeploymentStatus.Success,
|
|
null, DateTimeOffset.UtcNow),
|
|
new Commons.Messages.Deployment.DeploymentStatusResponse(
|
|
"dep-1", "instance-2", Commons.Types.Enums.DeploymentStatus.InProgress,
|
|
null, DateTimeOffset.UtcNow),
|
|
new Commons.Messages.Deployment.DeploymentStatusResponse(
|
|
"dep-1", "instance-3", Commons.Types.Enums.DeploymentStatus.Failed,
|
|
"Compilation error", DateTimeOffset.UtcNow),
|
|
};
|
|
|
|
Assert.Equal(3, statuses.Length);
|
|
Assert.All(statuses, s => Assert.Equal("dep-1", s.DeploymentId));
|
|
Assert.Equal(3, statuses.Select(s => s.InstanceUniqueName).Distinct().Count());
|
|
}
|
|
}
|