fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1)
- 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.
This commit is contained in:
@@ -330,6 +330,55 @@ public class StartupValidatorTests
|
||||
Assert.Contains("JwtSigningKey required for Central", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A present-but-short JWT signing key fails the boot (review 2026-10-21 05/U1): it signs the
|
||||
/// CLI bearer tokens, and JwtTokenService is only resolved on the first token request.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Central_ShortJwtSigningKey_FailsValidation()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:Security:JwtSigningKey"] = new string('k', 31);
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.Contains("JwtSigningKey must be at least 32 bytes", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>The key floor is measured in UTF-8 bytes, so a 32-byte key passes.</summary>
|
||||
[Fact]
|
||||
public void Central_ThirtyTwoByteJwtSigningKey_Passes()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values["ScadaBridge:Security:JwtSigningKey"] = new string('k', 32);
|
||||
var config = BuildConfig(values);
|
||||
|
||||
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
|
||||
}
|
||||
|
||||
/// <summary>A missing key reports only the "required" rule, not also the length rule.</summary>
|
||||
[Fact]
|
||||
public void Central_MissingJwtSigningKey_DoesNotAlsoReportLength()
|
||||
{
|
||||
var values = ValidCentralConfig();
|
||||
values.Remove("ScadaBridge:Security:JwtSigningKey");
|
||||
var config = BuildConfig(values);
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
|
||||
Assert.DoesNotContain("at least 32 bytes", ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Site nodes carry no signing key and are not subject to the floor.</summary>
|
||||
[Fact]
|
||||
public void Site_ShortJwtSigningKey_IsIgnored()
|
||||
{
|
||||
var values = ValidSiteConfig();
|
||||
values["ScadaBridge:Security:JwtSigningKey"] = "x";
|
||||
var config = BuildConfig(values);
|
||||
|
||||
Assert.Null(Record.Exception(() => StartupValidator.Validate(config)));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a central node missing API key pepper fails validation.</summary>
|
||||
/// <returns>The result.</returns>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user