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
@@ -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>();