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:
@@ -0,0 +1,62 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory, process-lifetime store of the bearer tokens the CLI obtains from
|
||||
/// <c>POST /auth/token</c> (review 2026-10-21 05/U1, 05/P1). One entry per
|
||||
/// <c>(management URL, username, password)</c> so every <see cref="ManagementHttpClient"/> a
|
||||
/// single CLI invocation creates shares one credential exchange — one LDAP bind per invocation
|
||||
/// instead of one per management call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Tokens are never written to disk, logged or echoed; the store dies with the process. The key
|
||||
/// includes a SHA-256 digest of the password rather than the password itself, so a different
|
||||
/// password for the same user never reuses a token minted for the old one.
|
||||
/// </remarks>
|
||||
internal sealed class CliTokenStore
|
||||
{
|
||||
/// <summary>The process-wide store used by the public <see cref="ManagementHttpClient"/> constructor.</summary>
|
||||
public static CliTokenStore Shared { get; } = new();
|
||||
|
||||
private readonly ConcurrentDictionary<string, Session> _sessions = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns the (shared) session for a management URL and credential pair.</summary>
|
||||
/// <param name="baseUrl">The management base URL.</param>
|
||||
/// <param name="username">The CLI username.</param>
|
||||
/// <param name="password">The CLI password.</param>
|
||||
/// <returns>The session carrying the cached token, if any.</returns>
|
||||
public Session For(string baseUrl, string username, string password)
|
||||
{
|
||||
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
|
||||
var key = $"{baseUrl.TrimEnd('/').ToLowerInvariant()}\n{username}\n{digest}";
|
||||
return _sessions.GetOrAdd(key, _ => new Session());
|
||||
}
|
||||
|
||||
/// <summary>Token state for one credential pair against one server.</summary>
|
||||
internal sealed class Session
|
||||
{
|
||||
/// <summary>Serialises token exchanges so concurrent calls share one bind.</summary>
|
||||
public SemaphoreSlim Gate { get; } = new(1, 1);
|
||||
|
||||
/// <summary>The current bearer token, or null when none is held.</summary>
|
||||
public string? Token { get; set; }
|
||||
|
||||
/// <summary>When <see cref="Token"/> should be treated as stale (expiry minus a safety margin).</summary>
|
||||
public DateTimeOffset RefreshAfter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once the server has shown it does not take bearer tokens on the management
|
||||
/// surfaces (no <c>/auth/token</c>, an older server whose tokens nothing accepts, or a
|
||||
/// login-disabled deployment): every later call goes straight to HTTP Basic.
|
||||
/// </summary>
|
||||
public bool BasicOnly { get; set; }
|
||||
|
||||
/// <summary>True while a non-stale token is held.</summary>
|
||||
/// <param name="now">The current instant.</param>
|
||||
/// <returns>Whether <see cref="Token"/> can be sent.</returns>
|
||||
public bool HasUsableToken(DateTimeOffset now) => Token is not null && now < RefreshAfter;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
@@ -90,12 +89,44 @@ public static class DebugCommands
|
||||
private static async Task<int> StreamDebugAsync(string baseUrl, string username, string password, int instanceId, string format)
|
||||
{
|
||||
var hubUrl = baseUrl.TrimEnd('/') + "/hubs/debug-stream";
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
|
||||
// Exchange the credentials for a bearer token once (review 2026-10-21 05/U1, 05/P1) so
|
||||
// the hub handshake — and every automatic reconnect — costs no LDAP bind. The hub
|
||||
// authenticates through ManagementAuthenticator, which accepts the token in the
|
||||
// Authorization header; the SignalR .NET client sends the AccessTokenProvider value
|
||||
// there on negotiate and on the WebSocket upgrade, and re-invokes the provider on each
|
||||
// reconnect, so a token that expired mid-stream is re-exchanged. A server without
|
||||
// bearer support (or SCADABRIDGE_AUTH_MODE=basic) keeps the Basic header.
|
||||
using var authClient = new ManagementHttpClient(baseUrl, username, password);
|
||||
ManagementHttpClient.BearerResult bearer;
|
||||
try
|
||||
{
|
||||
bearer = await authClient.TryGetBearerTokenAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (bearer.Failure is not null)
|
||||
{
|
||||
OutputFormatter.WriteError(bearer.Failure.Error ?? "Authentication failed.", bearer.Failure.ErrorCode ?? "AUTH_FAILED");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var connection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl, options =>
|
||||
{
|
||||
options.Headers.Add("Authorization", $"Basic {credentials}");
|
||||
if (bearer.Token is not null)
|
||||
{
|
||||
options.AccessTokenProvider = async () =>
|
||||
(await authClient.TryGetBearerTokenAsync(CancellationToken.None)).Token;
|
||||
}
|
||||
else
|
||||
{
|
||||
options.Headers.Add("Authorization", $"Basic {authClient.BasicCredentials}");
|
||||
}
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
@@ -1,12 +1,52 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the central management surfaces (<c>POST /management</c>, the audit REST
|
||||
/// endpoints). Authenticates with a bearer token exchanged once per process at
|
||||
/// <c>POST /auth/token</c>, falling back to HTTP Basic when the server does not offer bearer
|
||||
/// tokens (review 2026-10-21 05/U1, 05/P1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Bearer flow.</b> The first call a process makes for a given URL + credential pair posts
|
||||
/// the credentials to <c>/auth/token</c> (one LDAP bind) and keeps the returned JWT in memory
|
||||
/// (<see cref="CliTokenStore"/>; never persisted or logged). Every later call — from this or any
|
||||
/// other client instance in the same process — sends <c>Authorization: Bearer</c>, which the
|
||||
/// server validates without LDAP. A token nearing expiry is re-exchanged. A bearer call
|
||||
/// answered <c>401</c> re-exchanges once (unless the token was minted for that very call) and,
|
||||
/// if still refused, retries with Basic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Basic fallback.</b> A server whose <c>/auth/token</c> is absent (404/405), disabled
|
||||
/// (login-disabled deployment, 404) or does not advertise <c>management_bearer: true</c> (an
|
||||
/// older server, whose tokens no management surface accepts) is remembered as Basic-only for
|
||||
/// the rest of the process. A transient exchange failure (5xx) uses Basic for that call only.
|
||||
/// Bad credentials (<c>401</c>) and a throttle lockout (<c>429</c>) from the exchange are
|
||||
/// returned as-is — retrying them with Basic would only add a second failed bind against the
|
||||
/// login throttle.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>SCADABRIDGE_AUTH_MODE=basic</c> disables the exchange (Basic on every call).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class ManagementHttpClient : IDisposable
|
||||
{
|
||||
/// <summary>Environment variable selecting the auth mode: <c>token</c> (default) or <c>basic</c>.</summary>
|
||||
public const string AuthModeEnvVar = "SCADABRIDGE_AUTH_MODE";
|
||||
|
||||
/// <summary>Re-exchange a token this long before its stated expiry.</summary>
|
||||
internal static readonly TimeSpan TokenRefreshMargin = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AuthenticationHeaderValue _basic;
|
||||
private readonly string _username;
|
||||
private readonly string _password;
|
||||
private readonly CliTokenStore.Session? _session;
|
||||
|
||||
/// <summary>
|
||||
/// Default bound on the CONNECT phase only (30 s) — how long a TCP/TLS
|
||||
@@ -81,10 +121,19 @@ public class ManagementHttpClient : IDisposable
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
},
|
||||
baseUrl, username, password)
|
||||
baseUrl, username, password,
|
||||
IsBasicAuthModeForced() ? null : CliTokenStore.Shared)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <see cref="AuthModeEnvVar"/> is <c>basic</c>, which disables the token
|
||||
/// exchange for the process.
|
||||
/// </summary>
|
||||
/// <returns>Whether HTTP Basic is forced on every call.</returns>
|
||||
internal static bool IsBasicAuthModeForced() =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(AuthModeEnvVar)?.Trim(), "basic", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Test-only constructor that accepts a pre-built <see cref="HttpClient"/> (typically
|
||||
/// over a stub <see cref="HttpMessageHandler"/>) so the request/response handling can
|
||||
@@ -94,16 +143,217 @@ public class ManagementHttpClient : IDisposable
|
||||
/// <param name="baseUrl">The base URL for the management API.</param>
|
||||
/// <param name="username">The username for HTTP Basic authentication.</param>
|
||||
/// <param name="password">The password for HTTP Basic authentication.</param>
|
||||
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password)
|
||||
/// <param name="tokenStore">
|
||||
/// Bearer-token store to exchange credentials through; <see langword="null"/> (the default)
|
||||
/// sends HTTP Basic on every call, which keeps stub-handler tests to a single request.
|
||||
/// </param>
|
||||
internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password, CliTokenStore? tokenStore = null)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
// Test seam: exposes the constructed HttpClient's effective Timeout
|
||||
// without requiring reflection.
|
||||
EffectiveTimeout = httpClient.Timeout;
|
||||
_httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
_username = username;
|
||||
_password = password;
|
||||
_basic = new AuthenticationHeaderValue(
|
||||
"Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")));
|
||||
_session = tokenStore?.For(baseUrl, username, password);
|
||||
}
|
||||
|
||||
/// <summary>The HTTP Basic credential value (base64 <c>user:password</c>), for the debug-stream hub fallback.</summary>
|
||||
internal string BasicCredentials => _basic.Parameter!;
|
||||
|
||||
/// <summary>Outcome of <see cref="TryGetBearerTokenAsync"/>.</summary>
|
||||
/// <param name="Token">The bearer token, or <see langword="null"/> when the caller should use Basic.</param>
|
||||
/// <param name="Failure">A credential/throttle failure from the exchange that must be reported, not retried with Basic.</param>
|
||||
internal readonly record struct BearerResult(string? Token, ManagementResponse? Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a usable bearer token for this client's credentials, exchanging at
|
||||
/// <c>/auth/token</c> if none is cached — used by the debug-stream hub, which cannot replay a
|
||||
/// rejected handshake. <see cref="BearerResult.Token"/> is null when the caller should fall
|
||||
/// back to Basic; <see cref="BearerResult.Failure"/> is set for bad credentials or a lockout.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancels the exchange.</param>
|
||||
/// <returns>The token, a fall-back-to-Basic signal, or a failure to report.</returns>
|
||||
internal async Task<BearerResult> TryGetBearerTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var auth = await AcquireAuthAsync(forceExchange: false, cancellationToken).ConfigureAwait(false);
|
||||
if (auth.Failure is not null)
|
||||
{
|
||||
var failure = await ToManagementResponseAsync(auth.Failure, cancellationToken).ConfigureAwait(false);
|
||||
auth.Failure.Dispose();
|
||||
return new BearerResult(null, failure);
|
||||
}
|
||||
|
||||
return new BearerResult(auth.Header.Scheme == "Bearer" ? auth.Header.Parameter : null, null);
|
||||
}
|
||||
|
||||
private readonly record struct AuthChoice(AuthenticationHeaderValue Header, bool FreshlyMinted, HttpResponseMessage? Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Picks the Authorization header for the next request: the cached bearer token, a freshly
|
||||
/// exchanged one, or Basic. A non-null <see cref="AuthChoice.Failure"/> is the exchange's own
|
||||
/// 401/429 response, to be surfaced to the caller as-is.
|
||||
/// </summary>
|
||||
private async Task<AuthChoice> AcquireAuthAsync(bool forceExchange, CancellationToken ct)
|
||||
{
|
||||
var session = _session;
|
||||
if (session is null || session.BasicOnly)
|
||||
return new AuthChoice(_basic, false, null);
|
||||
|
||||
try
|
||||
{
|
||||
await session.Gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (ex is not TaskCanceledException)
|
||||
{
|
||||
// Callers map TaskCanceledException to their TIMEOUT result, as for the send itself.
|
||||
throw new TaskCanceledException(ex.Message, ex, ct);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (session.BasicOnly)
|
||||
return new AuthChoice(_basic, false, null);
|
||||
if (!forceExchange && session.HasUsableToken(DateTimeOffset.UtcNow))
|
||||
return new AuthChoice(new AuthenticationHeaderValue("Bearer", session.Token), false, null);
|
||||
|
||||
session.Token = null;
|
||||
using var form = new FormUrlEncodedContent(
|
||||
[
|
||||
new KeyValuePair<string, string>("username", _username),
|
||||
new KeyValuePair<string, string>("password", _password),
|
||||
]);
|
||||
var response = await _httpClient.PostAsync("auth/token", form, ct).ConfigureAwait(false);
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.TooManyRequests)
|
||||
return new AuthChoice(_basic, false, response);
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed)
|
||||
{
|
||||
session.BasicOnly = true;
|
||||
return new AuthChoice(_basic, false, null);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new AuthChoice(_basic, false, null); // transient: Basic for this call only
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (!TryParseToken(body, out var token, out var lifetime))
|
||||
{
|
||||
// An older server: /auth/token exists but no management surface takes its tokens.
|
||||
session.BasicOnly = true;
|
||||
return new AuthChoice(_basic, false, null);
|
||||
}
|
||||
|
||||
session.Token = token;
|
||||
var margin = lifetime > TokenRefreshMargin * 2 ? TokenRefreshMargin : TimeSpan.Zero;
|
||||
session.RefreshAfter = DateTimeOffset.UtcNow + lifetime - margin;
|
||||
return new AuthChoice(new AuthenticationHeaderValue("Bearer", token), true, null);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseToken(string body, out string token, out TimeSpan lifetime)
|
||||
{
|
||||
token = string.Empty;
|
||||
lifetime = TimeSpan.FromMinutes(15);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object
|
||||
|| !root.TryGetProperty("management_bearer", out var bearer) || bearer.ValueKind != JsonValueKind.True
|
||||
|| !root.TryGetProperty("access_token", out var accessToken) || accessToken.ValueKind != JsonValueKind.String
|
||||
|| string.IsNullOrWhiteSpace(accessToken.GetString()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
token = accessToken.GetString()!;
|
||||
if (root.TryGetProperty("expires_in", out var expiresIn) && expiresIn.TryGetInt32(out var seconds) && seconds > 0)
|
||||
lifetime = TimeSpan.FromSeconds(seconds);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a request built by <paramref name="buildRequest"/> with the chosen credentials,
|
||||
/// applying the bearer refresh-once-then-Basic policy on a 401.
|
||||
/// </summary>
|
||||
private async Task<HttpResponseMessage> SendAuthenticatedAsync(
|
||||
Func<HttpRequestMessage> buildRequest, HttpCompletionOption completion, CancellationToken ct)
|
||||
{
|
||||
var auth = await AcquireAuthAsync(forceExchange: false, ct).ConfigureAwait(false);
|
||||
if (auth.Failure is not null)
|
||||
return auth.Failure;
|
||||
|
||||
var response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer")
|
||||
return response;
|
||||
|
||||
// The server refused our bearer token. A token cached from an earlier call may simply be
|
||||
// stale (expired, signing key rotated): re-exchange once and retry.
|
||||
if (!auth.FreshlyMinted)
|
||||
{
|
||||
response.Dispose();
|
||||
auth = await AcquireAuthAsync(forceExchange: true, ct).ConfigureAwait(false);
|
||||
if (auth.Failure is not null)
|
||||
return auth.Failure;
|
||||
response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer")
|
||||
return response;
|
||||
}
|
||||
|
||||
// A token minted moments ago is still refused (e.g. the node that answered does not share
|
||||
// the signing key): fall back to Basic and stop exchanging for this process.
|
||||
response.Dispose();
|
||||
response = await SendWithAsync(buildRequest, _basic, completion, ct).ConfigureAwait(false);
|
||||
if (response.StatusCode != HttpStatusCode.Unauthorized && _session is not null)
|
||||
_session.BasicOnly = true;
|
||||
return response;
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> SendWithAsync(
|
||||
Func<HttpRequestMessage> buildRequest, AuthenticationHeaderValue header, HttpCompletionOption completion, CancellationToken ct)
|
||||
{
|
||||
var request = buildRequest();
|
||||
request.Headers.Authorization = header;
|
||||
return _httpClient.SendAsync(request, completion, ct);
|
||||
}
|
||||
|
||||
private static async Task<ManagementResponse> ToManagementResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return new ManagementResponse((int)response.StatusCode, responseBody, null, null);
|
||||
|
||||
string? error;
|
||||
string? code = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(responseBody);
|
||||
error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody;
|
||||
code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
error = responseBody;
|
||||
}
|
||||
|
||||
return new ManagementResponse((int)response.StatusCode, null, error, code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -120,12 +370,15 @@ public class ManagementHttpClient : IDisposable
|
||||
var body = JsonSerializer.Serialize(new { command = commandName, payload },
|
||||
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.PostAsync("management", content, cts.Token);
|
||||
httpResponse = await SendAuthenticatedAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Post, "management")
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
},
|
||||
HttpCompletionOption.ResponseContentRead, cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
@@ -165,7 +418,7 @@ public class ManagementHttpClient : IDisposable
|
||||
/// <c>/api/audit/query</c> endpoint) and returns the
|
||||
/// response body. Unlike <see cref="SendCommandAsync"/>, this does not wrap the call
|
||||
/// in the <c>POST /management</c> command envelope — the audit endpoints are plain
|
||||
/// REST resources. Authentication (HTTP Basic) and the base address are shared.
|
||||
/// REST resources. Authentication (bearer token or HTTP Basic) and the base address are shared.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path relative to the base URL, with query string.</param>
|
||||
/// <param name="timeout">The request timeout.</param>
|
||||
@@ -177,7 +430,9 @@ public class ManagementHttpClient : IDisposable
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.GetAsync(relativePath, cts.Token);
|
||||
httpResponse = await SendAuthenticatedAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
||||
HttpCompletionOption.ResponseContentRead, cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
@@ -216,7 +471,7 @@ public class ManagementHttpClient : IDisposable
|
||||
/// maintenance endpoints) with a JSON body and returns the response. Unlike
|
||||
/// <see cref="SendCommandAsync"/>, this does not wrap the call in the
|
||||
/// <c>POST /management</c> command envelope — these are plain REST resources.
|
||||
/// Authentication (HTTP Basic) and the base address are shared.
|
||||
/// Authentication (bearer token or HTTP Basic) and the base address are shared.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">Path relative to the base URL.</param>
|
||||
/// <param name="body">The JSON body to send, or <c>null</c> for an empty body.</param>
|
||||
@@ -226,12 +481,15 @@ public class ManagementHttpClient : IDisposable
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
|
||||
var content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json");
|
||||
|
||||
HttpResponseMessage httpResponse;
|
||||
try
|
||||
{
|
||||
httpResponse = await _httpClient.PostAsync(relativePath, content, cts.Token);
|
||||
httpResponse = await SendAuthenticatedAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Post, relativePath)
|
||||
{
|
||||
Content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json"),
|
||||
},
|
||||
HttpCompletionOption.ResponseContentRead, cts.Token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
@@ -276,7 +534,9 @@ public class ManagementHttpClient : IDisposable
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>The raw HTTP response message for streaming.</returns>
|
||||
public async Task<HttpResponseMessage> SendGetStreamAsync(string relativePath, CancellationToken cancellationToken)
|
||||
=> await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
=> await SendAuthenticatedAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Get, relativePath),
|
||||
HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the underlying HTTP client.
|
||||
|
||||
@@ -28,7 +28,7 @@ scadabridge --url http://central-host:5000 <command>
|
||||
scadabridge --url http://localhost:9000 <command>
|
||||
```
|
||||
|
||||
The management API is stateless (Basic Auth per request), so sticky sessions are unnecessary. Direct node URLs (`9001`/`9002`) are useful for diagnostics; use Traefik for active-node routing and failover.
|
||||
The management API is stateless (a self-contained bearer token, or Basic Auth per request), so sticky sessions are unnecessary. Direct node URLs (`9001`/`9002`) are useful for diagnostics; use Traefik for active-node routing and failover.
|
||||
|
||||
## Global Options
|
||||
|
||||
@@ -61,6 +61,7 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
|
||||
| `SCADABRIDGE_FORMAT` | Default output format (overrides config file) |
|
||||
| `SCADABRIDGE_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
|
||||
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
|
||||
| `SCADABRIDGE_AUTH_MODE` | `token` (default) exchanges credentials for a bearer token once per process; `basic` sends HTTP Basic on every call (see *Authentication* below). |
|
||||
|
||||
## Output
|
||||
|
||||
@@ -2672,13 +2673,23 @@ client deadline; explicit bundle/Flow Debugger timeouts retain their own policie
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It carries HTTP **Basic** credentials from `--username`/`--password`. Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.
|
||||
The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It authenticates with a bearer token (see *Authentication*). Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`.
|
||||
|
||||
There is no Akka dependency in the CLI at all: it does not join the cluster, does not use `ClusterClient`, and does not contact a `ClusterClientReceptionist`. (Earlier revisions of this document described a ClusterClient transport that was never built.)
|
||||
|
||||
An `HttpClient` is created per command invocation and disposed when the command completes.
|
||||
|
||||
Role enforcement is applied by the ManagementActor on the server side. The CLI sends `--username` / `--password` as HTTP Basic credentials. The server performs LDAP authentication, resolves group memberships, and maps them to roles such as Administrator, Designer, and Deployer. Operations require the appropriate role — for example, creating templates requires `Designer`, deploying requires `Deployer`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles.
|
||||
Role enforcement is applied by the ManagementActor on the server side. The server performs LDAP authentication (once, at the token exchange — or per call on the Basic fallback), resolves group memberships, and maps them to roles such as Administrator, Designer, and Deployer. Operations require the appropriate role — for example, creating templates requires `Designer`, deploying requires `Deployer`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles.
|
||||
|
||||
## Authentication
|
||||
|
||||
The CLI resolves `--username` / `--password` (or `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD`) and, on the first management call of the process, exchanges them at `POST /auth/token` for a short-lived bearer JWT (default 15 minutes) carrying the username, roles and permitted sites. That exchange is the invocation's only LDAP bind: every later call — `/management`, the audit REST endpoints, and `debug stream` (including its reconnects) — sends `Authorization: Bearer <token>`, which the server validates without contacting LDAP, so a short LDAP outage does not interrupt a run that already holds a token.
|
||||
|
||||
- The token is kept **in memory only** (never written to disk, logged or printed) and dies with the process.
|
||||
- A call refused with `401` re-exchanges once, then retries with HTTP Basic.
|
||||
- A server without bearer support — no `/auth/token`, a login-disabled deployment, or an older server whose token response lacks `management_bearer: true` — is used with HTTP Basic for the rest of the process (an LDAP bind per call, as before).
|
||||
- Bad credentials or a login-throttle lockout at the exchange are reported directly (exit 1) and not retried with Basic.
|
||||
- Set `SCADABRIDGE_AUTH_MODE=basic` to skip the exchange entirely.
|
||||
|
||||
## Issues & Missing Features
|
||||
|
||||
|
||||
Reference in New Issue
Block a user