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
+85
View File
@@ -0,0 +1,85 @@
# Auth — gaps & adoption backlog
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach the shared `ZB.MOM.WW.Auth` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
## Divergence vs spec
### §1 LDAP config schema
| Spec key | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Section nesting | 🟡 `Authentication:Ldap` (nested) | ✅ `MxGateway:Ldap` (nested) | ⛔ flat `ScadaBridge:Security:Ldap*` |
| `Transport` enum | ⛔ `UseTls` bool | ⛔ `UseTls` bool | ✅ `LdapTransport` enum |
| `AllowInsecure` | 🟡 `AllowInsecureLdap` | 🟡 `AllowInsecureLdap` | 🟡 `AllowInsecureLdap` (rename) |
| `UserNameAttribute` | ✅ `UserNameAttribute` | ✅ `UserNameAttribute` | ⛔ `LdapUserIdAttribute` |
| `GroupAttribute` | ✅ `memberOf` | ✅ `memberOf` | 🟡 `LdapGroupAttribute` (rename) |
| dev `SearchBase` | `dc=lmxopcua,dc=local` | `dc=lmxopcua,dc=local` | `dc=scadabridge,dc=local` |
**Gap A1:** adopt the `Transport` enum in OtOpcUa + gateway (replace `UseTls`).
**Gap A2:** ScadaBridge: nest keys + rename `LdapUserIdAttribute``UserNameAttribute`, `LdapGroupAttribute``GroupAttribute`.
**Gap A3:** unify the **dev base DN** (`dc=lmxopcua` vs `dc=scadabridge`) — pick one shared GLAuth base.
### §2 bind-then-search
All three do bind-then-search; ScadaBridge has the most complete hygiene (RFC-4514 + filter
escaping, per-op timeout, fail-closed on group lookup, username trim, service-account-bind
distinction). 🟡 OtOpcUa/gateway: confirm each has filter escaping + fail-closed-on-group-lookup
parity. → **Gap B1:** make ScadaBridge's hygiene the shared baseline; backfill any missing checks.
### §3 group→role mapping
**Mechanism split:** OtOpcUa + gateway map in **config** (`GroupToRole`); ScadaBridge maps in
the **database** (`LdapGroupMapping`). → **Gap C1:** `IGroupRoleMapper<CanonicalRole>` must support both
backings; ship a config-backed and a DB/delegate-backed mapper.
**Role vocabulary now standardized** to the canonical six ([`spec/CANONICAL-ROLES.md`](spec/CANONICAL-ROLES.md));
native enforcement stays per-project. → **Gap C2:** implement the `canonical → native` expansion in each
project. ⚠ Removing Auditor collapses ScadaBridge `AuditReadOnly`→Viewer and `Audit`→Administrator,
losing its auditor/admin separation-of-duties (accepted). OtOpcUa lacks a first-class `Deployer`
(publish ⊂ `FleetAdmin`); ScadaBridge has no `Operator`/`Engineer`; mxaccessgw no `Designer`/`Deployer`
each project assigns only the applicable subset.
### §4 API-key contract
| | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Has API keys | n/a (OPC UA transport security) | ✅ `mxgw_…`, SQLite, scopes + constraints | 🟡 `X-API-Key`, per-method approval (Inbound API only) |
| Peppered HMAC-SHA256 | — | ✅ | ✅ |
| Constant-time compare | — | ✅ | ✅ |
| Token format `<prefix>_<id>_<secret>` | — | ✅ | ⛔ raw `X-API-Key` (no keyId/prefix structure) |
| Audit log | — | ✅ append-only | 🟡 (verify) |
**Gap D1:** extract mxaccessgw's pipeline as `ZB.MOM.WW.Auth.ApiKeys`.
**Gap D2:** ScadaBridge Inbound API adopts it; reconcile token format and model "per-method approval" as the opaque constraint policy.
### §5 cookie / claim conventions
⛔ Cookie names differ (`MxGatewayDashboard` vs `ZB.MOM.WW.ScadaBridge.Auth` vs OtOpcUa control-plane cookie); claim-type conventions differ. → **Gap E1:** define canonical claim types + cookie defaults in `ZB.MOM.WW.Auth.AspNetCore`; each app keeps its own cookie *name* but shares attributes/claims.
### §6 dev / secrets
✅ All never-log-secrets and pepper-external. 🟡 escape-hatch flag names vary. Covered by A3 (base DN) + E.
## Adoption backlog (ordered)
| # | Item | Projects | Priority | Effort | Risk | Notes |
|---|---|---|---|---|---|---|
| 1 | Extract `ZB.MOM.WW.Auth.Ldap` from ScadaBridge's hardened impl | all 3 | High | M | Med | security-sensitive; needs strong tests before cutover |
| 2 | Extract `ZB.MOM.WW.Auth.ApiKeys` from mxaccessgw Model A | gw, SB | High | M | Med | gateway adopts first (it's the source), then SB |
| 3 | `IGroupRoleMapper<TRole>` seam + config & DB mappers (Gap C1) | all 3 | High | S | Low | unblocks per-project role retention |
| 4 | Config migration to §1 schema (Gaps A1A2) | all 3 | Med | S | Low | mechanical; do with #1 |
| 5 | `ZB.MOM.WW.Auth.AspNetCore` claims/cookie conventions (Gap E1) | all 3 (UIs) | Med | S | Low | incl. OtOpcUa Blazor Admin UI control-plane |
| 6 | Unify dev GLAuth base DN (Gap A3) | all 3 | Low | S | Low | dev-only; touches fixtures/infra |
| 7 | Decide shared JWT/refresh helper vs per-project | SB (+?) | Low | S | Low | only if a 2nd project wants the same |
| 8 | Adopt canonical roles: `canonical → native` mapping per project (Gap C2) | all 3 | Med | M | Med | governance (assign canonical role per LDAP group org-wide) + each project's expansion; SB audit roles collapse |
**Sequencing:** #3 first (cheap, unblocks), then #1 and #2 in parallel (independent libraries),
then #4#5 alongside cutover, then #6#7 as cleanup. Each extraction lands behind tests in the
source project before any consumer migrates. This stays consistent with the repos' loose coupling:
adoption is opt-in per project, one consumer version-bump at a time.
## Decisions still open
- Shared dev base DN value (A3).
- Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3).
- Shared JWT/refresh helper or not (#7).
+43
View File
@@ -0,0 +1,43 @@
# Auth (login / identity / authorization)
First normalized component. **Goal: path to shared code** — converge the three sister
projects onto a common identity + API-key contract, proposed as the `ZB.MOM.WW.Auth`
library set, while each project keeps its own authorization vocabulary.
- The one target: [`spec/SPEC.md`](spec/SPEC.md)
- The proposed shared library: [`shared-contract/ZB.MOM.WW.Auth.md`](shared-contract/ZB.MOM.WW.Auth.md)
- Divergences + backlog: [`GAPS.md`](GAPS.md)
- Current state, per project: [`current-state/`](current-state/)
## Why auth is a strong first candidate
All three projects authenticate humans via **LDAP** (GLAuth in dev), do **bind-then-search**,
read groups from **`memberOf`**, use a **service account**, support **TLS/StartTLS** with an
`AllowInsecureLdap` dev escape hatch, and **never log secrets**. Two of three implement an
almost identical **peppered HMAC-SHA256 API-key** scheme with constant-time comparison. That
common core is re-implemented per repo and has already drifted (config key names, dev base DN,
cookie names). Authorization, by contrast, is genuinely domain-specific and is **not** unified.
## Status by project
| Project | AuthN today | Machine auth | AuthZ model (stays per-project) | Sessions | Adoption status |
|---|---|---|---|---|---|
| **OtOpcUa** | LDAP (GLAuth) via OPC UA UserName token; X.509 + anonymous also | — (OPC UA transport security) | `NodePermissions` bitmask (data-plane ACL trie) + `AdminRole` (control-plane) | Per-session `UserAuthorizationState`, 5-min freshness / 15-min staleness, generation-bound | Not started |
| **MxAccessGateway** | LDAP (GLAuth) for **dashboard** | **API keys** (`mxgw_…`, SQLite, peppered HMAC, scopes + constraints) | gRPC **scopes** (`session:*`/`invoke:*`/`events:*`/`metadata:*`/`admin`) + dashboard `Admin`/`Viewer` | Dashboard cookie (8h sliding) + 30-min Data-Protection hub bearer | Not started |
| **ScadaBridge** | LDAP for UI/CLI/Management API (Basic→LDAP) | **API keys** (`X-API-Key`, peppered HMAC, per-method approval) — Inbound API only | Roles `Admin`/`Design`/`Deployment`/`Audit`/`AuditReadOnly` + **site-scoping** | Cookie (`…ScadaBridge.Auth`, 30-min idle) + 15-min refresh JWT for programmatic | Not started |
See each project's [`current-state/<project>/CURRENT-STATE.md`](current-state/) for the
code-verified detail and its adoption plan.
## Normalized vs. left per-project
**Normalized (the shared target):** LDAP/identity config schema + canonical key names;
bind-then-search behavior incl. DN/filter escaping and timeouts; a generic group→role
mapping seam; **the standardized canonical role set every project maps onto
([`spec/CANONICAL-ROLES.md`](spec/CANONICAL-ROLES.md))**; the API-key contract (token format,
peppered HMAC-SHA256, constant-time compare, audit); cookie/claim conventions; dev-bypass
flag conventions; secret handling.
**Left per-project (native enforcement, mapped onto the canonical roles):** the authorization
*enforcement* vocabularies (`NodePermissions` / gRPC scopes / app roles + site-scoping), OPC UA
transport security, OtOpcUa's generation/staleness session model, ScadaBridge's site-scope rules.
@@ -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).
@@ -0,0 +1,128 @@
# Proposed shared library: `ZB.MOM.WW.Auth`
A contract on paper — the public surface to extract so the three projects stop
re-implementing identity + API-key auth. Realizes [`../spec/SPEC.md`](../spec/SPEC.md).
**Not yet created.** Reference implementations already exist: mxaccessgw Model A (API keys),
ScadaBridge `LdapAuthService` (LDAP hygiene).
## Packages (.NET 10)
```
ZB.MOM.WW.Auth.Abstractions # interfaces, options, result records — the stable surface
ZB.MOM.WW.Auth.Ldap # bind-then-search authn (§2)
ZB.MOM.WW.Auth.ApiKeys # peppered-HMAC key auth + SQLite store (§4)
ZB.MOM.WW.Auth.AspNetCore # cookie/claim/DI helpers (§5) — OtOpcUa Admin UI, gateway, ScadaBridge
```
All four are .NET 10, which all auth-bearing processes are (OtOpcUa server, mxaccessgw
**gateway**, ScadaBridge central) — the x86/net48 mxaccessgw worker does no auth, so net48
multi-targeting is **not** required. Published to the Gitea NuGet feed; SemVer; one consumer
bump per release.
## Packaging & distribution
**Four NuGet packages, one DLL each**, on the Gitea NuGet feed, lockstep SemVer to start
(one version across all four; split to independent versions only if churn diverges). These
are **libraries** linked into each app and copied to its own `bin/` — there is **no central
auth service**. The repos stay separate processes sharing *code*, not a runtime dependency
(auth must run in-process anyway: OPC UA SDK callback, gRPC interceptor, ASP.NET middleware).
Consumers reference only what they need:
| Package (→ DLL) | Transitive deps | OtOpcUa | mxaccessgw | ScadaBridge |
|---|---|---|---|---|
| `…Auth.Abstractions` | none | ✅ | ✅ | ✅ |
| `…Auth.Ldap` | LDAP client (e.g. `System.DirectoryServices.Protocols`) | ✅ | ✅ | ✅ |
| `…Auth.ApiKeys` | `Microsoft.Data.Sqlite` | — | ✅ | ✅ |
| `…Auth.AspNetCore` | ASP.NET Core | ✅ (Admin UI) | ✅ | ✅ |
**Why OtOpcUa *does* take `.AspNetCore`:** it has two auth surfaces. Its OPC UA **data plane**
(UserName tokens via the SDK impersonation callback + ACL trie) is not HTTP and uses only
`.Ldap` + `.Abstractions` behind a bespoke `IOpcUaUserAuthenticator`. But its Blazor **Admin
UI control plane** (cookie + JWT + DataProtection + authorization policies) *is* ASP.NET Core,
so it shares the canonical claim/cookie conventions from `.AspNetCore`. Both surfaces share
`.Ldap` for the bind. (`.ApiKeys` is the only package OtOpcUa skips — it has no API-key surface.)
## `ZB.MOM.WW.Auth.Abstractions`
```csharp
public sealed record LdapOptions { // §1 canonical schema
public bool Enabled { get; init; } = true;
public string Server { get; init; } = "localhost";
public int Port { get; init; } = 3893;
public LdapTransport Transport { get; init; } = LdapTransport.Ldaps;
public bool AllowInsecure { get; init; }
public string SearchBase { get; init; } = "";
public string ServiceAccountDn { get; init; } = "";
public string ServiceAccountPassword { get; init; } = "";
public string UserNameAttribute { get; init; } = "cn";
public string DisplayNameAttribute { get; init; } = "cn";
public string GroupAttribute { get; init; } = "memberOf";
public int ConnectionTimeoutMs { get; init; } = 10_000;
}
public enum LdapTransport { Ldaps, StartTls, None }
public sealed record LdapAuthResult( // outcome of authn
bool Succeeded, string Username, string DisplayName,
IReadOnlyList<string> Groups, LdapAuthFailure? Failure);
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }
public interface ILdapAuthService { // §2
Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct);
}
public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator } // ../spec/CANONICAL-ROLES.md
public interface IGroupRoleMapper<TRole> { // §3 — TRole defaults to CanonicalRole; backing store stays per-project
Task<GroupRoleMapping<TRole>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct);
}
public sealed record GroupRoleMapping<TRole>(IReadOnlyList<TRole> Roles, object? Scope);
// Each project expands a CanonicalRole into its native permissions/scopes at enforcement time.
```
## `ZB.MOM.WW.Auth.ApiKeys`
```csharp
public sealed record ApiKeyOptions { // §4
public string TokenPrefix { get; init; } = "mxgw"; // configurable per project
public string PepperSecretName { get; init; } = ""; // resolved from secret store, never stored
public string SqlitePath { get; init; } = "";
public bool RunMigrationsOnStartup { get; init; } = true;
}
public interface IApiKeyVerifier {
Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct);
}
public sealed record ApiKeyVerification(bool Succeeded, ApiKeyIdentity? Identity, ApiKeyFailure? Failure);
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);
public interface IApiKeyStore { // default: SQLite (hash, scopes, constraints, audit)
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct);
Task MarkUsedAsync(string keyId, CancellationToken ct);
}
public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */ }
```
- Constraints are carried as an **opaque `object`** (project supplies the policy: mxaccessgw
globs/classification, ScadaBridge per-method approval). The library does the
parse→lookup→peppered-HMAC→constant-time-compare→audit pipeline; it does **not** interpret constraints.
- Ships the `apikey` admin verbs as a reusable command set.
## `ZB.MOM.WW.Auth.AspNetCore`
- Canonical `ClaimTypes` constants (name, display, username, role, scope-id).
- Cookie defaults per §5 (HttpOnly, SameSite=Strict, configurable Secure, sliding idle).
- DI helpers: `AddZbLdapAuth(IConfiguration)`, `AddZbApiKeyAuth(IConfiguration)`.
## What stays in each consumer
OtOpcUa: `IOpcUaUserAuthenticator` adapter, ACL trie, transport security, session model.
mxaccessgw: gRPC scope catalog + interceptor, constraint globs, hub tokens.
ScadaBridge: role set + site-scoping, `ManagementActor` enforcement, JWT refresh policy.
## Open contract questions
1. **Group→role store** must support both config and DB backings without leaking either (the `object? Scope` payload covers site-scoping). Validate against ScadaBridge's union semantics.
2. **JWT/refresh**: shared helper or per-project? Only ScadaBridge has the 15-min refresh model today; OtOpcUa has cookie+JWT control plane. Decide when 2+ projects want the same shape.
3. **Constraint opacity**: confirm the `object?` boundary is enough, or whether a small `IConstraintPolicy` interface is cleaner.
See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.
+68
View File
@@ -0,0 +1,68 @@
# Canonical roles (standardized)
Status: **Standardized**. The org-wide role set every sister project maps onto. This is a
mapping **layer above** each project's native authorization: native *enforcement* (OtOpcUa
`NodePermissions`, mxaccessgw gRPC scopes, ScadaBridge native roles + site-scoping) is
unchanged — each project adds a `canonical → native` expansion via the `IGroupRoleMapper<CanonicalRole>`
seam ([`SPEC.md`](SPEC.md) §3). LDAP groups (or API keys) are assigned a **canonical** role;
the project translates it to native permissions at runtime.
## Capabilities
| Capability | Meaning |
|---|---|
| `OBSERVE` | browse / read / subscribe / history **+ read audit logs** |
| `OPERATE` | operate-level writes; alarm acknowledge / confirm |
| `TUNE` | tune-level writes; alarm shelve; method calls |
| `AUTHOR` | create / edit configuration & templates |
| `DEPLOY` | publish / push configuration to runtime / sites (scoped) |
| `ADMINISTER` | manage users / security / system **+ export audit** |
There is no standalone `AUDIT` capability and **no Auditor role**: audit *read* is part of
`OBSERVE`, audit *export* is part of `ADMINISTER`.
## The six roles (capability bundles)
| Role | OBSERVE | OPERATE | TUNE | AUTHOR | DEPLOY | ADMINISTER |
|---|:-:|:-:|:-:|:-:|:-:|:-:|
| **Viewer** | ✓ | | | | | |
| **Operator** | ✓ | ✓ | | | | |
| **Engineer** | ✓ | ✓ | ✓ | | | |
| **Designer** | ✓ | | | ✓ | | |
| **Deployer** | ✓ | | | | ✓ | |
| **Administrator** | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two privilege axes meet at the top: **operations** (Viewer → Operator → Engineer) and
**configuration lifecycle** (Viewer → Designer → Deployer → Administrator). Administrator is
the only role that holds every capability.
## Orthogonal modifiers (not roles)
- **Scope** — a grant is `role × scope`. OtOpcUa scopes by OPC-UA node tree
(cluster→area→line→equipment→tag); ScadaBridge scopes `Deployer` by **site**; mxaccessgw
scopes API keys by subtree/tag globs. The canonical layer carries an abstract scope selector;
each project resolves it to its own granularity.
- **Principal type** — humans (LDAP group → canonical role) and machines (API key → a subset of
canonical capabilities) both take canonical roles.
## Mapping to each project
| Canonical | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| **Viewer** | data-plane `ReadOnly` bundle; ctrl `ConfigViewer` | dashboard `Viewer`; scopes `invoke:read`+`metadata:read`+`events:read`+`session:*` | `AuditReadOnly` (← audit read); ⚠ no general config-viewer role yet |
| **Operator** | data-plane `Operator` bundle | scope `invoke:write` | ⚠ N/A — runtime ops live at sites, not a central role |
| **Engineer** | data-plane `Engineer` bundle | scope `invoke:secure` (closest) | ⚠ N/A |
| **Designer** | ctrl `ConfigEditor` (+ `WriteConfigure`) | ⚠ N/A — no config-authoring surface | `Design` |
| **Deployer** | ◑ part of `FleetAdmin` (config publish/generation) | ⚠ N/A | `Deployment` (+ site scope) |
| **Administrator** | ctrl `FleetAdmin`; data-plane `Admin` bundle | dashboard `Admin`; scope `admin` | `Admin` (+ `Audit` export ←) |
## Consequences & gaps (accepted)
- **Auditor removed:** ScadaBridge `AuditReadOnly`**Viewer**, `Audit`**Administrator**.
This **loses ScadaBridge's auditor/admin separation-of-duties** (an auditor who can export but
is not a full admin no longer has a distinct role). Accepted as the cost of standardizing.
- Each project implements only the **subset** of canonical roles that applies; the ⚠ cells are
simply never assigned there (ScadaBridge: no Operator/Engineer; mxaccessgw: no Designer/Deployer).
- OtOpcUa has no first-class Deployer (config publish ⊂ `FleetAdmin`) — `Deployer` maps partially
until/unless OtOpcUa splits a publish-only control-plane role.
- Adoption is governance + a mapping layer, not enforcement changes — see [`GAPS.md`](../GAPS.md) backlog.
+95
View File
@@ -0,0 +1,95 @@
# Auth — normalized target spec
Status: **Draft**. The single design the sister projects converge on. Derived from the
three code-verified current-state docs (`../current-state/`). Goal is *path to shared code*
(`../shared-contract/ZB.MOM.WW.Auth.md`), so each normalized section maps to a shared library seam.
## 0. Scope
**Normalized here:** LDAP/identity config schema; bind-then-search behavior; the
group→role mapping *seam*; **the standardized canonical role set every project maps onto
([`CANONICAL-ROLES.md`](CANONICAL-ROLES.md))**; the API-key contract; cookie/claim conventions;
dev conventions; secret handling.
**Explicitly NOT normalized** (domain-specific — keep per project): the authorization
*enforcement* (OtOpcUa `NodePermissions` ACL trie, mxaccessgw gRPC scopes + constraints,
ScadaBridge native roles + site-scoping) — each project **maps its native model onto the
canonical roles** but keeps its own enforcement; OPC UA transport security; OtOpcUa's
generation/staleness session model; ScadaBridge's site-scope rules; mxaccessgw's hub-token model.
## 1. LDAP / identity configuration schema
One **nested `Ldap` options object**, bound under each app's own root section
(`OtOpcUa:Authentication:Ldap`, `MxGateway:Ldap`, `ScadaBridge:Security:Ldap`). Canonical keys/types:
| Key | Type | Notes |
|---|---|---|
| `Enabled` | bool | |
| `Server` | string | host |
| `Port` | int | 389 / 636 / 3893 (GLAuth dev) |
| `Transport` | enum `Ldaps` \| `StartTls` \| `None` | **adopt ScadaBridge's enum** over a `UseTls` bool — it expresses StartTLS, which the bool can't |
| `AllowInsecure` | bool (default false) | dev-only escape hatch; pairs with `Transport=None` |
| `SearchBase` | string | base DN |
| `ServiceAccountDn` | string | for bind-then-search |
| `ServiceAccountPassword` | string | from secret store, never source |
| `UserNameAttribute` | string | canonical name (default `cn` GLAuth / `sAMAccountName` AD). Supersedes ScadaBridge's `LdapUserIdAttribute` |
| `DisplayNameAttribute` | string | default `cn` |
| `GroupAttribute` | string | default `memberOf` |
| `ConnectionTimeoutMs` | int | per-operation socket timeout |
Migration: OtOpcUa `Authentication.Ldap.UseTls``Transport`; ScadaBridge flat `Ldap*`→nested + rename `LdapUserIdAttribute``UserNameAttribute`; gateway `MxGateway:Ldap` already close.
## 2. Bind-then-search behavior (canonical algorithm)
1. Connect to `Server:Port`. If `Transport != Ldaps/StartTls` and not `AllowInsecure`**refuse** (config error).
2. Bind the **service account**; a failure here is a *system misconfiguration*, surfaced distinctly from bad user creds.
3. Search under `SearchBase` with filter `({UserNameAttribute}={escapedUsername})`. **Escape** the username (LDAP filter rules: `\ * ( ) NUL`). Reject **multiple** matches (ambiguous); no match → auth failure.
4. **Re-bind as the resolved user DN** with the supplied password to verify it. RFC 4514-escape DN components.
5. Read `GroupAttribute`; normalize group names (strip leading `CN=`/RDN). **If the group lookup fails, fail the login** — never admit a user with zero resolved groups.
6. **Trim-normalize** the username once at entry so one person ≠ two identities.
Generic, injection-safe, fail-closed. (ScadaBridge's current impl is the closest reference.)
## 3. Group → role mapping seam
Resolved LDAP groups (and API-key principals) map to the **standardized canonical role set**
([`CANONICAL-ROLES.md`](CANONICAL-ROLES.md)) via `IGroupRoleMapper<CanonicalRole>`; each project
then **expands a canonical role into its native permissions/scopes** and applies its own scope
payload. The backing store is project-chosen — **config dict** (OtOpcUa `GroupToRole`, gateway
`Dashboard:GroupToRole`) **or database** (ScadaBridge `LdapGroupMapping`). The shared library
defines the seam + the `CanonicalRole` enum and ships both a config-backed and a delegate/DB-backed
mapper; native enforcement stays per-project.
## 4. API-key contract (machine-to-machine)
For projects with a programmatic surface (mxaccessgw gRPC, ScadaBridge Inbound API):
- **Token:** `<prefix>_<keyId>_<secret>` (prefix configurable, e.g. `mxgw`). Parsed/validated before any store hit.
- **Hashing:** HMAC-SHA256 with an **external pepper** (from secret store/config, never stored beside the hash). Secret = 32 random bytes, URL-safe base64.
- **Verify:** lookup by keyId → reject if revoked → hash presented secret with pepper → **constant-time compare** (`CryptographicOperations.FixedTimeEquals`). Discriminated failure reasons for audit; opaque error to the caller.
- **Store:** abstraction with a default SQLite implementation (hash, scopes, optional constraints, created/last-used/revoked) + **append-only audit**. Revoke = timestamp; delete only when revoked.
- **Scopes:** a `string` set gating operations (project supplies the catalog).
- **Constraints:** an **opaque, project-supplied policy** object (mxaccessgw subtree/tag globs + `MaxWriteClassification`; ScadaBridge per-method approval). The contract carries it; it does not hard-code either shape.
- **Admin CLI:** `init-db / create-key / list-keys / revoke-key / rotate-key / delete-key`.
(mxaccessgw Model A is the reference implementation to extract.)
## 5. Cookie / claim / session conventions
- **Cookie:** HttpOnly, `SameSite=Strict`, `Secure` configurable for dev (`RequireHttpsCookie`), sliding idle expiry. Name pattern `<App>.Auth`.
- **Claims:** canonical claim types for name, display name, username, role (one per role), and any project scope id — defined once in `ZB.MOM.WW.Auth.AspNetCore`.
- **DataProtection** keys persisted to shared storage so cookies/tokens survive node failover (OtOpcUa already does this via Config DB).
- Session *lifetime policy* (refresh windows, staleness, generation binding) stays per-project.
## 6. Dev & secret conventions
- **Dev directory:** GLAuth. **Unify the dev base DN** — today OtOpcUa/gateway use `dc=lmxopcua,dc=local` and ScadaBridge uses `dc=scadabridge,dc=local`; pick one shared dev base DN (see `../GAPS.md`).
- **Dev escape hatches** named consistently: `AllowInsecure` (LDAP), plus each project's documented bypass (`AllowAnonymousLocalhost`, `DevStubMode`) clearly dev-only and off in prod.
- **Secrets:** service-account passwords, JWT signing keys, and API-key peppers come from a secret store / env, never source; never logged (API keys, passwords, secured payloads, credentials).
## 7. Acceptance (what "converged" means)
A project is converged when: (a) its LDAP authn + (if applicable) API-key auth run on the
`ZB.MOM.WW.Auth` packages; (b) its config matches §1's schema; (c) its group→role mapping
implements the §3 seam; (d) cookie/claim conventions match §5; with all project-specific
authorization unchanged.