Add framework-neutral transport-aware trigger identity validation#218
Conversation
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
…on (#217) - Add ConnectorTriggerTransport (Body stream + Headers dictionary, BCL-only) - Add ConnectorTriggerIdentity sealed record (ConnectorName, OperationName) - Add ConnectorTriggerIdentityMismatchException with safe diagnostics - Add ConnectorTriggerHeaderNames provisional header name constants - Add ReadAsync<TPayload>(ConnectorTriggerTransport, ConnectorTriggerIdentity, ...) overload to ConnectorTriggerPayload; validates identity headers before deserialization - Add ConnectorTriggerPayloadTransportTests (16 new tests) - Update docs/triggers.md with transport overload guide and provisional header table"
There was a problem hiding this comment.
Pull request overview
This PR adds a framework-neutral way to validate Connector Namespace trigger callback identity (connector + operation) using request headers before deserializing the payload, enabling safer diagnostics and earlier detection of routing/configuration mistakes without coupling the SDK to any specific hosting framework.
Changes:
- Introduces
ConnectorTriggerTransportandConnectorTriggerIdentity, plusConnectorTriggerHeaderNamesconstants, to represent and validate trigger callbacks in a host-agnostic way. - Adds a new
ConnectorTriggerPayload.ReadAsync<TPayload>(ConnectorTriggerTransport, ConnectorTriggerIdentity, ...)overload that validates identity headers before delegating to the existing bounded-stream deserializer. - Adds
ConnectorTriggerIdentityMismatchException, documentation updates (docs/triggers.md), and a new test suite covering identity validation and safe diagnostics.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/Azure.Connectors.Sdk.Tests/ConnectorTriggerPayloadTransportTests.cs | Adds unit tests for transport-based identity validation behavior and exception diagnostics. |
| src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs | Adds a BCL-only transport wrapper for body + headers. |
| src/Azure.Connectors.Sdk/ConnectorTriggerPayload.cs | Adds transport overload plus identity validation and header-value extraction helpers. |
| src/Azure.Connectors.Sdk/ConnectorTriggerIdentityMismatchException.cs | Adds a dedicated exception type exposing only safe identity diagnostics. |
| src/Azure.Connectors.Sdk/ConnectorTriggerIdentity.cs | Adds a record representing the expected connector/operation identity. |
| src/Azure.Connectors.Sdk/ConnectorTriggerHeaderNames.cs | Adds constants for the (provisional) Connector Namespace identity headers. |
| docs/triggers.md | Documents the new overload, adapter pattern, exception diagnostics, and header contract table. |
| private static string? GetFirstHeaderValue( | ||
| IReadOnlyDictionary<string, IEnumerable<string>> headers, | ||
| string headerName) | ||
| { | ||
| if (!headers.TryGetValue(headerName, out IEnumerable<string>? values)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| foreach (string value in values) | ||
| { | ||
| if (!string.IsNullOrEmpty(value)) | ||
| { | ||
| return value; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } |
| expectedOperationName: expectedIdentity.OperationName, | ||
| actualConnectorName: actualConnectorName, | ||
| actualOperationName: actualOperationName, | ||
| presentIdentityHeaderNames: presentHeaders, |
| // Arrange — headers keyed with upper-case names in a plain Dictionary; the | ||
| // ConnectorTriggerTransport default comparer normalises lookup. |
|
[Dobby] The header-only identity mapping here is not valid: I updated issue #217 with the required end-to-end design. The validating overload must parse the four resource-context headers, use an authenticated Connector Namespace trigger-config GET to retrieve |
|
@copilot review comments on this PR: issue has been updated to close the specification gap from current available headers to connector name + operation name pair. Rework the proposed implementation accordingly. |
|
@copilot investigate CI gate failure 'Code quality findings with severity of warning or above were detected.' |
Investigated and fixed the CI gate trigger in 614c4ef. The failing signal was the GitHub code-quality warning on |
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
docs/triggers.md:157
- The transport-adapter example uses single-letter lambda parameters (
h => ...). The repo coding conventions require descriptive lambda parameter names (no single-letter identifiers), and keeping docs/examples consistent helps consumers copy/paste compliant code.
Headers = request.Headers.ToDictionary(
h => h.Key,
h => h.Value,
StringComparer.OrdinalIgnoreCase),
src/Azure.Connectors.Sdk/ConnectorTriggerTransport.cs:33
- The XML-doc example uses single-letter lambda parameters (
h => ...). The repo coding conventions require descriptive lambda parameter names (no single-letter identifiers), and this snippet is likely to be copied into user code.
/// .ToDictionary(
/// h => h.Key,
/// h => h.Value,
/// StringComparer.OrdinalIgnoreCase)
src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfigManagementResolver.cs:71
AudienceandApiVersionare validated for whitespace-only but then used without trimming. Leading/trailing whitespace will produce an invalid token scope (and potentially an invalid api-version query), causing hard-to-diagnose auth/request failures. Consider trimming before storing/using these values.
this._managementEndpoint = options.ManagementEndpoint;
this._apiVersion = options.ApiVersion;
this._audienceScopes = new[] { $"{options.Audience.TrimEnd('/')}/.default" };
daviburg
left a comment
There was a problem hiding this comment.
[Dobby] Review — Logic Apps senior review lens
Reviewed the server-side diff at d4c615e, plus the repo conventions in .github/copilot-instructions.md, Directory.Packages.props, Azure.Connectors.Sdk.csproj, and docs/. Findings are ordered correctness/risk first, then tests, then fan-out and style. Inline comments cover the file-anchored findings; two repo-level findings are below.
Generated by an agent under my identity, applying repo conventions and a distilled review checklist.
Top four (blocking)
- Uncached ARM GET on every callback —
ConnectorTriggerPayload.ReadAsyncwas CPU-only deserialization; it now takes a synchronous dependency on the management plane in the trigger hot path, with no cache. ARM read throttling is per-subscription per-hour, so a busy endpoint will 429 and fail callbacks. See inline onConnectorNamespaceTriggerConfigManagementResolver.GetTriggerConfigAsync. - Security framing in
docs/triggers.md— the resource identity is built entirely from unauthenticated request headers, so this is a misconfiguration guard, not defence-in-depth. See inline ondocs/triggers.md. - Management-endpoint timeouts surface as
OperationCanceledExceptionrather thanConnectorTriggerConfigurationResolutionException. Two sites, same defect. ConnectorTriggerConfigurationResolutionException.CorrelationIdis alwaysnullon the realistic failure paths (404/401/403/malformed), and the documented catch handler logs it.
Repo-level findings (no file anchor)
[Medium] The PR description no longer matches the diff.
The description describes a header-only comparison of three provisional headers. The diff ships five headers and eight new public types the description never mentions: ConnectorNamespaceTriggerConfig, ConnectorNamespaceTriggerConfigManagementResolver, ConnectorNamespaceTriggerConfigManagementResolverOptions, ConnectorNamespaceTriggerConfigResourceIdentity, IConnectorNamespaceTriggerConfigResolver, ConnectorTriggerConfigurationResolutionException, ConnectorTriggerResourceIdentityException. Specifics:
- The usage sample in the description omits the now-required
resolverargument and will not compile. - It claims "16 new tests in
ConnectorTriggerPayloadTransportTests". That file has 12[TestMethod]s; a second, unmentioned file adds 6 more. - It names a property
PresentIdentityHeaderNames; the shipped property isPresentResourceIdentityHeaderNames. - It claims tests for null argument guards, default empty headers, and case-insensitive header value lookup. None of those exist in the diff.
The design pivoted mid-review from header-trust to authoritative-config-resolution — a materially different security and availability posture. Please rewrite the description in the same iteration so the approval trail records the design that actually shipped.
[Medium] CHANGELOG.md and docs/azure-sdk-guidelines.md fan-out is missing.
The checklist leaves CHANGELOG.md updated unchecked while adding eight public types and a public overload. CHANGELOG.md [Unreleased] is cut verbatim into release_notes.md at release time, so this would ship a release with undocumented public API — please add an ### Added entry.
.github/copilot-instructions.md states: "Before adding new public API surface, check that document [docs/azure-sdk-guidelines.md]." A new ClientOptions-derived options type, a new TokenCredential-taking client, and a new resolver interface all land in its scope. Either record the new surface there or note explicitly why no entry is needed.
Confirmed clean
- No package fan-out needed —
Azure.CoreandAzure.Identitywere already referenced byAzure.Connectors.Sdk.csproj. - The
docs/triggers.mdsample constants are accurate:OneDriveForBusinessTriggerOperations.OnNewFilesresolves to"OnNewFilesV2". presentHeadersis correctly wrapped viaAsReadOnly(), so the earlier mutable-downcast concern is addressed.
Residual risk
- Two
connectors-sdk.publicADO legs were still pending at review time (10 green, 0 failing). - I did not build or run tests locally; all analysis is from the server-side diff at
d4c615e. - The unresolved
github-code-quality[bot]finding on a useless local assignment inConnectorNamespaceTriggerConfigManagementResolverTests.cswas not re-verified.
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public async ValueTask<ConnectorNamespaceTriggerConfig> GetTriggerConfigAsync( |
There was a problem hiding this comment.
[Dobby] [High] [Architecture / Availability] Every trigger callback now performs an uncached ARM GET.
This method builds a fresh pipeline message and issues GET .../connectorGateways/{ns}/triggerconfigs/{name}?api-version=... on every invocation. There is no cache, no memoization, and no TTL anywhere in the PR, and ConnectorTriggerPayload.ReadAsync calls it before every deserialization.
Why it matters: ConnectorTriggerPayload was a pure, CPU-only deserialization utility. The new overload turns it into a synchronous dependency on the management plane in the trigger hot path. ARM read throttling is per-subscription per-hour, so a moderately busy trigger endpoint will hit 429, which this code converts into ConnectorTriggerConfigurationResolutionException and fails the callback. Trigger config for a given resource identity is effectively immutable per deployment, so the round trip is pure overhead — one management call and one token check per inbound event.
Worth noting that IConnectorNamespaceTriggerConfigResolver.GetTriggerConfigAsync already returns ValueTask — the shape that exists specifically to allow a cached synchronous return — but nothing caches.
Expected fix: add a caching decorator (or cache inside this resolver) keyed on the four-part resource identity, with a bounded entry count and a TTL, and document the invalidation story. Please ship the caching resolver in this PR so the default path is not one ARM call per callback.
| .SendAsync(message, cancellationToken) | ||
| .ConfigureAwait(continueOnCapturedContext: false); | ||
| } | ||
| catch (OperationCanceledException) |
There was a problem hiding this comment.
[Dobby] [High] [Defensive code] Management-endpoint timeouts will surface as cancellation, not as a resolution failure.
Azure.Core surfaces HTTP timeouts as TaskCanceledException, which derives from OperationCanceledException. This bare rethrow therefore also catches genuine transport timeouts where the caller's token was never cancelled.
Why it matters: the caller sees OperationCanceledException instead of ConnectorTriggerConfigurationResolutionException. The handler documented in docs/triggers.md will not catch it, hosts commonly treat OperationCanceledException as shutdown/abort rather than a retryable failure, and the callback failure is misattributed.
Expected fix: guard on the caller token so real timeouts fall through to the wrapping catch:
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}Separately: the "don't catch-rethrow for nothing" thread on ConnectorTriggerPayload.cs was addressed in aec6b20 by moving to an exception filter, but this sibling occurrence was not swept. Applying the token guard here resolves both concerns and makes the two files use one shape for one concern.
| resourceIdentity: resourceIdentity, | ||
| status: response.Status, | ||
| correlationId: null, | ||
| detail: "The management API returned an unsuccessful status while resolving the trigger configuration."); |
There was a problem hiding this comment.
[Dobby] [High] [Diagnostics contract] CorrelationId is hardcoded to null at every throw site in this resolver.
All four CreateResolutionException calls in this file pass correlationId: null, because IConnectorNamespaceTriggerConfigResolver.GetTriggerConfigAsync has no parameter through which the callback correlation ID could reach the resolver. ConnectorTriggerPayload.ReadAsync then explicitly excludes ConnectorTriggerConfigurationResolutionException from re-wrapping, so these exceptions propagate to the caller unchanged.
Why it matters: 404 / 401 / 403 / malformed-response from ARM are the realistic failures, and every one of them loses the correlation ID that the diagnostics story is built around. The catch handler documented in docs/triggers.md logs ex.CorrelationId for exactly this exception type and will always print null.
Expected fix: plumb the correlation ID into the resolver (extend ConnectorNamespaceTriggerConfigResourceIdentity, or add a context parameter to the interface), and also forward it as x-ms-client-request-id on the outbound ARM request so the management-side and callback-side traces join up.
| detail: "The management API returned an unsuccessful status while resolving the trigger configuration."); | ||
| } | ||
|
|
||
| string responseContent = response.Content.ToString(); |
There was a problem hiding this comment.
[Dobby] [Low] [Performance] response.Content.ToString() materializes the whole ARM response as a string before parsing.
The string is used only for an emptiness check and then handed to JsonDocument.Parse. JsonDocument can parse UTF-8 bytes directly.
Expected fix: parse from response.Content.ToMemory() (or response.ContentStream) and replace the IsNullOrWhiteSpace check with a length check on the memory. That drops one full-payload allocation and the UTF-8 to UTF-16 transcode per callback — which matters more given this now runs on every inbound event (see the caching comment on GetTriggerConfigAsync).
| private const string TriggerConfigsSegmentName = "triggerconfigs"; | ||
|
|
||
| private readonly string _apiVersion; | ||
| private readonly string[] _audienceScopes; |
There was a problem hiding this comment.
[Dobby] [Low] [Dead code] _audienceScopes is written in the constructor and never read again.
Its only consumer is the BearerTokenAuthenticationPolicy construction a few lines below, inside the same constructor. Promoting it to a field implies per-instance state that is later reused, which is misleading.
Expected fix: make it a local.
| string.Join(", ", ex.PresentResourceIdentityHeaderNames)); | ||
| throw; | ||
| } | ||
| catch (ConnectorTriggerConfigurationResolutionException ex) |
There was a problem hiding this comment.
[Dobby] [High] [Docs / Diagnostics] This handler will always log null for CorrelationId.
ConnectorNamespaceTriggerConfigManagementResolver passes correlationId: null at all four of its throw sites, and ConnectorTriggerPayload.ReadAsync explicitly excludes ConnectorTriggerConfigurationResolutionException from re-wrapping — so on the realistic ARM failure paths (404 / 401 / 403 / malformed response) this exception reaches the caller with a null correlation ID.
The one test asserting a non-null CorrelationId for this exception (ReadAsync_Transport_ResolverFailure_…) uses a stub throwing InvalidOperationException, so it exercises the wrapping path in ConnectorTriggerPayload and never the resolver's own throws. It will keep passing while this documented handler stays broken.
Expected fix: plumb the correlation ID through the resolver contract (see the inline comment on IConnectorNamespaceTriggerConfigResolver), then add a test that drives a real ConnectorNamespaceTriggerConfigManagementResolver returning 404 and asserts CorrelationId is populated.
| public async Task GetTriggerConfigAsync_MalformedResponse_ThrowsConfigurationResolutionException() | ||
| { | ||
| // Arrange | ||
| const string malformedJson = "{\"properties\":{\"connectionDetails\":{}}}"; |
There was a problem hiding this comment.
[Dobby] [Medium] [Tests] This payload is well-formed JSON, so the catch (JsonException) branch is never exercised.
{"properties":{"connectionDetails":{}}} parses cleanly and then fails the property checks in ParseTriggerConfig, which is the "did not contain required trigger configuration properties" path — a different branch from the one the test name implies.
Expected fix: keep this case (rename it to something like _MissingRequiredProperties_) and add a genuinely malformed case, for example "{not json", asserting the JsonException branch and that the inner exception is preserved.
Two more uncovered branches in this file's SUT while you are here:
- The empty-response-body path (
string.IsNullOrWhiteSpace(responseContent)). - The "contained empty trigger configuration identity values" path — present-but-empty
connectorNameoroperationName, for example{"properties":{"operationName":"","connectionDetails":{"connectorName":"onedriveforbusiness"}}}.
| .ConfigureAwait(continueOnCapturedContext: false); | ||
| } | ||
|
|
||
| private static Mock<TokenCredential> CreateCredential() |
There was a problem hiding this comment.
[Dobby] [Medium] [Tests] No coverage for any of the four new constructor guards.
ConnectorNamespaceTriggerConfigManagementResolver's constructor validates four conditions, none of which are tested:
- null
credential—ArgumentNullException - relative
options.ManagementEndpoint—ArgumentException - null or whitespace
options.Audience—ArgumentException - null or whitespace
options.ApiVersion—ArgumentException
These are new public contract surfaces and all four are reachable directly through the public constructor, so no product-code changes are needed to cover them.
Also missing here: a URI-escaping case. The one URI assertion in this file uses resource names that need no escaping, so BuildRequestUri's Uri.EscapeDataString calls are effectively untested. A resource group or trigger-config name containing a space or a reserved character would lock that behavior in.
| } | ||
|
|
||
| [TestMethod] | ||
| public async Task ReadAsync_Transport_ResolverFailure_ThrowsConfigurationResolutionException() |
There was a problem hiding this comment.
[Dobby] [Medium] [Tests] This test asserts on the wrapping path, not the resolver path it appears to cover.
The stub throws InvalidOperationException, so the assertion Assert.AreEqual("trace-xyz", exception.CorrelationId) passes because ConnectorTriggerPayload.CreateConfigurationResolutionException supplies the correlation ID. The real ConnectorNamespaceTriggerConfigManagementResolver throws ConnectorTriggerConfigurationResolutionException itself with correlationId: null, and ReadAsync's catch filter deliberately does not re-wrap that type — so the shipped behavior on the realistic ARM failure paths is the opposite of what this test demonstrates.
Mutation check: change the resolver's correlationId: null to any other value and this test does not notice, because it never runs that code.
Expected fix: add a companion case where the stub throws ConnectorTriggerConfigurationResolutionException directly, asserting what actually reaches the caller. Then fix the underlying gap (see the inline comment on IConnectorNamespaceTriggerConfigResolver).
| } | ||
|
|
||
| [TestMethod] | ||
| public async Task ReadAsync_Transport_ResolvedIdentityMatches_ReturnsPayload() |
There was a problem hiding this comment.
[Dobby] [Medium] [Tests] Claimed tests that are not in the diff, plus uncovered branches on the new overload.
The PR description claims tests for null argument guards, default empty headers, case-insensitive header value lookup, and PresentIdentityHeaderNames diagnostics. None are present in this file (and the shipped property is PresentResourceIdentityHeaderNames). The description also says 16 tests here; there are 12.
Specific cases worth adding, all reachable through the existing public API:
ArgumentNullExceptionfor each oftransport,expectedIdentity,triggerConfigResolver.ConnectorTriggerTransportwith the defaultHeaders(construct with onlyBodyset) — should throwConnectorTriggerResourceIdentityExceptionwith an emptyPresentResourceIdentityHeaderNames.- A header present but whitespace-only — should be treated as absent, and the header name should be excluded from
PresentResourceIdentityHeaderNames. - A header value with surrounding whitespace — should be trimmed before being placed in the resource identity, asserted via
resolver.LastRequestedResourceIdentity. - The resolver returning
null— should throwConnectorTriggerConfigurationResolutionExceptionwith the "returned a null trigger configuration" detail. maxBodySizeByteson the new overload: a body over the limit, and an invalid value of0.- Case-insensitive value comparison, since
ValidateIdentityusesOrdinalIgnoreCaseon values as well as names — for example a resolved connector of"OneDriveForBusiness"against an expected"onedriveforbusiness"should pass.
Description
Adds transport-aware overloads to
ConnectorTriggerPayloadthat validate Connector Namespace trigger identity headers before payload deserialization — catching callback routing/configuration mistakes early with safe diagnostics, without coupling the core SDK to Azure Functions or ASP.NET Core.Changes
ConnectorTriggerTransport— BCL-only wrapper (required Stream Body+IReadOnlyDictionary<string, IEnumerable<string>> Headers) for host-agnostic request representation. Callers adapt their host's request type locally.ConnectorTriggerIdentity— Sealed record (ConnectorName,OperationName) declaring the expected callback identity.ConnectorTriggerIdentityMismatchException— Thrown when identity headers are absent or mismatched. Exposes expected/actual connector+operation, which known identity headers were present, and correlation ID. Never exposes authorization headers, callback URLs, payload content, lock tokens, or other secrets.ConnectorTriggerHeaderNames— Isolated constants for the three provisional Connector Namespace headers (x-ms-gateway-resource-name,x-ms-trigger-name,x-ms-client-request-id). Marked provisional; service team agreement required before treating these as stable contract.ConnectorTriggerPayload.ReadAsync<TPayload>(ConnectorTriggerTransport, ConnectorTriggerIdentity, ...)— New overload. Validates headers (ordinal, case-insensitive for both name and value), then delegates to the existing bounded-stream deserializer. ExistingRead<TPayload>(string)andReadAsync<TPayload>(Stream, ...)are unchanged.docs/triggers.md— Documents the transport overload, host-adapter pattern, exception diagnostics, and the provisional header contract table.Usage (Azure Functions isolated worker example):
Testing
dotnet test)16 new tests in
ConnectorTriggerPayloadTransportTests: matching identity, missing connector/operation headers, both absent, connector mismatch, operation mismatch, case-insensitive header name/value lookup, correlation ID in exceptions, no-secrets guarantee on exception properties and message,PresentIdentityHeaderNamesdiagnostics, null argument guards, default empty headers, backward-compatibility of the existingStreamoverload.Checklist
src/**/Generated/(see CONTRIBUTING.md)release_notes.mdupdated (if shipping a new version, clear previous version notes)eng/build/Version.props(if shipping a new version)