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:
@@ -0,0 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to ScadaBridge are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed — BREAKING: inbound API authentication
|
||||
|
||||
Inbound API authentication has migrated off the SQL Server `X-API-Key` scheme and
|
||||
onto the shared `ZB.MOM.WW.Auth.ApiKeys` library.
|
||||
|
||||
- **Credential format.** The inbound `POST /api/{methodName}` endpoint now
|
||||
authenticates an `Authorization: Bearer sbk_<keyId>_<secret>` token instead of the
|
||||
raw `X-API-Key: <key>` header. The secret is verified with a peppered, constant-time
|
||||
HMAC compare inside the shared library verifier.
|
||||
- **Storage.** Inbound API keys now live in the shared `ZB.MOM.WW.Auth.ApiKeys` SQLite
|
||||
store, not the SQL Server configuration database. The deterministic-HMAC `ApiKey`
|
||||
table is gone.
|
||||
- **Authorization model.** A key's allowed methods are now its per-key **scopes**
|
||||
(scope string == method name, ordinal/case-sensitive). The previous
|
||||
`ApiMethod.ApprovedApiKeyIds` CSV that linked methods to key IDs has been removed.
|
||||
- **Peppering.** Keys are peppered per environment via
|
||||
`ScadaBridge:InboundApi:ApiKeyPepper` (≥ 16 characters, **different per environment**,
|
||||
kept secret). The same configuration key now backs the library verifier's pepper
|
||||
secret.
|
||||
|
||||
> **BREAKING — all existing inbound API keys are INVALIDATED and must be re-issued.**
|
||||
> Old `X-API-Key` credentials and their stored HMAC hashes are not migrated and are
|
||||
> not recoverable; the `ApiKeys` table is dropped. Operators must re-issue every
|
||||
> inbound key as an `sbk_…` token and update every API client. See the runbook:
|
||||
> [`docs/operations/inbound-api-key-reissue.md`](docs/operations/inbound-api-key-reissue.md).
|
||||
|
||||
### Removed
|
||||
|
||||
- The SQL Server `ApiKey` entity (`ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey`),
|
||||
its EF Core mapping, and its `IInboundApiRepository` key methods
|
||||
(`GetApiKeyByIdAsync`, `GetAllApiKeysAsync`, `GetApiKeyByValueAsync`, `AddApiKeyAsync`,
|
||||
`UpdateApiKeyAsync`, `DeleteApiKeyAsync`, `GetApprovedKeysForMethodAsync`).
|
||||
- The `ApiMethod.ApprovedApiKeyIds` property, its EF mapping, and the CSV
|
||||
parse/serialize helpers.
|
||||
- The legacy hashing code: `ApiKeyHasher` / `IApiKeyHasher` and the in-repo inbound
|
||||
`ApiKeyValidator` (superseded by the shared `IApiKeyVerifier`), plus their DI
|
||||
registrations and tests.
|
||||
|
||||
### Migrations
|
||||
|
||||
- `RetireInboundApiKeyStore` — drops the `ApiKeys` table and the
|
||||
`ApiMethods.ApprovedApiKeyIds` column. `Down` recreates both, but **dropped keys are
|
||||
not recoverable**: rolling the migration back does not restore credentials. Rollback
|
||||
means reverting the deployment, then re-issuing keys.
|
||||
@@ -0,0 +1,175 @@
|
||||
# Inbound API Key Re-issue Runbook
|
||||
|
||||
**Status:** BREAKING change — action required on every environment that uses the
|
||||
inbound API (`POST /api/{methodName}`).
|
||||
**Date:** 2026-06-02
|
||||
**Migration:** `RetireInboundApiKeyStore`
|
||||
|
||||
This runbook covers the migration of inbound API authentication from the legacy SQL
|
||||
Server `X-API-Key` scheme to the shared `ZB.MOM.WW.Auth.ApiKeys` store. After this
|
||||
change **all existing inbound API keys are invalidated** and every API client must be
|
||||
re-issued a new credential.
|
||||
|
||||
---
|
||||
|
||||
## 1. What changed and why
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| Header | `X-API-Key: <key>` | `Authorization: Bearer sbk_<keyId>_<secret>` |
|
||||
| Verification | Deterministic HMAC hash, looked up in SQL Server | Peppered, constant-time HMAC compare in the shared `ZB.MOM.WW.Auth.ApiKeys` verifier |
|
||||
| Storage | SQL Server `ApiKeys` table (config DB) | `ZB.MOM.WW.Auth.ApiKeys` SQLite store |
|
||||
| Authorization | `ApiMethod.ApprovedApiKeyIds` CSV linking methods to key IDs | Per-key **scopes**, where each scope string is an allowed method name (ordinal, case-sensitive) |
|
||||
|
||||
**Why:** the inbound credential path now reuses the shared auth library that the rest
|
||||
of the `ZB.MOM.WW.*` family uses, with a single, tested, peppered verifier and a
|
||||
proper one-time-token issuance model. The deterministic SQL Server hash table and its
|
||||
method-link CSV are retired. The legacy `ApiKeyHasher` / `IApiKeyHasher` and the
|
||||
in-repo `ApiKeyValidator` are gone — inbound auth runs through `IApiKeyVerifier`.
|
||||
|
||||
> The old `X-API-Key` credentials are **not migrated**. There is no automated
|
||||
> conversion: the stored hashes are not reversible, and the new tokens have a
|
||||
> different shape (`sbk_<keyId>_<secret>`). Every key must be re-issued.
|
||||
|
||||
---
|
||||
|
||||
## 2. Required configuration (per environment)
|
||||
|
||||
Set these under the ScadaBridge configuration for each environment (appsettings,
|
||||
environment variables, or your secret store):
|
||||
|
||||
| Key | Value | Notes |
|
||||
|---|---|---|
|
||||
| `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` | Filesystem path to the SQLite key store | Defaults to `<content-root>/data/inbound-api-keys.sqlite` if unset. Choose a durable, backed-up path on a writable volume. |
|
||||
| `ScadaBridge:InboundApi:ApiKeyPepper` | A strong, random string, **≥ 16 characters** | **DIFFERENT per environment.** Keep it secret (secret store, not source control). This is the HMAC pepper that binds every stored key to this deployment; it is also the verifier's pepper secret. |
|
||||
|
||||
Notes:
|
||||
- The pepper must be present and at least 16 characters or the host fails fast at
|
||||
startup (`AddZbApiKeyAuth`).
|
||||
- Changing the pepper after keys are issued invalidates all keys in that environment
|
||||
(they would no longer verify). Set it once, per environment, and keep it stable.
|
||||
- The token prefix is `sbk` and migrations run on startup by default
|
||||
(`ScadaBridge:InboundApi:ApiKeyStore:RunMigrationsOnStartup = true`); these are
|
||||
wired by the Host and normally need no operator change.
|
||||
|
||||
---
|
||||
|
||||
## 3. Database migration step
|
||||
|
||||
Apply the EF Core migration `RetireInboundApiKeyStore` to the SQL Server
|
||||
configuration database. It:
|
||||
|
||||
- drops the `ApiKeys` table, and
|
||||
- drops the `ApprovedApiKeyIds` column from `ApiMethods`.
|
||||
|
||||
If migrations are applied automatically on deploy (the default for the central node),
|
||||
this happens as part of the rollout. To apply manually:
|
||||
|
||||
```bash
|
||||
dotnet ef database update RetireInboundApiKeyStore \
|
||||
--project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase \
|
||||
--startup-project src/ZB.MOM.WW.ScadaBridge.Host
|
||||
```
|
||||
|
||||
> Applying this migration **permanently drops** the old key data. Take a database
|
||||
> backup first if you need a record of the prior `ApiKeys` rows for audit purposes
|
||||
> (the hashes are not usable credentials, but the names/enabled flags may be of
|
||||
> record-keeping value).
|
||||
|
||||
The new inbound keys live in the **SQLite** store (section 2), not in SQL Server.
|
||||
|
||||
---
|
||||
|
||||
## 4. Operator re-issue procedure
|
||||
|
||||
Re-issue one key per client. Each key is created with the exact method names it is
|
||||
allowed to call (its scopes).
|
||||
|
||||
### Option A — Admin UI
|
||||
|
||||
1. Navigate to **`/admin/api-keys`** in the central UI.
|
||||
2. **Create** a new key: enter a display name and select the allowed method(s).
|
||||
3. The one-time token `sbk_<keyId>_<secret>` is shown **exactly once** — copy it now.
|
||||
It cannot be retrieved later.
|
||||
4. Distribute the token securely to the owning client.
|
||||
|
||||
### Option B — CLI
|
||||
|
||||
```bash
|
||||
scadabridge --url <central-url> security api-key create \
|
||||
--name <client-name> \
|
||||
--methods <method1,method2>
|
||||
```
|
||||
|
||||
- `--methods` is a comma-separated list of allowed method names — these become the
|
||||
key's scopes. A method name must match the registered `ApiMethod.Name` **exactly**
|
||||
(case-sensitive).
|
||||
- The command prints `API key created. KeyId: <id>` and then the one-time token on
|
||||
stdout (the "save this now — it will not be shown again" advisory goes to stderr, so
|
||||
piping stdout captures only the token).
|
||||
|
||||
Capture the `sbk_…` token at issue time; it is the only moment the secret is available.
|
||||
|
||||
To later change which methods a key may call:
|
||||
|
||||
```bash
|
||||
scadabridge --url <central-url> security api-key set-methods --key-id <id> --methods <m1,m2>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Client change
|
||||
|
||||
Each API client must replace its header:
|
||||
|
||||
- **Remove:** `X-API-Key: <old-key>`
|
||||
- **Add:** `Authorization: Bearer sbk_<keyId>_<secret>`
|
||||
|
||||
Example:
|
||||
|
||||
```http
|
||||
POST /api/CreateOrder HTTP/1.1
|
||||
Host: scadabridge.example.com
|
||||
Authorization: Bearer sbk_7f3a...._9c1e....
|
||||
Content-Type: application/json
|
||||
|
||||
```
|
||||
|
||||
The token is the full `sbk_<keyId>_<secret>` string exactly as issued — do not split
|
||||
or transform it.
|
||||
|
||||
---
|
||||
|
||||
## 6. Verification
|
||||
|
||||
1. **Authn (valid key):** call an allowed method with the new Bearer token → `200`
|
||||
(or the method's normal result).
|
||||
2. **Authn (no/old credential):** call with no `Authorization` header, or with the old
|
||||
`X-API-Key` header only → `401` with `{"error":"Invalid or missing API key"}`.
|
||||
3. **Authz (out of scope):** call a method the key is **not** scoped for → `403` with
|
||||
`{"error":"API key not approved for this method"}`. A non-existent method name
|
||||
returns the identical `403` body (enumeration-safe — by design).
|
||||
4. **Audit:** a successful call records the verified key's display name as the audit
|
||||
actor; an auth failure records `Actor=null`. Confirm via the audit log.
|
||||
5. Confirm no client is still sending `X-API-Key` (those requests now fail `401`).
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollback
|
||||
|
||||
The migration `Down` recreates the `ApiKeys` table and the `ApprovedApiKeyIds` column,
|
||||
**but the dropped key rows are not restored** — `Down` only rebuilds empty structures.
|
||||
Rolling the migration back does **not** recover any credential.
|
||||
|
||||
Therefore "rollback" means **reverting the deployment** to the prior build (which still
|
||||
speaks `X-API-Key`), not reverting the keys:
|
||||
|
||||
1. Redeploy the previous ScadaBridge build.
|
||||
2. If you took a SQL Server backup before section 3, restore the `ApiKeys` table from
|
||||
it so the old keys verify again.
|
||||
3. Without that backup, the old keys are gone and must be re-created under the legacy
|
||||
scheme as well.
|
||||
|
||||
Because rollback is costly and lossy, prefer rolling **forward**: complete the re-issue
|
||||
in section 4 and fix any straggler clients rather than reverting.
|
||||
in section 4 and fix any straggler clients rather than reverting.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-26
@@ -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);
|
||||
|
||||
|
||||
+1740
File diff suppressed because it is too large
Load Diff
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
-36
@@ -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
-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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -116,8 +116,9 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
// Mirror production order: routing → auth → audit
|
||||
// middleware → endpoint. The auth scheme always
|
||||
// succeeds; per-request auth-failure semantics are
|
||||
// produced INSIDE the endpoint handler (mirroring
|
||||
// ApiKeyValidator's in-handler short-circuit).
|
||||
// produced INSIDE the endpoint handler (mirroring the
|
||||
// shared ZB.MOM.WW.Auth.ApiKeys verifier's in-handler
|
||||
// short-circuit).
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
@@ -241,9 +242,9 @@ public class InboundApiAuditTests : IClassFixture<MsSqlMigrationFixture>
|
||||
|
||||
using var host = await BuildHostAsync(async ctx =>
|
||||
{
|
||||
// The production ApiKeyValidator returns 401 from inside the
|
||||
// handler when the X-API-Key header is missing or invalid; the
|
||||
// handler must NOT stash an actor name in that case so the
|
||||
// The production inbound endpoint returns 401 from inside the
|
||||
// handler when the Bearer token is missing or fails verification;
|
||||
// the handler must NOT stash an actor name in that case so the
|
||||
// middleware emits Actor=null on the resulting audit row.
|
||||
ctx.Response.StatusCode = 401;
|
||||
await ctx.Response.WriteAsync("unauthorized");
|
||||
|
||||
@@ -71,8 +71,6 @@ public class TransportExportPageTests : BunitContext
|
||||
.Returns(Task.FromResult<IReadOnlyList<NotificationList>>(new List<NotificationList>()));
|
||||
_notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<SmtpConfiguration>>(new List<SmtpConfiguration>()));
|
||||
_inboundApiRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiKey>>(new List<ApiKey>()));
|
||||
_inboundApiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<IReadOnlyList<ApiMethod>>(new List<ApiMethod>()));
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: the <see cref="ApiKey"/> entity must never carry the
|
||||
/// plaintext bearer credential as a persisted field — only its deterministic hash.
|
||||
/// </summary>
|
||||
public class ApiKeyTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApiKey_HasNoPlaintextKeyValueProperty()
|
||||
{
|
||||
// The plaintext key is shown to the operator once at creation and is never
|
||||
// persisted. The entity must therefore expose KeyHash, not KeyValue.
|
||||
var properties = typeof(ApiKey).GetProperties().Select(p => p.Name).ToArray();
|
||||
|
||||
Assert.DoesNotContain("KeyValue", properties);
|
||||
Assert.Contains("KeyHash", properties);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_FromPlaintext_StoresHashNotPlaintext()
|
||||
{
|
||||
var key = new ApiKey("MES-Production", "the-secret-key-value");
|
||||
|
||||
Assert.NotEqual("the-secret-key-value", key.KeyHash);
|
||||
Assert.Equal(ApiKeyHasher.Default.Hash("the-secret-key-value"), key.KeyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromHash_StoresHashVerbatim()
|
||||
{
|
||||
var key = ApiKey.FromHash("RecipeManager-Dev", "precomputed-hash-value");
|
||||
|
||||
Assert.Equal("RecipeManager-Dev", key.Name);
|
||||
Assert.Equal("precomputed-hash-value", key.KeyHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullArguments_Throw()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new ApiKey(null!, "value"));
|
||||
Assert.Throws<ArgumentNullException>(() => new ApiKey("name", (string)null!));
|
||||
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash(null!, "hash"));
|
||||
Assert.Throws<ArgumentNullException>(() => ApiKey.FromHash("name", null!));
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: the inbound-API bearer credential is stored as a
|
||||
/// deterministic keyed hash (HMAC-SHA256 with a server-side pepper) rather than
|
||||
/// plaintext. These tests pin the hasher contract that the entity, the validator,
|
||||
/// and the management create-path all depend on.
|
||||
/// </summary>
|
||||
public class ApiKeyHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Hash_IsDeterministic_SameInputSameOutput()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
var first = hasher.Hash("some-api-key-value");
|
||||
var second = hasher.Hash("some-api-key-value");
|
||||
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DoesNotEqualPlaintext()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
var hash = hasher.Hash("some-api-key-value");
|
||||
|
||||
Assert.NotEqual("some-api-key-value", hash);
|
||||
Assert.DoesNotContain("some-api-key-value", hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentInputs_ProduceDifferentHashes()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
Assert.NotEqual(hasher.Hash("key-one"), hasher.Hash("key-two"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_DifferentPeppers_ProduceDifferentHashes()
|
||||
{
|
||||
var a = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
var b = new ApiKeyHasher("a-different-but-equally-long-pepper-val");
|
||||
|
||||
// The pepper binds the hash to the server: a stolen DB dump is useless
|
||||
// without the pepper because the same key hashes differently under it.
|
||||
Assert.NotEqual(a.Hash("same-api-key"), b.Hash("same-api-key"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("too-short")]
|
||||
public void Constructor_MissingOrWeakPepper_FailsFast(string? pepper)
|
||||
{
|
||||
// The pepper must be present and of meaningful length; a missing or weak
|
||||
// pepper is a deployment misconfiguration and must fail loudly.
|
||||
Assert.Throws<ArgumentException>(() => new ApiKeyHasher(pepper!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_NullInput_Throws()
|
||||
{
|
||||
var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value");
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => hasher.Hash(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_IsUsableWithoutAPepper()
|
||||
{
|
||||
// The unpeppered default exists for tests and non-production wiring; it is
|
||||
// still a one-way HMAC-SHA256, just without the server-binding pepper.
|
||||
var hash = ApiKeyHasher.Default.Hash("some-api-key-value");
|
||||
|
||||
Assert.NotEqual("some-api-key-value", hash);
|
||||
Assert.Equal(ApiKeyHasher.Default.Hash("some-api-key-value"), hash);
|
||||
}
|
||||
}
|
||||
+6
-129
@@ -1,21 +1,24 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
// Auth re-arch (C5): the SQL Server ApiKey entity and the repository's key methods
|
||||
// (Add/Get/Update/Delete/GetApprovedKeysForMethod) were retired — inbound API keys
|
||||
// now live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store. The former key
|
||||
// round-trip, peppered-hasher, and ApprovedApiKeyIds CSV tests were removed with
|
||||
// them; only the API-method catalogue remains here.
|
||||
public class InboundApiRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly CapturingLogger<InboundApiRepository> _logger = new();
|
||||
private readonly InboundApiRepository _repository;
|
||||
|
||||
public InboundApiRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new InboundApiRepository(_context, hasherAccessor: null, logger: _logger);
|
||||
_repository = new InboundApiRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -24,53 +27,6 @@ public class InboundApiRepositoryTests : IDisposable
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddApiKey_AndGetById_RoundTrips()
|
||||
{
|
||||
var key = new ApiKey("Key1", "secret-value-1") { IsEnabled = true };
|
||||
await _repository.AddApiKeyAsync(key);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var loaded = await _repository.GetApiKeyByIdAsync(key.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("Key1", loaded!.Name);
|
||||
|
||||
var byValue = await _repository.GetApiKeyByValueAsync("secret-value-1");
|
||||
Assert.NotNull(byValue);
|
||||
Assert.Equal(key.Id, byValue!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CD016_GetApiKeyByValue_UsesInjectedPepperedHasher_NotDefault()
|
||||
{
|
||||
// CD-016 regression: stored KeyHash is produced by a peppered hasher.
|
||||
// A repository whose lookup uses ApiKeyHasher.Default (the pre-fix
|
||||
// behaviour) would compute a different digest and return null. With the
|
||||
// pepper-aware hasherAccessor wired in, the lookup must round-trip.
|
||||
var peppered = new Commons.Types.InboundApi.ApiKeyHasher("a-strong-test-pepper-of-sufficient-length");
|
||||
var pepperedHash = peppered.Hash("secret-with-pepper");
|
||||
var key = ApiKey.FromHash("Peppered", pepperedHash);
|
||||
key.IsEnabled = true;
|
||||
|
||||
using var ctx = SqliteTestHelper.CreateInMemoryContext();
|
||||
var repo = new InboundApiRepository(ctx, hasherAccessor: () => peppered, logger: _logger);
|
||||
await repo.AddApiKeyAsync(key);
|
||||
await repo.SaveChangesAsync();
|
||||
|
||||
var byValue = await repo.GetApiKeyByValueAsync("secret-with-pepper");
|
||||
Assert.NotNull(byValue);
|
||||
Assert.Equal(key.Id, byValue!.Id);
|
||||
|
||||
// And: a repository wired with the Default (unpeppered) hasher MUST
|
||||
// NOT find the same key — proving the lookup actually uses the
|
||||
// injected hasher and the original bug shape.
|
||||
var defaultRepo = new InboundApiRepository(ctx,
|
||||
hasherAccessor: () => Commons.Types.InboundApi.ApiKeyHasher.Default,
|
||||
logger: _logger);
|
||||
var missByDefault = await defaultRepo.GetApiKeyByValueAsync("secret-with-pepper");
|
||||
Assert.Null(missByDefault);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddApiMethod_AndGetByName_RoundTrips()
|
||||
{
|
||||
@@ -83,60 +39,6 @@ public class InboundApiRepositoryTests : IDisposable
|
||||
Assert.Equal(method.Id, loaded!.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithValidCsv_ReturnsAllKeys()
|
||||
{
|
||||
var k1 = new ApiKey("K1", "v1");
|
||||
var k2 = new ApiKey("K2", "v2");
|
||||
await _repository.AddApiKeyAsync(k1);
|
||||
await _repository.AddApiKeyAsync(k2);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id}, {k2.Id}" };
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Equal(2, keys.Count);
|
||||
Assert.Empty(_logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithMalformedCsvToken_LogsWarningAndDropsToken()
|
||||
{
|
||||
// Regression guard for ConfigurationDatabase-008: a corrupt token (a name where an
|
||||
// integer id is expected) must not be dropped silently — the corruption must be
|
||||
// observable via a logged warning, while the valid ids still resolve.
|
||||
var k1 = new ApiKey("K1", "v1");
|
||||
await _repository.AddApiKeyAsync(k1);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var method = new ApiMethod("M", "return 1;") { ApprovedApiKeyIds = $"{k1.Id},not-an-id" };
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Single(keys);
|
||||
Assert.Equal(k1.Id, keys[0].Id);
|
||||
Assert.Single(_logger.Warnings);
|
||||
Assert.Contains("not-an-id", _logger.Warnings[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetApprovedKeysForMethod_WithNullOrEmptyCsv_ReturnsEmptyWithoutWarning()
|
||||
{
|
||||
var method = new ApiMethod("M", "return 1;");
|
||||
await _repository.AddApiMethodAsync(method);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var keys = await _repository.GetApprovedKeysForMethodAsync(method.Id);
|
||||
|
||||
Assert.Empty(keys);
|
||||
Assert.Empty(_logger.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteApiMethod_RemovesEntity()
|
||||
{
|
||||
@@ -156,28 +58,3 @@ public class InboundApiRepositoryTests : IDisposable
|
||||
Assert.Throws<ArgumentNullException>(() => new InboundApiRepository(null!));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal ILogger that captures warning-level messages for assertions.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<string> Warnings { get; } = new();
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (logLevel == LogLevel.Warning)
|
||||
{
|
||||
Warnings.Add(formatter(state, exception));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-25
@@ -135,29 +135,8 @@ public class SplitQueryBehaviourTests : IDisposable
|
||||
Assert.Single(loaded.Scripts);
|
||||
}
|
||||
|
||||
// ConfigurationDatabase-012: the ApiKey table must persist the bearer credential
|
||||
// as a hash column (KeyHash) and must NOT carry a plaintext KeyValue column.
|
||||
|
||||
[Fact]
|
||||
public void ApiKey_KeyHashColumn_IsMappedAndUniquelyIndexed()
|
||||
{
|
||||
var entityType = _context.Model.FindEntityType(typeof(ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey))!;
|
||||
|
||||
var keyHash = entityType.FindProperty("KeyHash");
|
||||
Assert.NotNull(keyHash);
|
||||
Assert.False(keyHash!.IsNullable);
|
||||
|
||||
var hashIndex = entityType.GetIndexes()
|
||||
.FirstOrDefault(i => i.Properties.Any(p => p.Name == "KeyHash"));
|
||||
Assert.NotNull(hashIndex);
|
||||
Assert.True(hashIndex!.IsUnique);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiKey_HasNoPlaintextKeyValueColumn()
|
||||
{
|
||||
var entityType = _context.Model.FindEntityType(typeof(ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiKey))!;
|
||||
|
||||
Assert.Null(entityType.FindProperty("KeyValue"));
|
||||
}
|
||||
// Auth re-arch (C5): the SQL Server ApiKey entity was retired (inbound keys now
|
||||
// live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store), so the former
|
||||
// ApiKey_KeyHashColumn_IsMappedAndUniquelyIndexed and
|
||||
// ApiKey_HasNoPlaintextKeyValueColumn schema assertions were removed with it.
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ public class DbContextTests : IDisposable
|
||||
Assert.NotNull(_context.SharedScripts);
|
||||
Assert.NotNull(_context.LdapGroupMappings);
|
||||
Assert.NotNull(_context.SiteScopeRules);
|
||||
Assert.NotNull(_context.ApiKeys);
|
||||
// Auth re-arch (C5): the ApiKeys DbSet was retired (inbound keys moved to the
|
||||
// shared ZB.MOM.WW.Auth.ApiKeys SQLite store); only ApiMethods remains.
|
||||
Assert.NotNull(_context.ApiMethods);
|
||||
Assert.NotNull(_context.AuditLogEntries);
|
||||
|
||||
@@ -264,16 +265,16 @@ public class DbContextTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InboundApi_ApiKeyAndMethod()
|
||||
public void InboundApi_Method()
|
||||
{
|
||||
var key = new ApiKey("TestKey", "sk-test-123") { IsEnabled = true };
|
||||
// Auth re-arch (C5): the SQL Server ApiKey entity was retired (inbound keys
|
||||
// now live in the shared ZB.MOM.WW.Auth.ApiKeys SQLite store), so this case
|
||||
// covers only the surviving ApiMethod catalogue.
|
||||
var method = new ApiMethod("GetStatus", "return \"ok\";") { TimeoutSeconds = 30 };
|
||||
|
||||
_context.ApiKeys.Add(key);
|
||||
_context.ApiMethods.Add(method);
|
||||
_context.SaveChanges();
|
||||
|
||||
Assert.Single(_context.ApiKeys.Where(k => k.Name == "TestKey"));
|
||||
Assert.Single(_context.ApiMethods.Where(m => m.Name == "GetStatus"));
|
||||
}
|
||||
|
||||
|
||||
@@ -118,10 +118,11 @@ public class CentralCompositionRootTests : IDisposable
|
||||
["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
|
||||
["ScadaBridge:Security:Ldap:SearchBase"] = "dc=scadabridge,dc=local",
|
||||
["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=scadabridge,dc=local",
|
||||
// ConfigurationDatabase-012: inbound-API keys are hashed
|
||||
// with a server-side HMAC pepper; ApiKeyHasher fails fast
|
||||
// if it is missing or weak, so resolving ApiKeyValidator
|
||||
// requires a configured pepper.
|
||||
// Auth re-arch (C5): inbound-API keys live in the shared
|
||||
// ZB.MOM.WW.Auth.ApiKeys SQLite store. The verifier reuses
|
||||
// this same config key as its pepper secret (PepperSecretName),
|
||||
// and AddZbApiKeyAuth fails fast if it is missing/weak — so a
|
||||
// configured pepper is still required for the host to start.
|
||||
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
|
||||
});
|
||||
});
|
||||
@@ -211,8 +212,8 @@ public class CentralCompositionRootTests : IDisposable
|
||||
// Security (ILdapAuthService is now a singleton — see CentralSingletonServices)
|
||||
new object[] { typeof(JwtTokenService) },
|
||||
new object[] { typeof(RoleMapper) },
|
||||
// InboundAPI
|
||||
new object[] { typeof(ApiKeyValidator) },
|
||||
// InboundAPI — auth re-arch (C5): the legacy ApiKeyValidator was retired;
|
||||
// inbound auth runs through the shared ZB.MOM.WW.Auth.ApiKeys verifier.
|
||||
new object[] { typeof(RouteHelper) },
|
||||
// ExternalSystemGateway
|
||||
new object[] { typeof(ExternalSystemClient) },
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
using NSubstitute;
|
||||
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.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: <see cref="ApiKeyValidator"/> must authenticate by
|
||||
/// hashing the presented candidate with the same HMAC-SHA256 pepper used at
|
||||
/// creation, then comparing against the stored <see cref="ApiKey.KeyHash"/> — never
|
||||
/// against a plaintext key. The comparison stays constant-time.
|
||||
/// </summary>
|
||||
public class ApiKeyHashValidationTests
|
||||
{
|
||||
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
|
||||
|
||||
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
|
||||
|
||||
private static ApiKey StoredKey(ApiKeyHasher hasher, string liveKey, int id = 1, bool enabled = true)
|
||||
{
|
||||
var key = ApiKey.FromHash("MES-Production", hasher.Hash(liveKey));
|
||||
key.Id = id;
|
||||
key.IsEnabled = enabled;
|
||||
return key;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WithPepperedHasher_AcceptsKeyHashedWithSamePepper()
|
||||
{
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "live-secret-key");
|
||||
var method = new ApiMethod("ingest", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
_repository.GetMethodByNameAsync("ingest").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { stored });
|
||||
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
var result = await validator.ValidateAsync("live-secret-key", "ingest");
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WrongKey_FailsEvenWhenItHashesToSomethingNonNull()
|
||||
{
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "the-real-key");
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
var result = await validator.ValidateAsync("a-wrong-key", "ingest");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_StoredHashIsNotThePlaintextKey()
|
||||
{
|
||||
// Sanity guard: the value the validator compares against must be a hash, not
|
||||
// the live secret — a DB dump must not yield a usable credential.
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(hasher, "live-secret-key");
|
||||
|
||||
Assert.NotEqual("live-secret-key", stored.KeyHash);
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
var validator = new ApiKeyValidator(_repository, hasher);
|
||||
|
||||
// Presenting the stored hash itself must NOT authenticate — only the live key does.
|
||||
var result = await validator.ValidateAsync(stored.KeyHash, "ingest");
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_KeyHashedUnderADifferentPepper_DoesNotAuthenticate()
|
||||
{
|
||||
var creationHasher = new ApiKeyHasher(Pepper);
|
||||
var stored = StoredKey(creationHasher, "live-secret-key");
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { stored });
|
||||
|
||||
// A validator running with a different pepper cannot recognise the key.
|
||||
var otherHasher = new ApiKeyHasher("a-totally-different-server-side-pepper-val");
|
||||
var validator = new ApiKeyValidator(_repository, otherHasher);
|
||||
|
||||
var result = await validator.ValidateAsync("live-secret-key", "ingest");
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: Tests for API key validation — X-API-Key header, enabled/disabled keys,
|
||||
/// method approval.
|
||||
/// </summary>
|
||||
public class ApiKeyValidatorTests
|
||||
{
|
||||
private readonly IInboundApiRepository _repository = Substitute.For<IInboundApiRepository>();
|
||||
private readonly ApiKeyValidator _validator;
|
||||
|
||||
public ApiKeyValidatorTests()
|
||||
{
|
||||
_validator = new ApiKeyValidator(_repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingApiKey_Returns401()
|
||||
{
|
||||
var result = await _validator.ValidateAsync(null, "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyApiKey_Returns401()
|
||||
{
|
||||
var result = await _validator.ValidateAsync("", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidApiKey_Returns401()
|
||||
{
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey>());
|
||||
|
||||
var result = await _validator.ValidateAsync("bad-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledApiKey_Returns401()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = false };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_IsIndistinguishableFromNotApproved()
|
||||
{
|
||||
// InboundAPI-011: a "method not found" response must not be observably
|
||||
// different from a "key not approved" response, or a caller holding any
|
||||
// valid key could enumerate which method names exist on the central API.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("realMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null);
|
||||
_repository.GetMethodByNameAsync("realMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var notFound = await _validator.ValidateAsync("valid-key", "nonExistent");
|
||||
var notApproved = await _validator.ValidateAsync("valid-key", "realMethod");
|
||||
|
||||
Assert.False(notFound.IsValid);
|
||||
Assert.False(notApproved.IsValid);
|
||||
// Status code and error message must be identical so existence is not observable.
|
||||
Assert.Equal(notApproved.StatusCode, notFound.StatusCode);
|
||||
Assert.Equal(notApproved.ErrorMessage, notFound.ErrorMessage);
|
||||
Assert.Equal(403, notFound.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_MethodNotFound_ErrorMessageDoesNotEchoMethodName()
|
||||
{
|
||||
// InboundAPI-011: the error body must not echo the caller-supplied method
|
||||
// name back verbatim (reflected-input) and must not confirm non-existence.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("probe-XYZ").Returns((ApiMethod?)null);
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "probe-XYZ");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.DoesNotContain("probe-XYZ", result.ErrorMessage ?? string.Empty);
|
||||
Assert.DoesNotContain("not found", result.ErrorMessage ?? string.Empty,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_NotApprovedForMethod_Returns403()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey>());
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidKey_ApprovedForMethod_ReturnsValid()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
Assert.Equal(key, result.ApiKey);
|
||||
Assert.Equal(method, result.Method);
|
||||
}
|
||||
|
||||
// --- InboundAPI-003: API key must not be matched with a non-constant-time
|
||||
// (timing-oracle) secret-equality lookup. ---
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_DoesNotUseSecretEqualityLookup()
|
||||
{
|
||||
// GetApiKeyByValueAsync translates to a SQL "WHERE KeyValue = @secret" early-exit
|
||||
// comparison — a timing side-channel. The validator must not call it.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("testMethod", "return 1;") { Id = 10 };
|
||||
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
_repository.GetMethodByNameAsync("testMethod").Returns(method);
|
||||
_repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
await _validator.ValidateAsync("valid-key", "testMethod");
|
||||
|
||||
await _repository.DidNotReceive()
|
||||
.GetApiKeyByValueAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401()
|
||||
{
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("wrong-key", "testMethod");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_KeyOfDifferentLength_Returns401()
|
||||
{
|
||||
// FixedTimeEquals over UTF-8 bytes must reject length mismatches without leaking.
|
||||
var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true };
|
||||
_repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
|
||||
var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod");
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Equal(401, result.StatusCode);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -262,7 +262,8 @@ public class AuditWriteMiddlewareTests
|
||||
var mw = CreateMiddleware(_ =>
|
||||
{
|
||||
// The endpoint handler is expected to stash the resolved API key
|
||||
// name here once ApiKeyValidator.ValidateAsync has succeeded.
|
||||
// name here once the shared ZB.MOM.WW.Auth.ApiKeys verifier has
|
||||
// authenticated the Bearer token.
|
||||
ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
|
||||
ctx.Response.StatusCode = 200;
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||
|
||||
@@ -16,40 +11,11 @@ namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
|
||||
/// </summary>
|
||||
public class IntegrationSurfaceTests
|
||||
{
|
||||
// ── Inbound API: auth + routing + parameter validation + error codes ──
|
||||
|
||||
[Fact]
|
||||
public async Task InboundAPI_ApiKeyValidator_FullFlow_EndToEnd()
|
||||
{
|
||||
// Validates that ApiKeyValidator correctly chains all checks.
|
||||
var repository = Substitute.For<IInboundApiRepository>();
|
||||
var key = new ApiKey("test-key", "key-value-123") { Id = 1, IsEnabled = true };
|
||||
var method = new ApiMethod("getStatus", "return 1;")
|
||||
{
|
||||
Id = 10,
|
||||
ParameterDefinitions = "[{\"Name\":\"deviceId\",\"Type\":\"String\",\"Required\":true}]",
|
||||
TimeoutSeconds = 30
|
||||
};
|
||||
|
||||
// ConfigurationDatabase-012: the validator fetches every key and matches
|
||||
// the candidate by HMAC hash in constant time (no secret-equality lookup).
|
||||
repository.GetAllApiKeysAsync().Returns(new List<ApiKey> { key });
|
||||
repository.GetMethodByNameAsync("getStatus").Returns(method);
|
||||
repository.GetApprovedKeysForMethodAsync(10).Returns(new List<ApiKey> { key });
|
||||
|
||||
var validator = new ApiKeyValidator(repository);
|
||||
|
||||
// Valid key + approved method
|
||||
var result = await validator.ValidateAsync("key-value-123", "getStatus");
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(method, result.Method);
|
||||
|
||||
// Then validate parameters
|
||||
using var doc = JsonDocument.Parse("{\"deviceId\": \"pump-01\"}");
|
||||
var paramResult = ParameterValidator.Validate(doc.RootElement.Clone(), method.ParameterDefinitions);
|
||||
Assert.True(paramResult.IsValid);
|
||||
Assert.Equal("pump-01", paramResult.Parameters["deviceId"]);
|
||||
}
|
||||
// ── Inbound API: parameter validation + error codes ──
|
||||
// Auth re-arch (C5): the in-repo ApiKeyValidator full-flow case was removed
|
||||
// with the entity. Inbound auth now runs through the shared
|
||||
// ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>); its coverage
|
||||
// lives in the InboundAPI endpoint + Auth-library tests.
|
||||
|
||||
[Fact]
|
||||
public void InboundAPI_ParameterValidation_ExtendedTypes()
|
||||
|
||||
@@ -1206,8 +1206,6 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
_services.AddScoped(_ => notifRepo);
|
||||
|
||||
var inboundRepo = Substitute.For<IInboundApiRepository>();
|
||||
inboundRepo.GetAllApiKeysAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.InboundApi.ApiKey>());
|
||||
inboundRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
|
||||
_services.AddScoped(_ => inboundRepo);
|
||||
|
||||
+5
-5
@@ -822,14 +822,14 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert — no keys created, the method WAS created, the ignored count is
|
||||
// surfaced, and the import did not fault.
|
||||
// Assert — the method WAS created, the ignored count is surfaced, and the
|
||||
// import did not fault. Auth re-arch (C5): the SQL Server ApiKey store was
|
||||
// retired, so "no keys created" is now structural — the importer has no key
|
||||
// sink at all; the legacy ApiKeys section is counted (ApiKeysIgnored) and
|
||||
// discarded.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var inboundRepo = scope.ServiceProvider.GetRequiredService<IInboundApiRepository>();
|
||||
var keys = await inboundRepo.GetAllApiKeysAsync();
|
||||
Assert.Empty(keys);
|
||||
|
||||
var methods = await inboundRepo.GetAllApiMethodsAsync();
|
||||
Assert.Single(methods);
|
||||
Assert.Equal("CreateOrder", methods[0].Name);
|
||||
|
||||
Reference in New Issue
Block a user