From 1fcabcc94524270a1813117f01852e6a5d2315ff Mon Sep 17 00:00:00 2001 From: Wes Haggard Date: Wed, 4 Dec 2024 13:21:07 -0800 Subject: [PATCH] Add service connection permissions for generated pipelines (#9462) --- .../CommandParserOptions/GenerateOptions.cs | 3 + .../Conventions/PipelineConvention.cs | 2 +- .../PipelineGenerationContext.cs | 101 ++++++++++++++++-- .../Program.cs | 44 ++++++++ 4 files changed, 142 insertions(+), 8 deletions(-) diff --git a/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/CommandParserOptions/GenerateOptions.cs b/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/CommandParserOptions/GenerateOptions.cs index 613d45cb09a..a2d69bc5fe7 100644 --- a/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/CommandParserOptions/GenerateOptions.cs +++ b/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/CommandParserOptions/GenerateOptions.cs @@ -33,6 +33,9 @@ public class GenerateOptions : DefaultOptions [Option('v', "variablegroups", Required = false, HelpText = "Variable groups to link, separated by a space, e.g. --variablegroups 1 9 64")] public IEnumerable VariableGroups { get; set; } + [Option("serviceconnections", Required = false, HelpText = "Name of service connection to grant permission, separated by a space, e.g. --serviceconnections \"Azure\" \"azure-sdk-tests-public\"")] + public IEnumerable ServiceConnections { get; set; } + [Option("open", Required = false, HelpText = "Open a browser window to the definitions that are created")] public bool Open { get; set; } diff --git a/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Conventions/PipelineConvention.cs b/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Conventions/PipelineConvention.cs index 1130e0acd67..bb4e535e441 100644 --- a/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Conventions/PipelineConvention.cs +++ b/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Conventions/PipelineConvention.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Microsoft.TeamFoundation.Build.WebApi; using Microsoft.VisualStudio.Services.Common; using System; diff --git a/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/PipelineGenerationContext.cs b/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/PipelineGenerationContext.cs index 1ae76ea16fd..d18c2fcfdcd 100644 --- a/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/PipelineGenerationContext.cs +++ b/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/PipelineGenerationContext.cs @@ -1,19 +1,22 @@ +using Azure.Core; using Azure.Identity; -using Microsoft.Azure.Services.AppAuthentication; using Microsoft.Extensions.Logging; using Microsoft.TeamFoundation.Build.WebApi; using Microsoft.TeamFoundation.Core.WebApi; using Microsoft.TeamFoundation.DistributedTask.WebApi; using Microsoft.VisualStudio.Services.Client; -using Microsoft.VisualStudio.Services.Common; using Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi; using Microsoft.VisualStudio.Services.WebApi; using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; using System.Text; +using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using ServiceEndpoint = Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi.ServiceEndpoint; namespace PipelineGenerator { @@ -70,14 +73,19 @@ public PipelineGenerationContext( private VssConnection cachedConnection; + private TokenCredential GetAzureCredentials() + { + return new ChainedTokenCredential( + new AzureCliCredential(), + new AzurePowerShellCredential() + ); + } + private async Task GetConnectionAsync() { if (cachedConnection == null) { - var azureCredential = new ChainedTokenCredential( - new AzureCliCredential(), - new AzurePowerShellCredential() - ); + var azureCredential = GetAzureCredentials(); var devopsCredential = new VssAzureIdentityCredential(azureCredential); cachedConnection = new VssConnection(new Uri(organization), devopsCredential); await cachedConnection.ConnectAsync(); @@ -136,7 +144,7 @@ public async Task GetServiceEndpointClientAsync(Cance private Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi.ServiceEndpoint cachedServiceEndpoint; - public async Task GetServiceEndpointAsync(CancellationToken cancellationToken) + public async Task GetServiceEndpointAsync(CancellationToken cancellationToken) { if (cachedServiceEndpoint == null) { @@ -161,6 +169,85 @@ public async Task GetServiceEndpointClientAsync(Cance return cachedServiceEndpoint; } + public async Task> GetServiceConnectionsAsync(IEnumerable serviceConnections, CancellationToken cancellationToken) + { + var serviceEndpointClient = await GetServiceEndpointClientAsync(cancellationToken); + var projectReference = await GetProjectReferenceAsync(cancellationToken); + + var allServiceConnections = await serviceEndpointClient.GetServiceEndpointsAsync(projectReference.Id.ToString(), cancellationToken: cancellationToken); + + this.logger.LogDebug("Returned a total of {Count} service endpoints", allServiceConnections.Count); + + List endpoints = new List(); + foreach (var serviceConnection in allServiceConnections) + { + if (serviceConnections.Contains(serviceConnection.Name)) + { + endpoints.Add(serviceConnection); + } + } + return endpoints; + } + + private HttpClient cachedRawHttpClient = null; + + private async Task GetRawHttpClient(CancellationToken cancellationToken) + { + if (this.cachedRawHttpClient == null) + { + var credential = GetAzureCredentials(); + // Get token for Azure DevOps + var tokenRequestContext = new TokenRequestContext(new[] { "499b84ac-1321-427f-aa17-267ca6975798/.default" }); + var accessToken = await credential.GetTokenAsync(tokenRequestContext, cancellationToken); + var client = new HttpClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token); + this.cachedRawHttpClient = client; + } + return this.cachedRawHttpClient; + } + + private string GetPipelinePermissionsUrlForServiceConnections(Guid serviceConnectionId) + { + var apiVersion = "7.1-preview.1"; + // https://learn.microsoft.com/en-us/rest/api/azure/devops/approvalsandchecks/pipeline-permissions/update-pipeline-permisions-for-resource?view=azure-devops-rest-7.1&tabs=HTTP + return $"{this.organization}/{this.project}/_apis/pipelines/pipelinepermissions/endpoint/{serviceConnectionId}?api-version={apiVersion}"; + } + + public async Task GetPipelinePermissionsAsync(Guid serviceConnectionId, CancellationToken cancellationToken) + { + var url = GetPipelinePermissionsUrlForServiceConnections(serviceConnectionId); + var client = await GetRawHttpClient(cancellationToken); + var response = await client.GetAsync(url, cancellationToken); + + if (response.IsSuccessStatusCode) + { + return JsonNode.Parse(await response.Content.ReadAsStringAsync()); + } + else + { + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + throw new Exception($"GetPipelinePermissionsAsync throw an error [{response.StatusCode}]: {responseContent}"); + } + } + + public async Task UpdatePipelinePermissionsAsync(Guid serviceConnectionId, JsonNode serviceConnectionPermissions, CancellationToken cancellationToken) + { + var url = GetPipelinePermissionsUrlForServiceConnections(serviceConnectionId); + var client = await GetRawHttpClient(cancellationToken); + var request = new HttpRequestMessage(new HttpMethod("PATCH"), url) + { + Content = new StringContent(serviceConnectionPermissions.ToString(), Encoding.UTF8, "application/json") + }; + + var response = await client.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + throw new Exception($"UpdatePipelinePermissionsAsync throw an error [{response.StatusCode}]: {responseContent}"); + } + } + private BuildHttpClient cachedBuildClient; public async Task GetBuildHttpClientAsync(CancellationToken cancellationToken) diff --git a/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Program.cs b/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Program.cs index e46c463ad9a..c0b367da749 100644 --- a/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Program.cs +++ b/tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Program.cs @@ -10,6 +10,8 @@ using Microsoft.Extensions.Logging; using PipelineGenerator.Conventions; using PipelineGenerator.CommandParserOptions; +using Microsoft.TeamFoundation.Build.WebApi; +using System.Text.Json.Nodes; namespace PipelineGenerator { @@ -49,6 +51,7 @@ public static async Task Run(object commandObj, CancellationTokenSource cancella g.Agentpool, g.Convention, g.VariableGroups.ToArray(), + g.ServiceConnections, g.DevOpsPath, g.WhatIf, g.Open, @@ -132,6 +135,7 @@ public async Task RunAsync( string agentPool, string convention, int[] variableGroups, + IEnumerable serviceConnections, string devOpsPath, bool whatIf, bool open, @@ -181,6 +185,7 @@ public async Task RunAsync( return ExitCondition.DuplicateComponentsFound; } + var updatedDefinitions = new List(); foreach (var component in components) { logger.LogInformation("Processing component '{0}' in '{1}'.", component.Name, component.Path); @@ -196,6 +201,45 @@ public async Task RunAsync( { OpenBrowser(definition.GetWebUrl()); } + + updatedDefinitions.Add(definition); + } + } + + var serviceConnectionObjects = await context.GetServiceConnectionsAsync(serviceConnections, cancellationToken); + + foreach (var serviceConnection in serviceConnectionObjects) + { + // Get set of permissions for the service connection + JsonNode pipelinePermissions = await context.GetPipelinePermissionsAsync(serviceConnection.Id, cancellationToken); + + var pipelines = pipelinePermissions["pipelines"].AsArray(); + var pipelineIdsWithPermissions = new HashSet(pipelines.Select(p => p["id"].GetValue())); + + bool needsUpdate = false; + foreach (var definition in updatedDefinitions) + { + // Check this pipeline has permissions + if (!pipelineIdsWithPermissions.Contains(definition.Id)) + { + pipelines.Add( + new JsonObject + { + ["id"] = definition.Id, + ["authorized"] = true, + ["authorizedBy"] = null, + ["authorizedOn"] = null + } + ); + + needsUpdate = true; + } + } + + if (needsUpdate) + { + // Update the permissions if we added anything + await context.UpdatePipelinePermissionsAsync(serviceConnection.Id, pipelinePermissions, cancellationToken); } }