Initial commit: scadaproj umbrella — sister-project index, auth component normalization (design + GAPS), and the built ZB.MOM.WW.Auth shared library (0.1.0, flattened in).

This commit is contained in:
dohertj2
2026-06-01 03:59:23 -04:00
commit 37e23cf9f2
73 changed files with 6836 additions and 0 deletions
@@ -0,0 +1,55 @@
# Auth — current state: MxAccessGateway (`mxaccessgw`)
Repo: `~/Desktop/MxAccessGateway` (Gitea `mxaccessgw`). Stack: .NET 10 gateway (x64) + x86/net48 worker.
Auth lives entirely in the **gateway** (.NET 10); the worker never authenticates.
All paths relative to repo root; auth code under `src/ZB.MOM.WW.MxGateway.Server/`. Verified 2026-06-01.
The gateway has **two independent auth models**: gRPC API keys (programmatic clients) and
LDAP dashboard auth (web UI). They share nothing — different credentials, stores, and authz.
## Model A — gRPC API-key auth
Base: `Security/Authentication/` and `Security/Authorization/`.
- **Token format:** `Authorization: Bearer mxgw_<key-id>_<secret>`. Parsed by `ApiKeyParser.cs` (rejects malformed before any DB hit) → `ParsedApiKey(KeyId, Secret)`.
- **Hashing:** `ApiKeySecretHasher.cs` — HMAC-SHA256 with an **external pepper** (config key `MxGateway:ApiKeyPepper`, never stored beside the hash). Secrets generated by `ApiKeySecretGenerator.cs` (32 random bytes, URL-safe base64).
- **Verification:** `ApiKeyVerifier.cs` — parse → `IApiKeyStore.FindByKeyIdAsync` → reject if revoked → hash with pepper → **constant-time compare** (`CryptographicOperations.FixedTimeEquals`) → mark `LastUsedUtc`. Failures discriminated (`KeyNotFound/KeyRevoked/PepperUnavailable/SecretMismatch/MissingOrMalformedCredentials`) for audit without leaking to clients.
- **Storage:** SQLite (`AuthSqliteConnectionFactory.cs`, WAL), default `C:\ProgramData\MxGateway\gateway-auth.db`. Schema v2 (`SqliteAuthSchema.cs`): tables `api_keys` (hash, scopes JSON, constraints JSON, created/last_used/revoked), `api_key_audit` (append-only), `schema_version`. Read/write/audit split across `SqliteApiKeyStore` / `SqliteApiKeyAdminStore` / `SqliteApiKeyAuditStore`.
- **Scopes** (`GatewayScopes.cs`): `session:open/close`, `invoke:read/write/secure`, `events:read`, `metadata:read`, `admin`. Stored per key (ordinal-sorted JSON via `ApiKeyScopeSerializer.cs`).
- **Enforcement:** `Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs` + `GatewayGrpcScopeResolver.cs` — per RPC: authenticate (`Unauthenticated` on failure) → resolve required scope → check (`PermissionDenied` if missing) → push `ApiKeyIdentity` into async-local `IGatewayRequestIdentityAccessor`.
- **Constraints** (`ApiKeyConstraints.cs` + `ConstraintEnforcer.cs`): optional fine-grained limits — `ReadSubtrees/WriteSubtrees/ReadTagGlobs/WriteTagGlobs/BrowseSubtrees` (anchored case-insensitive globs), `MaxWriteClassification`, `ReadAlarmOnly`, `ReadHistorizedOnly`.
- **Admin CLI:** `apikey` subcommand (`ApiKeyAdminCliRunner.cs`, `ApiKeyAdminCommandLineParser.cs`): `init-db / create-key / list-keys / revoke-key / rotate-key / delete-key` (+ constraint flags). `delete` only works on already-revoked keys.
- **Config:** `Configuration/AuthenticationOptions.cs``MxGateway:Authentication:{Mode(ApiKey|Disabled), SqlitePath, PepperSecretName, RunMigrationsOnStartup}`.
## Model B — Dashboard LDAP auth (Blazor)
Base: `Dashboard/`.
- **Login:** `DashboardAuthenticator.cs` — connect (`MxGateway:Ldap`), bind service account, search user (`({UserNameAttribute}={escaped})`, RFC-escaped), re-bind as user DN to verify password, read `memberOf`. `/login` GET/POST with antiforgery (`DashboardEndpointRouteBuilderExtensions.cs`).
- **Group→role:** `MxGateway:Dashboard:GroupToRole` (case-insensitive; tries full DN then leading RDN) → roles `Admin` / `Viewer` (`DashboardRoles.cs`). Zero matched roles ⇒ login denied.
- **Cookie:** scheme `MxGateway.Dashboard`; cookie name **`MxGatewayDashboard`** (note: no `__Host-` prefix despite some docs); HttpOnly, SameSite=Strict, 8h sliding; `SecurePolicy` via `MxGateway:Dashboard:RequireHttpsCookie`. Config in `DashboardServiceCollectionExtensions.cs`.
- **SignalR hubs** (`/hubs/snapshot|alarms|events`): policy accepts the cookie OR a 30-min Data-Protection bearer minted at `GET /hubs/token` (`HubTokenService.cs`, purpose `…Dashboard.HubToken.v1`; `HubTokenAuthenticationHandler.cs` also reads `access_token` query for WS upgrades).
- **Loopback bypass:** `MxGateway:Dashboard:AllowAnonymousLocalhost` (default `true`) — `DashboardAuthorizationHandler.cs`.
- **LDAP config:** `Configuration/LdapOptions.cs``MxGateway:Ldap:{Enabled, Server, Port(3893), UseTls, AllowInsecureLdap, SearchBase(dc=lmxopcua,dc=local), ServiceAccountDn, ServiceAccountPassword, UserNameAttribute(cn), DisplayNameAttribute, GroupAttribute(memberOf)}`. Dev users/groups in `glauth.md` (incl. the gateway-specific `GwAdmin` group).
## Secrets & config
Pepper resolved from config (`MxGateway:ApiKeyPepper`), never stored with hashes. CLAUDE.md: API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` creds never logged. Docs: `docs/Authentication.md`, `docs/Authorization.md`, `docs/GatewayDashboardDesign.md`, `docs/DesignDecisions.md`, `glauth.md`.
## Notable limits / TODOs
EventsHub has no per-session ACL yet (any dashboard user can subscribe to any session); no reconnectable sessions; single event subscriber per session; authz is scope+constraint, not per-item ACL.
---
## Adoption plan → `ZB.MOM.WW.Auth`
**Replace with the shared library:**
- **Model A is the reference implementation for `ZB.MOM.WW.Auth.ApiKeys`.** The whole `Security/Authentication/` key pipeline (parser, hasher, generator, verifier, SQLite store, audit, scope serializer, admin CLI) maps almost 1:1 onto the proposed contract — extract it here first and have ScadaBridge's Inbound API adopt the same package. Token prefix becomes configurable (`mxgw_`).
- Model B LDAP: `DashboardAuthenticator`'s bind-then-search → `ZB.MOM.WW.Auth.Ldap`; cookie/claim wiring → `ZB.MOM.WW.Auth.AspNetCore`. Migrate `MxGateway:Ldap:*` to the canonical config schema.
**Keep bespoke:**
- gRPC **scope** catalog + `GatewayGrpcAuthorizationInterceptor` + constraint globs (domain authz).
- Dashboard `Admin`/`Viewer` role meaning, hub-token specifics, loopback bypass.
**Watch:** align the API-key `MaxWriteClassification`/constraint model with ScadaBridge's per-method approval when extracting — they are different shapes of "scope a key"; the shared contract should carry constraints as an opaque, project-supplied policy rather than hard-coding either.
@@ -0,0 +1,84 @@
# Auth — current state: OtOpcUa
Repo: `~/Desktop/OtOpcUa` (Gitea `lmxopcua`). Stack: .NET 10, OPC Foundation UA stack.
All paths below are relative to the repo root. Verified against source on 2026-06-01.
OtOpcUa has the richest auth of the three: OPC UA session-level identity, LDAP-backed
authentication, transport security profiles, and a trie-based per-operation ACL system,
plus a separate control-plane (Admin UI) auth stack.
## 1. Authentication
Three OPC UA identity-token types are accepted at session establishment
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs`, impersonation handler ~226288):
- **Anonymous** — passes through without custom validation; gets no LDAP groups.
- **UserName/password (LDAP-backed)** — the primary human path (see below).
- **X.509 certificate** — validated at the secure-channel/PKI level; CN→role mapping not yet implemented.
**UserName flow:** the SDK decrypts the password with the server certificate, then
`IOpcUaUserAuthenticator.AuthenticateUserNameAsync()` validates it.
- Seam: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs`
- Prod impl: `src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs` → wraps `ILdapAuthService`.
- UserName tokens are **always encrypted** by the SDK (via the server cert) regardless of transport profile, so LDAP login works even on a `None` endpoint.
**LDAP service** (`src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/`):
- `ILdapAuthService` / `LdapAuthService.cs` — bind-then-search (or direct bind), re-bind as user DN to verify the password, read `memberOf`, strip leading `CN=`. GLAuth fallback extracts the primary group from the `ou=` RDN.
- `LdapOptions.cs` — bound from config section **`Authentication.Ldap`**. Keys: `Enabled`, `Server`, `Port` (GLAuth `3893`), `UseTls`, `AllowInsecureLdap`, `SearchBase` (`dc=lmxopcua,dc=local`), `ServiceAccountDn`, `ServiceAccountPassword`, `UserNameAttribute` (`cn`; AD→`sAMAccountName`), `GroupAttribute` (`memberOf`), `DisplayNameAttribute`, `GroupToRole` (dict), `DevStubMode` (dev-only: accepts any non-empty creds).
- `RoleMapper.cs` — maps LDAP groups → control-plane `AdminRole` via `GroupToRole`.
- Dev LDAP server: GLAuth at `C:\publish\glauth\` (see `C:\publish\glauth\auth.md`).
## 2. Transport security
`OpcUaServer` config section; `EnabledSecurityProfiles` (default `[None, Basic256Sha256-Sign, Basic256Sha256-SignAndEncrypt]`; Aes128/Aes256 profiles also available), resolved by `SecurityProfileResolver` at startup. Server certificate auto-created under `PkiStoreRoot` (`own/ issuer/ trusted/ rejected/`). `AutoAcceptUntrustedClientCertificates` (default `false`). See `docs/security.md`.
- Profile enum + policy build: `OpcUaApplicationHost.cs` (~1523, ~374410).
## 3. Authorization (data-plane ACLs — stays bespoke)
Bitmask permissions in `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs`:
`Browse, Read, Subscribe, HistoryRead, WriteOperate, WriteTune, WriteConfigure, AlarmRead,
AlarmAcknowledge, AlarmConfirm, AlarmShelve, MethodCall` + bundles `ReadOnly/Operator/Engineer/Admin`.
The three write tiers mirror Galaxy `SecurityClassification` (Operate/Tune/Configure).
- Scope hierarchy: `NodeAclScopeKind.cs``Cluster, Namespace, UnsArea, UnsLine, Equipment, FolderSegment, Tag`.
- Grant entity: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs``(LdapGroup, ScopeKind, ScopeId, PermissionFlags)`, generation-versioned.
- Evaluation: `src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/``TriePermissionEvaluator.cs`, `PermissionTrie.cs`, `PermissionTrieCache.cs`, `PermissionTrieBuilder.cs`, `IPermissionEvaluator.cs`. Per-operation (`OpcUaOperation` enum); denials return `BadUserAccessDenied`.
- Additive-only grants (no Deny) in Phase 6.2.
**Control-plane (Admin UI) roles are independent** of data-plane ACLs (design decision #150):
`AdminRole` enum (`ConfigViewer / ConfigEditor / FleetAdmin`); `LdapGroupRoleMapping` entity maps groups→AdminRole. Cookie + JWT stack lives in `src/Server/ZB.MOM.WW.OtOpcUa.Security/` (`ServiceCollectionExtensions.cs`, `Endpoints/AuthEndpoints.cs``/login`,`/logout`,`/ping`; `Jwt/JwtTokenService.cs`; `Blazor/CookieAuthenticationStateProvider.cs`). DataProtection keys persisted in the Config DB so cookies survive failover.
## 4. Session / identity model (stays bespoke)
`src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs`:
`SessionId, ClusterId, LdapGroups, MembershipResolvedUtc, AuthGenerationId, MembershipVersion`,
`MembershipFreshnessInterval` (5 min, async refresh) and `AuthCacheMaxStaleness` (15 min, fail-closed).
Sessions are **generation-bound** at sign-in so grant changes can't take effect mid-session.
Auth is evaluated **per request**, not cached per session.
## 5. Secrets & config
`Authentication.Ldap.ServiceAccountPassword` should come from user-secrets/env, not source.
DataProtection keys in Config DB table `DataProtectionKeys`. Docs: `docs/security.md`, `docs/v2/acl-design.md`.
## 6. Notable limits / TODOs
No explicit Deny grants (Phase 6.2); no nested-group expansion (relies on directory flattening);
LDAP unreachable >15 min fails all sessions closed; X.509 CN→role mapping deferred;
`HistoryUpdate` currently mapped to the `HistoryRead` bit.
---
## Adoption plan → `ZB.MOM.WW.Auth`
**Replace with the shared library:**
- `LdapAuthService` + `LdapOptions` + `RoleMapper``ZB.MOM.WW.Auth.Ldap` (`ILdapAuthService`, `LdapAuthService`, `LdapOptions`, `LdapAuthResult`) + `IGroupRoleMapper<CanonicalRole>`; OtOpcUa expands each canonical role into its `AdminRole` (control-plane) and `NodePermissions` (data-plane) per [`../../spec/CANONICAL-ROLES.md`](../../spec/CANONICAL-ROLES.md). No first-class `Deployer` (publish ⊂ `FleetAdmin`).
- Migrate config from `Authentication.Ldap.*` to the canonical schema in [`../../spec/SPEC.md`](../../spec/SPEC.md) (notably `UseTls` → the canonical transport setting; `UserNameAttribute` keeps its name as the canonical one).
- **Control-plane Admin UI** (Blazor, `src/Server/ZB.MOM.WW.OtOpcUa.Security/`): adopt `ZB.MOM.WW.Auth.AspNetCore` for the canonical cookie/claim conventions + DI helpers (`ServiceCollectionExtensions.cs`, `Blazor/CookieAuthenticationStateProvider.cs`, `Endpoints/AuthEndpoints.cs`). This is OtOpcUa's HTTP auth surface — distinct from the OPC UA data plane below.
**Keep bespoke (thin adapter only):**
- `IOpcUaUserAuthenticator` / `LdapOpcUaUserAuthenticator` — keep as the OPC-UA-specific adapter that calls the shared `ILdapAuthService`.
- ALL of §3 authZ (`NodePermissions`, ACL trie, `NodeAcl`), the control-plane `AdminRole` vocabulary, the JWT/`DataProtection` specifics, and §4 session model — domain-specific, not extracted (only the cookie/claim *conventions* are shared via `.AspNetCore`).
- Transport security (§2) — OPC-UA-specific.
**No API-key work** — OtOpcUa has no API-key surface; it relies on OPC UA transport security instead.
@@ -0,0 +1,64 @@
# Auth — current state: ScadaBridge
Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Akka.NET; solution `ZB.MOM.WW.ScadaBridge.slnx`.
Auth code centers on the `ZB.MOM.WW.ScadaBridge.Security` project. All paths relative to repo root.
Verified 2026-06-01.
LDAP-centric identity with database-driven role mapping and **site-scoped** deployment;
multiple authenticated surfaces (Blazor UI, CLI, Management API, Inbound API).
## 1. Authentication
`src/ZB.MOM.WW.ScadaBridge.Security/`:
- `LdapAuthService.cs``AuthenticateAsync(username,password)``LdapAuthResult`. Direct LDAP bind (no Kerberos/NTLM). RFC 4514 DN escaping + LDAP filter escaping (injection-safe), username trim-normalization, per-operation socket timeout, distinct exception for service-account-bind failure vs bad user creds, **fails login if the group lookup fails** (won't admit with zero roles).
- `SecurityOptions.cs` (config section **`ScadaBridge:Security`**, flat keys): `LdapServer`, `LdapPort`, `LdapTransport` (**enum** `Ldaps`/`StartTls`/`None`), `AllowInsecureLdap`, `LdapSearchBase` (`dc=scadabridge,dc=local`), `LdapServiceAccountDn`, `LdapServiceAccountPassword`, `LdapUserIdAttribute` (`uid`; AD→`sAMAccountName`), `LdapDisplayNameAttribute`, `LdapGroupAttribute` (`memberOf`), `LdapConnectionTimeoutMs`, plus `JwtSigningKey`, `JwtExpiryMinutes` (15), `IdleTimeoutMinutes` (30), `JwtRefreshThresholdMinutes` (5), `RequireHttpsCookie`.
- `SecurityOptionsValidator.cs` — startup fail-fast if `LdapServer`/`LdapSearchBase` empty; `JwtSigningKey` ≥ 32 bytes.
- Dev LDAP: GLAuth in `infra/glauth/config.toml`, base `dc=scadabridge,dc=local`, groups `SCADA-Admins/Designers/Deploy-All/Deploy-SiteA`, users incl. `multi-role` / `admin` (pw `password`).
## 2. Authorization (roles + site scope — stays bespoke)
- Roles (`Security/Roles.cs`): `Admin`, `Design`, `Deployment`, `Audit`, `AuditReadOnly`.
- **Group→role is DB-driven** (not config): entity `Commons/Entities/Security/LdapGroupMapping.cs` (`LdapGroupName``Role`), repo `ConfigurationDatabase/Repositories/SecurityRepository.cs`, EF map `Configurations/SecurityConfiguration.cs`. `Security/RoleMapper.cs``MapGroupsToRolesAsync` returns roles + permitted site IDs + `isSystemWideDeployment` (union semantics: any unscoped Deployment mapping ⇒ system-wide).
- **Site-scoping:** `Commons/Entities/Security/SiteScopeRule.cs` (`LdapGroupMappingId``SiteId`). No scope rules on a Deployment mapping ⇒ all sites.
- ASP.NET policies (`Security/AuthorizationPolicies.cs`): `RequireAdmin/RequireDesign/RequireDeployment/OperationalAudit/AuditExport`.
## 3. Authenticated surfaces
| Surface | Entry | Mechanism | Role check | Site scope |
|---|---|---|---|---|
| Central UI (Blazor Server) | `/auth/login` form | LDAP → cookie | `[Authorize(Policy=…)]`, `AuthorizeView` | `CentralUI/Auth/SiteScopeService.cs` |
| CLI | `--username/--password` | HTTP Basic → Management API → LDAP | at Management API | at Management API |
| Management API | `POST /management` | HTTP Basic → LDAP | `ManagementActor.GetRequiredRole` then `Roles.Contains` | `ManagementActor.EnforceSiteScope` |
| Inbound API | `X-API-Key` header | **API key** hash lookup (not LDAP) | per-method approval in DB | n/a |
| Central↔Site | Akka ClusterClient + gRPC | none (cluster membership is the trust boundary; TLS in prod) | — | — |
Key files: `CentralUI/Auth/AuthEndpoints.cs` (`/auth/login`,`/auth/token`,`/auth/logout`,`/auth/ping`), `CentralUI/Auth/CookieAuthenticationStateProvider.cs`, `ManagementService/ManagementEndpoints.cs` + `ManagementActor.cs`, `CLI/ManagementHttpClient.cs`, `InboundAPI/ApiKeyValidator.cs`.
## 4. Session / identity model
Cookie auth (`Security/ServiceCollectionExtensions.cs`): cookie name `ZB.MOM.WW.ScadaBridge.Auth`, HttpOnly, SameSite=Strict, Secure conditional, sliding `IdleTimeoutMinutes` (30). Claims carry name/display/username/roles/site-ids. Separate **JWT** (`Security/JwtTokenService.cs`, HMAC-SHA256, issuer/aud `scadabridge-central`, 15-min expiry, refresh at 5-min threshold, idle-timeout enforced) used for programmatic/CLI via `/auth/token`. Shared `JwtSigningKey` across central nodes ⇒ no sticky sessions. Note: the cookie is the ASP.NET session token; JWT is *not* embedded in it.
**Inbound API keys** (`InboundAPI/ApiKeyValidator.cs`): `X-API-Key`, peppered HMAC-SHA256, **constant-time** compare, per-method approval, indistinguishable 403 for "no method"/"not approved".
## 5. Secrets & config
`ldap_login.txt` / `sql_login.txt` (real creds, git-ignored). Docker config in `docker/central-node-*/appsettings.Central.json`. Config hierarchy: `appsettings.json``appsettings.{Central|Site}.json` → env → CLI. Spec: `docs/requirements/Component-Security.md`.
## 6. Notable limits / TODOs
No Kerberos/NTLM; no dynamic role refresh (only on token expiry/LDAP re-query); no forced session revocation; `AddSecurityActors()` is a placeholder; no MFA; no password-reset (AD-managed).
---
## Adoption plan → `ZB.MOM.WW.Auth`
**Replace with the shared library:**
- `LdapAuthService` + `SecurityOptions` (LDAP portion) + `LdapAuthResult``ZB.MOM.WW.Auth.Ldap`. ScadaBridge's escaping/timeout/fail-closed hygiene is strong — fold it into the shared impl. Reconcile config: flat `ScadaBridge:Security:Ldap*` + `LdapTransport` enum vs the canonical schema in [`../../spec/SPEC.md`](../../spec/SPEC.md) (canonical uses the transport **enum**, which ScadaBridge already has — adopt ScadaBridge's `LdapTransport` shape, rename keys to canonical).
- `InboundAPI/ApiKeyValidator.cs``ZB.MOM.WW.Auth.ApiKeys` (same peppered-HMAC contract mxaccessgw extracts). Map "per-method approval" onto the contract's opaque constraint/policy hook.
- Cookie/JWT wiring → `ZB.MOM.WW.Auth.AspNetCore` (claims + cookie conventions). `JwtTokenService` is a candidate for a shared token helper if mxaccessgw/OtOpcUa want the same refresh model — otherwise keep bespoke.
**Keep bespoke:**
- Native role set (`Admin/Design/Deployment/Audit/AuditReadOnly`) + **site-scoping** (`SiteScopeRule`, union semantics) stay as the enforcement layer; implement `IGroupRoleMapper<CanonicalRole>` over the DB mapping and expand canonical→native. Per [`../../spec/CANONICAL-ROLES.md`](../../spec/CANONICAL-ROLES.md): `AuditReadOnly`→Viewer, `Audit`→Administrator (SoD collapse); no `Operator`/`Engineer`.
- `ManagementActor` role/scope enforcement, Akka cluster trust model.
**Note the group→role *mechanism* divergence:** ScadaBridge maps groups→roles in the **database**; OtOpcUa and mxaccessgw map in **config**. The shared seam (`IGroupRoleMapper`) must allow either backing store — see [`../../GAPS.md`](../../GAPS.md).