From 32fd9539694c400bf2117abdcd0d58ad33954d1c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 00:24:03 -0400 Subject: [PATCH] plan(phase1): Task 1.0 exploration findings + elaborated Auth cutover Per-app cutover steps mapped to the library surface; flags 5 findings that change the plan (OtOpcUa section is Security:Ldap not Authentication:Ldap; singleton 'bug' already mitigated; ScadaBridge inbound API keys are a re-architecture not a reformat; OtOpcUa config+DB mapping + DevStubMode + 2nd LDAP consumer; MxGateway ApiKeys is the low-risk donor path). --- ...6-06-02-auth-audit-normalization-phase1.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/plans/2026-06-02-auth-audit-normalization-phase1.md diff --git a/docs/plans/2026-06-02-auth-audit-normalization-phase1.md b/docs/plans/2026-06-02-auth-audit-normalization-phase1.md new file mode 100644 index 0000000..755a2b7 --- /dev/null +++ b/docs/plans/2026-06-02-auth-audit-normalization-phase1.md @@ -0,0 +1,99 @@ +# Phase 1 (Auth adoption) — elaborated steps + Task 1.0 findings + +Companion to `2026-06-02-auth-audit-normalization.md`. Produced by the Task 1.0 read-only +exploration gate (4 parallel explorers: library surface + 3 repos). All paths verified +2026-06-02 against source. + +## Cutover target — `ZB.MOM.WW.Auth` public surface + +| Package | Consumer entry points | +|---|---| +| `.Abstractions` | `ILdapAuthService`, `LdapOptions` (`Transport: LdapTransport{Ldaps,StartTls,None}`, `AllowInsecure`, `UserNameAttribute`, `GroupAttribute`, `ServiceAccountDn/Password`, `SearchBase`, `ConnectionTimeoutMs`, `ServerCertificateValidationCallback`), `LdapAuthResult(Succeeded,Username,DisplayName,Groups,Failure)`, `LdapAuthFailure`, `CanonicalRole{Viewer,Operator,Engineer,Designer,Deployer,Administrator}`, `IGroupRoleMapper` (**no default impl — consumer writes it**) → `GroupRoleMapping(Roles, Scope:object?)`, plus API-key abstractions (`IApiKeyVerifier`, `ApiKeyVerification`, `ApiKeyIdentity`, `IApiKeyStore`/`IApiKeyAdminStore`/`IApiKeyAuditStore`, `ApiKeyOptions{TokenPrefix,PepperSecretName,SqlitePath,RunMigrationsOnStartup}`) | +| `.Ldap` | `LdapAuthService(LdapOptions)` : `ILdapAuthService`. Bind-then-search, fail-closed, never throws. `LdapOptionsValidator` (TLS-or-AllowInsecure) auto-registered. | +| `.ApiKeys` | `ApiKeyVerifier(ApiKeyOptions, IApiKeyStore, IApiKeyPepperProvider, TimeProvider?)`, `ApiKeyParser.TryParse` (`__`), `ApiKeySecretGenerator.NewSecret()`, default SQLite stores, `ConfigurationApiKeyPepperProvider`. **Extracted from MxGateway — near-1:1 with its pipeline.** | +| `.AspNetCore` | `ZbClaimTypes{Name,Role,DisplayName,Username,ScopeId}`, `ZbCookieDefaults.Apply(opts, requireHttps, idleTimeout)`, DI: `AddZbLdapAuth(services, config, sectionPath)`, `AddZbApiKeyAuth(services, config, sectionPath)`. | + +## Per-app current state (verified) and elaborated cutover + +### OtOpcUa — packages: Abstractions + Ldap + AspNetCore (no ApiKeys) + +Current LDAP: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs` (impl), `ILdapAuthService.cs`, +`LdapOptions.cs` (**section `Security:Ldap`**, `UseTls` bool, `Enabled`, `DevStubMode`, embedded `GroupToRole` dict), +`LdapAuthResult.cs` (already carries `Roles`). Role mapping is **config + DB**: `RoleMapper.Map` (config +`GroupToRole`) + `RoleMapper.Merge` with DB `LdapGroupRoleMappingService`/`LdapGroupRoleMapping` (system-wide rows). +Native roles `AdminRole{ConfigViewer,ConfigEditor,FleetAdmin}` (control-plane only; data-plane is a separate +`NodePermissions` bitmask). DI: two `TryAddSingleton` sites +(`Security/ServiceCollectionExtensions.cs:42` + `Host/Program.cs:106`). Cookie `ZB.MOM.WW.OtOpcUa.Auth`, +single Cookie scheme (JWT inside cookie). **Second LDAP consumer:** OPC UA data-plane +`LdapOpcUaUserAuthenticator` + `OpcUaApplicationHost.HandleImpersonation` call the LDAP service too. + +- **1.1 mapper:** implement `IGroupRoleMapper` (or ``) wrapping `RoleMapper.Map` + DB `Merge`. +- **1.2 Ldap:** replace `LdapAuthService` with `Auth.Ldap`; restructure flow to `ILdapAuthService → Groups → IGroupRoleMapper → roles → claims`; **preserve `DevStubMode` app-side** (library has no stub); wire BOTH consumers (login endpoint + OPC UA impersonation). +- **1.4 config:** `UseTls`→`Transport` enum (section already `Security:Ldap` — see Finding #1). +- **1.5 cookie/claims:** use `ZbClaimTypes` + `ZbCookieDefaults.Apply`; keep cookie name. +- **1.7 roles:** `ConfigViewer→Viewer`, `ConfigEditor→Designer`, `FleetAdmin→Administrator(+Deployer; publish⊂FleetAdmin)`. Data-plane `NodePermissions` unaffected. + +### MxAccessGateway — packages: all 4 (ApiKeys **source**, cuts over first) + +Current API keys (`src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/`): `ApiKeyParser` (`mxgw__`), +`ApiKeySecretHasher` (HMAC-SHA256 + pepper `MxGateway:ApiKeyPepper`), `ApiKeySecretGenerator`, `ApiKeyVerifier` +(`FixedTimeEquals`), SQLite stores, `ConstraintEnforcer` + rich `ApiKeyConstraints`, gRPC +`GatewayGrpcAuthorizationInterceptor` + `GatewayScopes`. DI `AddSqliteAuthStore()`. → **near-1:1 with `Auth.ApiKeys`.** +LDAP: `Dashboard/DashboardAuthenticator.cs` (`MxGateway:Ldap`, `UseTls`), `GroupToRole` under `MxGateway:Dashboard`, +roles `Admin`/`Viewer`, cookie `MxGatewayDashboard`. + +- **1.1 mapper:** `IGroupRoleMapper` wrapping `DashboardAuthenticator.MapGroupsToRoles`. +- **1.2 Ldap:** replace `DashboardAuthenticator`'s LDAP internals with `Auth.Ldap` (keep dashboard claims/principal build). +- **1.3 ApiKeys:** delete the local parser/hasher/generator/verifier/stores; re-point to `Auth.ApiKeys`; **keep** `ConstraintEnforcer` + gRPC interceptor + scopes on top (constraints carried as the opaque blob). Lowest-risk ApiKeys cutover (it's the donor). +- **1.4 config:** `UseTls`→`Transport`. +- **1.5/1.7:** `ZbClaimTypes`/cookie defaults; `Viewer→Viewer`, `Admin→Administrator`. + +### ScadaBridge — packages: all 4 (Ldap **source**; ApiKeys consumer) + +Current LDAP (`src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs`): the hardened reference (RFC-4514 DN escape, +filter escape, per-op timeout, fail-closed group lookup, username trim, service-account-bind distinction). Config is +**flat** `ScadaBridge:Security:Ldap*` in `SecurityOptions.cs` with **`LdapTransport` enum already** (`Ldaps/StartTls/None`), +`AllowInsecureLdap`, `LdapUserIdAttribute`, `LdapGroupAttribute`, validated by `SecurityOptionsValidator : OptionsValidatorBase`. +Role mapping **DB-backed** with **site-scoping**: `RoleMapper.MapGroupsToRolesAsync` → `RoleMappingResult(Roles, PermittedSiteIds, IsSystemWideDeployment)` over `LdapGroupMapping` + `SiteScopeRule` (SQL Server). Roles +`Admin/Design/Deployment/Audit/AuditReadOnly`; SoD via `OperationalAudit{Admin,Audit,AuditReadOnly}` + `AuditExport{Admin,Audit}`. +Cookie `ZB.MOM.WW.ScadaBridge.Auth`; JWT-in-cookie via `JwtTokenService`. +**Inbound API keys** (`InboundAPI/ApiKeyValidator.cs`): **raw `X-API-Key`**, **deterministic** HMAC (`ApiKeyHasher`, no per-row salt, by-value lookup), `ApiKey{Name,KeyHash,IsEnabled}` in **SQL Server**, **per-method approval** via `ApiMethod.ApprovedApiKeyIds` — **architecturally different from the library's keyId/scope/SQLite model.** + +- **1.1 mapper:** `IGroupRoleMapper` wrapping `RoleMapper.MapGroupsToRolesAsync`, carrying `PermittedSiteIds`/`IsSystemWideDeployment` in `GroupRoleMapping.Scope`. +- **1.2 Ldap:** ScadaBridge is the donor — confirm `Auth.Ldap` behaviour-matches, then re-point `LdapAuthService` usages to the library type. Lowest-risk Ldap cutover. +- **1.3 ApiKeys:** **see Finding #3 — bigger than a token reformat; needs a scope decision.** +- **1.4 config:** nest flat `Security:Ldap*` under a sub-section + rename `LdapUserIdAttribute→UserNameAttribute`, `LdapGroupAttribute→GroupAttribute`, `LdapTransport→Transport` (+ `SecurityOptionsValidator` + appsettings). Enum already matches. +- **1.7 roles:** `Admin→Administrator`, `Design→Designer`, `Deployment→Deployer`, `Audit→Administrator` (collapse), `AuditReadOnly→Viewer` (collapse) — removes the `OperationalAudit`/`AuditExport` SoD (accepted). + +## Key findings that change the plan + +1. **OtOpcUa LDAP section is `Security:Ldap`, not `Authentication:Ldap`.** Both `components/auth/GAPS.md §1` + and the auth current-state doc are wrong; the code (and the prior fix in memory) use `Security:Ldap`. + → Task 1.4 for OtOpcUa is only `UseTls`→`Transport`, not a section move. +2. **OtOpcUa "double-singleton bug" is already mitigated.** Both registration sites use `TryAddSingleton` + (dedupes); the `Enabled` flag is an intentional fail-closed master switch. → Not a blocking fix; verify and + keep `Enabled`. Removes a risk the plan flagged. +3. **ScadaBridge inbound API keys are a re-architecture, not a token reformat.** The library's ApiKeys model + (`__` Bearer, keyId lookup + constant-time compare, SQLite store, scopes + opaque + constraints) is fundamentally different from ScadaBridge's (raw `X-API-Key`, deterministic by-value HMAC + lookup, SQL Server `ApiKey{Name,KeyHash}`, per-method approval list). Wholesale adoption means re-architecting + inbound-API auth AND resolving a SQLite-vs-SQL-Server storage mismatch. **Needs a scope decision (Decision A).** +4. **OtOpcUa role mapping is config + DB**, not just config (`RoleMapper.Map` baseline + DB `Merge`). The + `IGroupRoleMapper` impl must combine both. OtOpcUa also has `DevStubMode` (no library equivalent — keep app-side) + and a **second LDAP consumer** (OPC UA data-plane impersonation) that must be re-wired too. +5. **MxGateway ApiKeys cutover is the donor path — lowest risk** (delete locals, re-point to library; keep + `ConstraintEnforcer`/gRPC/scopes on top). Confirms the GAPS sequencing (gateway first). + +## Open decisions (check back with user) + +- **Decision A — ScadaBridge inbound API keys depth:** + (a) Full adopt `Auth.ApiKeys` (re-architect inbound auth: Bearer token format, keyId/scope model, move keys to the + library SQLite store or implement `IApiKeyStore` over its SQL Server tables) — faithful but large. + (b) Keep ScadaBridge's inbound-API auth as-is, adopt only the shared **token format + pepper/hashing convention** + (smallest behaviour-visible change; the GAPS D2 "reconcile token format" reading). + (c) Implement the library's `IApiKeyStore`/`IApiKeyVerifier` over ScadaBridge's existing SQL Server tables + + per-method-approval policy (middle path: shared verifier seam, keep storage + approval model). +- **Decision B — canonical role mappings:** confirm the per-app tables above (esp. OtOpcUa `ConfigEditor→Designer`, + `FleetAdmin→Administrator+Deployer`; ScadaBridge `Audit`/`AuditReadOnly` collapse). +- **Decision C — `DevStubMode`** (OtOpcUa) and MxGateway loopback/`AllowAnonymousLocalhost` bypass: keep app-side + (library has no equivalent) — confirm we preserve these dev escape hatches unchanged.