feat(auth)!: ScadaBridge retire SQL Server ApiKey entity + ApprovedApiKeyIds + legacy hashing; EF migration RetireInboundApiKeyStore; re-issue runbook + CHANGELOG (re-arch C5/E) — BREAKING: X-API-Key -> Bearer sbk_, keys re-issued

This commit is contained in:
Joseph Doherty
2026-06-02 05:39:59 -04:00
parent b13d7b3d28
commit afa55981d5
32 changed files with 2117 additions and 1193 deletions
@@ -1,68 +0,0 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
/// <summary>
/// An inbound-API bearer credential. Per ConfigurationDatabase-012 the plaintext key
/// is never persisted: the entity stores only <see cref="KeyHash"/>, a deterministic
/// keyed hash of the key (HMAC-SHA256 with a server-side pepper). The plaintext is
/// generated at creation, shown to the operator exactly once, and then discarded.
/// </summary>
public class ApiKey
{
/// <summary>Database primary key.</summary>
public int Id { get; set; }
/// <summary>Display name for the API key.</summary>
public string Name { get; set; }
/// <summary>
/// Deterministic keyed hash of the API key value. This is the only form of the
/// credential persisted; the plaintext key is never stored. Authentication hashes
/// the presented candidate with the same scheme and compares against this value.
/// </summary>
public string KeyHash { get; set; }
/// <summary>When false, the key is rejected even if the hash matches.</summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Creates an API key from a plaintext value, immediately hashing it with the
/// unpeppered default hasher (<see cref="ApiKeyHasher.Default"/>) so the entity
/// never holds the plaintext. Production code paths that have a configured pepper
/// should use <see cref="FromHash(string, string)"/> with a peppered hash instead.
/// </summary>
/// <param name="name">Display name for the API key.</param>
/// <param name="keyValue">Plaintext key value; hashed immediately and never stored.</param>
public ApiKey(string name, string keyValue)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
if (keyValue is null) throw new ArgumentNullException(nameof(keyValue));
KeyHash = ApiKeyHasher.Default.Hash(keyValue);
}
/// <summary>
/// Parameterless constructor for the EF Core materializer. Application code uses
/// <see cref="ApiKey(string, string)"/> or <see cref="FromHash(string, string)"/>.
/// </summary>
private ApiKey()
{
Name = string.Empty;
KeyHash = string.Empty;
}
/// <summary>
/// Creates an API key from an already-computed key hash. Used by the creation
/// path, which generates a random key, hashes it with the configured (peppered)
/// <see cref="IApiKeyHasher"/>, and stores only the resulting hash.
/// </summary>
/// <param name="name">Display name for the API key.</param>
/// <param name="keyHash">Pre-computed keyed hash of the API key value.</param>
public static ApiKey FromHash(string name, string keyHash)
{
return new ApiKey
{
Name = name ?? throw new ArgumentNullException(nameof(name)),
KeyHash = keyHash ?? throw new ArgumentNullException(nameof(keyHash)),
};
}
}
@@ -8,8 +8,6 @@ public class ApiMethod
public string Name { get; set; }
/// <summary>Gets or sets the C# script body executed when the method is invoked.</summary>
public string Script { get; set; }
/// <summary>Gets or sets the JSON-serialised list of API key IDs approved for this method, or <c>null</c> for unrestricted.</summary>
public string? ApprovedApiKeyIds { get; set; }
/// <summary>Gets or sets the JSON Schema describing the accepted parameters, or <c>null</c> if the method takes no parameters.</summary>
public string? ParameterDefinitions { get; set; }
/// <summary>Gets or sets the JSON Schema describing the return type, or <c>null</c> if the method returns nothing.</summary>
@@ -4,30 +4,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
public interface IInboundApiRepository
{
// ApiKey
/// <summary>Retrieves an API key by ID.</summary>
/// <param name="id">The API key ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default);
/// <summary>Retrieves all API keys.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default);
/// <summary>Retrieves an API key by value.</summary>
/// <param name="keyValue">The API key value.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default);
/// <summary>Adds a new API key.</summary>
/// <param name="apiKey">The API key to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
/// <summary>Updates an existing API key.</summary>
/// <param name="apiKey">The API key to update.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default);
/// <summary>Deletes an API key by ID.</summary>
/// <param name="id">The API key ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default);
// ApiKey persistence retired (re-arch C5): inbound API keys live in the shared
// ZB.MOM.WW.Auth.ApiKeys SQLite store, not the SQL Server configuration DB. The
// former GetApiKeyByIdAsync / GetAllApiKeysAsync / GetApiKeyByValueAsync /
// AddApiKeyAsync / UpdateApiKeyAsync / DeleteApiKeyAsync / GetApprovedKeysForMethodAsync
// methods were removed with the SQL Server ApiKey entity.
// ApiMethod
/// <summary>Retrieves an API method by ID.</summary>
@@ -41,10 +22,6 @@ public interface IInboundApiRepository
/// <param name="name">The API method name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>Retrieves API keys approved for a method.</summary>
/// <param name="methodId">The API method ID.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default);
/// <summary>Adds a new API method.</summary>
/// <param name="method">The API method to add.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -1,95 +0,0 @@
using System.Security.Cryptography;
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
/// <summary>
/// Computes a deterministic, keyed hash of an inbound-API key value
/// (ConfigurationDatabase-012). API keys are persisted as this hash, never as
/// plaintext, so a configuration-database dump does not yield usable credentials.
/// The hash is deterministic so authentication can still resolve a key by value.
/// </summary>
public interface IApiKeyHasher
{
/// <summary>
/// Returns the keyed hash of <paramref name="apiKey"/> as a Base64 string.
/// The same input always produces the same output (deterministic), which keeps
/// the by-value lookup working.
/// </summary>
/// <param name="apiKey">The raw API key to hash.</param>
/// <returns>A Base64-encoded HMAC-SHA256 hash of the key.</returns>
string Hash(string apiKey);
}
/// <summary>
/// HMAC-SHA256 implementation of <see cref="IApiKeyHasher"/>. The HMAC key is a
/// server-side <em>pepper</em> bound from configuration. A per-row random salt is
/// intentionally NOT used: an API key is already a high-entropy random token, and a
/// random salt would break the deterministic by-value lookup the authentication
/// path relies on. The pepper instead binds every hash to this deployment, so a
/// stolen database is useless without it.
/// </summary>
public sealed class ApiKeyHasher : IApiKeyHasher
{
/// <summary>
/// Minimum accepted pepper length. A pepper shorter than this is treated as a
/// deployment misconfiguration and rejected — see <see cref="ApiKeyHasher(string)"/>.
/// </summary>
public const int MinimumPepperLength = 16;
private readonly byte[] _pepper;
/// <summary>
/// An unpeppered hasher (HMAC-SHA256 keyed with a fixed, empty-equivalent value).
/// It is still a one-way hash, but carries no deployment-specific binding. It
/// exists for tests and non-production wiring; production must construct an
/// <see cref="ApiKeyHasher"/> with a real pepper.
/// </summary>
public static ApiKeyHasher Default { get; } = new ApiKeyHasher();
private ApiKeyHasher()
{
// Fixed, deployment-independent key for the unpeppered default.
_pepper = Encoding.UTF8.GetBytes("ZB.MOM.WW.ScadaBridge.InboundApi.DefaultApiKeyHasher");
}
/// <summary>
/// Creates a hasher keyed with the given server-side pepper.
/// </summary>
/// <param name="pepper">Server-side HMAC key; must be at least <see cref="MinimumPepperLength"/> characters.</param>
/// <exception cref="ArgumentException">
/// Thrown if <paramref name="pepper"/> is null, blank, or shorter than
/// <see cref="MinimumPepperLength"/> — a missing or weak pepper is a deployment
/// misconfiguration and must fail loudly rather than degrade silently.
/// </exception>
public ApiKeyHasher(string pepper)
{
if (string.IsNullOrWhiteSpace(pepper))
{
throw new ArgumentException(
"The API-key HMAC pepper must be configured. Set a strong, random value " +
"in configuration (ScadaBridge:InboundApi:ApiKeyPepper).",
nameof(pepper));
}
if (pepper.Trim().Length < MinimumPepperLength)
{
throw new ArgumentException(
$"The API-key HMAC pepper is too weak: it must be at least {MinimumPepperLength} " +
"characters. Use a strong, random value.",
nameof(pepper));
}
_pepper = Encoding.UTF8.GetBytes(pepper);
}
/// <inheritdoc />
public string Hash(string apiKey)
{
ArgumentNullException.ThrowIfNull(apiKey);
using var hmac = new HMACSHA256(_pepper);
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(apiKey));
return Convert.ToBase64String(hash);
}
}
@@ -4,29 +4,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations;
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
{
/// <summary>Configures the EF Core mapping for the <see cref="ApiKey"/> entity.</summary>
/// <param name="builder">Entity type builder used to apply the configuration.</param>
public void Configure(EntityTypeBuilder<ApiKey> builder)
{
builder.HasKey(k => k.Id);
builder.Property(k => k.Name)
.IsRequired()
.HasMaxLength(200);
// ConfigurationDatabase-012: the bearer credential is persisted only as a
// deterministic HMAC-SHA256 hash, never as plaintext. Base64 of a 32-byte
// HMAC-SHA256 digest is 44 characters; 256 leaves generous headroom.
builder.Property(k => k.KeyHash)
.IsRequired()
.HasMaxLength(256);
builder.HasIndex(k => k.Name).IsUnique();
builder.HasIndex(k => k.KeyHash).IsUnique();
}
}
// Auth re-arch (C5): the SQL Server ApiKey entity was retired — inbound API keys now
// live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store. The former
// ApiKeyConfiguration (and the ApiMethod.ApprovedApiKeyIds mapping) were removed; the
// ApiKeys table + ApprovedApiKeyIds column are dropped by the RetireInboundApiKeyStore
// migration.
public class ApiMethodConfiguration : IEntityTypeConfiguration<ApiMethod>
{
@@ -43,9 +25,6 @@ public class ApiMethodConfiguration : IEntityTypeConfiguration<ApiMethod>
builder.Property(m => m.Script)
.IsRequired();
builder.Property(m => m.ApprovedApiKeyIds)
.HasMaxLength(4000);
builder.Property(m => m.ParameterDefinitions)
.HasMaxLength(4000);
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class RetireInboundApiKeyStore : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ApiKeys");
migrationBuilder.DropColumn(
name: "ApprovedApiKeyIds",
table: "ApiMethods");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ApprovedApiKeyIds",
table: "ApiMethods",
type: "nvarchar(4000)",
maxLength: 4000,
nullable: true);
migrationBuilder.CreateTable(
name: "ApiKeys",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
IsEnabled = table.Column<bool>(type: "bit", nullable: false),
KeyHash = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ApiKeys", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_KeyHash",
table: "ApiKeys",
column: "KeyHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_Name",
table: "ApiKeys",
column: "Name",
unique: true);
}
}
}
@@ -553,38 +553,6 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
b.ToTable("ExternalSystemMethods");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("IsEnabled")
.HasColumnType("bit");
b.Property<string>("KeyHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.HasKey("Id");
b.HasIndex("KeyHash")
.IsUnique();
b.HasIndex("Name")
.IsUnique();
b.ToTable("ApiKeys");
});
modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b =>
{
b.Property<int>("Id")
@@ -593,10 +561,6 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ApprovedApiKeyIds")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -1,84 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class InboundApiRepository : IInboundApiRepository
{
private readonly ScadaBridgeDbContext _context;
// CD-016: lazily resolved so the InboundAPI ApiKeyHasher factory (which throws
// when no pepper is configured) is only invoked if GetApiKeyByValueAsync is
// actually called — Central/Host startup composition roots that never call
// this method (the production ApiKeyValidator deliberately doesn't) get to
// bring InboundApiRepository up without forcing every test to wire a
// throw-away pepper into InboundApiOptions.
private readonly Func<IApiKeyHasher> _hasherAccessor;
private readonly ILogger<InboundApiRepository> _logger;
/// <summary>
/// Initializes a new instance of the InboundApiRepository class.
/// </summary>
/// <param name="context">The database context for accessing inbound API data.</param>
/// <param name="hasherAccessor">
/// CD-016: factory that returns the API-key hasher used to digest a candidate
/// plaintext for the peppered <see cref="GetApiKeyByValueAsync"/> lookup.
/// Resolution is deferred to first call so a composition root that doesn't
/// register <see cref="IApiKeyHasher"/> (or whose factory would throw because
/// no pepper is configured) can still bring up the repository for callers that
/// don't touch the value-lookup path. Defaults to a factory returning
/// <see cref="ApiKeyHasher.Default"/>; production wires
/// <c>sp =&gt; sp.GetRequiredService&lt;IApiKeyHasher&gt;()</c> via DI so the
/// lookup uses the same peppered digest as the production write path.
/// </param>
/// <param name="logger">Optional logger instance for warnings and diagnostics.</param>
public InboundApiRepository(
ScadaBridgeDbContext context,
Func<IApiKeyHasher>? hasherAccessor = null,
ILogger<InboundApiRepository>? logger = null)
public InboundApiRepository(ScadaBridgeDbContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_hasherAccessor = hasherAccessor ?? (() => ApiKeyHasher.Default);
_logger = logger ?? NullLogger<InboundApiRepository>.Instance;
}
/// <inheritdoc />
public async Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().FindAsync(new object[] { id }, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
{
// CD-016: hash the candidate with the DI-provided peppered hasher so this
// lookup matches keys whose stored KeyHash was produced by the production
// ApiKeyHasher(pepper). The pre-fix call to ApiKeyHasher.Default would
// silently return null for every real key on any peppered deployment.
// Resolution is deferred until this method is actually called so the
// pepper-validating factory doesn't fire during startup composition.
var keyHash = _hasherAccessor().Hash(keyValue);
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
}
/// <inheritdoc />
public async Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
=> await _context.Set<ApiKey>().AddAsync(apiKey, cancellationToken);
/// <inheritdoc />
public Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
{ _context.Set<ApiKey>().Update(apiKey); return Task.CompletedTask; }
/// <inheritdoc />
public async Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default)
{
var entity = await GetApiKeyByIdAsync(id, cancellationToken);
if (entity != null) _context.Set<ApiKey>().Remove(entity);
}
/// <inheritdoc />
@@ -93,37 +29,6 @@ public class InboundApiRepository : IInboundApiRepository
public async Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().FirstOrDefaultAsync(m => m.Name == name, cancellationToken);
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default)
{
var method = await _context.Set<ApiMethod>().FindAsync(new object[] { methodId }, cancellationToken);
if (method?.ApprovedApiKeyIds == null)
return new List<ApiKey>();
// ApprovedApiKeyIds is a comma-separated string of integer ApiKey ids. A token that
// fails to parse indicates a corrupt value: it is dropped (it cannot identify a key),
// but the corruption is logged as a warning so it is observable rather than silent.
// A corrupt list would otherwise quietly approve fewer keys than intended.
var keyIds = new List<int>();
foreach (var token in method.ApprovedApiKeyIds.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var trimmed = token.Trim();
if (int.TryParse(trimmed, out var id) && id > 0)
{
keyIds.Add(id);
}
else
{
_logger.LogWarning(
"ApiMethod {MethodId} has a malformed approved-API-key id token '{Token}' " +
"in ApprovedApiKeyIds; it was dropped. The method may approve fewer keys than expected.",
methodId, trimmed);
}
}
return await _context.Set<ApiKey>().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
=> await _context.Set<ApiMethod>().AddAsync(method, cancellationToken);
@@ -115,8 +115,9 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext
public DbSet<SiteScopeRule> SiteScopeRules => Set<SiteScopeRule>();
// Inbound API
/// <summary>Gets the set of API keys.</summary>
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
// Auth re-arch (C5): the SQL Server ApiKeys DbSet was retired — inbound API keys
// now live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store. Only the method
// catalogue remains in the configuration database.
/// <summary>Gets the set of API methods.</summary>
public DbSet<ApiMethod> ApiMethods => Set<ApiMethod>();
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
@@ -54,15 +53,10 @@ public static class ServiceCollectionExtensions
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
// CD-016: factory registration wires a lazy accessor for IApiKeyHasher so
// the production peppered hasher is used (via DI) when GetApiKeyByValueAsync
// is actually called, but composition roots that never call it (and may
// not register IApiKeyHasher at all) still bring up the repository.
services.AddScoped<IInboundApiRepository>(sp => new InboundApiRepository(
sp.GetRequiredService<ScadaBridgeDbContext>(),
hasherAccessor: () => sp.GetService<Commons.Types.InboundApi.IApiKeyHasher>()
?? Commons.Types.InboundApi.ApiKeyHasher.Default,
logger: sp.GetService<ILogger<InboundApiRepository>>()));
// Auth re-arch (C5): inbound API keys are no longer persisted in SQL Server
// the repository now exposes only API-method access, so a plain scoped
// registration suffices (no peppered-hasher accessor to wire).
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>();
+8 -8
View File
@@ -118,14 +118,14 @@ try
builder.Services.AddCentralUI();
builder.Services.AddInboundAPI();
// Inbound-API auth re-arch (A+B), additive: stand up the shared
// ZB.MOM.WW.Auth.ApiKeys verifier + SQLite store + startup migration
// ALONGSIDE the legacy peppered-HMAC X-API-Key path. The POST
// /api/{methodName} endpoint now authenticates Bearer tokens
// (sbk_<keyId>_<secret>) and authorizes by scope == method name through
// this verifier. The legacy ApiKeyValidator/IApiKeyHasher remain
// registered (unused by the endpoint) until a later sub-task retires the
// SQL Server ApiKey entity.
// Inbound-API auth re-arch: the shared ZB.MOM.WW.Auth.ApiKeys verifier +
// SQLite store + startup migration are now the SOLE inbound-API auth path.
// The POST /api/{methodName} endpoint authenticates Bearer tokens
// (sbk_<keyId>_<secret>) and authorizes by scope == method name through this
// verifier. The legacy peppered-HMAC X-API-Key path — the SQL Server ApiKey
// entity, ApiKeyValidator, and IApiKeyHasher — was retired in re-arch C5; the
// ScadaBridge:InboundApi:ApiKeyPepper config key is now consumed only as the
// library verifier's pepper secret (PepperSecretName below).
//
// ApiKeyOptions is an init-only record, so the contract-mandated values
// are injected as in-memory configuration UNDER the bound section path
@@ -1,168 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-1: Validates API keys from X-API-Key header.
/// Checks that the key exists, is enabled, and is approved for the requested method.
/// </summary>
public class ApiKeyValidator
{
private readonly IInboundApiRepository _repository;
private readonly IApiKeyHasher _hasher;
// InboundAPI-011: the single message used for both "method not found" and
// "key not approved" so the two outcomes are indistinguishable to the caller.
private const string NotApprovedMessage = "API key not approved for this method";
/// <param name="repository">Inbound-API data access.</param>
/// <param name="hasher">
/// ConfigurationDatabase-012: hashes the presented candidate key with the same
/// HMAC-SHA256 pepper used at key creation, so authentication compares hashes —
/// the database never holds a plaintext credential. Defaults to the unpeppered
/// <see cref="ApiKeyHasher.Default"/>; production wiring injects the configured,
/// peppered hasher.
/// </param>
public ApiKeyValidator(IInboundApiRepository repository, IApiKeyHasher? hasher = null)
{
_repository = repository;
_hasher = hasher ?? ApiKeyHasher.Default;
}
/// <summary>
/// Validates an API key for a given method.
/// Returns (isValid, apiKey, statusCode, errorMessage).
/// </summary>
/// <param name="apiKeyValue">The API key value from the X-API-Key header.</param>
/// <param name="methodName">The name of the method being invoked.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<ApiKeyValidationResult> ValidateAsync(
string? apiKeyValue,
string methodName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(apiKeyValue))
{
return ApiKeyValidationResult.Unauthorized("Missing X-API-Key header");
}
// InboundAPI-003: do NOT resolve the key with a secret-equality lookup
// (GetApiKeyByValueAsync translates to a SQL "WHERE KeyHash = @hash" early-exit
// comparison — a timing side-channel). Fetch all keys and match in-process
// with a constant-time comparison so neither match position nor length is
// observable to a network attacker.
// ConfigurationDatabase-012: the database stores only the HMAC hash of each
// key, so the presented candidate is hashed with the same pepper and the
// comparison runs over the resulting hashes — never over plaintext.
var candidateHash = _hasher.Hash(apiKeyValue);
var apiKey = FindKeyConstantTime(
await _repository.GetAllApiKeysAsync(cancellationToken),
candidateHash);
if (apiKey == null || !apiKey.IsEnabled)
{
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
}
// InboundAPI-011: "method not found" and "key not approved" must produce an
// indistinguishable response. Otherwise a caller holding any valid key could
// enumerate which method names exist by observing the status/message
// difference. Both cases return 403 with the identical message below, and the
// caller-supplied method name is never echoed back into the response.
var method = await _repository.GetMethodByNameAsync(methodName, cancellationToken);
if (method == null)
{
return ApiKeyValidationResult.Forbidden(NotApprovedMessage);
}
// Check if this key is approved for the method
var approvedKeys = await _repository.GetApprovedKeysForMethodAsync(method.Id, cancellationToken);
var isApproved = approvedKeys.Any(k => k.Id == apiKey.Id);
if (!isApproved)
{
return ApiKeyValidationResult.Forbidden(NotApprovedMessage);
}
return ApiKeyValidationResult.Valid(apiKey, method);
}
/// <summary>
/// InboundAPI-003 / ConfigurationDatabase-012: Finds the key whose stored
/// <see cref="ApiKey.KeyHash"/> matches <paramref name="candidateHash"/> — the
/// HMAC hash of the presented key — using
/// <see cref="CryptographicOperations.FixedTimeEquals"/> over the UTF-8 bytes.
/// Every candidate row is compared so that the running time does not depend on the
/// match position; length mismatches return false without leaking length timing.
/// </summary>
private static ApiKey? FindKeyConstantTime(IEnumerable<ApiKey> keys, string candidateHash)
{
var candidateBytes = Encoding.UTF8.GetBytes(candidateHash);
ApiKey? match = null;
foreach (var key in keys)
{
var keyBytes = Encoding.UTF8.GetBytes(key.KeyHash);
if (CryptographicOperations.FixedTimeEquals(candidateBytes, keyBytes))
{
// Do not break — continuing keeps the loop's timing independent of
// where (or whether) a match is found.
match = key;
}
}
return match;
}
}
/// <summary>
/// Result of API key validation.
/// </summary>
public class ApiKeyValidationResult
{
/// <summary>
/// Whether the API key validation was successful.
/// </summary>
public bool IsValid { get; private init; }
/// <summary>
/// The HTTP status code for the validation result.
/// </summary>
public int StatusCode { get; private init; }
/// <summary>
/// Error message if validation failed, if any.
/// </summary>
public string? ErrorMessage { get; private init; }
/// <summary>
/// The validated API key, if successful.
/// </summary>
public ApiKey? ApiKey { get; private init; }
/// <summary>
/// The validated API method, if successful.
/// </summary>
public ApiMethod? Method { get; private init; }
/// <summary>
/// Creates a successful validation result.
/// </summary>
/// <param name="apiKey">The validated API key.</param>
/// <param name="method">The validated API method.</param>
public static ApiKeyValidationResult Valid(ApiKey apiKey, ApiMethod method) =>
new() { IsValid = true, StatusCode = 200, ApiKey = apiKey, Method = method };
/// <summary>
/// Creates an unauthorized validation result.
/// </summary>
/// <param name="message">The error message.</param>
public static ApiKeyValidationResult Unauthorized(string message) =>
new() { IsValid = false, StatusCode = 401, ErrorMessage = message };
/// <summary>
/// Creates a forbidden validation result.
/// </summary>
/// <param name="message">The error message.</param>
public static ApiKeyValidationResult Forbidden(string message) =>
new() { IsValid = false, StatusCode = 403, ErrorMessage = message };
}
@@ -20,15 +20,17 @@ public class InboundApiOptions
public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes;
/// <summary>
/// ConfigurationDatabase-012: server-side HMAC pepper used to hash inbound-API
/// bearer credentials. API keys are persisted as a deterministic keyed hash, never
/// as plaintext; this pepper is the HMAC key that binds every hash to this
/// deployment, so a stolen configuration database is not directly exploitable.
/// Server-side HMAC pepper for inbound-API bearer credentials, bound from
/// <c>ScadaBridge:InboundApi:ApiKeyPepper</c>.
/// <para>
/// This is a secret: supply a strong, random value via configuration or a secret
/// store, never hard-coded. It must be present and at least
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi.ApiKeyHasher.MinimumPepperLength"/>
/// characters — <c>AddInboundAPI</c> fails fast otherwise.
/// Auth re-arch (C5): the legacy SQL Server hashing path that consumed this
/// property was retired. The pepper itself is still required — the shared
/// ZB.MOM.WW.Auth.ApiKeys verifier reads the SAME configuration key
/// (<c>PepperSecretName</c> in the Host composition root points at it) to pepper
/// the SQLite-stored keys. It is a secret: supply a strong, random value
/// (≥ 16 characters), DIFFERENT per environment, via a secret store and never
/// hard-coded. This property is retained so the section still binds cleanly; the
/// value is consumed by the library verifier, not by <c>AddInboundAPI</c>.
/// </para>
/// </summary>
public string ApiKeyPepper { get; set; } = string.Empty;
@@ -1,33 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers all inbound API services (API key validator, script executor, route helper, and endpoint filter).
/// Registers all inbound API services (script executor, route helper, and endpoint filter).
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddInboundAPI(this IServiceCollection services)
{
services.AddScoped<ApiKeyValidator>();
// Auth re-arch (C5): inbound authentication is handled by the shared
// ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>), registered by
// AddZbApiKeyAuth in the Host composition root. The legacy ApiKeyValidator +
// peppered IApiKeyHasher and the SQL Server ApiKey store were retired, so this
// extension no longer registers them.
services.AddSingleton<InboundScriptExecutor>();
services.AddScoped<RouteHelper>();
// ConfigurationDatabase-012: API keys are persisted as a deterministic HMAC
// hash, never as plaintext. The hasher is keyed with a server-side pepper
// bound from configuration (InboundApiOptions.ApiKeyPepper). Constructing
// ApiKeyHasher throws if the pepper is missing or weak — so a misconfigured
// deployment fails fast the first time the hasher is resolved rather than
// silently hashing with no pepper.
services.AddSingleton<IApiKeyHasher>(sp =>
{
var options = sp.GetRequiredService<IOptions<InboundApiOptions>>().Value;
return new ApiKeyHasher(options.ApiKeyPepper);
});
// InboundAPI-017: routed calls go through the IInstanceRouter seam; the
// production implementation delegates to CommunicationService.
services.AddScoped<IInstanceRouter, CommunicationServiceInstanceRouter>();
@@ -2058,9 +2058,10 @@ public sealed class BundleImporter : IBundleImporter
}
case ResolutionAction.Overwrite when existing is not null:
existing.Script = dto.Script;
// ApprovedApiKeyIds is NOT overwritten from a bundle (re-arch C4):
// method→key scopes are re-granted per environment and any value on
// the target row is preserved across an import.
// Method→key scopes are not transported (re-arch C4) and the
// ApprovedApiKeyIds column was dropped with the SQL Server ApiKey
// entity (re-arch C5): scopes are re-granted per environment in the
// shared ZB.MOM.WW.Auth.ApiKeys store, never via an imported bundle.
existing.ParameterDefinitions = dto.ParameterDefinitions;
existing.ReturnDefinition = dto.ReturnDefinition;
existing.TimeoutSeconds = dto.TimeoutSeconds;
@@ -2086,8 +2087,10 @@ public sealed class BundleImporter : IBundleImporter
private static ApiMethod BuildApiMethod(ApiMethodDto dto, string? overrideName)
{
// ApprovedApiKeyIds is intentionally left at its default (null): keys are not
// transported (re-arch C4) and method→key scopes are re-granted per environment.
// Method→key scopes are not transported (re-arch C4); the ApprovedApiKeyIds
// column that once carried them was dropped with the SQL Server ApiKey entity
// (re-arch C5). Scopes are re-granted per environment in the shared
// ZB.MOM.WW.Auth.ApiKeys store.
return new ApiMethod(overrideName ?? dto.Name, dto.Script)
{
ParameterDefinitions = dto.ParameterDefinitions,