Skip to content

Commit

Permalink
Merge pull request #174 from NoxOrg/Feature/NOX-764-Project-security
Browse files Browse the repository at this point in the history
Feature/nox 764 project security
  • Loading branch information
jan-schutte authored Feb 12, 2024
2 parents 46329f0 + 7ed29dd commit 643fb8e
Show file tree
Hide file tree
Showing 26 changed files with 557 additions and 67 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.TeamFoundation.Build.WebApi;
using Microsoft.VisualStudio.Services.WebApi;
using Nox.Cli.Abstractions;
using Nox.Cli.Abstractions.Exceptions;
using Nox.Cli.Abstractions.Extensions;
using Nox.Cli.Abstractions.Helpers;
using Nox.Cli.Plugin.AzDevOps.Clients;
using Nox.Cli.Plugin.AzDevOps.DTO;
using RestSharp;
using RestSharp.Authenticators;

namespace Nox.Cli.Plugin.AzDevOps;

Expand Down Expand Up @@ -90,38 +87,8 @@ public async Task<IDictionary<string, object>> ProcessAsync(INoxWorkflowContext
{
try
{
var client = new RestClient(_server);

var request = new RestRequest($"/{_projectId}/_apis/pipelines/pipelinePermissions/queue/{_queueId}")
{
Method = Method.Patch
};
var base64Token = Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($":{_pat}"));

request.AddHeader("Authorization", $"Basic {base64Token}");
request.AddHeader("Content-Type", "application/json");
request.AddHeader("Accept", "application/json;api-version=5.1-preview.1");
var payload = new AuthorizeRequest()
{
Resource = new Resource
{
Type = "queue",
Id = _queueId.ToString()
},
AllPipelines = new PipelineAuthorizeAll
{
Authorized = true
}
};
request.AddJsonBody(JsonSerializer.Serialize(payload, JsonOptions.Instance));
var response = await client.ExecuteAsync<AuthorizeResponse>(request);
if (response.IsSuccessStatusCode)
{
ctx.SetState(ActionState.Success);
return outputs;
}

throw new NoxCliException(response.Content!);
var client = new PipelineClient(_server, _pat);
await client.AuthorizeAgentQueuePipelines(_projectId.Value, _queueId!.Value);
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Nox.Cli.Abstractions.Exceptions;
using Nox.Cli.Abstractions.Extensions;
using Nox.Cli.Abstractions.Helpers;
using Nox.Cli.Plugin.AzDevOps.Clients;
using Nox.Cli.Plugin.AzDevOps.DTO;
using RestSharp;

Expand Down Expand Up @@ -97,37 +98,8 @@ public async Task<IDictionary<string, object>> ProcessAsync(INoxWorkflowContext
{
try
{
var client = new RestClient(_server);

var request = new RestRequest($"/{_projectId}/_apis/pipelines/pipelinePermissions/endpoint/{_endpointId}")
{
Method = Method.Patch
};
var base64Token = Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($":{_pat}"));

request.AddHeader("Authorization", $"Basic {base64Token}");
request.AddHeader("Content-Type", "application/json");
request.AddHeader("Accept", "application/json;api-version=5.1-preview.1");
var payload = new AuthorizeRequest()
{
Pipelines = new List<PipelineAuthorize>
{
new PipelineAuthorize
{
Id = _pipelineId!.Value,
Authorized = true
}
}
};
request.AddJsonBody(JsonSerializer.Serialize(payload, JsonOptions.Instance));
var response = await client.ExecuteAsync<AuthorizeResponse>(request);
if (response.IsSuccessStatusCode)
{
ctx.SetState(ActionState.Success);
return outputs;
}

throw new NoxCliException(response.Content!);
var client = new PipelineClient(_server, _pat);
await client.AuthorizeEndpointPipeline(_projectId.Value, _endpointId.Value, _pipelineId!.Value);
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using Nox.Cli.Abstractions;
using Nox.Cli.Abstractions.Extensions;
using Nox.Cli.Plugin.AzDevOps.Clients;
using Nox.Cli.Plugin.AzDevOps.Enums;

namespace Nox.Cli.Plugin.AzDevOps;

public class AzDevopsAddProjectAadGroup_v1 : INoxCliAddin
{
public NoxActionMetaData Discover()
{
return new NoxActionMetaData
{
Name = "azdevops/add-project-aad-group@v1",
Author = "Jan Schutte",
Description = "Add an AAD group to a DevOps project group",

Inputs =
{
["server"] = new NoxActionInput {
Id = "server",
Description = "The DevOps server hostname or IP",
Default = "localhost",
IsRequired = true
},

["personal-access-token"] = new NoxActionInput {
Id = "personal-access-token",
Description = "The personal access token to connect to DevOps with",
Default = string.Empty,
IsRequired = true
},
["project-id"] = new NoxActionInput
{
Id = "project-id",
Description = "The DevOps project Id",
Default = Guid.Empty,
IsRequired = true
},
["project-group-name"] = new NoxActionInput
{
Id = "project-group-name",
Description = "The DevOps project group to add the AAD group to",
Default = string.Empty,
IsRequired = true
},
["aad-group-name"] = new NoxActionInput
{
Id = "aad-group-name",
Description = "The AAD group to add",
Default = Guid.Empty,
IsRequired = true
}
}
};
}

private string? _server;
private string? _pat;
private Guid? _projectId;
private string? _projectGroupName;
private string? _aadGroupName;
private bool _isServerContext = false;

public Task BeginAsync(IDictionary<string, object> inputs)
{
_server = inputs.Value<string>("server");
_pat = inputs.Value<string>("personal-access-token");
_projectId = inputs.Value<Guid>("project-id");
_aadGroupName = inputs.Value<string>("project-group-name");
_aadGroupName = inputs.Value<string>("aad-group-name");
return Task.CompletedTask;
}

public async Task<IDictionary<string, object>> ProcessAsync(INoxWorkflowContext ctx)
{
_isServerContext = ctx.IsServer;
var outputs = new Dictionary<string, object>();

ctx.SetState(ActionState.Error);

if (string.IsNullOrWhiteSpace(_server) ||
string.IsNullOrWhiteSpace(_pat) ||
_projectId == null ||
_projectId == Guid.Empty ||
string.IsNullOrEmpty(_projectGroupName) ||
string.IsNullOrEmpty(_aadGroupName))
{
ctx.SetErrorMessage("The devops add-project-aad-group action was not initialized");
}
else
{
try
{
var result = await AddAdmins(ctx);
if (result)
{
ctx.SetState(ActionState.Success);
}
}
catch (Exception ex)
{
ctx.SetErrorMessage(ex.Message);
}
}

return outputs;
}

public Task EndAsync()
{
return Task.CompletedTask;
}

private async Task<bool> AddAdmins(INoxWorkflowContext ctx)
{
var identityPickerClient = new IdentityPickerClient(_server!, _pat!);
var graphClient = new GraphClient(_server!, _pat!);

var aadGroups = await identityPickerClient.FindIdentity(_aadGroupName!, IdentityType.Group);
if (aadGroups == null)
{
ctx.SetErrorMessage($"Unable to locate the AAD group: {_aadGroupName}");
return false;
}

var aadGroup = aadGroups.First();


var projectDescriptor = await graphClient.GetDescriptor(_projectId.ToString()!);
if (!_projectGroupName!.StartsWith('\\')) _projectGroupName = '\\' + _projectGroupName;
var projectGroup = await graphClient.FindProjectGroup(projectDescriptor!, _projectGroupName);
if (projectGroup == null)
{
ctx.SetErrorMessage($"Unable to locate the project administrator group for this DevOps project");
return false;
}

var aadGroupDescriptor = await graphClient.GetDescriptor(projectGroup.Descriptor!, aadGroup.OriginId!);

if (string.IsNullOrEmpty(aadGroupDescriptor))
{
ctx.SetErrorMessage($"Unable to retrieve AAD group descriptor.");
return false;
}

return await graphClient.AddGroupMembership(projectGroup.Descriptor!, aadGroupDescriptor!);


}


}
118 changes: 118 additions & 0 deletions src/Nox.Cli.Plugins/Nox.Cli.Plugin.AzDevOps/Clients/GraphClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Text.Json;
using Microsoft.VisualStudio.Services.Graph.Client;
using Nox.Cli.Abstractions.Helpers;
using Nox.Cli.Plugin.AzDevOps.DTO;
using Nox.Cli.Plugin.AzDevOps.Exceptions;
using Nox.Cli.Plugin.AzDevOps.Helpers;
using RestSharp;

namespace Nox.Cli.Plugin.AzDevOps.Clients;

public class GraphClient
{
private readonly RestClient _client;
private readonly string _pat;


public GraphClient(string serverUri, string pat)
{
var builder = new UriBuilder(new Uri(serverUri));
builder.Host = $"vssps.{builder.Host}";
serverUri = builder.Uri.ToString();

_client = new RestClient(serverUri);
_pat = pat;
}

public async Task<string?> GetDescriptor(string storageKey)
{
var request = new RestRequest($"/_apis/graph/descriptors/{storageKey}")
{
Method = Method.Get
};

AddHeaders(request);
var response = await _client.ExecuteAsync<DescriptorResponse>(request);
if (!response.IsSuccessStatusCode)
{
throw new DevOpsClientException($"Unable to find Descriptor for storage key: {storageKey}");
}

return response.Data!.Value!;
}

public async Task<string?> GetDescriptor(string projectGroupDescriptor, string originId)
{
var request = new RestRequest($"/_apis/graph/groups")
{
Method = Method.Post
};
request.AddQueryParameter("groupDescriptors", projectGroupDescriptor);
var payload = new AadDescriptorRequest
{
OriginId = originId
};
request.AddJsonBody(JsonSerializer.Serialize(payload, JsonOptions.Instance));
AddHeaders(request);
var response = await _client.ExecuteAsync<AadDescriptorResponse>(request);
if (!response.IsSuccessStatusCode)
{
throw new DevOpsClientException($"Unable to find Descriptor for OriginId: {originId}, StorageKey:");
}

return response.Data!.Descriptor!;
}

public async Task<GraphGroupResult?> FindProjectGroup(string projectDescriptor, string query)
{
var request = new RestRequest($"/_apis/graph/groups")
{
Method = Method.Get
};
request.AddQueryParameter("scopeDescriptor", projectDescriptor);
AddHeaders(request);
var response = await _client.ExecuteAsync<GraphGroupPagedResponse>(request);
if (!response.IsSuccessStatusCode)
{
throw new DevOpsClientException($"Unable to find group: {query} ({response.ErrorMessage})");
}

foreach (var group in response.Data!.Value!)
{
if (group.PrincipalName!.Contains(query, StringComparison.OrdinalIgnoreCase))
{
return group;
}
}

return null;
}

public async Task<bool> AddGroupMembership(string projectGroupDescriptor, string memberDescriptor)
{
var request = new RestRequest($"/_apis/Graph/Memberships/{memberDescriptor}/{projectGroupDescriptor}")
{
Method = Method.Put
};
AddHeaders(request);
var response = await _client.ExecuteAsync<MembershipResponse>(request);
if (!response.IsSuccessStatusCode)
{
throw new DevOpsClientException($"An error occurred while trying to add project group membership ({response.ErrorMessage})");
}

if (response.Data!.ContainerDescriptor == projectGroupDescriptor && response.Data.MemberDescriptor == memberDescriptor)
{
return true;
}

return false;
}

private void AddHeaders(RestRequest request)
{
request.AddHeader("Authorization", $"Basic {_pat.ToEncoded()}");
request.AddHeader("Content-Type", "application/json");
request.AddHeader("Accept", "application/json;api-version=7.1-preview.1");
}
}
Loading

0 comments on commit 643fb8e

Please sign in to comment.