diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0de866b6 --- /dev/null +++ b/CHANGELOG.md @@ -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__` token instead of the + raw `X-API-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. diff --git a/docs/operations/inbound-api-key-reissue.md b/docs/operations/inbound-api-key-reissue.md new file mode 100644 index 00000000..ed1cc30c --- /dev/null +++ b/docs/operations/inbound-api-key-reissue.md @@ -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: ` | `Authorization: Bearer sbk__` | +| 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__`). 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 `/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__` 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 security api-key create \ + --name \ + --methods +``` + +- `--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: ` 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 security api-key set-methods --key-id --methods +``` + +--- + +## 5. Client change + +Each API client must replace its header: + +- **Remove:** `X-API-Key: ` +- **Add:** `Authorization: Bearer sbk__` + +Example: + +```http +POST /api/CreateOrder HTTP/1.1 +Host: scadabridge.example.com +Authorization: Bearer sbk_7f3a...._9c1e.... +Content-Type: application/json + +{ "orderId": "..." } +``` + +The token is the full `sbk__` 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. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/InboundApi/ApiKey.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/InboundApi/ApiKey.cs deleted file mode 100644 index d5ff47da..00000000 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/InboundApi/ApiKey.cs +++ /dev/null @@ -1,68 +0,0 @@ -using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; - -namespace ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; - -/// -/// An inbound-API bearer credential. Per ConfigurationDatabase-012 the plaintext key -/// is never persisted: the entity stores only , 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. -/// -public class ApiKey -{ - /// Database primary key. - public int Id { get; set; } - /// Display name for the API key. - public string Name { get; set; } - - /// - /// 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. - /// - public string KeyHash { get; set; } - - /// When false, the key is rejected even if the hash matches. - public bool IsEnabled { get; set; } - - /// - /// Creates an API key from a plaintext value, immediately hashing it with the - /// unpeppered default hasher () so the entity - /// never holds the plaintext. Production code paths that have a configured pepper - /// should use with a peppered hash instead. - /// - /// Display name for the API key. - /// Plaintext key value; hashed immediately and never stored. - 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); - } - - /// - /// Parameterless constructor for the EF Core materializer. Application code uses - /// or . - /// - private ApiKey() - { - Name = string.Empty; - KeyHash = string.Empty; - } - - /// - /// 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) - /// , and stores only the resulting hash. - /// - /// Display name for the API key. - /// Pre-computed keyed hash of the API key value. - 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)), - }; - } -} diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/InboundApi/ApiMethod.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/InboundApi/ApiMethod.cs index 3ff26f1e..ea4a6895 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/InboundApi/ApiMethod.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Entities/InboundApi/ApiMethod.cs @@ -8,8 +8,6 @@ public class ApiMethod public string Name { get; set; } /// Gets or sets the C# script body executed when the method is invoked. public string Script { get; set; } - /// Gets or sets the JSON-serialised list of API key IDs approved for this method, or null for unrestricted. - public string? ApprovedApiKeyIds { get; set; } /// Gets or sets the JSON Schema describing the accepted parameters, or null if the method takes no parameters. public string? ParameterDefinitions { get; set; } /// Gets or sets the JSON Schema describing the return type, or null if the method returns nothing. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IInboundApiRepository.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IInboundApiRepository.cs index 6d553b4e..f12e3c65 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IInboundApiRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Interfaces/Repositories/IInboundApiRepository.cs @@ -4,30 +4,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; public interface IInboundApiRepository { - // ApiKey - /// Retrieves an API key by ID. - /// The API key ID. - /// Cancellation token. - Task GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default); - /// Retrieves all API keys. - /// Cancellation token. - Task> GetAllApiKeysAsync(CancellationToken cancellationToken = default); - /// Retrieves an API key by value. - /// The API key value. - /// Cancellation token. - Task GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default); - /// Adds a new API key. - /// The API key to add. - /// Cancellation token. - Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default); - /// Updates an existing API key. - /// The API key to update. - /// Cancellation token. - Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default); - /// Deletes an API key by ID. - /// The API key ID. - /// Cancellation token. - 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 /// Retrieves an API method by ID. @@ -41,10 +22,6 @@ public interface IInboundApiRepository /// The API method name. /// Cancellation token. Task GetMethodByNameAsync(string name, CancellationToken cancellationToken = default); - /// Retrieves API keys approved for a method. - /// The API method ID. - /// Cancellation token. - Task> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default); /// Adds a new API method. /// The API method to add. /// Cancellation token. diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/ApiKeyHasher.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/ApiKeyHasher.cs deleted file mode 100644 index 1e2a078f..00000000 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/InboundApi/ApiKeyHasher.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; - -/// -/// 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. -/// -public interface IApiKeyHasher -{ - /// - /// Returns the keyed hash of as a Base64 string. - /// The same input always produces the same output (deterministic), which keeps - /// the by-value lookup working. - /// - /// The raw API key to hash. - /// A Base64-encoded HMAC-SHA256 hash of the key. - string Hash(string apiKey); -} - -/// -/// HMAC-SHA256 implementation of . The HMAC key is a -/// server-side pepper 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. -/// -public sealed class ApiKeyHasher : IApiKeyHasher -{ - /// - /// Minimum accepted pepper length. A pepper shorter than this is treated as a - /// deployment misconfiguration and rejected — see . - /// - public const int MinimumPepperLength = 16; - - private readonly byte[] _pepper; - - /// - /// 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 - /// with a real pepper. - /// - 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"); - } - - /// - /// Creates a hasher keyed with the given server-side pepper. - /// - /// Server-side HMAC key; must be at least characters. - /// - /// Thrown if is null, blank, or shorter than - /// — a missing or weak pepper is a deployment - /// misconfiguration and must fail loudly rather than degrade silently. - /// - 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); - } - - /// - 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); - } -} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InboundApiConfiguration.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InboundApiConfiguration.cs index 05122a2e..3a1f4174 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InboundApiConfiguration.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Configurations/InboundApiConfiguration.cs @@ -4,29 +4,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Configurations; -public class ApiKeyConfiguration : IEntityTypeConfiguration -{ - /// Configures the EF Core mapping for the entity. - /// Entity type builder used to apply the configuration. - public void Configure(EntityTypeBuilder 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 { @@ -43,9 +25,6 @@ public class ApiMethodConfiguration : IEntityTypeConfiguration builder.Property(m => m.Script) .IsRequired(); - builder.Property(m => m.ApprovedApiKeyIds) - .HasMaxLength(4000); - builder.Property(m => m.ParameterDefinitions) .HasMaxLength(4000); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602092753_RetireInboundApiKeyStore.Designer.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602092753_RetireInboundApiKeyStore.Designer.cs new file mode 100644 index 00000000..568371d0 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602092753_RetireInboundApiKeyStore.Designer.cs @@ -0,0 +1,1740 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + [DbContext(typeof(ScadaBridgeDbContext))] + [Migration("20260602092753_RetireInboundApiKeyStore")] + partial class RetireInboundApiKeyStore + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditEvent", b => + { + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("OccurredAtUtc") + .HasColumnType("datetime2"); + + b.Property("Actor") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DurationMs") + .HasColumnType("int"); + + b.Property("ErrorDetail") + .HasColumnType("nvarchar(max)"); + + b.Property("ErrorMessage") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("Extra") + .HasColumnType("nvarchar(max)"); + + b.Property("ForwardState") + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("ParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("PayloadTruncated") + .HasColumnType("bit"); + + b.Property("RequestSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseSummary") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceInstanceId") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(128) + .IsUnicode(false) + .HasColumnType("varchar(128)"); + + b.Property("SourceSiteId") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.HasKey("EventId", "OccurredAtUtc"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("IX_AuditLog_CorrelationId") + .HasFilter("[CorrelationId] IS NOT NULL"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_AuditLog_EventId"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_AuditLog_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("OccurredAtUtc") + .IsDescending() + .HasDatabaseName("IX_AuditLog_OccurredAtUtc"); + + b.HasIndex("ParentExecutionId") + .HasDatabaseName("IX_AuditLog_ParentExecution") + .HasFilter("[ParentExecutionId] IS NOT NULL"); + + b.HasIndex("SourceNode", "OccurredAtUtc") + .HasDatabaseName("IX_AuditLog_Node_Occurred"); + + b.HasIndex("SourceSiteId", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Site_Occurred"); + + b.HasIndex("Target", "OccurredAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_AuditLog_Target_Occurred") + .HasFilter("[Target] IS NOT NULL"); + + b.HasIndex("Channel", "Status", "OccurredAtUtc") + .IsDescending(false, false, true) + .HasDatabaseName("IX_AuditLog_Channel_Status_Occurred"); + + b.ToTable("AuditLog", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.AuditLogEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AfterStateJson") + .HasColumnType("nvarchar(max)"); + + b.Property("BundleImportId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Timestamp") + .HasColumnType("datetimeoffset"); + + b.Property("User") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("BundleImportId") + .HasDatabaseName("IX_AuditLogEntries_BundleImportId"); + + b.HasIndex("EntityId"); + + b.HasIndex("EntityType"); + + b.HasIndex("Timestamp"); + + b.HasIndex("User"); + + b.ToTable("AuditLogEntries"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall", b => + { + b.Property("TrackedOperationId") + .HasMaxLength(36) + .IsUnicode(false) + .HasColumnType("varchar(36)"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("HttpStatus") + .HasColumnType("int"); + + b.Property("IngestedAtUtc") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceSite") + .IsRequired() + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .IsUnicode(false) + .HasColumnType("varchar(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(256) + .IsUnicode(false) + .HasColumnType("varchar(256)"); + + b.Property("TerminalAtUtc") + .HasColumnType("datetime2"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("TrackedOperationId"); + + b.HasIndex("SourceSite", "CreatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Source_Created"); + + b.HasIndex("Status", "UpdatedAtUtc") + .IsDescending(false, true) + .HasDatabaseName("IX_SiteCalls_Status_Updated"); + + b.ToTable("SiteCalls", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfigurationJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("DeploymentId"); + + b.HasIndex("InstanceId") + .IsUnique(); + + b.ToTable("DeployedConfigSnapshots"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DeploymentId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ErrorMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("RevisionHash") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.HasIndex("DeploymentId") + .IsUnique(); + + b.HasIndex("InstanceId"); + + b.ToTable("DeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.SystemArtifactDeploymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ArtifactType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DeployedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeployedBy") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PerSiteStatus") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("DeployedAt"); + + b.ToTable("SystemArtifactDeploymentRecords"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.DatabaseConnectionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConnectionString") + .IsRequired() + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("DatabaseConnectionDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthConfiguration") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("EndpointUrl") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ExternalSystemDefinitions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalSystemDefinitionId") + .HasColumnType("int"); + + b.Property("HttpMethod") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ExternalSystemDefinitionId", "Name") + .IsUnique(); + + b.ToTable("ExternalSystemMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi.ApiMethod", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Script") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TimeoutSeconds") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiMethods"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentAreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentAreaId"); + + b.HasIndex("SiteId", "ParentAreaId", "Name") + .IsUnique() + .HasFilter("[ParentAreaId] IS NOT NULL"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.Property("State") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("UniqueName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + b.HasIndex("TemplateId"); + + b.HasIndex("SiteId", "UniqueName") + .IsUnique(); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlarmCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("PriorityLevelOverride") + .HasColumnType("int"); + + b.Property("TriggerConfigurationOverride") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AlarmCanonicalName") + .IsUnique(); + + b.ToTable("InstanceAlarmOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("OverrideValue") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceAttributeOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AttributeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("DataConnectionId") + .HasColumnType("int"); + + b.Property("DataSourceReferenceOverride") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DataConnectionId"); + + b.HasIndex("InstanceId", "AttributeName") + .IsUnique(); + + b.ToTable("InstanceConnectionBindings"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilterOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionNameOverride") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("InstanceId") + .HasColumnType("int"); + + b.Property("SourceCanonicalName") + .IsRequired() + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("SourceReferenceOverride") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId", "SourceCanonicalName") + .IsUnique(); + + b.ToTable("InstanceNativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.Notification", b => + { + b.Property("NotificationId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeliveredAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ListName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NextAttemptAt") + .HasColumnType("datetimeoffset"); + + b.Property("OriginExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("OriginParentExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResolvedTargets") + .HasColumnType("nvarchar(max)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("SiteEnqueuedAt") + .HasColumnType("datetimeoffset"); + + b.Property("SourceInstanceId") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceNode") + .HasMaxLength(64) + .IsUnicode(false) + .HasColumnType("varchar(64)"); + + b.Property("SourceScript") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceSiteId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TypeData") + .HasColumnType("nvarchar(max)"); + + b.HasKey("NotificationId"); + + b.HasIndex("SourceSiteId", "CreatedAt"); + + b.HasIndex("Status", "NextAttemptAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("NotificationLists"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NotificationListId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NotificationListId"); + + b.ToTable("NotificationRecipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.SmtpConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("ConnectionTimeoutSeconds") + .HasColumnType("int"); + + b.Property("Credentials") + .HasMaxLength(8000) + .HasColumnType("nvarchar(max)"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MaxConcurrentConnections") + .HasColumnType("int"); + + b.Property("MaxRetries") + .HasColumnType("int"); + + b.Property("Port") + .HasColumnType("int"); + + b.Property("RetryDelay") + .HasColumnType("time"); + + b.Property("TlsMode") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.ToTable("SmtpConfigurations"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts.SharedScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("SharedScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("LdapGroupName") + .IsUnique(); + + b.ToTable("LdapGroupMappings"); + + b.HasData( + new + { + Id = 1, + LdapGroupName = "SCADA-Admins", + Role = "Admin" + }, + new + { + Id = 2, + LdapGroupName = "SCADA-Designers", + Role = "Design" + }, + new + { + Id = 3, + LdapGroupName = "SCADA-Deploy-All", + Role = "Deployment" + }, + new + { + Id = 4, + LdapGroupName = "SCADA-Deploy-SiteA", + Role = "Deployment" + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("LdapGroupMappingId") + .HasColumnType("int"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.HasIndex("LdapGroupMappingId", "SiteId") + .IsUnique(); + + b.ToTable("SiteScopeRules"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BackupConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("FailoverRetryCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(3); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PrimaryConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("SiteId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("DataConnections"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("GrpcNodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("GrpcNodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NodeAAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeBAddress") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SiteIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("SiteIdentifier") + .IsUnique(); + + b.ToTable("Sites"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FolderId") + .HasColumnType("int"); + + b.Property("IsDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OwnerCompositionId") + .HasColumnType("int"); + + b.Property("ParentTemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("FolderId"); + + b.HasIndex("Name") + .IsUnique() + .HasFilter("[IsDerived] = 0"); + + b.HasIndex("ParentTemplateId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("OnTriggerScriptId") + .HasColumnType("int"); + + b.Property("PriorityLevel") + .HasColumnType("int"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAlarms"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DataSourceReference") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("Value") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateAttributes"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComposedTemplateId") + .HasColumnType("int"); + + b.Property("InstanceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ComposedTemplateId"); + + b.HasIndex("TemplateId", "InstanceName") + .IsUnique(); + + b.ToTable("TemplateCompositions"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParentFolderId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentFolderId", "Name") + .IsUnique() + .HasFilter("[ParentFolderId] IS NOT NULL"); + + b.ToTable("TemplateFolders"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConditionFilter") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ConnectionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateNativeAlarmSources"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsInherited") + .HasColumnType("bit"); + + b.Property("IsLocked") + .HasColumnType("bit"); + + b.Property("LockedInDerived") + .HasColumnType("bit"); + + b.Property("MinTimeBetweenRuns") + .HasColumnType("time"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("ParameterDefinitions") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ReturnDefinition") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TemplateId") + .HasColumnType("int"); + + b.Property("TriggerConfiguration") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("TriggerType") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Name") + .IsUnique(); + + b.ToTable("TemplateScripts"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeployedConfigSnapshot", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment.DeploymentRecord", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemMethod", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems.ExternalSystemDefinition", null) + .WithMany() + .HasForeignKey("ExternalSystemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany("Children") + .HasForeignKey("ParentAreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", null) + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAlarmOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AlarmOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceAttributeOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("AttributeOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceConnectionBinding", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", null) + .WithMany() + .HasForeignKey("DataConnectionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("ConnectionBindings") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.InstanceNativeAlarmSourceOverride", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", null) + .WithMany("NativeAlarmSourceOverrides") + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationRecipient", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", null) + .WithMany("Recipients") + .HasForeignKey("NotificationListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.SiteScopeRule", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Security.LdapGroupMapping", null) + .WithMany() + .HasForeignKey("LdapGroupMappingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.DataConnection", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("FolderId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ParentTemplateId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAlarm", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Alarms") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateAttribute", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Attributes") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateComposition", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany() + .HasForeignKey("ComposedTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Compositions") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateFolder", null) + .WithMany() + .HasForeignKey("ParentFolderId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateNativeAlarmSource", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("NativeAlarmSources") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.TemplateScript", b => + { + b.HasOne("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", null) + .WithMany("Scripts") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Area", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances.Instance", b => + { + b.Navigation("AlarmOverrides"); + + b.Navigation("AttributeOverrides"); + + b.Navigation("ConnectionBindings"); + + b.Navigation("NativeAlarmSourceOverrides"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications.NotificationList", b => + { + b.Navigation("Recipients"); + }); + + modelBuilder.Entity("ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates.Template", b => + { + b.Navigation("Alarms"); + + b.Navigation("Attributes"); + + b.Navigation("Compositions"); + + b.Navigation("NativeAlarmSources"); + + b.Navigation("Scripts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602092753_RetireInboundApiKeyStore.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602092753_RetireInboundApiKeyStore.cs new file mode 100644 index 00000000..44f95e43 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/20260602092753_RetireInboundApiKeyStore.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations +{ + /// + public partial class RetireInboundApiKeyStore : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys"); + + migrationBuilder.DropColumn( + name: "ApprovedApiKeyIds", + table: "ApiMethods"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ApprovedApiKeyIds", + table: "ApiMethods", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: true); + + migrationBuilder.CreateTable( + name: "ApiKeys", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + IsEnabled = table.Column(type: "bit", nullable: false), + KeyHash = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Name = table.Column(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); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs index 42baea9d..9b8347ec 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Migrations/ScadaBridgeDbContextModelSnapshot.cs @@ -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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("IsEnabled") - .HasColumnType("bit"); - - b.Property("KeyHash") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("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("Id") @@ -593,10 +561,6 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("ApprovedApiKeyIds") - .HasMaxLength(4000) - .HasColumnType("nvarchar(4000)"); - b.Property("Name") .IsRequired() .HasMaxLength(200) diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/InboundApiRepository.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/InboundApiRepository.cs index d5d4a4d0..0878fbb4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/InboundApiRepository.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/InboundApiRepository.cs @@ -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 _hasherAccessor; - private readonly ILogger _logger; /// /// Initializes a new instance of the InboundApiRepository class. /// /// The database context for accessing inbound API data. - /// - /// CD-016: factory that returns the API-key hasher used to digest a candidate - /// plaintext for the peppered lookup. - /// Resolution is deferred to first call so a composition root that doesn't - /// register (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 - /// ; production wires - /// sp => sp.GetRequiredService<IApiKeyHasher>() via DI so the - /// lookup uses the same peppered digest as the production write path. - /// - /// Optional logger instance for warnings and diagnostics. - public InboundApiRepository( - ScadaBridgeDbContext context, - Func? hasherAccessor = null, - ILogger? logger = null) + public InboundApiRepository(ScadaBridgeDbContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); - _hasherAccessor = hasherAccessor ?? (() => ApiKeyHasher.Default); - _logger = logger ?? NullLogger.Instance; - } - - /// - public async Task GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default) - => await _context.Set().FindAsync(new object[] { id }, cancellationToken); - - /// - public async Task> GetAllApiKeysAsync(CancellationToken cancellationToken = default) - => await _context.Set().ToListAsync(cancellationToken); - - /// - public async Task 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().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken); - } - - /// - public async Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default) - => await _context.Set().AddAsync(apiKey, cancellationToken); - - /// - public Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default) - { _context.Set().Update(apiKey); return Task.CompletedTask; } - - /// - public async Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default) - { - var entity = await GetApiKeyByIdAsync(id, cancellationToken); - if (entity != null) _context.Set().Remove(entity); } /// @@ -93,37 +29,6 @@ public class InboundApiRepository : IInboundApiRepository public async Task GetMethodByNameAsync(string name, CancellationToken cancellationToken = default) => await _context.Set().FirstOrDefaultAsync(m => m.Name == name, cancellationToken); - /// - public async Task> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default) - { - var method = await _context.Set().FindAsync(new object[] { methodId }, cancellationToken); - if (method?.ApprovedApiKeyIds == null) - return new List(); - - // 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(); - 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().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken); - } - /// public async Task AddApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default) => await _context.Set().AddAsync(method, cancellationToken); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs index 483c4f30..58ea4e90 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ScadaBridgeDbContext.cs @@ -115,8 +115,9 @@ public class ScadaBridgeDbContext : DbContext, IDataProtectionKeyContext public DbSet SiteScopeRules => Set(); // Inbound API - /// Gets the set of API keys. - public DbSet ApiKeys => Set(); + // 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. /// Gets the set of API methods. public DbSet ApiMethods => Set(); diff --git a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs index bd7a0824..b05da487 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs @@ -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(); services.AddScoped(); services.AddScoped(); - // 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(sp => new InboundApiRepository( - sp.GetRequiredService(), - hasherAccessor: () => sp.GetService() - ?? Commons.Types.InboundApi.ApiKeyHasher.Default, - logger: sp.GetService>())); + // 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(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 906e30bd..38d89120 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -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__) 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__) 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 diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ApiKeyValidator.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ApiKeyValidator.cs deleted file mode 100644 index febc62de..00000000 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ApiKeyValidator.cs +++ /dev/null @@ -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; - -/// -/// WP-1: Validates API keys from X-API-Key header. -/// Checks that the key exists, is enabled, and is approved for the requested method. -/// -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"; - - /// Inbound-API data access. - /// - /// 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 - /// ; production wiring injects the configured, - /// peppered hasher. - /// - public ApiKeyValidator(IInboundApiRepository repository, IApiKeyHasher? hasher = null) - { - _repository = repository; - _hasher = hasher ?? ApiKeyHasher.Default; - } - - /// - /// Validates an API key for a given method. - /// Returns (isValid, apiKey, statusCode, errorMessage). - /// - /// The API key value from the X-API-Key header. - /// The name of the method being invoked. - /// Cancellation token. - public async Task 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); - } - - /// - /// InboundAPI-003 / ConfigurationDatabase-012: Finds the key whose stored - /// matches — the - /// HMAC hash of the presented key — using - /// 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. - /// - private static ApiKey? FindKeyConstantTime(IEnumerable 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; - } -} - -/// -/// Result of API key validation. -/// -public class ApiKeyValidationResult -{ - /// - /// Whether the API key validation was successful. - /// - public bool IsValid { get; private init; } - /// - /// The HTTP status code for the validation result. - /// - public int StatusCode { get; private init; } - /// - /// Error message if validation failed, if any. - /// - public string? ErrorMessage { get; private init; } - /// - /// The validated API key, if successful. - /// - public ApiKey? ApiKey { get; private init; } - /// - /// The validated API method, if successful. - /// - public ApiMethod? Method { get; private init; } - - /// - /// Creates a successful validation result. - /// - /// The validated API key. - /// The validated API method. - public static ApiKeyValidationResult Valid(ApiKey apiKey, ApiMethod method) => - new() { IsValid = true, StatusCode = 200, ApiKey = apiKey, Method = method }; - - /// - /// Creates an unauthorized validation result. - /// - /// The error message. - public static ApiKeyValidationResult Unauthorized(string message) => - new() { IsValid = false, StatusCode = 401, ErrorMessage = message }; - - /// - /// Creates a forbidden validation result. - /// - /// The error message. - public static ApiKeyValidationResult Forbidden(string message) => - new() { IsValid = false, StatusCode = 403, ErrorMessage = message }; -} diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs index 0fba48ed..9fefeef5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/InboundApiOptions.cs @@ -20,15 +20,17 @@ public class InboundApiOptions public long MaxRequestBodyBytes { get; set; } = DefaultMaxRequestBodyBytes; /// - /// 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 + /// ScadaBridge:InboundApi:ApiKeyPepper. /// - /// 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 - /// - /// characters — AddInboundAPI 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 + /// (PepperSecretName 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 AddInboundAPI. /// /// public string ApiKeyPepper { get; set; } = string.Empty; diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ServiceCollectionExtensions.cs index fe33f39c..fa639bf9 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ServiceCollectionExtensions.cs @@ -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 { /// - /// 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). /// /// The service collection to register into. public static IServiceCollection AddInboundAPI(this IServiceCollection services) { - services.AddScoped(); + // Auth re-arch (C5): inbound authentication is handled by the shared + // ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk__), 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(); services.AddScoped(); - // 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(sp => - { - var options = sp.GetRequiredService>().Value; - return new ApiKeyHasher(options.ApiKeyPepper); - }); - // InboundAPI-017: routed calls go through the IInstanceRouter seam; the // production implementation delegates to CommunicationService. services.AddScoped(); diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs index 17069bd5..5cecdcb7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs @@ -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, diff --git a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs index 8601f26e..3a151714 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Integration/InboundApiAuditTests.cs @@ -116,8 +116,9 @@ public class InboundApiAuditTests : IClassFixture // 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 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"); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs index ee4d3425..a5bb1ac2 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/Design/TransportExportPageTests.cs @@ -71,8 +71,6 @@ public class TransportExportPageTests : BunitContext .Returns(Task.FromResult>(new List())); _notificationRepo.GetAllSmtpConfigurationsAsync(Arg.Any()) .Returns(Task.FromResult>(new List())); - _inboundApiRepo.GetAllApiKeysAsync(Arg.Any()) - .Returns(Task.FromResult>(new List())); _inboundApiRepo.GetAllApiMethodsAsync(Arg.Any()) .Returns(Task.FromResult>(new List())); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/ApiKeyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/ApiKeyTests.cs deleted file mode 100644 index add13d37..00000000 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Entities/ApiKeyTests.cs +++ /dev/null @@ -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; - -/// -/// ConfigurationDatabase-012: the entity must never carry the -/// plaintext bearer credential as a persisted field — only its deterministic hash. -/// -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(() => new ApiKey(null!, "value")); - Assert.Throws(() => new ApiKey("name", (string)null!)); - Assert.Throws(() => ApiKey.FromHash(null!, "hash")); - Assert.Throws(() => ApiKey.FromHash("name", null!)); - } -} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ApiKeyHasherTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ApiKeyHasherTests.cs deleted file mode 100644 index 9395ec52..00000000 --- a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Types/ApiKeyHasherTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; - -namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Types; - -/// -/// 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. -/// -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(() => new ApiKeyHasher(pepper!)); - } - - [Fact] - public void Hash_NullInput_Throws() - { - var hasher = new ApiKeyHasher("a-sufficiently-long-server-pepper-value"); - - Assert.Throws(() => 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); - } -} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs index 8bb9dd97..2daba38c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/InboundApiRepositoryTests.cs @@ -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 _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(() => new InboundApiRepository(null!)); } } - -/// Minimal ILogger that captures warning-level messages for assertions. -internal sealed class CapturingLogger : ILogger -{ - public List Warnings { get; } = new(); - - public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, - Func formatter) - { - if (logLevel == LogLevel.Warning) - { - Warnings.Add(formatter(state, exception)); - } - } - - private sealed class NullScope : IDisposable - { - public static readonly NullScope Instance = new(); - public void Dispose() { } - } -} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs index fe706928..325dfb5c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/SchemaConfigurationTests.cs @@ -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. } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs index a8d8cc7b..445a54e1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/UnitTest1.cs @@ -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")); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs index 12a7b829..d8e119bf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs @@ -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) }, diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiKeyHashValidationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiKeyHashValidationTests.cs deleted file mode 100644 index 53352e8a..00000000 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiKeyHashValidationTests.cs +++ /dev/null @@ -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; - -/// -/// ConfigurationDatabase-012: must authenticate by -/// hashing the presented candidate with the same HMAC-SHA256 pepper used at -/// creation, then comparing against the stored — never -/// against a plaintext key. The comparison stays constant-time. -/// -public class ApiKeyHashValidationTests -{ - private const string Pepper = "a-sufficiently-long-server-side-pepper-value"; - - private readonly IInboundApiRepository _repository = Substitute.For(); - - 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 { stored }); - _repository.GetMethodByNameAsync("ingest").Returns(method); - _repository.GetApprovedKeysForMethodAsync(10).Returns(new List { 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 { 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 { 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 { 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); - } -} diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiKeyValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiKeyValidatorTests.cs deleted file mode 100644 index 18a54a77..00000000 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ApiKeyValidatorTests.cs +++ /dev/null @@ -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; - -/// -/// WP-1: Tests for API key validation — X-API-Key header, enabled/disabled keys, -/// method approval. -/// -public class ApiKeyValidatorTests -{ - private readonly IInboundApiRepository _repository = Substitute.For(); - 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()); - - 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 { 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 { key }); - _repository.GetMethodByNameAsync("nonExistent").Returns((ApiMethod?)null); - _repository.GetMethodByNameAsync("realMethod").Returns(method); - _repository.GetApprovedKeysForMethodAsync(10).Returns(new List()); - - 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 { 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 { key }); - _repository.GetMethodByNameAsync("testMethod").Returns(method); - _repository.GetApprovedKeysForMethodAsync(10).Returns(new List()); - - 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 { key }); - _repository.GetMethodByNameAsync("testMethod").Returns(method); - _repository.GetApprovedKeysForMethodAsync(10).Returns(new List { 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 { key }); - _repository.GetMethodByNameAsync("testMethod").Returns(method); - _repository.GetApprovedKeysForMethodAsync(10).Returns(new List { key }); - - await _validator.ValidateAsync("valid-key", "testMethod"); - - await _repository.DidNotReceive() - .GetApiKeyByValueAsync(Arg.Any(), Arg.Any()); - } - - [Fact] - public async Task ValidateAsync_WrongKey_ConstantTimePath_Returns401() - { - var key = new ApiKey("test", "valid-key") { Id = 1, IsEnabled = true }; - _repository.GetAllApiKeysAsync().Returns(new List { 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 { key }); - - var result = await _validator.ValidateAsync("valid-key-with-extra", "testMethod"); - - Assert.False(result.IsValid); - Assert.Equal(401, result.StatusCode); - } -} diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs index cc11b9b4..96cad893 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/Middleware/AuditWriteMiddlewareTests.cs @@ -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; diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs index a212b48a..e52462db 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/IntegrationSurfaceTests.cs @@ -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; /// 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(); - 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 { key }); - repository.GetMethodByNameAsync("getStatus").Returns(method); - repository.GetApprovedKeysForMethodAsync(10).Returns(new List { 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__); its coverage + // lives in the InboundAPI endpoint + Auth-library tests. [Fact] public void InboundAPI_ParameterValidation_ExtendedTypes() diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 6ab85964..45fcbc46 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -1206,8 +1206,6 @@ public class ManagementActorTests : TestKit, IDisposable _services.AddScoped(_ => notifRepo); var inboundRepo = Substitute.For(); - inboundRepo.GetAllApiKeysAsync(Arg.Any()) - .Returns(new List()); inboundRepo.GetAllApiMethodsAsync(Arg.Any()) .Returns(new List()); _services.AddScoped(_ => inboundRepo); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs index 7beca08a..c4ee193e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/Import/BundleImporterApplyTests.cs @@ -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(); - var keys = await inboundRepo.GetAllApiKeysAsync(); - Assert.Empty(keys); - var methods = await inboundRepo.GetAllApiMethodsAsync(); Assert.Single(methods); Assert.Equal("CreateOrder", methods[0].Name);