feat(acl): API key minting — AdminUI page + admin service (#520)
Pulled forward from stage 7 because without it the feature is unusable and un-live-gateable: there was no way to create a key short of hand-inserting a row and computing an HMAC by hand. The minting logic is an IApiKeyAdminService in Core.Authorization, NOT logic in the Razor page — this repo has no bUnit, so anything only a page can reach is untestable, and minting credentials is not where that belongs. The page is a thin shell over it. Moved to Core.Authorization so both ends can reach them: ApiKeyOptions (was in Host) and OpcUaPrincipals (was in OpcUaServer.Security). The OPC UA server resolves principals; the AdminUI must refuse to mint a key claiming a reserved one. OpcUaServer already gained a Core reference in stage 1b, so no new edge — AdminUI gains one. ApiKeyOptions is now bound UNCONDITIONALLY rather than in the driver branch: a driver node needs the pepper to VERIFY keys and an admin node needs it to MINT them, and getting that wrong on either side is a silent authentication failure rather than a startup error. Validation rules, all covered: the reserved 'anonymous' principal is refused case-insensitively (so "ANONYMOUS" cannot slip past into a distinct principal that the evaluator would then match case-INsensitively against anonymous's grants); blank name/principal; a principal longer than the column, rejected rather than truncated, because silent truncation yields a key that authenticates and is granted nothing with no visible cause; a past expiry. Generation runs BEFORE the DbContext is opened so an unconfigured pepper surfaces as a clear error instead of a stored row whose hash nothing can verify. The page shows the plaintext ONCE and says so; it is held in component state and written nowhere else, because the server keeps only a hash. Revocation is flagged in the UI as taking effect only after the next deployment — driver-only nodes have no ConfigDb, so keys ride the artifact. FleetAdmin-only (Administrator), not ConfigEditor: this mints credentials. Core.Tests 68 green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>Create / list / revoke OPC UA data-plane API keys (#520).</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public interface IApiKeyAdminService
|
||||
{
|
||||
/// <summary>Lists every key, newest first. Never returns a secret — there is none stored.</summary>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>All key rows.</returns>
|
||||
Task<IReadOnlyList<ApiKey>> ListAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="name">Human label, e.g. the consuming system.</param>
|
||||
/// <param name="principal">The principal the key authenticates as.</param>
|
||||
/// <param name="expiresUtc">Optional expiry.</param>
|
||||
/// <param name="notes">Optional operator notes.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>The presented key, shown once.</returns>
|
||||
Task<string> CreateAsync(
|
||||
string name, string principal, DateTime? expiresUtc, string? notes, CancellationToken ct);
|
||||
|
||||
/// <summary>Enables or disables a key.</summary>
|
||||
/// <param name="apiKeyId">The public key id.</param>
|
||||
/// <param name="enabled">Whether the key may authenticate.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the operation.</returns>
|
||||
Task SetEnabledAsync(string apiKeyId, bool enabled, CancellationToken ct);
|
||||
|
||||
/// <summary>Permanently deletes a key row.</summary>
|
||||
/// <param name="apiKeyId">The public key id.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
/// <returns>A task representing the operation.</returns>
|
||||
Task DeleteAsync(string apiKeyId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>EF-backed <see cref="IApiKeyAdminService"/>.</summary>
|
||||
public sealed class ApiKeyAdminService : IApiKeyAdminService
|
||||
{
|
||||
/// <summary>Longest accepted principal name, matching the column width.</summary>
|
||||
public const int MaxPrincipalLength = 256;
|
||||
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _factory;
|
||||
private readonly string _pepper;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ApiKeyAdminService"/> class.</summary>
|
||||
/// <param name="factory">The config DB context factory.</param>
|
||||
/// <param name="pepper">The server-side pepper used to hash key secrets.</param>
|
||||
public ApiKeyAdminService(IDbContextFactory<OtOpcUaConfigDbContext> factory, string pepper)
|
||||
{
|
||||
_factory = factory;
|
||||
_pepper = pepper;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<ApiKey>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Security:ApiKeys</c> — OPC UA data-plane API key settings (#520). The data plane is
|
||||
/// machine-to-machine, so a consuming system presents a key rather than an LDAP user.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyOptions
|
||||
{
|
||||
/// <summary>The configuration section name.</summary>
|
||||
public const string SectionName = "Security:ApiKeys";
|
||||
|
||||
/// <summary>
|
||||
/// The static username every key-bearing client presents. The username carries no information —
|
||||
/// the key identifies the principal — but OPC UA's UserName token requires one, so it is fixed
|
||||
/// and checked rather than ignored, which keeps a typo an explicit denial instead of a silent
|
||||
/// "any username works".
|
||||
/// </summary>
|
||||
public string Username { get; set; } = "apikey";
|
||||
|
||||
/// <summary>
|
||||
/// Server-side pepper for the stored key hashes. Supply via <c>Security__ApiKeys__Pepper</c> or a
|
||||
/// <c>${secret:}</c> reference — <b>never commit it</b>. Rotating it invalidates every existing key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately has no default. An empty pepper still hashes "successfully" and would produce a
|
||||
/// working-looking deployment with none of the protection it believes it has, so
|
||||
/// <c>ApiKeySecret.ComputeHash</c> throws on one and this stays empty to make that fire early.
|
||||
/// </remarks>
|
||||
public string Pepper { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||
|
||||
/// <summary>Reserved principal names for the OPC UA data plane (#520).</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static class OpcUaPrincipals
|
||||
{
|
||||
/// <summary>
|
||||
/// The principal an anonymous session authenticates as. <c>NodeAcl</c> rows naming it grant
|
||||
/// anonymous clients permissions exactly as they would a key's principal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public const string Anonymous = "anonymous";
|
||||
}
|
||||
Reference in New Issue
Block a user