Files
ScadaBridge/docs/components/Security.md
T

18 KiB

Security & Auth

The Security & Auth component handles user authentication against an LDAP/Active Directory server and enforces role-based authorization across all central cluster operations. It owns the cookie session model (interactive UI) and the bearer-JWT model (CLI/API), the LDAP-group-to-role mapping pipeline, site-scoped deployment permissions, and the inbound API key management seam.

Overview

Security & Auth (#10) runs exclusively on the central cluster — sites have no user-facing interface and perform no independent authentication. The component code lives in src/ZB.MOM.WW.ScadaBridge.Security/, which is a component library (it accepts no IConfiguration directly; wiring is Options-pattern only). The Host composition root calls AddZbLdapAuth with the ScadaBridge:Security:Ldap section before calling AddSecurity, because the shared LDAP service is config-coupled and the component library is not allowed to bind configuration itself.

The component registers:

  • JwtTokenService — mints and validates the short-lived HS256 bearer JWT issued by POST /auth/token for CLI/API callers (it no longer participates in the interactive cookie session; the sliding-refresh/idle members were removed in the 2026-10-21 review remediation).
  • RoleMapper — DB-backed LDAP-group-to-role resolution with site-scope union semantics.
  • ScadaBridgeGroupRoleMapper — adapter exposing RoleMapper on the shared IGroupRoleMapper<string> seam.
  • HttpAuditActorAccessor — resolves the authenticated username from the ambient HTTP context for audit Actor stamping.
  • ASP.NET Core cookie authentication (sliding idle window, HttpOnly/Secure defaults via ZbCookieDefaults.Apply).
  • Authorization policies (RequireAdmin, RequireDesign, RequireDeployment, OperationalAudit, AuditExport).

Key Concepts

Direct LDAP bind

Authentication uses a direct username/password bind against the LDAP/AD server via the shared ILdapAuthService (ZB.MOM.WW.Auth.Ldap). The flow is: service-account bind → search for the user entry by username → user-credential bind → group-membership query. The app never caches credentials locally. LDAPS (port 636) or StartTLS is required for production; the AllowInsecure flag in LdapOptions gates unencrypted use to explicitly opted-in deployments (local dev only). No Kerberos/NTLM path exists.

LDAP failure behavior is fail-closed at the login boundary and fail-open at the session boundary: a new login fails immediately if the directory is unreachable; an active cookie session continues with its current claims (its mid-session role refresh is DB-only and never calls LDAP), and a CLI holding a bearer token keeps working until the token expires. This avoids disrupting engineers mid-work during a brief directory outage. Group-membership changes are picked up at the next login.

Interactive UI. POST /auth/login signs the user in with ASP.NET Core cookie authentication. The HttpOnly/Secure cookie carries a data-protected claims ticket (no embedded JWT) built by the shared SessionClaimBuilder: display name, username, roles, permitted site IDs (site-scoped Deployer), the raw LDAP groups and the idle / role-refresh anchors. The cookie is the transport because Blazor Server's SignalR circuits do not carry Authorization headers on reconnect. Both central nodes share the Data Protection key ring, so either node validates a cookie the other issued. The cookie's ExpireTimeSpan is IdleTimeoutMinutes (default 30) with sliding expiration; passive traffic does not renew it. Once the role-refresh anchor is older than RoleRefreshThresholdMinutes (default 15) the validation hook re-runs the DB-backed RoleMapper on the stored groups (no LDAP call).

CLI / API. POST /auth/token performs one LDAP bind per invocation and returns a short-lived HS256 JWT (JwtExpiryMinutes, default 15) signed with JwtSigningKey, shared by both central nodes; the response advertises management_bearer: true. ManagementAuthenticator accepts Authorization: Bearer alongside HTTP Basic on POST /management, the audit REST endpoints and the debug-stream hub. Roles inside a valid token are authoritative until it expires, so a revocation reaches a bearer caller within JwtExpiryMinutes; rotating JwtSigningKey invalidates every token. Under DisableLogin, /auth/token answers 404. Full spec: docs/requirements/Component-Security.md.

Claims in the bearer token:

Claim type Constant Value
ZbClaimTypes.DisplayName JwtTokenService.DisplayNameClaimType Human-readable display name
ZbClaimTypes.Username JwtTokenService.UsernameClaimType Authenticated username
ClaimTypes.Role (URI) JwtTokenService.RoleClaimType One claim per role
ZbClaimTypes.ScopeId JwtTokenService.SiteIdClaimType One claim per permitted site (Deployer only)

Claim-type maps are disabled on mint and validate (MapInboundClaims = false), so the canonical role URI and zb: claim types stay byte-identical in the token and in every policy check; validation allows a 30 s clock skew for node-to-node drift.

Architecture

Registration split between Host and component

AddSecurity (component library) registers everything except the LDAP service itself:

public static IServiceCollection AddSecurity(this IServiceCollection services)
{
    services.AddScoped<JwtTokenService>();
    services.AddScoped<RoleMapper>();
    services.AddHttpContextAccessor();
    services.AddSingleton<IAuditActorAccessor, HttpAuditActorAccessor>();
    services.AddScoped<IGroupRoleMapper<string>, ScadaBridgeGroupRoleMapper>();

    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddCookie(options =>
        {
            options.LoginPath = "/login";
            options.LogoutPath = "/auth/logout";
        });

    services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
        .Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
        {
            ZbCookieDefaults.Apply(
                cookieOptions,
                requireHttps: securityOptions.Value.RequireHttpsCookie,
                idleTimeout: TimeSpan.FromMinutes(securityOptions.Value.IdleTimeoutMinutes));

            var cookieName = securityOptions.Value.CookieName;
            cookieOptions.Cookie.Name = string.IsNullOrWhiteSpace(cookieName)
                ? SecurityOptions.DefaultCookieName
                : cookieName;
        });

    services.AddScadaBridgeAuthorization();
    return services;
}

The Host composition root calls AddZbLdapAuth(configuration, LdapSectionPath) before AddSecurity(). AddZbLdapAuth registers ILdapAuthService as a singleton, binds LdapOptions from ScadaBridge:Security:Ldap, and registers IValidateOptions<LdapOptions> with ValidateOnStart so a misconfigured directory fails at boot rather than at first login. The Host also registers LibraryInboundApiKeyAdmin as the IInboundApiKeyAdmin singleton via AddSingleton — this is not done by AddSecurity.

Role mapping and site scoping

RoleMapper.MapGroupsToRolesAsync loads all LdapGroupMapping rows from the database, matches the supplied LDAP group names (case-insensitive), and accumulates roles. For the Deployer role it also loads associated SiteScopeRule rows — each row carries a SiteId limiting that mapping to one site. Union semantics (Security-016): if any matched Deployer mapping has no scope rules, the result is system-wide and all accumulated site IDs are discarded:

var isSystemWide = hasUnscopedDeploymentMapping
                   || (hasDeploymentRole && !hasScopedDeploymentMapping);

if (isSystemWide)
{
    permittedSiteIds.Clear();
}

return new RoleMappingResult(
    matchedRoles.ToList(),
    permittedSiteIds.ToList(),
    isSystemWide);

ScadaBridgeGroupRoleMapper adapts RoleMappingResult onto the shared IGroupRoleMapper<string> seam, carrying the full RoleMappingResult (including PermittedSiteIds and IsSystemWideDeployment) as the opaque Scope field so no site-scope information is lost at the seam boundary.

Authorization policies

Five named policies are registered by AuthorizationPolicies.AddScadaBridgeAuthorization. Every policy uses RequireClaim(JwtTokenService.RoleClaimType, ...) — no custom requirement handlers — making the policy check a direct look-up into the JWT's role claims.

Policy Constant Roles satisfied
RequireAdmin AuthorizationPolicies.RequireAdmin Administrator
RequireDesign AuthorizationPolicies.RequireDesign Designer
RequireDeployment AuthorizationPolicies.RequireDeployment Deployer
OperationalAudit AuthorizationPolicies.OperationalAudit Administrator, Viewer
AuditExport AuthorizationPolicies.AuditExport Administrator

Role names are declared in Roles (the single source of truth). The four active roles (Administrator, Designer, Deployer, Viewer) are the canonical subset of the shared six-role vocabulary; Operator and Engineer exist upstream but are not used. The OperationalAudit and AuditExport roles arrays are public (AuthorizationPolicies.OperationalAuditRoles, AuditExportRoles) so the ManagementService HTTP API can reuse the exact same sets when gating /api/audit/* routes through its own Basic-Auth + LDAP role check.

LDAP failure messages

LdapAuthFailureMessages.ToMessage maps the structured LdapAuthFailure enum from the shared library to user-facing strings. BadCredentials and UserNotFound both return the generic "Invalid username or password." — intentionally identical to prevent username enumeration. AmbiguousUser and ServiceAccountBindFailed (which also covers a directory that is unreachable at connect/search time) return a misconfiguration message. GroupLookupFailed (post-bind directory outage, or a successful-but-empty group set) returns a transient-outage message.

Inbound API key management

LibraryInboundApiKeyAdmin implements the Commons IInboundApiKeyAdmin seam over the shared ApiKeyAdminCommands facade. Keys use the sbk_<keyId>_<secret> token format (prefix sbk), with the key ID as a 32-hex-character GUID ("N" format, no hyphens, because hyphens are not valid in the delimiter-separated token). The library stores keys in a SQLite file (data/inbound-api-keys.sqlite by default). Scopes in the library map 1:1 to method names in ScadaBridge. Delete is implemented as revoke-then-delete because the library only permits deleting an already-revoked key.

Data Protection key sharing

AddConfigurationDatabase calls AddDataProtection().PersistKeysToDbContext<ScadaBridgeDbContext>(). Both central nodes therefore read and write Data Protection keys from the same MS SQL database, which means either node can protect and unprotect the same data (including the cookie payload) regardless of which node issued it — a prerequisite for load-balancer failover transparency.

Usage

Login flow (Central UI /auth/login and /auth/token):

  1. Call ILdapAuthService.AuthenticateAsync(username, password) (registered by Host via AddZbLdapAuth).
  2. On success, call RoleMapper.MapGroupsToRolesAsync(ldapGroups) to resolve roles and site scope.
  3. /auth/login: build the claims via SessionClaimBuilder and call the ASP.NET cookie SignInAsync. /auth/token: call JwtTokenService.GenerateToken(displayName, username, roles, permittedSiteIds) and return the bearer JWT.

On each subsequent cookie request the validation hook enforces the idle timeout and, past the role-refresh threshold, re-maps the stored groups against the DB. Bearer requests validate the token with JwtTokenService.ValidateToken and perform no LDAP bind or role re-map.

Authorization gates use the named policies:

// Razor page or controller — declarative
[Authorize(Policy = AuthorizationPolicies.RequireDesign)]

// ManagementActor — imperative, reusing the same role arrays
if (!AuthorizationPolicies.OperationalAuditRoles.Contains(userRole))
    return Unauthorized();

Configuration

SecurityOptions is bound from the ScadaBridge:Security section. LDAP connection settings are bound separately from ScadaBridge:Security:Ldap (the LdapSectionPath constant) into the shared LdapOptions by AddZbLdapAuth.

ScadaBridge:Security — SecurityOptions

Key Default Description
JwtSigningKey (required) Symmetric HMAC-SHA256 signing key. Must be at least 32 bytes (256 bits); StartupValidator rejects a shorter key on Central.
JwtExpiryMinutes 15 Lifetime of the bearer JWT issued by /auth/token.
IdleTimeoutMinutes 30 Cookie session idle timeout; set as the cookie ExpireTimeSpan.
RoleRefreshThresholdMinutes 15 Age of the role-refresh anchor after which the cookie's roles are re-mapped from the DB (LDAP-free).
RoleMappingCacheSeconds 30 Process-wide RoleMappingCache TTL (0 disables); invalidated on the local node after mapping/scope-rule edits.
RequireHttpsCookie true When true, the cookie is Secure-only (HTTPS required). Set false for HTTP-only dev deployments; a warning is logged at startup.
CookieName ZB.MOM.WW.ScadaBridge.Auth Authentication cookie name. Override per deployment when two ScadaBridge stacks share a hostname — browsers scope cookies by host+path but not by port.

ScadaBridge:Security:Ldap — LdapOptions (shared library)

Key Description
Server LDAP/AD server hostname or IP.
Port LDAP port. Use 636 for LDAPS or 389 for StartTLS.
Transport Ldaps, StartTls, or None (dev only — requires AllowInsecure = true).
AllowInsecure Must be true to permit Transport = None. Default false.
SearchBase LDAP search base DN (e.g. dc=corp,dc=example,dc=com).
ServiceAccountDn Service-account distinguished name used for the initial bind and group search.
ServiceAccountPassword Service-account password.

LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth) enforces that Server, SearchBase, ServiceAccountDn, and a secure transport are configured before the first request is served.

Dependencies & Interactions

  • Commons (#16) — defines ISecurityRepository (LDAP mapping + scope rule read/write), IInboundApiKeyAdmin (key admin seam), IAuditActorAccessor (audit actor resolution), LdapGroupMapping, and SiteScopeRule entities, plus ManagementEnvelope (carries username/roles into every Management command).
  • Configuration Database (#17) — provides the scoped ISecurityRepository implementation (SecurityRepository) backed by LdapGroupMappings and SiteScopeRules tables in MS SQL, and hosts the Data Protection key ring via PersistKeysToDbContext<ScadaBridgeDbContext>().
  • Central UI (#9) — every Blazor Server page and Razor component passes through cookie authentication and named policy authorization. The login page drives the LDAP bind → role map → token mint flow. The Admin → LDAP Mappings page is gated by RequireAdmin and calls ISecurityRepository directly.
  • Management Service (#18) — the ManagementActor enforces role and site-scope rules on every incoming command using identity carried in the ManagementEnvelope. The CLI authenticates users via the same LDAP bind and passes identity in every request.
  • Inbound API (#14) — inbound API requests authenticate via Authorization: Bearer sbk_<keyId>_<secret> (library verifier, sbk_* token format) rather than the cookie/JWT path. HttpAuditActorAccessor resolves the authenticated username for audit Actor stamping on the interactive path; the inbound API path keeps its own actor/fallback.
  • Audit Log (#23) — IAuditActorAccessor is a seam this component implements; the Inbound API audit path calls CurrentActor to record the authenticated user as the event actor.
  • Transport (#24) — export gates on RequireDesign; import gates on RequireAdmin, enforced at both the Razor page layer and inside the Transport service entrypoints.
  • Design spec: Component-Security.md.

Troubleshooting

Login fails: "Authentication service is misconfigured"

This message maps from LdapAuthFailure.ServiceAccountBindFailed or LdapAuthFailure.AmbiguousUser. The service-account DN or password in ScadaBridge:Security:Ldap is wrong, the LDAP server is unreachable at connect time, or the search for the username returned more than one entry. Check ServiceAccountDn, ServiceAccountPassword, and Server in configuration. LdapOptionsValidator enforces these keys at startup, so a complete absence fails fast — this error at login time indicates a runtime connectivity or data problem.

Login fails: "The directory is temporarily unavailable"

Maps from LdapAuthFailure.GroupLookupFailed. The user-credential bind succeeded but the subsequent group-membership query failed. The directory is partially reachable (user bind works) but the group search is failing. Existing sessions with valid JWTs continue to operate unaffected.

Session expires unexpectedly

Check IdleTimeoutMinutes and JwtExpiryMinutes in SecurityOptions. Passive traffic (/auth/ping, Blazor transport validation) does not renew the cookie; only the antiforgery-protected /auth/activity endpoint does. If the idle timeout fires earlier than expected, confirm genuine user interaction reaches that endpoint.

Two ScadaBridge environments on the same host clobber each other's session

Set a distinct CookieName in ScadaBridge:Security for each deployment. Browsers scope cookies by host+path, not by port, so two stacks on localhost:9000 and localhost:9100 share cookie namespace under the default name.