feat(scadabridge): emit scadabridge.inbound_api.requests (by method) at inbound API entry
This commit is contained in:
@@ -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 → "<unauthorized>", 403 → "<forbidden>").
|
||||
ScadaBridgeTelemetry.RecordInboundApiRequest(
|
||||
validationResult.StatusCode == StatusCodes.Status401Unauthorized
|
||||
? "<unauthorized>"
|
||||
: "<forbidden>");
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 ("<forbidden>") 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 == "<forbidden>" && m.Value == 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures <c>scadabridge.inbound_api.requests</c> measurements (value + the
|
||||
/// <c>method</c> tag) via a <see cref="MeterListener"/> for the duration of a test.
|
||||
/// </summary>
|
||||
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<long>((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)
|
||||
|
||||
Reference in New Issue
Block a user