From 782fb73015c2a69b0f5f5ba0f1f6b0685a602588 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 1 Jun 2026 17:03:10 -0400 Subject: [PATCH] feat(scadabridge): emit scadabridge.inbound_api.requests (by method) at inbound API entry --- .../EndpointExtensions.cs | 17 +++ .../EndpointExtensionsTests.cs | 110 ++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs index ba451c54..a04c3f2e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Observability; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; namespace ZB.MOM.WW.ScadaBridge.InboundAPI; @@ -44,6 +45,16 @@ public static class EndpointExtensions if (!validationResult.IsValid) { + // Telemetry follow-on: count every inbound request, including auth + // failures. The raw {methodName} route value is arbitrary caller input + // and would be high-cardinality, so failures are tagged with a small + // bounded set of sentinels keyed off the validator's status code rather + // than the unvalidated name (401 → "", 403 → ""). + ScadaBridgeTelemetry.RecordInboundApiRequest( + validationResult.StatusCode == StatusCodes.Status401Unauthorized + ? "" + : ""); + // WP-5: Failures-only logging logger.LogWarning( "Inbound API auth failure for method {Method}: {Error} (status {StatusCode})", @@ -56,6 +67,12 @@ public static class EndpointExtensions var method = validationResult.Method!; + // Telemetry follow-on: count this inbound request against the resolved, + // registered method name. method.Name comes from the repository's method + // catalogue (an exact-name lookup), so the `method` tag is bounded to the + // set of configured API methods — never the raw caller-supplied route value. + ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name); + // Audit Log (#23 M4 Bundle D): publish the resolved API key name so // AuditWriteMiddleware can populate AuditEvent.Actor in its finally // block. Done AFTER validation succeeded — auth failures leave the diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs index 9e5dd20b..a5a14fcb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs @@ -9,8 +9,10 @@ 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.Commons.Observability; using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware; +using System.Diagnostics.Metrics; using System.Net; using System.Text; @@ -232,6 +234,114 @@ public class EndpointExtensionsTests Assert.Equal("audit-actor-name", capture.CapturedActor); } + [Fact] + public async Task ValidRequest_EmitsInboundApiRequestCounter_TaggedWithResolvedMethodName() + { + // Telemetry follow-on: a successful inbound request increments + // scadabridge.inbound_api.requests once, tagged with the resolved, + // registered method name (method.Name) — the bounded identifier, not the + // raw route value. + var key = SeedKey(); + var method = SeedMethod(1, "echo", "return Parameters[\"value\"];", + """[{"name":"value","type":"Integer","required":true}]"""); + + using var collector = new InboundApiRequestCounterCollector(); + + using var host = await BuildHostAsync(key, method); + var client = host.GetTestClient(); + + var response = await client.SendAsync(BuildPost("echo", """{"value":7}""")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + // Filter by the method tag this test produced: the counter is a process-wide + // static, so a parallel test class could otherwise leak measurements in. + var echoTotal = collector.Measurements + .Where(m => m.Method == "echo") + .Sum(m => m.Value); + Assert.Equal(1, echoTotal); + } + + [Fact] + public async Task UnknownMethod_EmitsInboundApiRequestCounter_WithBoundedForbiddenSentinel() + { + // Telemetry follow-on: an auth/authz failure is still counted, but the + // tag is a bounded sentinel ("") rather than the arbitrary + // caller-supplied route value — so an attacker posting random method + // names cannot blow up the `method` tag cardinality. + var key = SeedKey(); + var method = SeedMethod(1, "knownMethod", "return 1;"); + + using var collector = new InboundApiRequestCounterCollector(); + + using var host = await BuildHostAsync(key, method); + var client = host.GetTestClient(); + + var response = await client.SendAsync(BuildPost("totally-made-up-name", "{}")); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + var measurements = collector.Measurements; + // Cardinality safety: the arbitrary route value is never used as a tag. + Assert.DoesNotContain(measurements, m => m.Method == "totally-made-up-name"); + // The failure path counts the request against the bounded sentinel. + Assert.Contains(measurements, m => m.Method == "" && m.Value == 1); + } + + /// + /// Captures scadabridge.inbound_api.requests measurements (value + the + /// method tag) via a for the duration of a test. + /// + private sealed class InboundApiRequestCounterCollector : IDisposable + { + private readonly MeterListener _listener; + private readonly List<(long Value, string? Method)> _measurements = new(); + private readonly object _gate = new(); + + public InboundApiRequestCounterCollector() + { + _listener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName + && instrument.Name == "scadabridge.inbound_api.requests") + { + listener.EnableMeasurementEvents(instrument); + } + }, + }; + _listener.SetMeasurementEventCallback((instrument, value, tags, state) => + { + string? method = null; + foreach (var tag in tags) + { + if (tag.Key == "method") + { + method = tag.Value as string; + } + } + + lock (_gate) + { + _measurements.Add((value, method)); + } + }); + _listener.Start(); + } + + public IReadOnlyList<(long Value, string? Method)> Measurements + { + get + { + lock (_gate) + { + return _measurements.ToList(); + } + } + } + + public void Dispose() => _listener.Dispose(); + } + private static HttpRequestMessage BuildPost(string methodName, string body) { var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)