Skip to content

Commit 7f30741

Browse files
authored
Added prompting for tenant to azure config experiences (#12172)
* Added prompting for tenant to azure config experiences - When we create a provisioning context, we gather the subscription id using the default tenant based on the azure credential. This is problematic when users want to switch between work and personal accounts so ask the user which one they want to use and store that along with sub, location and rg. * Add validation for tenant input in provisioning context prompt test * Update subscription ID assignment to use null-coalescing assignment operator; add test to skip tenant prompt when subscription ID is provided * Refactor tenant validation logic to simplify error handling and improve readability * Remove tenant ID check from provisioning options validation to streamline logic * Refactor tenant ID assignment to use input from provisioning options when subscription ID is not set * Refactor tenant ID retrieval to simplify logic by directly assigning from input * Add tenant ID interaction validation in DeployAsync test
1 parent ac052c2 commit 7f30741

26 files changed

+997
-98
lines changed

src/Aspire.Hosting.Azure/Provisioning/AzureProvisionerOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ namespace Aspire.Hosting.Azure.Provisioning;
77

88
internal sealed class AzureProvisionerOptions
99
{
10+
public string? TenantId { get; set; }
11+
1012
public string? SubscriptionId { get; set; }
1113

1214
public string? ResourceGroup { get; set; }

src/Aspire.Hosting.Azure/Provisioning/Internal/BaseProvisioningContextProvider.cs

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ internal abstract partial class BaseProvisioningContextProvider(
3030
internal const string LocationName = "Location";
3131
internal const string SubscriptionIdName = "SubscriptionId";
3232
internal const string ResourceGroupName = "ResourceGroup";
33+
internal const string TenantName = "Tenant";
3334

3435
protected readonly IInteractionService _interactionService = interactionService;
3536
protected readonly AzureProvisionerOptions _options = options.Value;
@@ -161,6 +162,10 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
161162
azureSection["Location"] = _options.Location;
162163
azureSection["SubscriptionId"] = _options.SubscriptionId;
163164
azureSection["ResourceGroup"] = resourceGroupName;
165+
if (!string.IsNullOrEmpty(_options.TenantId))
166+
{
167+
azureSection["TenantId"] = _options.TenantId;
168+
}
164169
if (_options.AllowResourceGroupCreation.HasValue)
165170
{
166171
azureSection["AllowResourceGroupCreation"] = _options.AllowResourceGroupCreation.Value;
@@ -180,7 +185,56 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
180185

181186
protected abstract string GetDefaultResourceGroupName();
182187

183-
protected async Task<(List<KeyValuePair<string, string>>? subscriptionOptions, bool fetchSucceeded)> TryGetSubscriptionsAsync(CancellationToken cancellationToken)
188+
protected async Task<(List<KeyValuePair<string, string>>? tenantOptions, bool fetchSucceeded)> TryGetTenantsAsync(CancellationToken cancellationToken)
189+
{
190+
List<KeyValuePair<string, string>>? tenantOptions = null;
191+
var fetchSucceeded = false;
192+
193+
try
194+
{
195+
var credential = _tokenCredentialProvider.TokenCredential;
196+
var armClient = _armClientProvider.GetArmClient(credential);
197+
var availableTenants = await armClient.GetAvailableTenantsAsync(cancellationToken).ConfigureAwait(false);
198+
var tenantList = availableTenants.ToList();
199+
200+
if (tenantList.Count > 0)
201+
{
202+
tenantOptions = tenantList
203+
.Select(t =>
204+
{
205+
var tenantId = t.TenantId?.ToString() ?? "";
206+
207+
// Build display name: prefer DisplayName, fall back to domain, then to "Unknown"
208+
var displayName = !string.IsNullOrEmpty(t.DisplayName)
209+
? t.DisplayName
210+
: !string.IsNullOrEmpty(t.DefaultDomain)
211+
? t.DefaultDomain
212+
: "Unknown";
213+
214+
// Build full description
215+
var description = displayName;
216+
if (!string.IsNullOrEmpty(t.DefaultDomain) && t.DisplayName != t.DefaultDomain)
217+
{
218+
description += $" ({t.DefaultDomain})";
219+
}
220+
description += $" — {tenantId}";
221+
222+
return KeyValuePair.Create(tenantId, description);
223+
})
224+
.OrderBy(kvp => kvp.Value)
225+
.ToList();
226+
fetchSucceeded = true;
227+
}
228+
}
229+
catch (Exception ex)
230+
{
231+
_logger.LogWarning(ex, "Failed to enumerate available tenants. Falling back to manual input.");
232+
}
233+
234+
return (tenantOptions, fetchSucceeded);
235+
}
236+
237+
protected async Task<(List<KeyValuePair<string, string>>? subscriptionOptions, bool fetchSucceeded)> TryGetSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken)
184238
{
185239
List<KeyValuePair<string, string>>? subscriptionOptions = null;
186240
var fetchSucceeded = false;
@@ -189,7 +243,7 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
189243
{
190244
var credential = _tokenCredentialProvider.TokenCredential;
191245
var armClient = _armClientProvider.GetArmClient(credential);
192-
var availableSubscriptions = await armClient.GetAvailableSubscriptionsAsync(cancellationToken).ConfigureAwait(false);
246+
var availableSubscriptions = await armClient.GetAvailableSubscriptionsAsync(tenantId, cancellationToken).ConfigureAwait(false);
193247
var subscriptionList = availableSubscriptions.ToList();
194248

195249
if (subscriptionList.Count > 0)
@@ -208,6 +262,11 @@ public virtual async Task<ProvisioningContext> CreateProvisioningContextAsync(Js
208262
return (subscriptionOptions, fetchSucceeded);
209263
}
210264

265+
protected async Task<(List<KeyValuePair<string, string>>? subscriptionOptions, bool fetchSucceeded)> TryGetSubscriptionsAsync(CancellationToken cancellationToken)
266+
{
267+
return await TryGetSubscriptionsAsync(_options.TenantId, cancellationToken).ConfigureAwait(false);
268+
}
269+
211270
protected async Task<(List<KeyValuePair<string, string>> locationOptions, bool fetchSucceeded)> TryGetLocationsAsync(string subscriptionId, CancellationToken cancellationToken)
212271
{
213272
List<KeyValuePair<string, string>>? locationOptions = null;

src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultArmClientProvider.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ private sealed class DefaultArmClient(ArmClient armClient) : IArmClient
5050
return (subscriptionResource, tenantResource);
5151
}
5252

53+
public async Task<IEnumerable<ITenantResource>> GetAvailableTenantsAsync(CancellationToken cancellationToken = default)
54+
{
55+
var tenants = new List<ITenantResource>();
56+
57+
await foreach (var tenant in armClient.GetTenants().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
58+
{
59+
tenants.Add(new DefaultTenantResource(tenant));
60+
}
61+
62+
return tenants;
63+
}
64+
5365
public async Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default)
5466
{
5567
var subscriptions = new List<ISubscriptionResource>();
@@ -62,6 +74,27 @@ public async Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsA
6274
return subscriptions;
6375
}
6476

77+
public async Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default)
78+
{
79+
if (string.IsNullOrEmpty(tenantId))
80+
{
81+
return await GetAvailableSubscriptionsAsync(cancellationToken).ConfigureAwait(false);
82+
}
83+
84+
var subscriptions = new List<ISubscriptionResource>();
85+
86+
await foreach (var subscription in armClient.GetSubscriptions().GetAllAsync(cancellationToken: cancellationToken).ConfigureAwait(false))
87+
{
88+
// Filter subscriptions by tenant ID
89+
if (subscription.Data.TenantId?.ToString().Equals(tenantId, StringComparison.OrdinalIgnoreCase) == true)
90+
{
91+
subscriptions.Add(new DefaultSubscriptionResource(subscription));
92+
}
93+
}
94+
95+
return subscriptions;
96+
}
97+
6598
public async Task<IEnumerable<(string Name, string DisplayName)>> GetAvailableLocationsAsync(string subscriptionId, CancellationToken cancellationToken = default)
6699
{
67100
var subscription = await armClient.GetSubscriptions().GetAsync(subscriptionId, cancellationToken).ConfigureAwait(false);
@@ -78,6 +111,7 @@ public async Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsA
78111
private sealed class DefaultTenantResource(TenantResource tenantResource) : ITenantResource
79112
{
80113
public Guid? TenantId => tenantResource.Data.TenantId;
114+
public string? DisplayName => tenantResource.Data.DisplayName;
81115
public string? DefaultDomain => tenantResource.Data.DefaultDomain;
82116
}
83117
}

src/Aspire.Hosting.Azure/Provisioning/Internal/DefaultTokenCredentialProvider.cs

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,34 +15,38 @@ internal class DefaultTokenCredentialProvider : ITokenCredentialProvider
1515

1616
public DefaultTokenCredentialProvider(
1717
ILogger<DefaultTokenCredentialProvider> logger,
18-
IOptions<AzureProvisionerOptions> options,
19-
DistributedApplicationExecutionContext distributedApplicationExecutionContext)
18+
IOptions<AzureProvisionerOptions> options)
2019
{
2120
_logger = logger;
2221

2322
// Optionally configured in AppHost appSettings under "Azure" : { "CredentialSource": "AzureCli" }
24-
var credentialSetting = options.Value.CredentialSource;
2523

26-
// Use AzureCli as default for publish mode when no explicit credential source is set
27-
var credentialSource = credentialSetting switch
24+
TokenCredential credential = options.Value.CredentialSource switch
2825
{
29-
null or "Default" when distributedApplicationExecutionContext.IsPublishMode => "AzureCli",
30-
_ => credentialSetting ?? "Default"
31-
};
32-
33-
TokenCredential credential = credentialSource switch
34-
{
35-
"AzureCli" => new AzureCliCredential(),
36-
"AzurePowerShell" => new AzurePowerShellCredential(),
37-
"VisualStudio" => new VisualStudioCredential(),
38-
"AzureDeveloperCli" => new AzureDeveloperCliCredential(),
26+
"AzureCli" => new AzureCliCredential(new()
27+
{
28+
AdditionallyAllowedTenants = { "*" }
29+
}),
30+
"AzurePowerShell" => new AzurePowerShellCredential(new()
31+
{
32+
AdditionallyAllowedTenants = { "*" }
33+
}),
34+
"VisualStudio" => new VisualStudioCredential(new()
35+
{
36+
AdditionallyAllowedTenants = { "*" }
37+
}),
38+
"AzureDeveloperCli" => new AzureDeveloperCliCredential(new()
39+
{
40+
AdditionallyAllowedTenants = { "*" }
41+
}),
3942
"InteractiveBrowser" => new InteractiveBrowserCredential(),
4043
_ => new DefaultAzureCredential(new DefaultAzureCredentialOptions()
4144
{
4245
ExcludeManagedIdentityCredential = true,
4346
ExcludeWorkloadIdentityCredential = true,
4447
ExcludeAzurePowerShellCredential = true,
45-
CredentialProcessTimeout = TimeSpan.FromSeconds(15)
48+
CredentialProcessTimeout = TimeSpan.FromSeconds(15),
49+
AdditionallyAllowedTenants = { "*" }
4650
})
4751
};
4852

src/Aspire.Hosting.Azure/Provisioning/Internal/IProvisioningServices.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,21 @@ internal interface IArmClient
7070
/// </summary>
7171
Task<(ISubscriptionResource subscription, ITenantResource tenant)> GetSubscriptionAndTenantAsync(CancellationToken cancellationToken = default);
7272

73+
/// <summary>
74+
/// Gets all tenants accessible to the current user.
75+
/// </summary>
76+
Task<IEnumerable<ITenantResource>> GetAvailableTenantsAsync(CancellationToken cancellationToken = default);
77+
7378
/// <summary>
7479
/// Gets all subscriptions accessible to the current user.
7580
/// </summary>
7681
Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsAsync(CancellationToken cancellationToken = default);
7782

83+
/// <summary>
84+
/// Gets all subscriptions accessible to the current user filtered by tenant ID.
85+
/// </summary>
86+
Task<IEnumerable<ISubscriptionResource>> GetAvailableSubscriptionsAsync(string? tenantId, CancellationToken cancellationToken = default);
87+
7888
/// <summary>
7989
/// Gets all available locations for the specified subscription.
8090
/// </summary>
@@ -174,6 +184,11 @@ internal interface ITenantResource
174184
/// </summary>
175185
Guid? TenantId { get; }
176186

187+
/// <summary>
188+
/// Gets the display name.
189+
/// </summary>
190+
string? DisplayName { get; }
191+
177192
/// <summary>
178193
/// Gets the default domain.
179194
/// </summary>

src/Aspire.Hosting.Azure/Provisioning/Internal/PublishModeProvisioningContextProvider.cs

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,16 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati
7777
{
7878
while (_options.Location == null || _options.SubscriptionId == null)
7979
{
80+
// Skip tenant prompting if subscription ID is already set
81+
if (_options.TenantId == null && _options.SubscriptionId == null)
82+
{
83+
await PromptForTenantAsync(cancellationToken).ConfigureAwait(false);
84+
if (_options.TenantId == null)
85+
{
86+
continue;
87+
}
88+
}
89+
8090
if (_options.SubscriptionId == null)
8191
{
8292
await PromptForSubscriptionAsync(cancellationToken).ConfigureAwait(false);
@@ -97,6 +107,105 @@ private async Task RetrieveAzureProvisioningOptions(CancellationToken cancellati
97107
}
98108
}
99109

110+
private async Task PromptForTenantAsync(CancellationToken cancellationToken)
111+
{
112+
List<KeyValuePair<string, string>>? tenantOptions = null;
113+
var fetchSucceeded = false;
114+
115+
var step = await activityReporter.CreateStepAsync(
116+
"fetch-tenant",
117+
cancellationToken).ConfigureAwait(false);
118+
119+
await using (step.ConfigureAwait(false))
120+
{
121+
try
122+
{
123+
var task = await step.CreateTaskAsync("Fetching available tenants", cancellationToken).ConfigureAwait(false);
124+
125+
await using (task.ConfigureAwait(false))
126+
{
127+
(tenantOptions, fetchSucceeded) = await TryGetTenantsAsync(cancellationToken).ConfigureAwait(false);
128+
}
129+
130+
if (fetchSucceeded)
131+
{
132+
await step.SucceedAsync($"Found {tenantOptions!.Count} available tenant(s)", cancellationToken).ConfigureAwait(false);
133+
}
134+
else
135+
{
136+
await step.WarnAsync("Failed to fetch tenants, falling back to manual entry", cancellationToken).ConfigureAwait(false);
137+
}
138+
}
139+
catch (Exception ex)
140+
{
141+
_logger.LogError(ex, "Failed to retrieve Azure tenant information.");
142+
await step.FailAsync($"Failed to retrieve tenant information: {ex.Message}", cancellationToken).ConfigureAwait(false);
143+
throw;
144+
}
145+
}
146+
147+
if (tenantOptions?.Count > 0)
148+
{
149+
var result = await _interactionService.PromptInputsAsync(
150+
AzureProvisioningStrings.TenantDialogTitle,
151+
AzureProvisioningStrings.TenantSelectionMessage,
152+
[
153+
new InteractionInput
154+
{
155+
Name = TenantName,
156+
InputType = InputType.Choice,
157+
Label = AzureProvisioningStrings.TenantLabel,
158+
Required = true,
159+
Options = [..tenantOptions]
160+
}
161+
],
162+
new InputsDialogInteractionOptions
163+
{
164+
EnableMessageMarkdown = false
165+
},
166+
cancellationToken).ConfigureAwait(false);
167+
168+
if (!result.Canceled)
169+
{
170+
_options.TenantId = result.Data[TenantName].Value;
171+
return;
172+
}
173+
}
174+
175+
var manualResult = await _interactionService.PromptInputsAsync(
176+
AzureProvisioningStrings.TenantDialogTitle,
177+
AzureProvisioningStrings.TenantManualEntryMessage,
178+
[
179+
new InteractionInput
180+
{
181+
Name = TenantName,
182+
InputType = InputType.SecretText,
183+
Label = AzureProvisioningStrings.TenantLabel,
184+
Placeholder = AzureProvisioningStrings.TenantPlaceholder,
185+
Required = true
186+
}
187+
],
188+
new InputsDialogInteractionOptions
189+
{
190+
EnableMessageMarkdown = false,
191+
ValidationCallback = static (validationContext) =>
192+
{
193+
var tenantInput = validationContext.Inputs[TenantName];
194+
if (!Guid.TryParse(tenantInput.Value, out var _))
195+
{
196+
validationContext.AddValidationError(tenantInput, AzureProvisioningStrings.ValidationTenantIdInvalid);
197+
}
198+
return Task.CompletedTask;
199+
}
200+
},
201+
cancellationToken).ConfigureAwait(false);
202+
203+
if (!manualResult.Canceled)
204+
{
205+
_options.TenantId = manualResult.Data[TenantName].Value;
206+
}
207+
}
208+
100209
private async Task PromptForSubscriptionAsync(CancellationToken cancellationToken)
101210
{
102211
List<KeyValuePair<string, string>>? subscriptionOptions = null;
@@ -114,7 +223,7 @@ private async Task PromptForSubscriptionAsync(CancellationToken cancellationToke
114223

115224
await using (task.ConfigureAwait(false))
116225
{
117-
(subscriptionOptions, fetchSucceeded) = await TryGetSubscriptionsAsync(cancellationToken).ConfigureAwait(false);
226+
(subscriptionOptions, fetchSucceeded) = await TryGetSubscriptionsAsync(_options.TenantId, cancellationToken).ConfigureAwait(false);
118227
}
119228

120229
if (fetchSucceeded)

0 commit comments

Comments
 (0)