Skip to content

Commit daa30b9

Browse files
Authenticate requests from APIView to DevOps using managed Identity (#8086)
* Authenticate requests from APIView to DevOps using managed Identity
1 parent 2bd8966 commit daa30b9

3 files changed

Lines changed: 87 additions & 103 deletions

File tree

src/dotnet/APIView/APIViewWeb/APIViewWeb.csproj

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
2727
<PackageReference Include="Azure.AI.OpenAI" Version="1.0.0-beta.6" />
2828
<PackageReference Include="Azure.Data.AppConfiguration" Version="1.3.0" />
29-
<PackageReference Include="Azure.Identity" Version="1.10.4" />
29+
<PackageReference Include="Azure.Identity" Version="1.11.0" />
3030
<PackageReference Include="Azure.Search.Documents" Version="11.5.0-beta.4" />
3131
<PackageReference Include="Azure.Storage.Blobs" Version="12.13.0" />
3232
<PackageReference Include="CsvHelper" Version="30.0.1" />
@@ -45,12 +45,14 @@
4545
<PackageReference Include="Microsoft.Azure.AppConfiguration.AspNetCore" Version="7.0.0" />
4646
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.37.1" />
4747
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="7.0.15" />
48-
<PackageReference Include="Microsoft.TeamFoundationServer.Client" Version="16.205.1" />
48+
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.4" />
49+
<PackageReference Include="Microsoft.TeamFoundationServer.Client" Version="19.225.1" />
4950
<PackageReference Include="Microsoft.TypeScript.MSBuild" Version="5.3.3">
5051
<PrivateAssets>all</PrivateAssets>
5152
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
5253
</PackageReference>
53-
<PackageReference Include="Microsoft.VisualStudio.Services.Client" Version="16.205.1" />
54+
<PackageReference Include="Microsoft.VisualStudio.Services.Client" Version="19.225.1" />
55+
<PackageReference Include="Microsoft.VisualStudio.Services.InteractiveClient" Version="19.225.1" />
5456
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="7.0.11" PrivateAssets="All" />
5557
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.0.0-beta2-19367-01" PrivateAssets="All" />
5658
<PackageReference Include="MongoDB.Driver" Version="2.23.1" />

src/dotnet/APIView/APIViewWeb/Controllers/PullRequestController.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ public async Task<ActionResult> DetectApiChanges(
6363
string codeFile = null,
6464
string baselineCodeFile = null,
6565
bool commentOnPR = true,
66-
string language = null)
66+
string language = null,
67+
string project = "internal")
6768
{
6869
if (!ValidateInputParams())
6970
{
@@ -80,7 +81,7 @@ public async Task<ActionResult> DetectApiChanges(
8081
repoName: repoName, packageName: packageName,
8182
prNumber: pullRequestNumber, hostName: this.Request.Host.ToUriComponent(),
8283
codeFileName: codeFile, baselineCodeFileName: baselineCodeFile,
83-
commentOnPR: commentOnPR, language: language);
84+
commentOnPR: commentOnPR, language: language, project: project);
8485

8586
return !string.IsNullOrEmpty(reviewUrl) ? StatusCode(statusCode: StatusCodes.Status201Created, reviewUrl) : StatusCode(statusCode: StatusCodes.Status208AlreadyReported);
8687
}
@@ -102,7 +103,7 @@ private async Task<string> DetectAPIChanges(string buildId,
102103
string baselineCodeFileName = null,
103104
bool commentOnPR = true,
104105
string language = null,
105-
string project = "public")
106+
string project = "internal")
106107
{
107108
language = LanguageServiceHelpers.MapLanguageAlias(language: language);
108109
var requestTelemetry = new RequestTelemetry { Name = "Detecting API changes for PR: " + prNumber };
Lines changed: 78 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,160 +1,141 @@
1-
using Microsoft.ApplicationInsights.Extensibility;
2-
using Microsoft.ApplicationInsights;
3-
using Microsoft.AspNetCore.Http;
4-
using Microsoft.Extensions.Caching.Memory;
1+
using Azure.Core;
2+
using Azure.Identity;
3+
using Microsoft.ApplicationInsights;
54
using Microsoft.Extensions.Configuration;
6-
using Microsoft.TeamFoundation.Build.WebApi;
7-
using Microsoft.TeamFoundation.Core.WebApi;
8-
using Microsoft.VisualStudio.Services.Common;
9-
using Microsoft.VisualStudio.Services.WebApi;
10-
using Newtonsoft.Json;
11-
using Octokit;
5+
using Microsoft.TeamFoundation.Build.WebApi;
6+
using Microsoft.TeamFoundation.Core.WebApi;
7+
using Microsoft.VisualStudio.Services.Client;
8+
using Microsoft.VisualStudio.Services.WebApi;
9+
using Newtonsoft.Json;
10+
using Polly;
1211
using System;
1312
using System.Collections.Generic;
1413
using System.IO;
1514
using System.Linq;
16-
using System.Net;
1715
using System.Net.Http;
1816
using System.Net.Http.Headers;
19-
using System.Text.Json;
17+
using System.Threading;
2018
using System.Threading.Tasks;
2119

2220
namespace APIViewWeb.Repositories
2321
{
2422
public class DevopsArtifactRepository : IDevopsArtifactRepository
2523
{
26-
private readonly HttpClient _devopsClient;
2724
private readonly IConfiguration _configuration;
28-
private readonly string _devopsAccessToken;
2925
private readonly string _hostUrl;
3026
private readonly TelemetryClient _telemetryClient;
3127

3228
public DevopsArtifactRepository(IConfiguration configuration, TelemetryClient telemetryClient)
3329
{
3430
_configuration = configuration;
35-
_devopsAccessToken = Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes(string.Format("{0}:{1}", "", _configuration["Azure-Devops-PAT"])));
3631
_hostUrl = _configuration["APIVIew-Host-Url"];
3732
_telemetryClient = telemetryClient;
38-
39-
_devopsClient = new HttpClient();
40-
_devopsClient.DefaultRequestHeaders.Accept.Clear();
41-
_devopsClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
42-
_devopsClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", _devopsAccessToken);
4333
}
4434

4535
public async Task<Stream> DownloadPackageArtifact(string repoName, string buildId, string artifactName, string filePath, string project, string format= "file")
4636
{
47-
var downloadUrl = await GetDownloadArtifactUrl(repoName, buildId, artifactName, project);
48-
if (!string.IsNullOrEmpty(downloadUrl))
37+
var downloadUrl = await getDownloadArtifactUrl(buildId, artifactName, project);
38+
if (string.IsNullOrEmpty(downloadUrl))
39+
{
40+
throw new Exception(string.Format("Failed to get download url for artifact {0} in build {1} in project {2}", artifactName, buildId, project));
41+
}
42+
43+
if(!string.IsNullOrEmpty(filePath))
4944
{
50-
if(!string.IsNullOrEmpty(filePath))
45+
if (!filePath.StartsWith("/"))
5146
{
52-
if (!filePath.StartsWith("/"))
53-
{
54-
filePath = "/" + filePath;
55-
}
56-
downloadUrl = downloadUrl.Split("?")[0] + "?format=" + format + "&subPath=" + filePath;
47+
filePath = "/" + filePath;
5748
}
58-
59-
var downloadResp = await GetFromDevopsAsync(downloadUrl);
60-
downloadResp.EnsureSuccessStatusCode();
61-
return await downloadResp.Content.ReadAsStreamAsync();
49+
downloadUrl = downloadUrl.Split("?")[0] + "?format=" + format + "&subPath=" + filePath;
6250
}
63-
return null;
51+
52+
HttpResponseMessage downloadResp = await GetFromDevopsAsync(downloadUrl);
53+
downloadResp.EnsureSuccessStatusCode();
54+
return await downloadResp.Content.ReadAsStreamAsync();
6455
}
6556

66-
private async Task<HttpResponseMessage> GetFromDevopsAsync(string request)
57+
private async Task<string> getDownloadArtifactUrl(string buildId, string artifactName, string project)
6758
{
68-
var downloadResp = await _devopsClient.GetAsync(request);
69-
70-
71-
if (!downloadResp.IsSuccessStatusCode)
72-
{
73-
var retryAfter = downloadResp.Headers.GetValues("Retry-After");
74-
var rateLimitResource = downloadResp.Headers.GetValues("X-RateLimit-Resource");
75-
var rateLimitDelay = downloadResp.Headers.GetValues("X-RateLimit-Delay");
76-
var rateLimitLimit = downloadResp.Headers .GetValues("X-RateLimit-Limit");
77-
var rateLimitRemaining = downloadResp.Headers.GetValues("X-RateLimit-Remaining");
78-
var rateLimitReset = downloadResp.Headers.GetValues("X-RateLimit-Reset");
79-
80-
var traceMessage = $"request: {request} failed with statusCode: {downloadResp.StatusCode}," +
81-
$"retryAfter: {retryAfter.FirstOrDefault()}, rateLimitResource: {rateLimitResource.FirstOrDefault()}, rateLimitDelay: {rateLimitDelay.FirstOrDefault()}," +
82-
$"rateLimitLimit: {rateLimitLimit.FirstOrDefault()}, rateLimitRemaining: {rateLimitRemaining.FirstOrDefault()}, rateLimitReset: {rateLimitReset.FirstOrDefault()}";
83-
84-
_telemetryClient.TrackTrace(traceMessage);
85-
}
59+
var connection = await CreateVssConnection();
60+
var buildClient = connection.GetClient<BuildHttpClient>();
61+
var artifact = await buildClient.GetArtifactAsync(project, int.Parse(buildId), artifactName);
62+
return artifact?.Resource?.DownloadUrl;
63+
}
8664

87-
int count = 0;
88-
int[] waitTimes = new int[] { 0, 1, 2, 4, 8, 16, 32, 64, 128, 256 };
89-
while ((downloadResp.StatusCode == HttpStatusCode.TooManyRequests || downloadResp.StatusCode == HttpStatusCode.BadRequest) && count < waitTimes.Length)
90-
{
91-
_telemetryClient.TrackTrace($"Download request from devops artifact is either throttled or flaky, waiting {waitTimes[count]} seconds before retrying, Retry count: {count}");
92-
await Task.Delay(TimeSpan.FromSeconds(waitTimes[count]));
93-
downloadResp = await _devopsClient.GetAsync(request);
94-
count++;
95-
}
96-
return downloadResp;
65+
private async Task<VssConnection> CreateVssConnection()
66+
{
67+
var accessToken = await getAccessToken();
68+
var token = new VssAadToken("Bearer", accessToken);
69+
return new VssConnection(new Uri("https://dev.azure.com/azure-sdk/"), new VssAadCredential(token));
9770
}
9871

99-
private async Task<string> GetDownloadArtifactUrl(string repoName, string buildId, string artifactName, string project)
72+
private async Task<string> getAccessToken()
10073
{
101-
var artifactGetReq = GetArtifactRestAPIForRepo(repoName).Replace("{buildId}", buildId).Replace("{artifactName}", artifactName).Replace("{project}", project);
102-
var response = await GetFromDevopsAsync(artifactGetReq);
103-
response.EnsureSuccessStatusCode();
104-
var buildResource = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
105-
if (buildResource == null)
106-
{
107-
return null;
108-
}
109-
return buildResource.RootElement.GetProperty("resource").GetProperty("downloadUrl").GetString();
74+
// APIView deployed instances uses managed identity to authenticate requests to Azure DevOps.
75+
// For local testing, VS will use developer credentials to create token
76+
var credential = new DefaultAzureCredential();
77+
var tokenRequestContext = new TokenRequestContext(VssAadSettings.DefaultScopes);
78+
var token = await credential.GetTokenAsync(tokenRequestContext, CancellationToken.None);
79+
return token.Token;
11080
}
11181

112-
private string GetArtifactRestAPIForRepo(string repoName)
82+
private async Task<HttpResponseMessage> GetFromDevopsAsync(string request)
11383
{
114-
var downloadArtifactRestApi = _configuration["download-artifact-rest-api-for-" + repoName];
115-
if (downloadArtifactRestApi == null)
84+
var httpClient = new HttpClient();
85+
var accessToken = await getAccessToken();
86+
httpClient.DefaultRequestHeaders.Accept.Clear();
87+
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
88+
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
89+
var maxRetryAttempts = 10;
90+
var pauseBetweenFailures = TimeSpan.FromSeconds(2);
91+
92+
var retryPolicy = Policy
93+
.Handle<HttpRequestException>()
94+
.WaitAndRetryAsync(maxRetryAttempts, i => pauseBetweenFailures);
95+
96+
HttpResponseMessage downloadResp = null;
97+
await retryPolicy.ExecuteAsync(async () =>
11698
{
117-
downloadArtifactRestApi = _configuration["download-artifact-rest-api"];
118-
}
119-
return downloadArtifactRestApi;
99+
downloadResp = await httpClient.GetAsync(request);
100+
});
101+
return downloadResp;
120102
}
121103

122104
public async Task RunPipeline(string pipelineName, string reviewDetails, string originalStorageUrl)
123105
{
124106
//Create dictionary of all required parametes to run tools - generate-<language>-apireview pipeline in azure devops
125107
var reviewDetailsDict = new Dictionary<string, string> { { "Reviews", reviewDetails }, { "APIViewUrl", _hostUrl }, { "StorageContainerUrl", originalStorageUrl } };
126-
var devOpsCreds = new VssBasicCredential("nobody", _configuration["Azure-Devops-PAT"]);
127-
var devOpsConnection = new VssConnection(new Uri($"https://dev.azure.com/azure-sdk/"), devOpsCreds);
108+
var devOpsConnection = await CreateVssConnection();
128109
string projectName = _configuration["Azure-Devops-internal-project"] ?? "internal";
129110

130111
BuildHttpClient buildClient = await devOpsConnection.GetClientAsync<BuildHttpClient>();
131112
var projectClient = await devOpsConnection.GetClientAsync<ProjectHttpClient>();
132113
string envName = _configuration["apiview-deployment-environment"];
133114
string updatedPipelineName = string.IsNullOrEmpty(envName) ? pipelineName : $"{pipelineName}-{envName}";
134115
int definitionId = await GetPipelineId(updatedPipelineName, buildClient, projectName);
135-
if (definitionId == 0)
136-
{
137-
throw new Exception(string.Format("Azure Devops pipeline is not found with name {0}. Please recheck and ensure pipeline exists with this name", updatedPipelineName));
116+
if (definitionId == 0)
117+
{
118+
throw new Exception(string.Format("Azure Devops pipeline is not found with name {0}. Please recheck and ensure pipeline exists with this name", updatedPipelineName));
138119
}
139120

140-
var definition = await buildClient.GetDefinitionAsync(projectName, definitionId);
121+
var definition = await buildClient.GetDefinitionAsync(projectName, definitionId);
141122
var project = await projectClient.GetProject(projectName);
142-
await buildClient.QueueBuildAsync(new Build()
143-
{
144-
Definition = definition,
145-
Project = project,
146-
Parameters = JsonConvert.SerializeObject(reviewDetailsDict)
123+
await buildClient.QueueBuildAsync(new Build()
124+
{
125+
Definition = definition,
126+
Project = project,
127+
Parameters = JsonConvert.SerializeObject(reviewDetailsDict)
147128
});
148129
}
149130

150-
private async Task<int> GetPipelineId(string pipelineName, BuildHttpClient client, string projectName)
151-
{
152-
var pipelines = await client.GetFullDefinitionsAsync2(project: projectName);
153-
if (pipelines != null)
154-
{
155-
return pipelines.Single(p => p.Name == pipelineName).Id;
156-
}
157-
return 0;
131+
private async Task<int> GetPipelineId(string pipelineName, BuildHttpClient client, string projectName)
132+
{
133+
var pipelines = await client.GetFullDefinitionsAsync2(project: projectName);
134+
if (pipelines != null)
135+
{
136+
return pipelines.Single(p => p.Name == pipelineName).Id;
137+
}
138+
return 0;
158139
}
159140
}
160141
}

0 commit comments

Comments
 (0)