diff --git a/docs/requirements/Component-Security.md b/docs/requirements/Component-Security.md index 8ad9b9b4..4ebdd359 100644 --- a/docs/requirements/Component-Security.md +++ b/docs/requirements/Component-Security.md @@ -43,7 +43,7 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid ### CLI / API sessions — bearer JWT (`POST /auth/token`) - `POST /auth/token` (form `username`/`password`) performs the LDAP bind (login-throttled), maps the groups to roles and site scope, and returns a short-lived **HS256 JWT** signed with `SecurityOptions.JwtSigningKey`, shared by both central nodes: `{ access_token, token_type: "Bearer", expires_in, management_bearer: true, username, display_name, roles }`. - **JWT claims**: display name, username, roles, and for a site-scoped Deployer the permitted site IDs (no site claims = system-wide, the same normalisation the Basic path uses). Issuer and audience are both fixed to `scadabridge-central`. Lifetime is `JwtExpiryMinutes` (default **15**). -- `ManagementAuthenticator` accepts `Authorization: Bearer ` alongside HTTP Basic on every CLI-facing surface: `POST /management`, the audit REST endpoints, and the debug-stream hub (`/hubs/debug-stream`, token in the `Authorization` header of the negotiate request and WebSocket upgrade). Validation checks the HS256 signature, issuer, audience and lifetime (30 s clock skew for node-to-node drift); unsigned (`alg=none`) or other-algorithm tokens are rejected, and a failure is a plain `401 AUTH_FAILED` (no LDAP bind, so it does not feed the login throttle). The token is never logged. +- `ManagementAuthenticator` accepts `Authorization: Bearer ` alongside HTTP Basic on every CLI-facing surface: `POST /management`, the audit REST endpoints, and the debug-stream hub (`/hubs/debug-stream`, token in the `Authorization` header of the negotiate request and WebSocket upgrade). Validation checks the HS256 signature, issuer, audience and lifetime (30 s clock skew for node-to-node drift; `nbf` is back-dated by the same skew so a peer whose clock lags the issuer accepts a fresh token after failover); unsigned (`alg=none`) or other-algorithm tokens are rejected, and a failure is a plain `401 AUTH_FAILED` (no LDAP bind, so it does not feed the login throttle). The token is never logged. - **Roles and scope inside a valid token are authoritative for its lifetime**: a bearer request performs no LDAP bind and no role re-map. A role revocation therefore reaches a bearer caller when its token expires (≤ `JwtExpiryMinutes`); rotating `JwtSigningKey` invalidates every outstanding token immediately. - The CLI exchanges credentials once per process, keeps the token in memory only, re-exchanges once on a `401`, and falls back to HTTP Basic when the server has no bearer support — see `Component-CLI.md`. HTTP Basic stays supported on every surface. - Under the dev/test `DisableLogin` flag `/auth/token` answers `404`: the management surfaces already authenticate every request as the dev user, and no credential-free token is minted that another deployment sharing the key might honour. @@ -53,7 +53,7 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid - **Idle timeout**: Configurable, default **30 minutes**. Without eligible activity within the idle window, the session is rejected and the user must re-login. Passive `/auth/ping`, activity prevalidation, and Blazor transport requests preserve the activity anchor and suppress sliding renewal; the activity endpoint renews only after authentication and antiforgery validation. Tracked via a `LastActivity` last-activity timestamp claim. The cookie's `ExpireTimeSpan` is set to the idle timeout and `SlidingExpiration` renews it on activity, so the cookie window and the explicit `OnValidatePrincipal` idle check use the **same** value and cannot contradict each other. - **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Ordinary HTTP validation uses this threshold. A live circuit refreshes mappings in fresh scopes on authentication-state reads — display reads (`GetAuthenticationStateAsync`) at most once per `CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval` (10 s) per circuit — and mutation entrypoints (`GetAuthoritativeStateAsync`) require a successful current mapping before every dispatch. -- **Role-mapping cache**: `RoleMapper` reads the LDAP-group→role table and the scope rules of every Deployer mapping through a process-wide `RoleMappingCache` snapshot (`SecurityOptions.RoleMappingCacheSeconds`, default **30**; `0` disables). One refresh loads the whole table plus all Deployer scope rules, so a cache hit costs no database round-trip — previously every Basic-authenticated management request and every circuit auth-state read issued `GetAllMappingsAsync` plus one `GetScopeRulesForMappingAsync` per matched Deployer mapping. A mapping or scope-rule change is therefore visible on every node within the TTL. `RoleMappingCache.Invalidate()` makes it immediate on the node that served the change; wiring it into the ManagementActor role-mapping / scope-rule handlers is a pending follow-up, so today the TTL alone bounds the staleness (a refresh started before an invalidation is never published over it). +- **Role-mapping cache**: `RoleMapper` reads the LDAP-group→role table and the scope rules of every Deployer mapping through a process-wide `RoleMappingCache` snapshot (`SecurityOptions.RoleMappingCacheSeconds`, default **30**; `0` disables). One refresh loads the whole table plus all Deployer scope rules, so a cache hit costs no database round-trip — previously every Basic-authenticated management request and every circuit auth-state read issued `GetAllMappingsAsync` plus one `GetScopeRulesForMappingAsync` per matched Deployer mapping. Without an explicit invalidation a mapping or scope-rule change is therefore visible on every node within the TTL. `RoleMappingCache.Invalidate()` discards the calling node's snapshot so its next mapping re-reads the database (a refresh started before an invalidation is never published over it); other nodes still rely on the TTL. `RoleMappingCacheSeconds = 0` bypasses the snapshot and reads only the matched mappings' scope rules per call. #### Session Validation (`OnValidatePrincipal`) - The cookie principal is built at login by a **single shared claim-builder** (`SessionClaimBuilder`). The `OnValidatePrincipal` role-refresh path rebuilds the principal through the **same** builder, so the login and refresh claim shapes cannot drift. diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs index e3bf19d2..fe324914 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs @@ -146,7 +146,10 @@ public class JwtTokenService issuer: TokenIssuer, audience: TokenAudience, claims: claims, - notBefore: now, + // nbf is back-dated by the validation skew: a token minted on one central node and + // presented to its peer straight after a failover must not be refused because the + // peer's clock lags the issuer's. + notBefore: now - ValidationClockSkew, expires: now.Add(TokenLifetime), signingCredentials: credentials); diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs index 45418a9a..21ab7dee 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs @@ -39,7 +39,9 @@ public class RoleMapper IReadOnlyList ldapGroups, CancellationToken ct = default) { - IReadOnlyList rows = _cache is not null + // A disabled cache (TTL 0) takes the matched-rows path: the snapshot loader would read + // the scope rules of EVERY Deployer mapping on each call. + IReadOnlyList rows = _cache is { Enabled: true } ? await _cache.GetAsync(_securityRepository, ct) : await LoadMatchedRowsAsync(ldapGroups, ct); @@ -98,7 +100,7 @@ public class RoleMapper isSystemWide); } - // Uncached path (no RoleMappingCache supplied): reads the table and the scope rules of the + // Uncached path (no RoleMappingCache supplied, or its TTL is 0): reads the table and the scope rules of the // MATCHED Deployer mappings only, exactly as the mapper always did. private async Task> LoadMatchedRowsAsync( IReadOnlyList ldapGroups, CancellationToken ct) diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs index e59bed87..2ee4f12e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs @@ -11,8 +11,12 @@ namespace ZB.MOM.WW.ScadaBridge.Security; /// GetAllMappingsAsync plus one GetScopeRulesForMappingAsync per matched Deployer /// mapping. The table changes only through Administrator commands, so a /// window (default 30 s) of staleness is -/// an acceptable price: a role revocation reaches every node within that window, and the node -/// that served the change sees it at once when the mutation path calls . +/// an acceptable price: without an explicit , a mapping or scope-rule +/// change is visible on every node within that window. discards this +/// node's snapshot so the next mapping re-reads the database; calling it after a mutation makes +/// the change immediate on the node that performed it (other nodes still rely on the TTL). +/// A TTL of 0 disables the cache: is false and +/// reads the repository directly on every call. /// /// /// @@ -54,6 +58,13 @@ public sealed class RoleMappingCache private TimeSpan Ttl => TimeSpan.FromSeconds(Math.Max(0, _options?.CurrentValue.RoleMappingCacheSeconds ?? 30)); + /// + /// False when is 0; callers then + /// bypass the snapshot (which would otherwise load every Deployer mapping's scope rules on + /// each call) and read only what they match. + /// + public bool Enabled => Ttl > TimeSpan.Zero; + /// /// Discards the cached snapshot so the next mapping re-reads the database. Call after any /// LDAP-group-mapping or site-scope-rule create/update/delete. diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs index 8cd81193..e91fa5d5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs @@ -67,9 +67,9 @@ public class SecurityOptions /// How long, in seconds, may serve the LDAP-group→role mapping /// table and its site-scope rules from memory before re-reading the configuration /// database. Default: 30. 0 disables the cache (every mapping reads the - /// database). A mapping change is visible on every node within this window; the node - /// that served the change sees it immediately once - /// is called. + /// database, matched scope rules only). Without an explicit invalidation a mapping change + /// is visible on every node within this window; + /// discards the calling node's snapshot so its next mapping re-reads the database. /// public int RoleMappingCacheSeconds { get; set; } = 30; diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.cs index 6b41c675..aea2dea2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.cs @@ -30,14 +30,14 @@ public class ManagementAuthenticatorBearerTests new(Options.Create(new SecurityOptions { JwtSigningKey = key, JwtExpiryMinutes = 15 }), NullLogger.Instance, clock); - private static (HttpContext Context, ILdapAuthService Ldap) ContextWithAuthorization(string authorization) + private static (HttpContext Context, ILdapAuthService Ldap) ContextWithAuthorization(string authorization, string key = Key) { var ldap = Substitute.For(); var services = new ServiceCollection(); services.AddSingleton>( Options.Create(new AuthDisableLoginOptions { DisableLogin = false })); services.AddSingleton>( - Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = 15 })); + Options.Create(new SecurityOptions { JwtSigningKey = key, JwtExpiryMinutes = 15 })); services.AddLogging(); services.AddSingleton(ldap); services.AddScoped(); @@ -162,6 +162,90 @@ public class ManagementAuthenticatorBearerTests Assert.NotNull(outcome.Failure); } + private static string HandMinted(string key, string algorithm, string issuer, string audience) + { + var credentials = new Microsoft.IdentityModel.Tokens.SigningCredentials( + new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), algorithm); + var jwt = new JwtSecurityToken( + issuer: issuer, + audience: audience, + claims: [new Claim(JwtTokenService.UsernameClaimType, "admin"), + new Claim(JwtTokenService.RoleClaimType, Roles.Administrator)], + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: credentials); + var handler = new JwtSecurityTokenHandler(); + handler.OutboundClaimTypeMap.Clear(); + return handler.WriteToken(jwt); + } + + /// A token signed with the right key but HS512 is rejected: only HS256 is accepted. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Hs512WithSameKey_Is401() + { + // 64+ bytes so HS512 itself is satisfied; only the algorithm allow-list can reject it. + var longKey = new string('k', 80); + var token = HandMinted(longKey, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha512, + JwtTokenService.TokenIssuer, JwtTokenService.TokenAudience); + var (context, _) = ContextWithAuthorization($"Bearer {token}", longKey); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.User); + Assert.NotNull(outcome.Failure); + } + + /// The same key and algorithm with a hand-minted HS256 token is accepted (control for the cases below). + /// A task that represents the asynchronous operation. + [Fact] + public async Task HandMintedHs256_OwnIssuerAndAudience_IsAccepted() + { + var token = HandMinted(Key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256, + JwtTokenService.TokenIssuer, JwtTokenService.TokenAudience); + var (context, _) = ContextWithAuthorization($"Bearer {token}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.NotNull(outcome.User); + } + + /// A correctly signed token for another issuer or audience is rejected. + /// The token's issuer. + /// The token's audience. + /// A task that represents the asynchronous operation. + [Theory] + [InlineData("some-other-system", JwtTokenService.TokenAudience)] + [InlineData(JwtTokenService.TokenIssuer, "some-other-audience")] + public async Task IssuerOrAudienceMismatch_Is401(string issuer, string audience) + { + var token = HandMinted(Key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256, issuer, audience); + var (context, _) = ContextWithAuthorization($"Bearer {token}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.User); + Assert.NotNull(outcome.Failure); + } + + /// + /// nbf is back-dated by the validation skew, so a peer node whose clock lags the issuer by + /// up to twice the skew accepts a fresh token (review L1). + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task FreshToken_FromIssuerAhead_IsAcceptedByLaggingPeer() + { + // Issuer clock 45 s ahead of this (validating) node — beyond the 30 s skew alone, within + // skew + the nbf back-dating. + var token = Jwt(clock: new FixedClock(DateTimeOffset.UtcNow.AddSeconds(45))) + .GenerateToken("A", "admin", [Roles.Administrator], null); + var (context, _) = ContextWithAuthorization($"Bearer {token}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.NotNull(outcome.User); + } + private static byte[] Base64UrlDecode(string s) { var padded = s.Replace('-', '+').Replace('_', '/'); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RoleMappingCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RoleMappingCacheTests.cs index 9de52c06..3dad0580 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RoleMappingCacheTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RoleMappingCacheTests.cs @@ -29,13 +29,17 @@ public class RoleMappingCacheTests public List Mappings { get; } = []; public Dictionary> ScopeRules { get; } = []; public int MappingReads { get; private set; } + public Func? BeforeMappingRead { get; set; } public int ScopeReads { get; private set; } /// - public Task> GetAllMappingsAsync(CancellationToken cancellationToken = default) + public async Task> GetAllMappingsAsync(CancellationToken cancellationToken = default) { MappingReads++; - return Task.FromResult>(Mappings.ToList()); + var snapshot = Mappings.ToList(); + if (BeforeMappingRead is not null) + await BeforeMappingRead(); + return snapshot; } /// @@ -140,10 +144,13 @@ public class RoleMappingCacheTests Assert.Empty(after.Roles); } - /// A zero TTL disables caching entirely. + /// + /// A zero TTL disables caching entirely and reads only the MATCHED Deployer mappings' scope + /// rules, not every Deployer mapping's (review L6). + /// /// A task that represents the asynchronous operation. [Fact] - public async Task ZeroTtl_DisablesCache() + public async Task ZeroTtl_DisablesCache_AndReadsOnlyMatchedScopeRules() { var (repo, _, _, mapper) = Create(ttlSeconds: 0); @@ -151,6 +158,41 @@ public class RoleMappingCacheTests await mapper.MapGroupsToRolesAsync(["Admins"]); Assert.Equal(2, repo.MappingReads); + Assert.Equal(0, repo.ScopeReads); // "Admins" matches no Deployer mapping + } + + /// + /// A refresh that read the table before an is not + /// published over it: the next mapping re-reads (review L5). + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task RefreshRacingInvalidate_IsNotPublished() + { + var (repo, _, cache, mapper) = Create(); + var readStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + repo.BeforeMappingRead = async () => + { + readStarted.TrySetResult(); + await release.Task; + }; + + // Refresh #1 snapshots the table WITH the Admins mapping, then stalls. + var inFlight = mapper.MapGroupsToRolesAsync(["Admins"]); + await readStarted.Task; + repo.BeforeMappingRead = null; + + // The mapping is revoked and the cache invalidated while #1 is still in flight. + repo.Mappings.RemoveAll(m => m.Id == 1); + cache.Invalidate(); + release.SetResult(); + Assert.Contains(Roles.Administrator, (await inFlight).Roles); // #1 answers its own caller + + var after = await mapper.MapGroupsToRolesAsync(["Admins"]); + + Assert.Empty(after.Roles); // the stale snapshot was never published + Assert.Equal(2, repo.MappingReads); } /// The cache is shared by mappers built over different (scoped) repositories.