Files
scadaproj/components/auth/shared-contract/ZB.MOM.WW.Auth.md
T

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 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 for the adoption order and effort/risk.