diff --git a/docs/requirements/Component-ManagementService.md b/docs/requirements/Component-ManagementService.md index 99450fe2..3931eecc 100644 --- a/docs/requirements/Component-ManagementService.md +++ b/docs/requirements/Component-ManagementService.md @@ -156,6 +156,8 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B - **ListExternalSystems** / **GetExternalSystem**: Query external system definitions. - **CreateExternalSystem** / **UpdateExternalSystem** / **DeleteExternalSystem**: Manage external system definitions. +> **Secret elision (arch-review C3).** The `AuthConfiguration` blob holds API keys / Basic credentials. All command responses **and** audit `afterState` are projected through `ExternalSystemPublicShape`, which drops `AuthConfiguration` entirely and surfaces only a `hasAuthConfiguration` presence flag. **Update** is preserve-if-null: an omitted (`null`) `AuthConfiguration` leaves the stored secret intact (mirrors the SMTP/SMS credential rule). + ### Notifications - **ListNotificationLists** / **GetNotificationList**: Query notification lists. diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 57fd228a..239c6ed2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -1661,16 +1661,36 @@ public class ManagementActor : ReceiveActor // External System handlers // ======================================================================== + /// + /// Secret-elided ExternalSystemDefinition projection (arch-review C3): the + /// opaque AuthConfiguration blob holds API keys / Basic credentials and + /// must never leave this boundary via responses or audit afterState. Presence + /// is surfaced as HasAuthConfiguration; every non-secret field is + /// projected verbatim. Mirrors . + /// + private static object ExternalSystemPublicShape(ExternalSystemDefinition d) => new + { + d.Id, + d.Name, + d.EndpointUrl, + d.AuthType, + d.MaxRetries, + d.RetryDelay, + d.TimeoutSeconds, + HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration), + }; + private static async Task HandleListExternalSystems(IServiceProvider sp) { var repo = sp.GetRequiredService(); - return await repo.GetAllExternalSystemsAsync(); + return (await repo.GetAllExternalSystemsAsync()).Select(ExternalSystemPublicShape).ToList(); } private static async Task HandleGetExternalSystem(IServiceProvider sp, GetExternalSystemCommand cmd) { var repo = sp.GetRequiredService(); - return await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId); + var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId); + return def is null ? null : ExternalSystemPublicShape(def); } private static async Task HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd, string user) @@ -1683,8 +1703,9 @@ public class ManagementActor : ReceiveActor }; await repo.AddExternalSystemAsync(def); await repo.SaveChangesAsync(); - await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, def); - return def; + var publicShape = ExternalSystemPublicShape(def); + await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, publicShape); + return publicShape; } private static async Task HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd, string user) @@ -1695,12 +1716,15 @@ public class ManagementActor : ReceiveActor def.Name = cmd.Name; def.EndpointUrl = cmd.EndpointUrl; def.AuthType = cmd.AuthType; - def.AuthConfiguration = cmd.AuthConfiguration; + // Preserve-if-null: an update that omits AuthConfiguration leaves the stored + // secret intact (mirrors the SMTP/SMS credential preserve-if-null rule). + if (cmd.AuthConfiguration is not null) def.AuthConfiguration = cmd.AuthConfiguration; if (cmd.TimeoutSeconds is { } ts) def.TimeoutSeconds = ts; await repo.UpdateExternalSystemAsync(def); await repo.SaveChangesAsync(); - await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, def); - return def; + var publicShape = ExternalSystemPublicShape(def); + await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, publicShape); + return publicShape; } private static async Task HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd, string user) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecretProjectionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecretProjectionTests.cs index e474f1dc..1b276fae 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecretProjectionTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecretProjectionTests.cs @@ -131,4 +131,91 @@ public class SecretProjectionTests : TestKit, IDisposable Arg.Is(o => !ManagementActor.SerializeResult(o).Contains("newpw")), Arg.Any()); } + + // ======================================================================== + // Task 8 — ExternalSystem AuthConfiguration elision + // ======================================================================== + + private IExternalSystemRepository AddExternalSystemRepo(ExternalSystemDefinition def) + { + var repo = Substitute.For(); + repo.GetExternalSystemByIdAsync(def.Id, Arg.Any()).Returns(def); + repo.GetAllExternalSystemsAsync(Arg.Any()) + .Returns(new List { def }); + _services.AddScoped(_ => repo); + return repo; + } + + [Fact] + public void GetExternalSystem_ResponseOmitsAuthConfiguration() + { + AddExternalSystemRepo(new ExternalSystemDefinition("es", "https://x", "ApiKey") + { + Id = 1, + AuthConfiguration = """{"apiKey":"es-secret"}""", + }); + + var actor = CreateActor(); + actor.Tell(Envelope(new GetExternalSystemCommand(1), "Viewer")); + + var resp = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.DoesNotContain("es-secret", resp.JsonData); + Assert.Contains("hasAuthConfiguration", resp.JsonData); + } + + [Fact] + public void ListExternalSystems_ResponseOmitsAuthConfiguration() + { + AddExternalSystemRepo(new ExternalSystemDefinition("es", "https://x", "ApiKey") + { + Id = 1, + AuthConfiguration = """{"apiKey":"es-secret"}""", + }); + + var actor = CreateActor(); + actor.Tell(Envelope(new ListExternalSystemsCommand(), "Viewer")); + + var resp = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.DoesNotContain("es-secret", resp.JsonData); + Assert.Contains("hasAuthConfiguration", resp.JsonData); + } + + [Fact] + public async Task UpdateExternalSystem_NullAuthConfiguration_PreservesStoredSecret() + { + var stored = new ExternalSystemDefinition("es", "https://x", "ApiKey") + { + Id = 1, + AuthConfiguration = """{"apiKey":"es-secret"}""", + }; + var repo = AddExternalSystemRepo(stored); + + var actor = CreateActor(); + actor.Tell(Envelope(new UpdateExternalSystemCommand(1, "es", "https://y", "ApiKey", null), "Designer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + + await repo.Received(1).UpdateExternalSystemAsync( + Arg.Is(d => d.AuthConfiguration == """{"apiKey":"es-secret"}"""), + Arg.Any()); + } + + [Fact] + public void UpdateExternalSystem_AuditAfterState_OmitsAuthConfiguration() + { + var stored = new ExternalSystemDefinition("es", "https://x", "ApiKey") + { + Id = 1, + AuthConfiguration = """{"apiKey":"es-secret"}""", + }; + AddExternalSystemRepo(stored); + + var actor = CreateActor(); + actor.Tell(Envelope(new UpdateExternalSystemCommand(1, "es", "https://y", "ApiKey", """{"apiKey":"newsecret"}"""), "Designer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + + _auditService.Received().LogAsync(Arg.Any(), "Update", "ExternalSystem", + Arg.Any(), Arg.Any(), + Arg.Is(o => !ManagementActor.SerializeResult(o).Contains("newsecret")), + Arg.Any()); + } }