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:
+1
-96
@@ -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 => sp.GetRequiredService<IApiKeyHasher>()</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);
|
||||
|
||||
Reference in New Issue
Block a user