-
Notifications
You must be signed in to change notification settings - Fork 2
Add framework-neutral transport-aware trigger identity validation #218
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0c17ad0
cda4322
e3ed49f
b7ce8ca
4b1ad43
e493715
847b7eb
aec6b20
0c75129
25d25a9
4dd36f5
5156777
614c4ef
d4c615e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
||
| ```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. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Dobby] [Low] [Docs] Says "four resource-context headers"; the code reads 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Dobby] [High] [Docs / Diagnostics] This handler will always log
The one test asserting a non-null Expected fix: plumb the correlation ID through the resolver contract (see the inline comment on |
||
| { | ||
| 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: | ||
|
|
||
| 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; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Dobby] [Low] [Dead code] Its only consumer is the 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Why it matters: Worth noting that 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Why it matters: the caller sees 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 |
||
| { | ||
| 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."); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Dobby] [High] [Diagnostics contract] All four 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 Expected fix: plumb the correlation ID into the resolver (extend |
||
| } | ||
|
|
||
| string responseContent = response.Content.ToString(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Dobby] [Low] [Performance] The string is used only for an emptiness check and then handed to Expected fix: parse from |
||
| 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( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Dobby] [Low] [Duplication] This factory and Both emit Expected fix: consolidate into a single factory — a |
||
| 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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) inConnectorTriggerPayload.GetResourceIdentity, with no authentication of the caller and no binding to any caller-side allow-list.Why it matters:
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.