Skip to content

Commit cb35a72

Browse files
ceciliaavilaTracy Boehrer
authored and
Tracy Boehrer
committed
[#6889] CQA to support TokenCredential instead of key (#6892)
* Add support for MSI to access CQA service * Use IsNullOrWhiteSpace instead of IsNullOrEmpty * Catch exception in GetTokenAsync call
1 parent d2c59e4 commit cb35a72

File tree

7 files changed

+165
-33
lines changed

7 files changed

+165
-33
lines changed

libraries/Microsoft.Bot.Builder.AI.QnA/CustomQuestionAnswering.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
using System.Net.Http;
99
using System.Threading;
1010
using System.Threading.Tasks;
11-
using Microsoft.Bot.Builder.AI.QnA.Models;
1211
using Microsoft.Bot.Builder.AI.QnA.Utils;
1312
using Newtonsoft.Json;
1413

@@ -48,9 +47,9 @@ public CustomQuestionAnswering(QnAMakerEndpoint endpoint, QnAMakerOptions option
4847
throw new ArgumentException(nameof(endpoint.Host));
4948
}
5049

51-
if (string.IsNullOrEmpty(endpoint.EndpointKey))
50+
if (string.IsNullOrEmpty(endpoint.EndpointKey) && string.IsNullOrEmpty(endpoint.ManagedIdentityClientId))
5251
{
53-
throw new ArgumentException(nameof(endpoint.EndpointKey));
52+
throw new ArgumentException("Either the EndpointKey or the ManagedIdentityCliendId must be provided");
5453
}
5554

5655
if (_endpoint.Host.EndsWith("v2.0", StringComparison.Ordinal) || _endpoint.Host.EndsWith("v3.0", StringComparison.Ordinal))

libraries/Microsoft.Bot.Builder.AI.QnA/Dialogs/QnAMakerDialog.cs

Lines changed: 123 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public class QnAMakerDialog : WaterfallDialog
8080
/// </summary>
8181
/// <param name="dialogId">The ID of the <see cref="Dialog"/>.</param>
8282
/// <param name="knowledgeBaseId">The ID of the QnA Maker knowledge base to query.</param>
83-
/// <param name="endpointKey">The QnA Maker endpoint key to use to query the knowledge base.</param>
83+
/// <param name="endpointKey">**Deprecated - use WithEndpointKey() instead**.The QnA Maker endpoint key to use to query the knowledge base.</param>
8484
/// <param name="hostName">The QnA Maker host URL for the knowledge base, starting with "https://" and
8585
/// ending with "/qnamaker".</param>
8686
/// <param name="noAnswer">The activity to send the user when QnA Maker does not find an answer.</param>
@@ -121,36 +121,38 @@ public QnAMakerDialog(
121121
[CallerFilePath] string sourceFilePath = "",
122122
[CallerLineNumber] int sourceLineNumber = 0,
123123
bool useTeamsAdaptiveCard = false)
124-
: base(dialogId)
124+
: this(
125+
dialogId,
126+
knowledgeBaseId,
127+
hostName,
128+
noAnswer,
129+
threshold,
130+
activeLearningCardTitle,
131+
cardNoMatchText,
132+
top,
133+
cardNoMatchResponse,
134+
strictFilters,
135+
filters,
136+
qnAServiceType,
137+
sourceFilePath,
138+
sourceLineNumber,
139+
useTeamsAdaptiveCard,
140+
httpClient)
125141
{
126-
this.RegisterSourceLocation(sourceFilePath, sourceLineNumber);
127-
this.KnowledgeBaseId = knowledgeBaseId ?? throw new ArgumentNullException(nameof(knowledgeBaseId));
128-
this.HostName = hostName ?? throw new ArgumentNullException(nameof(hostName));
129-
this.EndpointKey = endpointKey ?? throw new ArgumentNullException(nameof(endpointKey));
130-
this.Threshold = threshold;
131-
this.Top = top;
132-
this.ActiveLearningCardTitle = activeLearningCardTitle;
133-
this.CardNoMatchText = cardNoMatchText;
134-
this.StrictFilters = strictFilters;
135-
this.NoAnswer = new BindToActivity(noAnswer ?? MessageFactory.Text(DefaultNoAnswer));
136-
this.CardNoMatchResponse = new BindToActivity(cardNoMatchResponse ?? MessageFactory.Text(DefaultCardNoMatchResponse));
137-
Filters = filters;
138-
QnAServiceType = qnAServiceType;
139-
this.HttpClient = httpClient;
140-
this.UseTeamsAdaptiveCard = useTeamsAdaptiveCard;
142+
if (!string.IsNullOrWhiteSpace(endpointKey))
143+
{
144+
Console.WriteLine(
145+
"Providing an endpointKey in the QnAMakerDialog constructor is deprecated, use WithEndpointKey() method instead and provide 'null' or 'empty' value in the constructor.");
141146

142-
// add waterfall steps
143-
this.AddStep(CallGenerateAnswerAsync);
144-
this.AddStep(CallTrainAsync);
145-
this.AddStep(CheckForMultiTurnPromptAsync);
146-
this.AddStep(DisplayQnAResultAsync);
147+
EndpointKey = endpointKey;
148+
}
147149
}
148150

149151
/// <summary>
150152
/// Initializes a new instance of the <see cref="QnAMakerDialog"/> class.
151153
/// </summary>
152154
/// <param name="knowledgeBaseId">The ID of the QnA Maker knowledge base to query.</param>
153-
/// <param name="endpointKey">The QnA Maker endpoint key to use to query the knowledge base.</param>
155+
/// <param name="endpointKey">**Deprecated - use WithEndpointKey() instead**.The QnA Maker endpoint key to use to query the knowledge base.</param>
154156
/// <param name="hostName">The QnA Maker host URL for the knowledge base, starting with "https://" and
155157
/// ending with "/qnamaker".</param>
156158
/// <param name="noAnswer">The activity to send the user when QnA Maker does not find an answer.</param>
@@ -232,6 +234,47 @@ public QnAMakerDialog([CallerFilePath] string sourceFilePath = "", [CallerLineNu
232234
this.AddStep(DisplayQnAResultAsync);
233235
}
234236

237+
internal QnAMakerDialog(
238+
string dialogId,
239+
string knowledgeBaseId,
240+
string hostName,
241+
Activity noAnswer = null,
242+
float threshold = DefaultThreshold,
243+
string activeLearningCardTitle = DefaultCardTitle,
244+
string cardNoMatchText = DefaultCardNoMatchText,
245+
int top = DefaultTopN,
246+
Activity cardNoMatchResponse = null,
247+
Metadata[] strictFilters = null,
248+
Filters filters = null,
249+
ServiceType qnAServiceType = ServiceType.QnAMaker,
250+
[CallerFilePath] string sourceFilePath = "",
251+
[CallerLineNumber] int sourceLineNumber = 0,
252+
bool useTeamsAdaptiveCard = false,
253+
HttpClient httpClient = null)
254+
: base(dialogId)
255+
{
256+
RegisterSourceLocation(sourceFilePath, sourceLineNumber);
257+
KnowledgeBaseId = knowledgeBaseId ?? throw new ArgumentNullException(nameof(knowledgeBaseId));
258+
HostName = hostName ?? throw new ArgumentNullException(nameof(hostName));
259+
Threshold = threshold;
260+
Top = top;
261+
ActiveLearningCardTitle = activeLearningCardTitle;
262+
CardNoMatchText = cardNoMatchText;
263+
StrictFilters = strictFilters;
264+
NoAnswer = new BindToActivity(noAnswer ?? MessageFactory.Text(DefaultNoAnswer));
265+
CardNoMatchResponse = new BindToActivity(cardNoMatchResponse ?? MessageFactory.Text(DefaultCardNoMatchResponse));
266+
Filters = filters;
267+
QnAServiceType = qnAServiceType;
268+
HttpClient = httpClient;
269+
UseTeamsAdaptiveCard = useTeamsAdaptiveCard;
270+
271+
// add waterfall steps
272+
AddStep(CallGenerateAnswerAsync);
273+
AddStep(CallTrainAsync);
274+
AddStep(CheckForMultiTurnPromptAsync);
275+
AddStep(DisplayQnAResultAsync);
276+
}
277+
235278
/// <summary>
236279
/// Gets or sets the <see cref="HttpClient"/> instance to use for requests to the QnA Maker service.
237280
/// </summary>
@@ -266,6 +309,15 @@ public QnAMakerDialog([CallerFilePath] string sourceFilePath = "", [CallerLineNu
266309
[JsonProperty("endpointKey")]
267310
public StringExpression EndpointKey { get; set; }
268311

312+
/// <summary>
313+
/// Gets or sets the ClientId of the Managed Identity resource. Access control (IAM) role `Cognitive Services User` must be assigned in the Language resource to the Managed Identity resource.
314+
/// </summary>
315+
/// <value>
316+
/// The ClientId of the Managed Identity resource.
317+
/// </value>
318+
[JsonProperty("managedIdentityClientId")]
319+
public StringExpression ManagedIdentityClientId { get; set; }
320+
269321
/// <summary>
270322
/// Gets or sets the threshold for answers returned, based on score.
271323
/// </summary>
@@ -417,6 +469,44 @@ public QnAMakerDialog([CallerFilePath] string sourceFilePath = "", [CallerLineNu
417469
[JsonProperty("qnAServiceType")]
418470
public EnumExpression<ServiceType> QnAServiceType { get; set; } = ServiceType.QnAMaker;
419471

472+
/// <summary>
473+
/// Uses the provided QnA Maker EndpointKey to authenticate against the resource to query the knowledge base.
474+
/// </summary>
475+
/// <param name="endpointKey">The QnA Maker endpoint key to use to query the knowledge base.</param>
476+
public void WithEndpointKey(string endpointKey)
477+
{
478+
if (string.IsNullOrWhiteSpace(endpointKey))
479+
{
480+
throw new ArgumentNullException(nameof(endpointKey));
481+
}
482+
483+
if (ManagedIdentityClientId != null)
484+
{
485+
throw new ArgumentException("Cannot set EndpointKey when ManagedIdentityClientId is already set");
486+
}
487+
488+
EndpointKey = endpointKey;
489+
}
490+
491+
/// <summary>
492+
/// Uses the provided QnA Maker ManagedIdentityClientId to authenticate against the resource to query the knowledge base.
493+
/// </summary>
494+
/// <param name="managedIdentityClientId">The QnA Maker managed identity client id to use to query the knowledge base.</param>
495+
public void WithManagedIdentityClientId(string managedIdentityClientId)
496+
{
497+
if (string.IsNullOrWhiteSpace(managedIdentityClientId))
498+
{
499+
throw new ArgumentNullException(nameof(managedIdentityClientId));
500+
}
501+
502+
if (EndpointKey != null)
503+
{
504+
throw new ArgumentException("Cannot set ManagedIdentityClientId when EndpointKey is already set");
505+
}
506+
507+
ManagedIdentityClientId = managedIdentityClientId;
508+
}
509+
420510
/// <summary>
421511
/// Called when the dialog is started and pushed onto the dialog stack.
422512
/// </summary>
@@ -532,9 +622,18 @@ protected virtual async Task<IQnAMakerClient> GetQnAMakerClientAsync(DialogConte
532622

533623
var httpClient = dc.Context.TurnState.Get<HttpClient>() ?? HttpClient;
534624

625+
var endpointKey = EndpointKey?.GetValue(dc.State);
626+
var managedIdentityClientId = ManagedIdentityClientId?.GetValue(dc.State);
627+
628+
if (string.IsNullOrWhiteSpace(endpointKey) && string.IsNullOrWhiteSpace(managedIdentityClientId))
629+
{
630+
throw new ArgumentException("An authorization method is required. Either EndpointKey or ManagedIdentityClientId must be set");
631+
}
632+
535633
var endpoint = new QnAMakerEndpoint
536634
{
537-
EndpointKey = this.EndpointKey.GetValue(dc.State),
635+
EndpointKey = endpointKey,
636+
ManagedIdentityClientId = managedIdentityClientId,
538637
Host = this.HostName.GetValue(dc.State),
539638
KnowledgeBaseId = KnowledgeBaseId.GetValue(dc.State),
540639
QnAServiceType = QnAServiceType.GetValue(dc.State)

libraries/Microsoft.Bot.Builder.AI.QnA/Microsoft.Bot.Builder.AI.QnA.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
<ItemGroup>
3333
<!-- Force System.Text.Json to a safe version. -->
34+
<PackageReference Include="Azure.Identity" Version="1.13.2" />
3435
<PackageReference Include="System.Text.Json" Version="8.0.5" />
3536
<PackageReference Include="Microsoft.Bot.Configuration" Condition=" '$(ReleasePackageVersion)' == '' " Version="$(LocalPackageVersion)" />
3637
<PackageReference Include="Microsoft.Bot.Configuration" Condition=" '$(ReleasePackageVersion)' != '' " Version="$(ReleasePackageVersion)" />

libraries/Microsoft.Bot.Builder.AI.QnA/QnAMakerEndpoint.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,14 @@ public QnAMakerEndpoint(QnAMakerService service)
6767
/// </value>
6868
[JsonProperty("host")]
6969
public string Host { get; set; }
70+
71+
/// <summary>
72+
/// Gets or sets the ClientId of the Managed Identity resource. Access control (IAM) role `Cognitive Services User` must be assigned in the Language resource to the Managed Identity resource.
73+
/// </summary>
74+
/// <value>
75+
/// The ClientId of the Managed Identity resource.
76+
/// </value>
77+
[JsonProperty("managedIdentityClientId")]
78+
public string ManagedIdentityClientId { get; set; }
7079
}
7180
}

libraries/Microsoft.Bot.Builder.AI.QnA/Utils/HttpRequestUtils.cs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Runtime.Versioning;
1010
using System.Text;
1111
using System.Threading.Tasks;
12+
using Azure.Identity;
1213

1314
namespace Microsoft.Bot.Builder.AI.QnA
1415
{
@@ -60,7 +61,7 @@ public async Task<HttpResponseMessage> ExecuteHttpRequestAsync(string requestUrl
6061
{
6162
request.Content = new StringContent(payloadBody, Encoding.UTF8, "application/json");
6263

63-
SetHeaders(request, endpoint);
64+
await SetHeadersAsync(request, endpoint);
6465

6566
var response = await _httpClient.SendAsync(request).ConfigureAwait(false);
6667
response.EnsureSuccessStatusCode();
@@ -69,10 +70,33 @@ public async Task<HttpResponseMessage> ExecuteHttpRequestAsync(string requestUrl
6970
}
7071
}
7172

72-
private static void SetHeaders(HttpRequestMessage request, QnAMakerEndpoint endpoint)
73+
private static async Task SetHeadersAsync(HttpRequestMessage request, QnAMakerEndpoint endpoint)
7374
{
74-
request.Headers.Add("Authorization", $"EndpointKey {endpoint.EndpointKey}");
75-
request.Headers.Add("Ocp-Apim-Subscription-Key", endpoint.EndpointKey);
75+
if (!string.IsNullOrWhiteSpace(endpoint.EndpointKey))
76+
{
77+
request.Headers.Add("Authorization", $"EndpointKey {endpoint.EndpointKey}");
78+
request.Headers.Add("Ocp-Apim-Subscription-Key", endpoint.EndpointKey);
79+
}
80+
else if (!string.IsNullOrWhiteSpace(endpoint.ManagedIdentityClientId))
81+
{
82+
try
83+
{
84+
var client = new ManagedIdentityCredential(endpoint.ManagedIdentityClientId);
85+
var accessToken = await client.GetTokenAsync(new Azure.Core.TokenRequestContext(["https://cognitiveservices.azure.com/.default"]));
86+
request.Headers.Add("Authorization", $"Bearer {accessToken.Token}");
87+
}
88+
catch (Exception ex)
89+
{
90+
throw new InvalidOperationException(
91+
$"Failed to acquire token using Managed Identity Client ID '{endpoint.ManagedIdentityClientId}'. " +
92+
$"Ensure the Managed Identity exists and has the 'Cognitive Services User' role assigned.", ex);
93+
}
94+
}
95+
else
96+
{
97+
throw new ArgumentNullException(nameof(endpoint), "Either EndpointKey or ManagedIdentityClientId must be provided.");
98+
}
99+
76100
request.Headers.UserAgent.Add(botBuilderInfo);
77101
request.Headers.UserAgent.Add(platformInfo);
78102
}

tests/Microsoft.Bot.Builder.AI.QnA.Tests/LanguageServiceTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2220,7 +2220,7 @@ public class LanguageServiceTestDialog : ComponentDialog, IDialogDependencies
22202220
public LanguageServiceTestDialog(string knowledgeBaseId, string endpointKey, string hostName, HttpClient httpClient)
22212221
: base(nameof(LanguageServiceTestDialog))
22222222
{
2223-
AddDialog(new QnAMakerDialog(knowledgeBaseId, endpointKey, hostName, httpClient: httpClient));
2223+
AddDialog(new QnAMakerDialog(knowledgeBaseId, endpointKey: endpointKey, hostName, httpClient: httpClient));
22242224
}
22252225

22262226
/// <summary>

tests/Microsoft.Bot.Builder.AI.QnA.Tests/QnAMakerTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1986,7 +1986,7 @@ public class QnaMakerTestDialog : ComponentDialog, IDialogDependencies
19861986
public QnaMakerTestDialog(string knowledgeBaseId, string endpointKey, string hostName, HttpClient httpClient)
19871987
: base(nameof(QnaMakerTestDialog))
19881988
{
1989-
AddDialog(new QnAMakerDialog(knowledgeBaseId, endpointKey, hostName, httpClient: httpClient));
1989+
AddDialog(new QnAMakerDialog(knowledgeBaseId, endpointKey: endpointKey, hostName, httpClient: httpClient));
19901990
}
19911991

19921992
/// <summary>

0 commit comments

Comments
 (0)