Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions docs/triggers.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,107 @@ byte[]? fileBytes = await ConnectorTriggerPayload

If a binary-content (string) body is read into a metadata payload type, deserialization throws an actionable `JsonException` that points to the binary-content helpers.

### Transport-aware identity validation

The body-only overloads above remain the simplest choice when the trigger configuration is a compile-time constant (a single function handles exactly one connector/operation). For scenarios where the same endpoint handles multiple connectors, or for defence-in-depth against routing mistakes, use the transport-aware overload that validates trigger identity headers before deserialization:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Dobby] [High] [Security framing] "Defence-in-depth" overstates what this validates.

The resource identity is built entirely from request headers (x-ms-subscription-id, x-ms-resource-group, x-ms-gateway-resource-name, x-ms-trigger-name) in ConnectorTriggerPayload.GetResourceIdentity, with no authentication of the caller and no binding to any caller-side allow-list.

Why it matters:

  1. Anyone who can reach the callback endpoint chooses which trigger config the SDK resolves. Supplying a resource identity that the app's managed identity can read and that happens to match the expected connector/operation passes validation. The check is therefore not a security boundary, and documenting it as defence-in-depth invites callers to rely on it as one.
  2. It lets an unauthenticated request drive a token acquisition plus an ARM GET against the caller's own subscription — a request-amplification and quota-burn vector that did not exist before this overload.

Expected fix: describe this as a misconfiguration guard and state explicitly that it must not be relied on for authorization. If a security property is actually intended, the caller must supply the expected resource identity (or an allow-list) and the SDK must compare the headers against it rather than trusting them. That is a design decision worth confirming with the Connector Namespace service team — the same gate the provisional header contract is already waiting on.


```csharp
// 1. Build a framework-neutral transport from the host-specific request.
// This adapter pattern keeps Azure.Connectors.Sdk free of Functions/ASP.NET Core references.
var transport = new ConnectorTriggerTransport
{
Body = request.Body,
Headers = request.Headers.ToDictionary(
h => h.Key,
h => h.Value,
StringComparer.OrdinalIgnoreCase),
};

// 2. Declare the expected identity — use the generated constants for compile-time safety.
var identity = new ConnectorTriggerIdentity(
ConnectorName: ConnectorNames.OneDriveForBusiness,
OperationName: OneDriveForBusinessTriggerOperations.OnNewFiles);

// 3. Configure the authoritative trigger-config resolver.
var resolver = new ConnectorNamespaceTriggerConfigManagementResolver(
credential: new DefaultAzureCredential(),
options: new ConnectorNamespaceTriggerConfigManagementResolverOptions
{
ManagementEndpoint = new Uri("https://management.azure.com"),
Audience = "https://management.azure.com",
ApiVersion = "2026-05-01-preview",
});

// 4. Read and validate in one call.
var payload = await ConnectorTriggerPayload
.ReadAsync<OneDriveForBusinessOnNewFilesTriggerPayload>(
transport,
identity,
resolver,
cancellationToken: cancellationToken)
.ConfigureAwait(continueOnCapturedContext: false);
```

The validating overload does **not** treat `x-ms-gateway-resource-name` and `x-ms-trigger-name` as connector/operation metadata. Instead it:

1. Reads the four resource-context headers from the callback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Dobby] [Low] [Docs] Says "four resource-context headers"; the code reads five.

ConnectorTriggerPayload.ReadAsync reads x-ms-client-request-id in addition to the four identity headers, and the table further down this page correctly lists all five.

2. Builds a `ConnectorNamespaceTriggerConfigResourceIdentity`.
3. Uses `IConnectorNamespaceTriggerConfigResolver` to GET the authoritative trigger config.
4. Compares `properties.connectionDetails.connectorName` and `properties.operationName` to the caller-selected `ConnectorTriggerIdentity`.
5. Only then delegates to the existing bounded stream deserializer.

If the resolved trigger configuration does not match `expectedIdentity`, a `ConnectorTriggerIdentityMismatchException` is thrown **before** any JSON deserialization. The exception exposes only safe diagnostics — expected vs. resolved connector/operation names, the trigger-config resource identity, and the correlation ID — and never exposes authorization headers, callback URLs, payload content, lock tokens, or response bodies.

```csharp
catch (ConnectorTriggerResourceIdentityException ex)
{
logger.LogError(
"Trigger resource identity headers were invalid. CorrelationId: {CorrelationId}. Present headers: {PresentHeaders}.",
ex.CorrelationId,
string.Join(", ", ex.PresentResourceIdentityHeaderNames));
throw;
}
catch (ConnectorTriggerConfigurationResolutionException ex)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

{
logger.LogError(
"Failed to resolve trigger config for {Namespace}/{Trigger}. Status: {Status}. CorrelationId: {CorrelationId}.",
ex.ResourceIdentity.ConnectorNamespaceName,
ex.ResourceIdentity.TriggerConfigName,
ex.Status,
ex.CorrelationId);
throw;
}
catch (ConnectorTriggerIdentityMismatchException ex)
{
logger.LogError(
"Trigger identity mismatch. Expected {Connector}/{Operation}, " +
"resolved {ResolvedConnector}/{ResolvedOperation}. CorrelationId: {CorrelationId}. " +
"Resource: {Namespace}/{Trigger}.",
ex.ExpectedConnectorName, ex.ExpectedOperationName,
ex.ResolvedConnectorName, ex.ResolvedOperationName,
ex.CorrelationId,
ex.ResourceIdentity.ConnectorNamespaceName,
ex.ResourceIdentity.TriggerConfigName);
throw;
}
```

#### Resource-context header names (provisional service contract)

The header names consumed by the transport overload are defined in `ConnectorTriggerHeaderNames`:

| Constant | Header | Value example | Meaning |
|----------|--------|---------------|---------|
| `ConnectorTriggerHeaderNames.SubscriptionId` | `x-ms-subscription-id` | `00000000-0000-0000-0000-000000000000` | Subscription that owns the Connector Namespace |
| `ConnectorTriggerHeaderNames.ResourceGroupName` | `x-ms-resource-group` | `prod-connectors-rg` | Resource group that owns the Connector Namespace |
| `ConnectorTriggerHeaderNames.ConnectorNamespaceName` | `x-ms-gateway-resource-name` | `my-gateway` | Connector Namespace resource name |
| `ConnectorTriggerHeaderNames.TriggerConfigName` | `x-ms-trigger-name` | `email-trigger` | Trigger-config resource name |
| `ConnectorTriggerHeaderNames.CorrelationId` | `x-ms-client-request-id` | `abc-123` | Callback correlation identifier |

> **Important — provisional contract:** These header names reflect the current Connector Namespace webhook implementation and have not yet been formally agreed as a stable, versioned API surface with the Connector Namespace service team. They may change before reaching general availability. The names are isolated behind `ConnectorTriggerHeaderNames` constants so a future update is a single-line change.

Header-name lookup is implemented by the SDK with `StringComparison.OrdinalIgnoreCase`, so callers do not need to supply a case-insensitive dictionary comparer for correctness.

## Trigger Operation Constants

Each connector exposes a `{Connector}TriggerOperations` static class with the operation name strings:
Expand Down
12 changes: 12 additions & 0 deletions src/Azure.Connectors.Sdk/ConnectorNamespaceTriggerConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
//------------------------------------------------------------

namespace Azure.Connectors.Sdk;

/// <summary>
/// The authoritative connector and operation identity resolved from a Connector Namespace trigger config.
/// </summary>
/// <param name="ConnectorName">The connector API name (for example <c>office365</c>).</param>
/// <param name="OperationName">The trigger operation name (for example <c>OnNewEmailV3</c>).</param>
public sealed record ConnectorNamespaceTriggerConfig(string ConnectorName, string OperationName);
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
//------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
//------------------------------------------------------------

using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using global::Azure;
using global::Azure.Core;
using global::Azure.Core.Pipeline;

namespace Azure.Connectors.Sdk;

/// <summary>
/// Resolves Connector Namespace trigger configuration through the management API using <see cref="TokenCredential"/>.
/// </summary>
public sealed class ConnectorNamespaceTriggerConfigManagementResolver : IConnectorNamespaceTriggerConfigResolver
{
private const string ConnectorNamePropertyName = "connectorName";
private const string ConnectionDetailsPropertyName = "connectionDetails";
private const string MicrosoftWebProviderName = "Microsoft.Web";
private const string OperationNamePropertyName = "operationName";
private const string PropertiesPropertyName = "properties";
private const string ResourceGroupsSegmentName = "resourceGroups";
private const string SubscriptionsSegmentName = "subscriptions";
private const string TriggerConfigsSegmentName = "triggerconfigs";

private readonly string _apiVersion;
private readonly string[] _audienceScopes;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

private readonly Uri _managementEndpoint;
private readonly HttpPipeline _pipeline;

/// <summary>
/// Initializes a new instance of the <see cref="ConnectorNamespaceTriggerConfigManagementResolver"/> class.
/// </summary>
/// <param name="credential">The credential used for management-plane authentication.</param>
/// <param name="options">Optional resolver options for endpoint, audience, API version, retry, and transport.</param>
public ConnectorNamespaceTriggerConfigManagementResolver(
TokenCredential credential,
ConnectorNamespaceTriggerConfigManagementResolverOptions? options = null)
{
ArgumentNullException.ThrowIfNull(credential);

options ??= new ConnectorNamespaceTriggerConfigManagementResolverOptions();
ArgumentNullException.ThrowIfNull(options.ManagementEndpoint);

if (!options.ManagementEndpoint.IsAbsoluteUri)
{
throw new ArgumentException(
message: $"The management endpoint '{options.ManagementEndpoint}' must be an absolute URI.",
paramName: nameof(options));
}

if (string.IsNullOrWhiteSpace(options.Audience))
{
throw new ArgumentException(
message: "The management audience cannot be null or whitespace.",
paramName: nameof(options));
}

if (string.IsNullOrWhiteSpace(options.ApiVersion))
{
throw new ArgumentException(
message: "The management API version cannot be null or whitespace.",
paramName: nameof(options));
}

this._managementEndpoint = options.ManagementEndpoint;
this._apiVersion = options.ApiVersion;
this._audienceScopes = new[] { $"{options.Audience.TrimEnd('/')}/.default" };
this._pipeline = HttpPipelineBuilder.Build(
options,
perRetryPolicies: new HttpPipelinePolicy[]
{
new BearerTokenAuthenticationPolicy(credential, this._audienceScopes)
});
}

/// <inheritdoc />
public async ValueTask<ConnectorNamespaceTriggerConfig> GetTriggerConfigAsync(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(resourceIdentity);

using var message = this._pipeline.CreateMessage();
var request = message.Request;
request.Method = RequestMethod.Get;
request.Uri.Reset(this.BuildRequestUri(resourceIdentity));
request.Headers.Add("Accept", "application/json");

try
{
await this._pipeline
.SendAsync(message, cancellationToken)
.ConfigureAwait(continueOnCapturedContext: false);
}
catch (OperationCanceledException)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

{
throw;
}
catch (Exception ex) when (!ex.IsFatal())
{
int? requestFailedStatus = ex is RequestFailedException requestFailedException &&
requestFailedException.Status > 0
? requestFailedException.Status
: null;

throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException(
resourceIdentity: resourceIdentity,
status: requestFailedStatus,
correlationId: null,
detail: "The management request failed before a trigger configuration could be resolved.",
innerException: ex);
}

var response = message.Response;
if (response.IsError)
{
throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException(
resourceIdentity: resourceIdentity,
status: response.Status,
correlationId: null,
detail: "The management API returned an unsuccessful status while resolving the trigger configuration.");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

}

string responseContent = response.Content.ToString();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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).

if (string.IsNullOrWhiteSpace(responseContent))
{
throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException(
resourceIdentity: resourceIdentity,
status: response.Status,
correlationId: null,
detail: "The management API returned an empty trigger configuration response.");
}

try
{
using var document = JsonDocument.Parse(responseContent);
return ConnectorNamespaceTriggerConfigManagementResolver.ParseTriggerConfig(document, resourceIdentity, response.Status);
}
catch (JsonException ex)
{
throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException(
resourceIdentity: resourceIdentity,
status: response.Status,
correlationId: null,
detail: "The management API returned malformed trigger configuration JSON.",
innerException: ex);
}
}

private static ConnectorTriggerConfigurationResolutionException CreateResolutionException(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Dobby] [Low] [Duplication] This factory and ConnectorTriggerPayload.CreateConfigurationResolutionException build near-identical messages with copy-pasted format strings.

Both emit "Subscription '…', resource group '…', Connector Namespace '…', trigger config '…'." followed by the same optional Status: and Correlation ID: clauses. One uses string concatenation, the other a StringBuilder, so the two will drift.

Expected fix: consolidate into a single factory — a static creator on ConnectorTriggerConfigurationResolutionException is the natural home, and it also gives the type one place to enforce the no-secrets guarantee its <remarks> promises.

ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity,
int? status,
string? correlationId,
string detail,
Exception? innerException = null)
{
var message =
$"{detail} Subscription '{resourceIdentity.SubscriptionId}', resource group '{resourceIdentity.ResourceGroupName}', " +
$"Connector Namespace '{resourceIdentity.ConnectorNamespaceName}', trigger config '{resourceIdentity.TriggerConfigName}'.";

if (status.HasValue)
{
message += $" Status: '{status.Value}'.";
}

if (correlationId is not null)
{
message += $" Correlation ID: '{correlationId}'.";
}

return new ConnectorTriggerConfigurationResolutionException(
message: message,
resourceIdentity: resourceIdentity,
status: status,
correlationId: correlationId,
innerException: innerException);
}

private static string EscapePathSegment(string value)
{
return Uri.EscapeDataString(value);
}

private static ConnectorNamespaceTriggerConfig ParseTriggerConfig(
JsonDocument document,
ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity,
int status)
{
if (document.RootElement.ValueKind != JsonValueKind.Object ||
!document.RootElement.TryGetProperty(ConnectorNamespaceTriggerConfigManagementResolver.PropertiesPropertyName, out JsonElement propertiesElement) ||
propertiesElement.ValueKind != JsonValueKind.Object ||
!propertiesElement.TryGetProperty(ConnectorNamespaceTriggerConfigManagementResolver.ConnectionDetailsPropertyName, out JsonElement connectionDetailsElement) ||
connectionDetailsElement.ValueKind != JsonValueKind.Object ||
!connectionDetailsElement.TryGetProperty(ConnectorNamespaceTriggerConfigManagementResolver.ConnectorNamePropertyName, out JsonElement connectorNameElement) ||
connectorNameElement.ValueKind != JsonValueKind.String ||
!propertiesElement.TryGetProperty(ConnectorNamespaceTriggerConfigManagementResolver.OperationNamePropertyName, out JsonElement operationNameElement) ||
operationNameElement.ValueKind != JsonValueKind.String)
{
throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException(
resourceIdentity: resourceIdentity,
status: status,
correlationId: null,
detail: "The management API response did not contain required trigger configuration properties.");
}

string connectorName = connectorNameElement.GetString() ?? string.Empty;
string operationName = operationNameElement.GetString() ?? string.Empty;

if (string.IsNullOrWhiteSpace(connectorName) ||
string.IsNullOrWhiteSpace(operationName))
{
throw ConnectorNamespaceTriggerConfigManagementResolver.CreateResolutionException(
resourceIdentity: resourceIdentity,
status: status,
correlationId: null,
detail: "The management API response contained empty trigger configuration identity values.");
}

return new ConnectorNamespaceTriggerConfig(
ConnectorName: connectorName,
OperationName: operationName);
}

private Uri BuildRequestUri(ConnectorNamespaceTriggerConfigResourceIdentity resourceIdentity)
{
string managementPath =
$"{this._managementEndpoint.AbsolutePath.TrimEnd('/')}/" +
$"{ConnectorNamespaceTriggerConfigManagementResolver.SubscriptionsSegmentName}/" +
$"{ConnectorNamespaceTriggerConfigManagementResolver.EscapePathSegment(resourceIdentity.SubscriptionId)}/" +
$"{ConnectorNamespaceTriggerConfigManagementResolver.ResourceGroupsSegmentName}/" +
$"{ConnectorNamespaceTriggerConfigManagementResolver.EscapePathSegment(resourceIdentity.ResourceGroupName)}/" +
$"providers/{ConnectorNamespaceTriggerConfigManagementResolver.MicrosoftWebProviderName}/connectorGateways/" +
$"{ConnectorNamespaceTriggerConfigManagementResolver.EscapePathSegment(resourceIdentity.ConnectorNamespaceName)}/" +
$"{ConnectorNamespaceTriggerConfigManagementResolver.TriggerConfigsSegmentName}/" +
$"{ConnectorNamespaceTriggerConfigManagementResolver.EscapePathSegment(resourceIdentity.TriggerConfigName)}";

var builder = new UriBuilder(this._managementEndpoint)
{
Path = managementPath,
Query = $"api-version={Uri.EscapeDataString(this._apiVersion)}",
};

return builder.Uri;
}
}
Loading
Loading