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.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
||||||
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
|
||||||
@@ -44,6 +45,16 @@ public static class EndpointExtensions
|
|||||||
|
|
||||||
if (!validationResult.IsValid)
|
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
|
// WP-5: Failures-only logging
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Inbound API auth failure for method {Method}: {Error} (status {StatusCode})",
|
"Inbound API auth failure for method {Method}: {Error} (status {StatusCode})",
|
||||||
@@ -56,6 +67,12 @@ public static class EndpointExtensions
|
|||||||
|
|
||||||
var method = validationResult.Method!;
|
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
|
// Audit Log (#23 M4 Bundle D): publish the resolved API key name so
|
||||||
// AuditWriteMiddleware can populate AuditEvent.Actor in its finally
|
// AuditWriteMiddleware can populate AuditEvent.Actor in its finally
|
||||||
// block. Done AFTER validation succeeded — auth failures leave the
|
// 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.Entities.InboundApi;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
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.Commons.Types.InboundApi;
|
||||||
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
|
||||||
|
using System.Diagnostics.Metrics;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -232,6 +234,114 @@ public class EndpointExtensionsTests
|
|||||||
Assert.Equal("audit-actor-name", capture.CapturedActor);
|
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)
|
private static HttpRequestMessage BuildPost(string methodName, string body)
|
||||||
{
|
{
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
var request = new HttpRequestMessage(HttpMethod.Post, "/api/" + methodName)
|
||||||
|
|||||||
Reference in New Issue
Block a user