231 lines
18 KiB
Markdown
231 lines
18 KiB
Markdown
# 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+JWT hybrid session model, 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` — token generation, validation, idle-timeout enforcement, and sliding refresh logic.
|
|
- `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.
|
|
- `LibraryInboundApiKeyAdmin` — implements `IInboundApiKeyAdmin` over the shared `ApiKeyAdminCommands` facade.
|
|
- 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 session (valid cookie+JWT) continues with its current claims until the JWT expires. This avoids disrupting engineers mid-work during a brief directory outage. When the directory recovers, the next token refresh re-queries groups and issues a fresh token.
|
|
|
|
### Cookie+JWT hybrid session
|
|
|
|
On successful login the server mints a JWT via `JwtTokenService.GenerateToken` and writes it into an HttpOnly/Secure authentication cookie. The cookie is the transport — not a bearer header — because Blazor Server's persistent SignalR circuits do not carry `Authorization` headers on reconnect. The browser sends the cookie on every HTTP and SignalR request automatically.
|
|
|
|
The JWT is signed with HMAC-SHA256 using a shared symmetric key (`JwtSigningKey`). Both central nodes share the same key, so either node can issue and validate tokens without a shared session store; the load balancer needs no sticky-session configuration. `ClockSkew` is set to `TimeSpan.Zero` to close the standard five-minute tolerance window.
|
|
|
|
Claims embedded in every 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) |
|
|
| `"LastActivity"` | `JwtTokenService.LastActivityClaimType` | ISO 8601 idle-timeout anchor |
|
|
|
|
`MapInboundClaims = false` and `MapOutboundClaims` cleared on both mint and validate paths prevent `JwtSecurityTokenHandler`'s default claim-type rewrite maps from transforming the canonical role URI or `zb:` claim types, keeping the type strings byte-for-byte identical in the token and in every policy check.
|
|
|
|
### Token lifecycle and idle timeout
|
|
|
|
The JWT lifetime (`JwtExpiryMinutes`, default 15 minutes) and the cookie idle window (`IdleTimeoutMinutes`, default 30 minutes) are separate layers. ASP.NET cookie auth's `SlidingExpiration = true` with `ExpireTimeSpan = IdleTimeout` models the idle window: the middleware re-issues the cookie once the session passes its halfway mark, keeping active users signed in. The JWT within that cookie has its own 15-minute expiry.
|
|
|
|
`JwtTokenService.ShouldRefresh` checks whether remaining JWT lifetime is below `JwtRefreshThresholdMinutes` (default 5 minutes). `RefreshToken` issues a fresh JWT while **preserving** the existing `LastActivity` anchor — a background refresh is not treated as user activity. `RecordActivity` advances the anchor to now. `IsIdleTimedOut` checks whether the elapsed time since `LastActivity` exceeds `IdleTimeoutMinutes`; `RefreshToken` enforces the idle check internally so an idle-expired session cannot be kept alive by background polling regardless of caller discipline (Security-014).
|
|
|
|
## Architecture
|
|
|
|
### Registration split between Host and component
|
|
|
|
`AddSecurity` (component library) registers everything except the LDAP service itself:
|
|
|
|
```csharp
|
|
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.
|
|
|
|
### 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:
|
|
|
|
```csharp
|
|
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. Call `JwtTokenService.GenerateToken(displayName, username, roles, permittedSiteIds)` to mint a signed JWT.
|
|
4. Write the JWT into the HttpOnly cookie via the ASP.NET cookie auth `SignInAsync`.
|
|
|
|
On each subsequent request, middleware reads the cookie, validates the embedded JWT with `JwtTokenService.ValidateToken`, and checks `IsIdleTimedOut`. If the token is near expiry (`ShouldRefresh`), fresh claims are re-queried from LDAP and `RefreshToken` issues a replacement. Genuine user interactions call `RecordActivity` to advance the last-activity anchor.
|
|
|
|
Authorization gates use the named policies:
|
|
|
|
```csharp
|
|
// 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); validated at `JwtTokenService` construction — startup fails if too short. |
|
|
| `JwtExpiryMinutes` | `15` | JWT lifetime in minutes before embedded token expires and must be refreshed. |
|
|
| `JwtRefreshThresholdMinutes` | `5` | Minutes before JWT expiry at which `ShouldRefresh` triggers a re-issue. |
|
|
| `IdleTimeoutMinutes` | `30` | Session idle timeout in minutes. Cookie `ExpireTimeSpan` is set to this value; `IsIdleTimedOut` enforces it from the `LastActivity` claim. |
|
|
| `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)](./Commons.md) — 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)](./ConfigurationDatabase.md) — 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)](./CentralUI.md) — 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)](./ManagementService.md) — 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)](./InboundAPI.md) — inbound API requests authenticate via `X-API-Key` (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)](./AuditLog.md) — `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)](./Transport.md) — 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](../requirements/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`. A background refresh that fires while the user is idle preserves the `LastActivity` anchor (`RefreshToken` does not advance it); `IsIdleTimedOut` enforces the window from the last genuine user interaction. If the idle timeout fires before the expected window, confirm that `RecordActivity` is being called on genuine user requests.
|
|
|
|
### 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.
|
|
|
|
## Related Documentation
|
|
|
|
- [Security & Auth design specification](../requirements/Component-Security.md)
|
|
- [Configuration Database](./ConfigurationDatabase.md)
|
|
- [Commons](./Commons.md)
|
|
- [Central UI](./CentralUI.md)
|
|
- [Management Service](./ManagementService.md)
|
|
- [Inbound API](./InboundAPI.md)
|
|
- [Audit Log](./AuditLog.md)
|
|
- [Transport](./Transport.md)
|