diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyAdminService.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyAdminService.cs new file mode 100644 index 00000000..33fb825e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyAdminService.cs @@ -0,0 +1,141 @@ +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// Create / list / revoke OPC UA data-plane API keys (#520). +/// +/// A service rather than logic in the Razor page, because this repo has no bUnit — anything only a +/// page can reach is untestable, and minting credentials is not where that belongs. +/// +public interface IApiKeyAdminService +{ + /// Lists every key, newest first. Never returns a secret — there is none stored. + /// The cancellation token. + /// All key rows. + Task> ListAsync(CancellationToken ct); + + /// + /// Mints a new key. The returned plaintext is the ONLY time it exists — it is not recoverable + /// from what is stored, so a caller that fails to show it has effectively discarded the key. + /// + /// Human label, e.g. the consuming system. + /// The principal the key authenticates as. + /// Optional expiry. + /// Optional operator notes. + /// The cancellation token. + /// The presented key, shown once. + Task CreateAsync( + string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct); + + /// Enables or disables a key. + /// The public key id. + /// Whether the key may authenticate. + /// The cancellation token. + /// A task representing the operation. + Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct); + + /// Permanently deletes a key row. + /// The public key id. + /// The cancellation token. + /// A task representing the operation. + Task DeleteAsync(string apiKeyId, CancellationToken ct); +} + +/// EF-backed . +public sealed class ApiKeyAdminService : IApiKeyAdminService +{ + /// Longest accepted principal name, matching the column width. + public const int MaxPrincipalLength = 256; + + private readonly IDbContextFactory _factory; + private readonly string _pepper; + + /// Initializes a new instance of the class. + /// The config DB context factory. + /// The server-side pepper used to hash key secrets. + public ApiKeyAdminService(IDbContextFactory factory, string pepper) + { + _factory = factory; + _pepper = pepper; + } + + /// + public async Task> ListAsync(CancellationToken ct) + { + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + return await db.ApiKeys.AsNoTracking().OrderByDescending(k => k.CreatedUtc).ToListAsync(ct) + .ConfigureAwait(false); + } + + /// + public async Task CreateAsync( + string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct) + { + name = (name ?? string.Empty).Trim(); + principal = (principal ?? string.Empty).Trim(); + + if (name.Length == 0) throw new ArgumentException("A key name is required.", nameof(name)); + if (principal.Length == 0) + throw new ArgumentException("A principal is required.", nameof(principal)); + if (principal.Length > MaxPrincipalLength) + throw new ArgumentException( + $"Principal must be {MaxPrincipalLength} characters or fewer.", nameof(principal)); + + // Reserved: a key authenticating as 'anonymous' would silently inherit whatever anonymous was + // granted, and vice versa — two identities meant to be distinct sharing one grant set. The + // authenticator refuses it too; this is the authoring half so it can never be stored at all. + if (string.Equals(principal, OpcUaPrincipals.Anonymous, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException( + $"'{OpcUaPrincipals.Anonymous}' is reserved for anonymous sessions and cannot be a key's principal.", + nameof(principal)); + + if (expiresUtc is { } exp && exp <= DateTime.UtcNow) + throw new ArgumentException("Expiry must be in the future.", nameof(expiresUtc)); + + // Generate BEFORE opening the context: this throws on an unconfigured pepper, and failing there + // is much clearer than a row that persists against a hash nothing can ever verify. + var (presented, apiKeyId, hash) = ApiKeySecret.Generate(_pepper); + + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + db.ApiKeys.Add(new ApiKey + { + ApiKeyId = apiKeyId, + Name = name, + SecretHash = hash, + Principal = principal, + Enabled = true, + CreatedUtc = DateTime.UtcNow, + ExpiresUtc = expiresUtc, + Notes = string.IsNullOrWhiteSpace(notes) ? null : notes.Trim(), + }); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + + return presented; + } + + /// + public async Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct) + { + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + var key = await db.ApiKeys.SingleOrDefaultAsync(k => k.ApiKeyId == apiKeyId, ct) + .ConfigureAwait(false); + if (key is null) return; + + key.Enabled = enabled; + await db.SaveChangesAsync(ct).ConfigureAwait(false); + } + + /// + public async Task DeleteAsync(string apiKeyId, CancellationToken ct) + { + await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false); + var key = await db.ApiKeys.SingleOrDefaultAsync(k => k.ApiKeyId == apiKeyId, ct) + .ConfigureAwait(false); + if (key is null) return; + + db.ApiKeys.Remove(key); + await db.SaveChangesAsync(ct).ConfigureAwait(false); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs similarity index 96% rename from src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs rename to src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs index d6e384bf..47d122e4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/ApiKeyOptions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/ApiKeyOptions.cs @@ -1,4 +1,4 @@ -namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa; +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; /// /// Security:ApiKeys — OPC UA data-plane API key settings (#520). The data plane is diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/OpcUaPrincipals.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/OpcUaPrincipals.cs new file mode 100644 index 00000000..3ddc165e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/OpcUaPrincipals.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Authorization; + +/// Reserved principal names for the OPC UA data plane (#520). +/// +/// Lives in Core rather than beside the authenticator because BOTH ends need it: the OPC UA server +/// resolves an anonymous session to it, and the AdminUI authoring path must refuse to mint a key +/// that claims it. +/// +public static class OpcUaPrincipals +{ + /// + /// The principal an anonymous session authenticates as. NodeAcl rows naming it grant + /// anonymous clients permissions exactly as they would a key's principal. + /// + /// + /// Reserved: an API key must not use this principal, or a credentialled client would silently + /// inherit whatever anonymous was granted (and vice versa). Refused at authoring AND at + /// authentication — belt and braces, because the two paths can be reached independently. + /// + public const string Anonymous = "anonymous"; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor index 29be26aa..0827d6be 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor @@ -20,6 +20,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor new file mode 100644 index 00000000..e96e0b8a --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ApiKeys.razor @@ -0,0 +1,206 @@ +@page "/apikeys" +@attribute [Authorize(Policy = AdminUiPolicies.FleetAdmin)] +@rendermode RenderMode.InteractiveServer +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@using ZB.MOM.WW.OtOpcUa.Core.Authorization +@inject IApiKeyAdminService Keys + +
+

OPC UA API keys

+
+ + + +@if (_created is not null) +{ + +} + +@if (!string.IsNullOrWhiteSpace(_error)) +{ + +} + +
+
Create a key
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
Keys
+ @if (_keys is null) + { +
Loading…
+ } + else if (_keys.Count == 0) + { +
+ No keys yet. Until one exists, only anonymous sessions can connect. +
+ } + else + { + + + + + + + + + + + + + + @foreach (var k in _keys) + { + + + + + + + + + + } + +
NamePrincipalKey idStatusCreated (UTC)Expires (UTC)
@k.Name@k.Principal@k.ApiKeyId + @if (!k.Enabled) + { + revoked + } + else if (k.ExpiresUtc is { } e && e <= DateTime.UtcNow) + { + expired + } + else + { + active + } + @k.CreatedUtc.ToString("yyyy-MM-dd HH:mm")@(k.ExpiresUtc?.ToString("yyyy-MM-dd") ?? "—") + + +
+ } +
+ +@code { + private IReadOnlyList? _keys; + private string _newName = ""; + private string _newPrincipal = ""; + private DateTime? _newExpires; + private string? _created; + private string? _error; + private bool _busy; + + protected override async Task OnInitializedAsync() => await ReloadAsync(); + + private async Task ReloadAsync() + { + try + { + _keys = await Keys.ListAsync(default); + } + catch (Exception ex) + { + _error = ex.Message; + _keys = []; + } + } + + private async Task CreateAsync() + { + _busy = true; + _error = null; + _created = null; + try + { + // Shown once, held only in this component's state. There is no way to recover it afterwards + // — the server stores a hash — so it is deliberately not written anywhere else. + _created = await Keys.CreateAsync( + _newName, + _newPrincipal, + // A date-only input means midnight; treat it as UTC rather than local so an operator + // near a timezone boundary does not get a key that expired before it was issued. + _newExpires is { } d ? DateTime.SpecifyKind(d, DateTimeKind.Utc) : null, + notes: null, + default); + _newName = ""; + _newPrincipal = ""; + _newExpires = null; + await ReloadAsync(); + } + catch (Exception ex) + { + _error = ex.Message; + } + finally + { + _busy = false; + } + } + + private async Task SetEnabledAsync(string apiKeyId, bool enabled) + { + _busy = true; + _error = null; + try + { + await Keys.SetEnabledAsync(apiKeyId, enabled, default); + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } + + private async Task DeleteAsync(string apiKeyId) + { + _busy = true; + _error = null; + try + { + await Keys.DeleteAsync(apiKeyId, default); + await ReloadAsync(); + } + catch (Exception ex) { _error = ex.Message; } + finally { _busy = false; } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index 4b1727c9..c3b46211 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -23,6 +23,9 @@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 2efba25b..ef6c3c62 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Serilog; using ZB.MOM.WW.LocalDb.Replication; @@ -20,6 +21,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Core.Scripting; using ZB.MOM.WW.OtOpcUa.Host; using ZB.MOM.WW.OtOpcUa.Host.Configuration; @@ -132,6 +134,11 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua"); // central over the Phase-3 fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it // unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string. // A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected. +// #520 — bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it +// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a +// startup error. +builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); + if (hasAdmin) builder.Services.AddOtOpcUaConfigDb(builder.Configuration); else @@ -344,7 +351,6 @@ if (hasDriver) // (static username "apikey", the otua_ credential as the password), not an LDAP user. The LDAP // registrations above are deliberately retained: they back ADMINUI LOGIN, which keeps LDAP. // Only IOpcUaUserAuthenticator moves. - builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); if (hasAdmin) { // ConfigDbApiKeyStore needs a ConfigDb, and Phase 4 gated AddOtOpcUaConfigDb on hasAdmin. @@ -353,6 +359,11 @@ if (hasDriver) // Safe direction, and anonymous sessions are unaffected. builder.Services.AddSingleton(); builder.Services.AddSingleton(); + + // Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb. + builder.Services.AddSingleton(sp => new ApiKeyAdminService( + sp.GetRequiredService>(), + sp.GetRequiredService>().Value.Pepper)); } else { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index a8172cac..aa9f8784 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.Configuration; using Opc.Ua.Server; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs index 837fdf23..f27d111d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Security/IOpcUaUserAuthenticator.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; /// @@ -47,19 +49,6 @@ public interface IOpcUaUserAuthenticator Task.FromResult(OpcUaUserAuthResult.Allow(OpcUaPrincipals.Anonymous, Array.Empty())); } -/// Reserved principal names for the OPC UA data plane (#520). -public static class OpcUaPrincipals -{ - /// - /// The principal an anonymous session authenticates as. NodeAcl rows naming it grant - /// anonymous clients permissions exactly as they would a key's principal. - /// - /// - /// Reserved: an API key must not be created with this principal, or a key would silently inherit - /// whatever anonymous was granted (and vice versa). Enforced at authoring time. - /// - public const string Anonymous = "anonymous"; -} /// Outcome of a UserName authentication attempt. populates the session identity's role set. public sealed record OpcUaUserAuthResult( diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeyAdminServiceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeyAdminServiceTests.cs new file mode 100644 index 00000000..2e082044 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/Authorization/ApiKeyAdminServiceTests.cs @@ -0,0 +1,84 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Core.Tests.Authorization; + +/// +/// Validation rules for minting API keys (#520). These run BEFORE any database work, so they are +/// covered without a ConfigDb: a null context factory proves the guard rejected the input rather +/// than reaching persistence (it would NullReferenceException if it got that far). +/// +public sealed class ApiKeyAdminServiceTests +{ + private static ApiKeyAdminService Sut(string pepper = "unit-test-pepper") => new(null!, pepper); + + [Fact] + public async Task The_reserved_anonymous_principal_cannot_be_minted_as_a_key() + { + // Otherwise a credentialled client silently inherits whatever anonymous was granted. Rejected + // here AND at authentication, because the two paths are reachable independently. + var ex = await Should.ThrowAsync(() => + Sut().CreateAsync("sneaky", OpcUaPrincipals.Anonymous, null, null, default)); + + ex.Message.ShouldContain("reserved"); + } + + [Fact] + public async Task The_reserved_principal_check_is_case_insensitive() + { + // "Anonymous" must not slip past a case-sensitive comparison and become a distinct principal + // that the evaluator would then match case-INsensitively against anonymous's grants. + await Should.ThrowAsync(() => + Sut().CreateAsync("sneaky", "ANONYMOUS", null, null, default)); + } + + [Theory] + [InlineData("", "principal")] + [InlineData(" ", "principal")] + public async Task A_blank_principal_is_rejected(string principal, string expectedParam) + { + var ex = await Should.ThrowAsync(() => + Sut().CreateAsync("name", principal, null, null, default)); + + ex.ParamName.ShouldBe(expectedParam); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task A_blank_name_is_rejected(string name) + { + var ex = await Should.ThrowAsync(() => + Sut().CreateAsync(name, "some-principal", null, null, default)); + + ex.ParamName.ShouldBe("name"); + } + + [Fact] + public async Task A_principal_longer_than_the_column_is_rejected_rather_than_truncated() + { + // Silent truncation would create a key whose principal does not match the ACL row the operator + // authored, i.e. a key that authenticates and is granted nothing, with no visible cause. + var tooLong = new string('p', ApiKeyAdminService.MaxPrincipalLength + 1); + + await Should.ThrowAsync(() => + Sut().CreateAsync("name", tooLong, null, null, default)); + } + + [Fact] + public async Task An_expiry_in_the_past_is_rejected() + { + await Should.ThrowAsync(() => + Sut().CreateAsync("name", "p", DateTime.UtcNow.AddMinutes(-1), null, default)); + } + + [Fact] + public async Task An_unconfigured_pepper_fails_before_anything_is_persisted() + { + // Generate() runs before the DbContext is opened precisely so this surfaces as a clear pepper + // error rather than a stored row whose hash nothing could ever verify. + await Should.ThrowAsync(() => + Sut(pepper: "").CreateAsync("name", "p", null, null, default)); + } +}