Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add service connection permissions for generated pipelines #9462

Merged
merged 1 commit into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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" });
weshaggard marked this conversation as resolved.
Show resolved Hide resolved
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)
benbp marked this conversation as resolved.
Show resolved Hide resolved
{
// 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
Loading