Skip to content

Commit

Permalink
Add service connection permissions for generated pipelines (#9462)
Browse files Browse the repository at this point in the history
  • Loading branch information
weshaggard authored Dec 4, 2024
1 parent f5579cd commit 1fcabcc
Show file tree
Hide file tree
Showing 4 changed files with 142 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> 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<string> ServiceConnections { get; set; }

[Option("open", Required = false, HelpText = "Open a browser window to the definitions that are created")]
public bool Open { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using Microsoft.TeamFoundation.Build.WebApi;
using Microsoft.VisualStudio.Services.Common;
using System;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down Expand Up @@ -70,14 +73,19 @@ public PipelineGenerationContext(

private VssConnection cachedConnection;

private TokenCredential GetAzureCredentials()
{
return new ChainedTokenCredential(
new AzureCliCredential(),
new AzurePowerShellCredential()
);
}

private async Task<VssConnection> 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();
Expand Down Expand Up @@ -136,7 +144,7 @@ public async Task<ServiceEndpointHttpClient> GetServiceEndpointClientAsync(Cance

private Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi.ServiceEndpoint cachedServiceEndpoint;

public async Task<Microsoft.VisualStudio.Services.ServiceEndpoints.WebApi.ServiceEndpoint> GetServiceEndpointAsync(CancellationToken cancellationToken)
public async Task<ServiceEndpoint> GetServiceEndpointAsync(CancellationToken cancellationToken)
{
if (cachedServiceEndpoint == null)
{
Expand All @@ -161,6 +169,85 @@ public async Task<ServiceEndpointHttpClient> GetServiceEndpointClientAsync(Cance
return cachedServiceEndpoint;
}

public async Task<IEnumerable<ServiceEndpoint>> GetServiceConnectionsAsync(IEnumerable<string> 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<ServiceEndpoint> endpoints = new List<ServiceEndpoint>();
foreach (var serviceConnection in allServiceConnections)
{
if (serviceConnections.Contains(serviceConnection.Name))
{
endpoints.Add(serviceConnection);
}
}
return endpoints;
}

private HttpClient cachedRawHttpClient = null;

private async Task<HttpClient> 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<JsonNode> 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<BuildHttpClient> GetBuildHttpClientAsync(CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -132,6 +135,7 @@ public async Task<ExitCondition> RunAsync(
string agentPool,
string convention,
int[] variableGroups,
IEnumerable<string> serviceConnections,
string devOpsPath,
bool whatIf,
bool open,
Expand Down Expand Up @@ -181,6 +185,7 @@ public async Task<ExitCondition> RunAsync(
return ExitCondition.DuplicateComponentsFound;
}

var updatedDefinitions = new List<BuildDefinition>();
foreach (var component in components)
{
logger.LogInformation("Processing component '{0}' in '{1}'.", component.Name, component.Path);
Expand All @@ -196,6 +201,45 @@ public async Task<ExitCondition> 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<int>(pipelines.Select(p => p["id"].GetValue<int>()));

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);
}
}

Expand Down

0 comments on commit 1fcabcc

Please sign in to comment.