6.9 KiB
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.
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
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
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
apikeyadmin verbs as a reusable command set.
ZB.MOM.WW.Auth.AspNetCore
- Canonical
ClaimTypesconstants (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
- Group→role store must support both config and DB backings without leaking either (the
object? Scopepayload covers site-scoping). Validate against ScadaBridge's union semantics. - 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.
- Constraint opacity: confirm the
object?boundary is enough, or whether a smallIConstraintPolicyinterface is cleaner.
See ../GAPS.md for the adoption order and effort/risk.