refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,142 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Centralised authorization policy names + the role→permission mapping
/// that defines them.
///
/// <para>
/// The codebase uses a thin role-claim model: each policy expresses a
/// permission, satisfied when the principal carries any role claim
/// (<see cref="JwtTokenService.RoleClaimType"/>) that maps to that
/// permission. Role names are free strings configured via
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping"/> rows
/// (see <see cref="RoleMapper"/>) — there is no permission claim, just a
/// fan-out from role to allowed policies.
/// </para>
///
/// <para>
/// Default role → permission mapping (#23 M7-T15 / Bundle G):
/// <list type="table">
/// <listheader>
/// <term>Role</term>
/// <description>Policies granted</description>
/// </listheader>
/// <item>
/// <term><c>Admin</c></term>
/// <description><see cref="RequireAdmin"/>,
/// <see cref="OperationalAudit"/>, <see cref="AuditExport"/> — admins hold
/// every permission by convention so an Admin-only user never loses
/// access to a new surface.</description>
/// </item>
/// <item>
/// <term><c>Design</c></term>
/// <description><see cref="RequireDesign"/></description>
/// </item>
/// <item>
/// <term><c>Deployment</c></term>
/// <description><see cref="RequireDeployment"/></description>
/// </item>
/// <item>
/// <term><c>Audit</c></term>
/// <description><see cref="OperationalAudit"/>,
/// <see cref="AuditExport"/> — the full audit surface (read + bulk
/// export) per <c>Component-AuditLog.md</c> §"Authorization".</description>
/// </item>
/// <item>
/// <term><c>AuditReadOnly</c></term>
/// <description><see cref="OperationalAudit"/> only — operators who
/// should see the Audit Log + drill in to incidents but not pull bulk
/// CSV exports. Use this when delegating triage without granting
/// forensic-export capability.</description>
/// </item>
/// </list>
/// LDAP group → role mapping is configured via the central UI Admin → LDAP
/// Mappings page (rows in <c>LdapGroupMappings</c>); the same code path
/// reads them whether the role is one of the four built-ins above or any
/// future addition. Adding a role here means adding the LDAP mapping row in
/// the deployment; no schema migration is needed.
/// </para>
/// </summary>
public static class AuthorizationPolicies
{
public const string RequireAdmin = "RequireAdmin";
public const string RequireDesign = "RequireDesign";
public const string RequireDeployment = "RequireDeployment";
/// <summary>
/// Read access to the Audit Log #23 surface (Audit Log page,
/// Configuration Audit Log page, Audit nav group). Granted to the
/// <c>Audit</c> role, the <c>AuditReadOnly</c> role, and the
/// <c>Admin</c> role.
/// </summary>
public const string OperationalAudit = "OperationalAudit";
/// <summary>
/// Permission to pull a bulk CSV export of the Audit Log. Separate from
/// <see cref="OperationalAudit"/> so a triage operator can read the
/// table without being able to exfiltrate it in bulk. Granted to the
/// <c>Audit</c> role and the <c>Admin</c> role.
/// </summary>
public const string AuditExport = "AuditExport";
/// <summary>
/// Roles that satisfy <see cref="OperationalAudit"/>. Held in one place
/// so the seed/docs and the policy stay in lockstep.
/// </summary>
/// <remarks>
/// Public so the ManagementService HTTP API (#23 M8) — which gates the
/// <c>/api/audit/*</c> routes with a manual Basic-Auth + LDAP role check
/// rather than the ASP.NET authorization-policy pipeline — can reuse the
/// exact same role set the <see cref="OperationalAudit"/> policy enforces.
/// </remarks>
public static readonly string[] OperationalAuditRoles = { Roles.Admin, Roles.Audit, Roles.AuditReadOnly };
/// <summary>
/// Roles that satisfy <see cref="AuditExport"/>. A strict subset of
/// <see cref="OperationalAuditRoles"/> — read access does NOT imply
/// export permission.
/// </summary>
/// <remarks>
/// Public for the same reason as <see cref="OperationalAuditRoles"/> —
/// the ManagementService <c>/api/audit/export</c> route checks roles
/// against this set directly.
/// </remarks>
public static readonly string[] AuditExportRoles = { Roles.Admin, Roles.Audit };
/// <summary>
/// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport).
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddScadaBridgeAuthorization(this IServiceCollection services)
{
services.AddAuthorization(options =>
{
options.AddPolicy(RequireAdmin, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Admin));
options.AddPolicy(RequireDesign, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Design));
options.AddPolicy(RequireDeployment, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, Roles.Deployment));
// Multi-role permission policies — the policy succeeds when the
// principal holds ANY of the mapped roles. RequireClaim with
// multiple allowed values is the right primitive: it checks
// whether *any* role claim's value is in the allowed set, so a
// user with role=Admin (and nothing else) satisfies the
// OperationalAudit policy without needing a separate Audit
// role claim.
options.AddPolicy(OperationalAudit, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, OperationalAuditRoles));
options.AddPolicy(AuditExport, policy =>
policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles));
});
return services;
}
}
@@ -0,0 +1,240 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace ZB.MOM.WW.ScadaBridge.Security;
public class JwtTokenService
{
private readonly SecurityOptions _options;
private readonly ILogger<JwtTokenService> _logger;
public const string DisplayNameClaimType = "DisplayName";
public const string UsernameClaimType = "Username";
public const string RoleClaimType = "Role";
public const string SiteIdClaimType = "SiteId";
public const string LastActivityClaimType = "LastActivity";
/// <summary>
/// Fixed issuer bound into every token and required on validation. Binding
/// issuer/audience is defence-in-depth: even though the HMAC key is shared only
/// between the two central nodes, accidental reuse of the same secret for an
/// unrelated internal token would otherwise be silently exploitable.
/// </summary>
public const string TokenIssuer = "scadabridge-central";
/// <summary>Fixed audience bound into every token and required on validation.</summary>
public const string TokenAudience = "scadabridge-central";
/// <summary>Initializes the service and validates the JWT signing key meets the minimum length requirement.</summary>
/// <param name="options">Security options containing the JWT signing key, expiry, and timeout settings.</param>
/// <param name="logger">Logger instance.</param>
public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Fail fast: a missing or short signing key produces trivially forgeable tokens.
// HMAC-SHA256 requires a key of at least 256 bits (32 bytes).
var keyByteLength = string.IsNullOrEmpty(_options.JwtSigningKey)
? 0
: Encoding.UTF8.GetByteCount(_options.JwtSigningKey);
if (keyByteLength < SecurityOptions.MinJwtSigningKeyBytes)
{
throw new InvalidOperationException(
$"SecurityOptions.JwtSigningKey must be at least {SecurityOptions.MinJwtSigningKeyBytes} bytes " +
$"(256 bits) for HMAC-SHA256; the configured key is {keyByteLength} byte(s). " +
"Configure a strong signing key before starting the service.");
}
}
/// <summary>
/// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
/// anchor; when omitted (a brand-new login) it defaults to now. On a token
/// refresh the caller MUST pass the existing anchor forward so the idle window
/// continues to be measured from the user's last genuine activity rather than
/// from token issuance time.
/// </summary>
/// <param name="displayName">Human-readable display name embedded as a claim.</param>
/// <param name="username">Authenticated username embedded as a claim.</param>
/// <param name="roles">Role names to embed as <see cref="RoleClaimType"/> claims.</param>
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
/// <param name="lastActivity">Idle-timeout anchor; defaults to now when null (fresh login).</param>
public string GenerateToken(
string displayName,
string username,
IReadOnlyList<string> roles,
IReadOnlyList<string>? permittedSiteIds,
DateTimeOffset? lastActivity = null)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new(DisplayNameClaimType, displayName),
new(UsernameClaimType, username),
new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
};
foreach (var role in roles)
{
claims.Add(new Claim(RoleClaimType, role));
}
if (permittedSiteIds != null)
{
foreach (var siteId in permittedSiteIds)
{
claims.Add(new Claim(SiteIdClaimType, siteId));
}
}
var token = new JwtSecurityToken(
issuer: TokenIssuer,
audience: TokenAudience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>Validates a JWT string and returns the decoded <see cref="ClaimsPrincipal"/>, or null if the token is invalid or expired.</summary>
/// <param name="token">The JWT string to validate.</param>
/// <returns>The decoded principal, or <c>null</c> if validation fails.</returns>
public ClaimsPrincipal? ValidateToken(string token)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = TokenIssuer,
ValidateAudience = true,
ValidAudience = TokenAudience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = key,
ClockSkew = TimeSpan.Zero
};
try
{
var handler = new JwtSecurityTokenHandler();
var principal = handler.ValidateToken(token, validationParameters, out _);
return principal;
}
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
{
_logger.LogDebug(ex, "Token validation failed");
return null;
}
}
/// <summary>Returns true if the token carried by <paramref name="principal"/> is within the sliding-refresh threshold and should be reissued.</summary>
/// <param name="principal">The current authenticated principal.</param>
/// <returns><c>true</c> when the remaining token lifetime falls below <see cref="SecurityOptions.JwtRefreshThresholdMinutes"/>.</returns>
public bool ShouldRefresh(ClaimsPrincipal principal)
{
var expClaim = principal.FindFirst("exp");
if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix))
return false;
var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix);
var remaining = expiry - DateTimeOffset.UtcNow;
return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes;
}
/// <summary>Returns true if the session has exceeded the idle timeout based on the <see cref="LastActivityClaimType"/> claim.</summary>
/// <param name="principal">The current authenticated principal.</param>
/// <returns><c>true</c> when the elapsed time since last activity exceeds <see cref="SecurityOptions.IdleTimeoutMinutes"/>.</returns>
public bool IsIdleTimedOut(ClaimsPrincipal principal)
{
var lastActivityClaim = principal.FindFirst(LastActivityClaimType);
if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity))
return true;
return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
}
/// <summary>
/// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
/// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
/// triggered by a request, but it must not be treated as user activity — the
/// idle window must keep being measured from the user's last genuine interaction,
/// otherwise the documented 30-minute idle timeout could never fire for a client
/// that polls in the background. Call <see cref="RecordActivity"/> to advance the
/// anchor when handling a genuine user request.
/// <para>
/// A principal that is already past the idle timeout cannot be refreshed: this
/// method returns <c>null</c> (the same "cannot refresh" signal it uses for missing
/// claims). Enforcing the idle check here — rather than relying on the caller to
/// invoke <see cref="IsIdleTimedOut"/> first — guarantees the documented 30-minute
/// idle policy holds regardless of caller discipline; otherwise an idle-expired
/// session could be kept alive indefinitely by background refreshes (Security-014).
/// </para>
/// </summary>
/// <param name="currentPrincipal">The current authenticated principal whose claims carry identity and the last-activity anchor.</param>
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
/// <returns>A new token string, or <c>null</c> if the session is idle-expired or claims are missing.</returns>
public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
{
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
if (displayName == null || username == null)
{
_logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims");
return null;
}
// An idle-expired session must not be renewed — the user must re-login.
if (IsIdleTimedOut(currentPrincipal))
{
_logger.LogInformation(
"Cannot refresh token for {Username}: session is past the idle timeout", username);
return null;
}
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
ReadLastActivity(currentPrincipal));
}
/// <summary>
/// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
/// advanced to now. This is the explicit "user did something" path — distinct
/// from <see cref="RefreshToken"/> — to be called by the request pipeline when
/// handling a genuine user interaction.
/// </summary>
/// <param name="currentPrincipal">The current authenticated principal.</param>
/// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
/// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
/// <returns>A new token string with an updated last-activity anchor, or <c>null</c> if claims are missing.</returns>
public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
{
var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;
if (displayName == null || username == null)
{
_logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
return null;
}
return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
DateTimeOffset.UtcNow);
}
private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
{
var claim = principal.FindFirst(LastActivityClaimType);
return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
? value
: null;
}
}
@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
public record LdapAuthResult(
bool Success,
string? DisplayName,
string? Username,
IReadOnlyList<string>? Groups,
string? ErrorMessage);
@@ -0,0 +1,417 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.ScadaBridge.Security;
public class LdapAuthService
{
private readonly SecurityOptions _options;
private readonly ILogger<LdapAuthService> _logger;
/// <summary>Initializes a new instance of <see cref="LdapAuthService"/> with the given options and logger.</summary>
/// <param name="options">Security configuration options including LDAP server settings.</param>
/// <param name="logger">Logger for authentication diagnostics.</param>
public LdapAuthService(IOptions<SecurityOptions> options, ILogger<LdapAuthService> logger)
{
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
// virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit
// endpoints) can substitute the LDAP bind without standing up a directory.
/// <summary>Authenticates a user against the configured LDAP directory and returns an auth result with roles.</summary>
/// <param name="username">The plain-text username to authenticate.</param>
/// <param name="password">The plain-text password to bind with.</param>
/// <param name="ct">Cancellation token.</param>
public virtual async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username))
return new LdapAuthResult(false, null, null, null, "Username is required.");
if (string.IsNullOrWhiteSpace(password))
return new LdapAuthResult(false, null, null, null, "Password is required.");
// Trim once, up front: a username with leading/trailing whitespace (copy-paste
// artefacts, mobile keyboards) is otherwise passed verbatim into the LDAP filter,
// the fallback bind DN, and — most consequentially — the JWT Username claim and
// audit trail, producing two distinct identities for the same person
// (Security-015). The IsNullOrWhiteSpace guard above already rejects an
// all-whitespace value, so the trimmed result here is always non-empty.
username = NormalizeUsername(username);
// Enforce TLS unless explicitly allowed for dev/test
if (_options.LdapTransport == LdapTransport.None && !_options.AllowInsecureLdap)
{
return new LdapAuthResult(false, null, null, null,
"Insecure LDAP connections are not allowed. Enable TLS or set AllowInsecureLdap for dev/test.");
}
try
{
using var connection = new LdapConnection();
// Bound how long a hung LDAP server can pin a thread-pool thread. The
// `ct` passed to Task.Run below only prevents the work item from starting;
// it cannot interrupt an in-progress blocking Connect/Bind/Search. This
// timeout is the real safeguard (Security-009).
ApplyConnectionTimeout(connection);
// LDAPS: TLS negotiated at connection time. StartTLS: connect plaintext,
// then upgrade the session before any credentials are sent.
if (_options.LdapTransport == LdapTransport.Ldaps)
{
connection.SecureSocketLayer = true;
}
await Task.Run(() => connection.Connect(_options.LdapServer, _options.LdapPort), ct);
if (_options.LdapTransport == LdapTransport.StartTls)
{
await Task.Run(() => connection.StartTls(), ct);
if (!connection.Tls)
{
return new LdapAuthResult(false, null, null, null,
"StartTLS upgrade did not produce an encrypted session.");
}
}
// Resolve the user's actual DN, then bind with their credentials
var bindDn = await ResolveUserDnAsync(connection, username, ct);
await Task.Run(() => connection.Bind(bindDn, password), ct);
// Re-bind as service account for attribute/group lookup (user may lack search rights).
// A failure here is the SYSTEM's misconfiguration (wrong service-account credentials,
// disabled/locked account) — not the user's credential problem. The user bind on the
// line above already succeeded, so masking this as "Invalid username or password" would
// route operators down the wrong incident path (Security-019).
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await BindServiceAccountAsync(connection, ct);
}
// Query for user attributes and group memberships
var displayName = username;
var groups = new List<string>();
var groupLookupSucceeded = true;
try
{
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
LdapConnection.ScopeSub,
searchFilter,
new[] { _options.LdapDisplayNameAttribute, _options.LdapGroupAttribute },
false), ct);
// `HasMore()` is the loop guard for end-of-results; it returns false
// when the enumeration is exhausted. An LdapException thrown by
// `Next()` inside a HasMore()-guarded loop is therefore NOT a benign
// "no more results" sentinel — it is a genuine error (referral failure,
// server-side limit, transport drop mid-enumeration). The previous
// `catch (LdapException) { break; }` silently truncated the group list
// and masked a partial outage (Security-012); such an exception now
// propagates to the outer catch and fails the login.
while (searchResults.HasMore())
{
var entry = searchResults.Next();
var dnAttr = entry.GetAttribute(_options.LdapDisplayNameAttribute);
if (dnAttr != null)
displayName = dnAttr.StringValue;
var groupAttr = entry.GetAttribute(_options.LdapGroupAttribute);
if (groupAttr != null)
{
foreach (var groupDn in groupAttr.StringValueArray)
{
groups.Add(ExtractFirstRdnValue(groupDn));
}
}
}
}
catch (LdapException ex)
{
// A failed group/attribute lookup on initial login means the directory
// is partially unavailable. The design's LDAP-failure rule requires new
// logins to FAIL when LDAP is unavailable — admitting the user here
// would yield an authenticated session with zero roles (Security-012).
_logger.LogWarning(ex, "LDAP group/attribute lookup failed for user {Username}; failing the login per the LDAP-failure rule", username);
groupLookupSucceeded = false;
}
connection.Disconnect();
return BuildAuthResultFromGroupLookup(username, displayName, groups, groupLookupSucceeded);
}
catch (ServiceAccountBindException ex)
{
// Distinct from the user-credential catch below so the operator
// sees the *system* misconfiguration rather than blaming user input
// (Security-019). The inner exception was already logged at Error
// by BindServiceAccountAsync; nothing further to log here.
_ = ex;
return new LdapAuthResult(false, null, username, null,
"Authentication service is misconfigured. Contact an administrator.");
}
catch (LdapException ex)
{
_logger.LogWarning(ex, "LDAP authentication failed for user {Username}", username);
return new LdapAuthResult(false, null, username, null, "Invalid username or password.");
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Unexpected error during LDAP authentication for user {Username}", username);
return new LdapAuthResult(false, null, username, null, "An unexpected error occurred during authentication.");
}
}
/// <summary>
/// Binds the supplied connection as the configured service account. A failure here is
/// a system-misconfiguration condition (Security-019) — wrong service-account DN /
/// password, locked or disabled account, server-side ACL change — not a user-credential
/// problem. The underlying <see cref="LdapException"/> is logged at Error and rethrown
/// as <see cref="ServiceAccountBindException"/> so callers can distinguish it from a
/// user-bind failure.
/// </summary>
private async Task BindServiceAccountAsync(LdapConnection connection, CancellationToken ct)
{
try
{
await Task.Run(() =>
connection.Bind(_options.LdapServiceAccountDn, _options.LdapServiceAccountPassword), ct);
}
catch (LdapException ex)
{
_logger.LogError(ex,
"Service-account rebind failed; check LdapServiceAccountDn / LdapServiceAccountPassword configuration");
throw new ServiceAccountBindException(ex);
}
}
/// <summary>
/// Applies <see cref="SecurityOptions.LdapConnectionTimeoutMs"/> to both the socket
/// connect timeout and the per-operation (bind/search) time limit, so a hung or
/// unresponsive LDAP server cannot pin a thread-pool thread indefinitely. The
/// <c>CancellationToken</c> handed to the <c>Task.Run</c> wrappers only guards
/// work-item scheduling and cannot interrupt an in-progress blocking call.
/// </summary>
private void ApplyConnectionTimeout(LdapConnection connection)
{
var timeoutMs = _options.LdapConnectionTimeoutMs;
if (timeoutMs <= 0)
return;
connection.ConnectionTimeout = timeoutMs;
// LdapConstraints.TimeLimit is the server-side operation time limit in ms.
var constraints = connection.Constraints;
constraints.TimeLimit = timeoutMs;
connection.Constraints = constraints;
}
/// <summary>
/// Resolves the user's full DN. When a service account is configured, performs a
/// search-then-bind lookup. Otherwise falls back to constructing the DN directly.
/// </summary>
private async Task<string> ResolveUserDnAsync(LdapConnection connection, string username, CancellationToken ct)
{
// If a service account is configured, search for the user's actual DN.
// The service-account bind is routed through BindServiceAccountAsync so a
// misconfiguration surfaces distinctly rather than masking as
// "Invalid username or password" (Security-019).
if (!string.IsNullOrWhiteSpace(_options.LdapServiceAccountDn))
{
await BindServiceAccountAsync(connection, ct);
var searchFilter = $"({_options.LdapUserIdAttribute}={EscapeLdapFilter(username)})";
var searchResults = await Task.Run(() =>
connection.Search(
_options.LdapSearchBase,
LdapConnection.ScopeSub,
searchFilter,
new[] { "dn" },
false), ct);
if (searchResults.HasMore())
{
var entry = searchResults.Next();
return entry.Dn;
}
throw new LdapException("User not found", LdapException.NoSuchObject,
$"No entry found for {_options.LdapUserIdAttribute}={username}");
}
// Fallback: construct the bind DN directly from the configured user-id
// attribute. The username is RFC 4514 DN-escaped so it cannot alter the
// DN structure (Security-005). The previous Contains('=') shortcut that
// accepted a raw caller-supplied DN has been removed — accepting an
// arbitrary DN from untrusted input let a client choose the bind identity.
return BuildFallbackUserDn(username, _options.LdapSearchBase, _options.LdapUserIdAttribute);
}
/// <summary>
/// Builds the no-service-account fallback bind DN as
/// <c>{userIdAttribute}={escaped-username}[,{searchBase}]</c>. The username is
/// escaped per RFC 4514 so DN metacharacters in untrusted input cannot inject
/// additional RDN components or change the bind identity.
/// </summary>
/// <param name="username">The username to embed in the DN value.</param>
/// <param name="searchBase">The LDAP search base to append after the RDN, if any.</param>
/// <param name="userIdAttribute">The attribute name (e.g. <c>uid</c> or <c>sAMAccountName</c>) used as the RDN type.</param>
public static string BuildFallbackUserDn(string username, string searchBase, string userIdAttribute)
{
var rdn = $"{userIdAttribute}={EscapeLdapDn(username)}";
return string.IsNullOrWhiteSpace(searchBase) ? rdn : $"{rdn},{searchBase}";
}
/// <summary>
/// Escapes a string for use as an RFC 4514 DN attribute value: the special
/// characters <c>, + " \ &lt; &gt; ;</c> are backslash-escaped, as are a leading
/// or trailing space and a leading <c>#</c>.
/// </summary>
/// <param name="input">The raw string to escape.</param>
public static string EscapeLdapDn(string input)
{
if (string.IsNullOrEmpty(input))
return input;
var sb = new System.Text.StringBuilder(input.Length + 8);
for (var i = 0; i < input.Length; i++)
{
var c = input[i];
var isEdgeSpace = c == ' ' && (i == 0 || i == input.Length - 1);
var isLeadingHash = c == '#' && i == 0;
switch (c)
{
case ',':
case '+':
case '"':
case '\\':
case '<':
case '>':
case ';':
sb.Append('\\').Append(c);
break;
case '\0':
sb.Append("\\00");
break;
default:
if (isEdgeSpace || isLeadingHash)
sb.Append('\\');
sb.Append(c);
break;
}
}
return sb.ToString();
}
private static string EscapeLdapFilter(string input)
{
return input
.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
}
/// <summary>
/// Normalises a username by trimming leading and trailing whitespace. Applied once
/// at the top of <see cref="AuthenticateAsync"/> so the same canonical value flows
/// into the LDAP filter, the fallback bind DN, and the JWT <c>Username</c> claim —
/// avoiding two distinct identities for the same person (Security-015).
/// </summary>
/// <param name="username">The raw username input to normalise.</param>
public static string NormalizeUsername(string username)
=> username?.Trim() ?? string.Empty;
/// <summary>
/// Builds the final <see cref="LdapAuthResult"/> for a login attempt once the user
/// bind has succeeded. When the group/attribute lookup failed
/// (<paramref name="groupLookupSucceeded"/> is false) the directory is partially
/// unavailable, so the login is FAILED per the design's LDAP-failure rule rather
/// than returning an authenticated session with zero roles (Security-012). When the
/// lookup succeeded, an empty <paramref name="groups"/> list is a genuine
/// "no mapped groups" outcome and the login succeeds.
/// </summary>
/// <param name="username">The normalised username that was authenticated.</param>
/// <param name="displayName">The display name resolved from the directory.</param>
/// <param name="groups">The list of group names resolved from the directory.</param>
/// <param name="groupLookupSucceeded">Whether the group/attribute lookup completed without error.</param>
public static LdapAuthResult BuildAuthResultFromGroupLookup(
string username,
string displayName,
IReadOnlyList<string> groups,
bool groupLookupSucceeded)
{
if (!groupLookupSucceeded)
{
return new LdapAuthResult(false, null, username, null,
"The directory is temporarily unavailable. Please try again.");
}
return new LdapAuthResult(true, displayName, username, groups, null);
}
/// <summary>
/// Extracts the value of the first RDN from a DN, e.g.
/// <c>ou=SCADA-Admins,ou=groups,dc=...</c> → <c>SCADA-Admins</c>. The scan is
/// RFC 4514 escape-aware: a backslash-escaped <c>,</c> inside the RDN value does
/// not terminate it, and recognised escape sequences are unescaped, so a group CN
/// that legitimately contains a comma is returned intact (Security-013).
/// </summary>
/// <param name="dn">The distinguished name string to parse.</param>
public static string ExtractFirstRdnValue(string dn)
{
if (string.IsNullOrEmpty(dn))
return dn;
var equalsIndex = dn.IndexOf('=');
if (equalsIndex < 0)
return dn;
var valueStart = equalsIndex + 1;
var sb = new System.Text.StringBuilder(dn.Length - valueStart);
for (var i = valueStart; i < dn.Length; i++)
{
var c = dn[i];
if (c == '\\' && i + 1 < dn.Length)
{
var next = dn[i + 1];
// RFC 4514 hex escape: \XX (two hex digits).
if (i + 2 < dn.Length && IsHexDigit(next) && IsHexDigit(dn[i + 2]))
{
sb.Append((char)Convert.ToInt32(dn.Substring(i + 1, 2), 16));
i += 2;
}
else
{
// Single-character escape (e.g. \, \+ \\ \" \; etc.) — emit the
// escaped character literally and skip the backslash.
sb.Append(next);
i += 1;
}
continue;
}
if (c == ',')
{
// Unescaped comma terminates the first RDN.
break;
}
sb.Append(c);
}
return sb.ToString();
}
private static bool IsHexDigit(char c)
=> (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Transport security mode for the LDAP connection. The design requires either
/// LDAPS or StartTLS in production; <see cref="None"/> is for dev/test only and
/// must be paired with <see cref="SecurityOptions.AllowInsecureLdap"/>.
/// </summary>
public enum LdapTransport
{
/// <summary>
/// LDAPS — TLS negotiated at connection time (typically port 636).
/// </summary>
Ldaps,
/// <summary>
/// StartTLS — connect in plaintext (typically port 389), then upgrade the
/// session to TLS before binding.
/// </summary>
StartTls,
/// <summary>
/// No transport security. Dev/test only — requires
/// <see cref="SecurityOptions.AllowInsecureLdap"/> to be true.
/// </summary>
None
}
@@ -0,0 +1,83 @@
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
namespace ZB.MOM.WW.ScadaBridge.Security;
public class RoleMapper
{
private readonly ISecurityRepository _securityRepository;
/// <summary>Initializes the mapper with the security repository.</summary>
/// <param name="securityRepository">Repository used to retrieve LDAP group-to-role mappings and scope rules.</param>
public RoleMapper(ISecurityRepository securityRepository)
{
_securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository));
}
// virtual: a test seam so HTTP-pipeline tests (e.g. the #23 M8 audit
// endpoints) can substitute the LDAP-group→role resolution.
/// <summary>Maps a list of LDAP group names to ScadaBridge roles and computes site-scope permissions.</summary>
/// <param name="ldapGroups">LDAP group names from the authenticated user's directory entry.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A <see cref="RoleMappingResult"/> containing matched roles, permitted site IDs, and the system-wide flag.</returns>
public virtual async Task<RoleMappingResult> MapGroupsToRolesAsync(
IReadOnlyList<string> ldapGroups,
CancellationToken ct = default)
{
var allMappings = await _securityRepository.GetAllMappingsAsync(ct);
var matchedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var permittedSiteIds = new HashSet<string>();
var hasDeploymentRole = false;
var hasScopedDeploymentMapping = false;
var hasUnscopedDeploymentMapping = false;
foreach (var mapping in allMappings)
{
// Match LDAP group names (case-insensitive)
if (!ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase)))
continue;
matchedRoles.Add(mapping.Role);
if (mapping.Role.Equals(Roles.Deployment, StringComparison.OrdinalIgnoreCase))
{
hasDeploymentRole = true;
var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct);
if (scopeRules.Count > 0)
{
hasScopedDeploymentMapping = true;
foreach (var rule in scopeRules)
{
permittedSiteIds.Add(rule.SiteId.ToString());
}
}
else
{
hasUnscopedDeploymentMapping = true;
}
}
}
// Union semantics (Security-016): a Deployment user is system-wide iff
// *any* matched Deployment mapping has no scope rules. A user in both
// SCADA-Deploy-All (unscoped) and SCADA-Deploy-SiteA (scoped to Site A)
// gets the broader grant, not the narrower one — matching the design's
// "roles are independent — there is no implied hierarchy" rule.
var isSystemWide = hasUnscopedDeploymentMapping
|| (hasDeploymentRole && !hasScopedDeploymentMapping);
// When system-wide, drop any accumulated scope ids — the empty
// permitted set is the system-wide signal downstream consumers
// (SiteScopeService, ManagementActor) already use.
if (isSystemWide)
{
permittedSiteIds.Clear();
}
return new RoleMappingResult(
matchedRoles.ToList(),
permittedSiteIds.ToList(),
isSystemWide);
}
}
@@ -0,0 +1,6 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
public record RoleMappingResult(
IReadOnlyList<string> Roles,
IReadOnlyList<string> PermittedSiteIds,
bool IsSystemWideDeployment);
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Single source of truth for role-name string literals used across the
/// Security module and downstream authorization checks.
/// </summary>
/// <remarks>
/// Role names appear in three independent contexts: <see cref="RoleMapper"/>
/// (LDAP-group → role resolution), <see cref="AuthorizationPolicies"/>
/// (policy <c>RequireClaim</c> values + the audit role arrays), and at LDAP
/// mapping rows configured by an operator. Holding the literals here means a
/// rename either succeeds everywhere or fails to compile, eliminating the
/// "string drift" class that Security-018 documented.
/// </remarks>
public static class Roles
{
public const string Admin = "Admin";
public const string Design = "Design";
public const string Deployment = "Deployment";
public const string Audit = "Audit";
public const string AuditReadOnly = "AuditReadOnly";
}
@@ -0,0 +1,109 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
public class SecurityOptions
{
/// <summary>Hostname or IP address of the LDAP server.</summary>
public string LdapServer { get; set; } = string.Empty;
/// <summary>TCP port for the LDAP connection (default 389; 636 for LDAPS).</summary>
public int LdapPort { get; set; } = 389;
/// <summary>
/// Transport security mode for the LDAP connection. Defaults to LDAPS.
/// Use <see cref="LdapTransport.StartTls"/> to connect on the plaintext port
/// and upgrade the session before binding.
/// </summary>
public LdapTransport LdapTransport { get; set; } = LdapTransport.Ldaps;
/// <summary>
/// True when the configured transport provides encryption (LDAPS or StartTLS).
/// Retained for backward compatibility: assigning a value maps onto
/// <see cref="LdapTransport"/> (true =&gt; LDAPS, false =&gt; None).
/// </summary>
public bool LdapUseTls
{
get => LdapTransport != LdapTransport.None;
set => LdapTransport = value ? LdapTransport.Ldaps : LdapTransport.None;
}
/// <summary>
/// Allow insecure (non-TLS) LDAP connections. ONLY for dev/test with GLAuth.
/// Must be false in production.
/// </summary>
public bool AllowInsecureLdap { get; set; } = false;
/// <summary>
/// Base DN for LDAP searches (e.g., "dc=example,dc=com").
/// </summary>
public string LdapSearchBase { get; set; } = string.Empty;
/// <summary>
/// Service account DN for LDAP user searches (e.g., "cn=admin,dc=example,dc=com").
/// Required for search-then-bind authentication. If empty, direct bind with
/// {LdapUserIdAttribute}={username},{LdapSearchBase} is attempted instead.
/// </summary>
public string LdapServiceAccountDn { get; set; } = string.Empty;
/// <summary>
/// LDAP attribute that identifies a user. Used both for the search-then-bind
/// filter (<c>({LdapUserIdAttribute}={username})</c>) and for constructing the
/// fallback bind DN when no service account is configured, so the two
/// authentication modes are interchangeable. Common values: <c>uid</c> (OpenLDAP),
/// <c>sAMAccountName</c> (Active Directory).
/// </summary>
public string LdapUserIdAttribute { get; set; } = "uid";
/// <summary>
/// Service account password for LDAP user searches.
/// </summary>
public string LdapServiceAccountPassword { get; set; } = string.Empty;
/// <summary>
/// LDAP attribute that contains the user's display name.
/// </summary>
public string LdapDisplayNameAttribute { get; set; } = "cn";
/// <summary>
/// LDAP attribute that contains group membership.
/// </summary>
public string LdapGroupAttribute { get; set; } = "memberOf";
/// <summary>
/// Network timeout, in milliseconds, applied to the LDAP socket connect and to
/// LDAP operations (bind/search). The synchronous Novell LDAP calls are wrapped
/// in <c>Task.Run</c>, where the <c>CancellationToken</c> only guards work-item
/// scheduling — it cannot interrupt an in-progress blocking call. This timeout is
/// the real safeguard: it bounds how long a hung LDAP server can pin a thread-pool
/// thread (Security-009). Default 10 seconds.
/// </summary>
public int LdapConnectionTimeoutMs { get; set; } = 10_000;
/// <summary>
/// Symmetric HMAC-SHA256 signing key for cookie-embedded JWTs. Must be at least
/// 32 bytes (256 bits) — validated at <see cref="JwtTokenService"/> construction.
/// </summary>
public string JwtSigningKey { get; set; } = string.Empty;
/// <summary>
/// Minimum signing-key length in bytes required for HMAC-SHA256 (256 bits).
/// </summary>
public const int MinJwtSigningKeyBytes = 32;
/// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
public int JwtExpiryMinutes { get; set; } = 15;
/// <summary>Session idle timeout in minutes; sessions inactive beyond this are expired.</summary>
public int IdleTimeoutMinutes { get; set; } = 30;
/// <summary>
/// Minutes before token expiry to trigger refresh.
/// </summary>
public int JwtRefreshThresholdMinutes { get; set; } = 5;
/// <summary>
/// When true (default) the authentication cookie is always marked
/// <c>Secure</c> (sent only over HTTPS) — the correct production setting,
/// since the cookie carries the embedded JWT bearer credential. Set false
/// for an HTTP-only deployment such as the local Docker dev cluster: the
/// cookie then uses <c>SameAsRequest</c>, so it is still <c>Secure</c> on
/// any HTTPS request but is usable over plain HTTP.
/// </summary>
public bool RequireHttpsCookie { get; set; } = true;
}
@@ -0,0 +1,70 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Security-020: validates <see cref="SecurityOptions"/> at startup so a
/// missing or empty required LDAP field fails fast at boot with a clear,
/// key-naming message — rather than surfacing minutes or hours later as a
/// generic "An unexpected error occurred during authentication" on the first
/// real login attempt.
///
/// <para>
/// The LDAP-side required fields validated here are <see cref="SecurityOptions.LdapServer"/>
/// (no sane default — the host must be specified) and
/// <see cref="SecurityOptions.LdapSearchBase"/> (the DN root every directory
/// search runs against). A typo in the appsettings section name, a missing
/// environment-variable substitution, or a misconfigured Docker compose file
/// leaves both defaulted to <c>string.Empty</c> — without this validator the
/// process would start cleanly and only fail on the first login when
/// <c>LdapConnection.Connect("")</c> throws a low-level exception that does
/// not name the offending config key.
/// </para>
///
/// <para>
/// <see cref="SecurityOptions.JwtSigningKey"/> is intentionally NOT validated
/// here — it already fails fast at <see cref="JwtTokenService"/> construction
/// (Security-003 fix), with a length-aware error message. Centralising it
/// here would duplicate that guard; leaving it on the constructor keeps the
/// minimum-byte length contract co-located with the type that enforces it.
/// </para>
/// </summary>
public sealed class SecurityOptionsValidator : IValidateOptions<SecurityOptions>
{
/// <summary>
/// The configuration section name <see cref="SecurityOptions"/> is bound
/// to (matches the Host's <c>builder.Configuration.GetSection("Security")</c>
/// call). Exposed so validation messages can name the full
/// <c>Security:Field</c> key the operator would edit, not just the field
/// name.
/// </summary>
public const string ConfigSectionName = "Security";
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, SecurityOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var failures = new List<string>();
if (string.IsNullOrWhiteSpace(options.LdapServer))
{
failures.Add(
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapServer)} is required " +
"but was empty or whitespace — set it to the LDAP server hostname or IP " +
"(e.g. \"ldap.example.com\").");
}
if (string.IsNullOrWhiteSpace(options.LdapSearchBase))
{
failures.Add(
$"{ConfigSectionName}:{nameof(SecurityOptions.LdapSearchBase)} is required " +
"but was empty or whitespace — set it to the search-base DN " +
"(e.g. \"dc=example,dc=com\").");
}
return failures.Count == 0
? ValidateOptionsResult.Success
: ValidateOptionsResult.Fail(failures);
}
}
@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Thrown by <see cref="LdapAuthService"/> when the configured LDAP service-account
/// rebind fails. Distinct from a user-bind <c>LdapException</c> so the outer login
/// pipeline can surface "Authentication service is misconfigured" instead of
/// masking the system fault as "Invalid username or password" (Security-019).
/// </summary>
public sealed class ServiceAccountBindException : Exception
{
public ServiceAccountBindException(Exception innerException)
: base("LDAP service-account rebind failed", innerException)
{
}
}
@@ -0,0 +1,101 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.ScadaBridge.Security;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers LDAP authentication, JWT token service, role mapper, cookie authentication, and authorization policies.
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddSecurity(this IServiceCollection services)
{
services.AddScoped<LdapAuthService>();
services.AddScoped<JwtTokenService>();
services.AddScoped<RoleMapper>();
// Security-020: register the IValidateOptions<SecurityOptions> so a
// missing/empty LdapServer or LdapSearchBase fails fast at startup
// with a clear, key-naming message rather than a generic LDAP error
// on the first real login. ValidateOnStart() forces the validation to
// run during host startup rather than lazily on the first
// IOptions<SecurityOptions> resolve. TryAddEnumerable so multiple
// AddSecurity calls (or future additional validators) don't pile up.
services.AddOptions<SecurityOptions>().ValidateOnStart();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IValidateOptions<SecurityOptions>, SecurityOptionsValidator>());
// Register ASP.NET Core authentication with cookie scheme
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.Cookie.Name = "ZB.MOM.WW.ScadaBridge.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
// Cookie.SecurePolicy is set in the PostConfigure block below so it
// can honour SecurityOptions.RequireHttpsCookie.
});
// CentralUI-005: configure the cookie session as a sliding window so the
// code matches the documented policy ("sliding refresh, 30-minute idle
// timeout"). ASP.NET cookie auth exposes a single ExpireTimeSpan plus a
// SlidingExpiration flag — it cannot natively model a 15-minute sliding
// token AND a separate 30-minute absolute idle cap. The faithful
// interpretation: the cookie window IS the idle timeout
// (SecurityOptions.IdleTimeoutMinutes, default 30) and SlidingExpiration
// renews it on activity (the middleware re-issues the cookie once past
// the halfway mark of the window). An active user is therefore kept
// signed in; an idle user is signed out after the idle timeout. The
// 15-minute JwtExpiryMinutes governs the lifetime of the embedded JWT
// itself (see JwtTokenService) — a separate layer from the cookie
// session window. Bound here via PostConfigure so SecurityOptions
// (configured by the Host after AddSecurity) is honoured.
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
{
var idleMinutes = securityOptions.Value.IdleTimeoutMinutes;
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes);
cookieOptions.SlidingExpiration = true;
// The cookie carries the embedded JWT bearer credential. Production
// keeps it HTTPS-only (Always); an HTTP-only deployment (e.g. the
// local Docker dev cluster) opts out via RequireHttpsCookie=false and
// uses SameAsRequest — still Secure on any HTTPS request.
cookieOptions.Cookie.SecurePolicy = securityOptions.Value.RequireHttpsCookie
? Microsoft.AspNetCore.Http.CookieSecurePolicy.Always
: Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
// Security-021: when the operator opts out of HTTPS-only cookies,
// log a Warning so an HTTP-only deployment is at least audible in
// the startup log. The cookie carries the embedded JWT bearer
// credential — over plain HTTP that travels in cleartext on every
// request. The default is true; this branch fires only on an
// explicit opt-out (typically the dev Docker cluster).
if (!securityOptions.Value.RequireHttpsCookie)
{
loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
}
});
services.AddScadaBridgeAuthorization();
return services;
}
/// <summary>
/// Registers security-related Akka actors (placeholder for future actor registrations).
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddSecurityActors(this IServiceCollection services)
{
// Phase 0: placeholder for Akka actor registration
return services;
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.Authorization" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
</ItemGroup>
</Project>