ps/Modules/Alkami.Ops.Certificates/SecretServer/SecretServerClient.cs

701 lines
29 KiB
C#
Raw Normal View History

2023-05-30 22:51:22 -07:00
using Alkami.Ops.Certificates.SecretServer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
namespace Alkami.Ops.Certificates.SecretServer
{
internal class SecretServerClient : IDisposable
{
public readonly string Site;
private readonly string _username;
private readonly string _password;
private readonly string _apiEndpoint;
private string _accessToken;
private HttpClient _httpClient;
public SecretServerClient(string site, string username, string password)
{
this.Site = site;
this._username = username;
this._password = password;
_apiEndpoint = $"{Site}/api/v1";
// Create httpclient.
System.Net.ServicePointManager.SecurityProtocol = System.Net.SecurityProtocolType.Tls12;
_httpClient = new HttpClient();
// Obtain auth token.
Authenticate().Wait();
}
/// <summary>
/// Authenticates and grabs an oauth token, which is attached to common headers for future usage of the secret server client.
/// </summary>
/// <returns></returns>
private async Task Authenticate()
{
var data = new
{
username = _username,
password = _password,
grant_type = "password"
};
var test = $"username={_username}&password={_password}&grant_type=password";
var body = JsonConvert.SerializeObject(data);
var content = new StringContent(test);
var tokenRoute = $"{Site}/oauth2/token";
var response = await _httpClient.PostAsync(tokenRoute, content);
var jsonResponse = await response.Content.ReadAsStringAsync();
JObject parsedResponse = JObject.Parse(jsonResponse);
_accessToken = parsedResponse["access_token"].ToString();
// Add auth token to the default headers of the http client.
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_accessToken}");
}
/// <summary>
/// Returns a single folder with the name.
/// Throws an exception if there is more than one server with a particular folder name.
/// </summary>
/// <param name="folderName">The name of the folder to search for.</param>
/// <param name="parentID">The parent folder ID, to look for names within a particular folder.</param>
/// <returns>The Folder</returns>
public Folder GetFolderByName(string folderName, int parentID = -1)
{
var folders = GetFoldersByName(folderName, parentID);
// Limit to exact name. Searching for POD1 will also return POD10
folders = folders.Where(folder => string.Equals(folder.FolderName, folderName, StringComparison.OrdinalIgnoreCase)).ToArray();
if (folders == null || folders.Length == 0)
{
return null;
}
else if (folders.Length > 1)
{
throw new Exception($"Found more than one folder with name {folderName}. Investigate.");
}
return folders.First();
}
/// <summary>
/// Returns folders with a given name.
/// Only returns the first page of results (10 entries), because their documentation doesn't describe how pagination works!
/// </summary>
/// <param name="folderName">The name of the folder to search for</param>
/// <returns>The Folder</returns>
public Folder[] GetFoldersByName(string folderName, int parentID = -1)
{
var parameters = $"?filter.includeRestricted=true&filter.searchText={folderName}";
if (parentID >= 0)
{
parameters += $"&filter.parentFolderId={parentID}";
}
var url = $"{_apiEndpoint}/folders{parameters}";
var folders = GetPageEnumerable<Folder>(url);
return folders.ToArray();
}
/// <summary>
/// Returns the folders underneath a parent folder.
/// </summary>
/// <param name="parentID">The folder to pull folders from.</param>
/// <returns></returns>
public Folder[] GetFoldersByParentFolder(int parentID)
{
if (parentID <= 0)
{
return null;
}
var parameters = $"?filter.parentFolderId={parentID}";
var url = $"{_apiEndpoint}/folders/{parameters}";
var folders = GetPageEnumerable<Folder>(url);
return folders.ToArray();
}
/// <summary>
/// Creates a secret server folder given a parent folder.
/// </summary>
/// <param name="parentFolder">The parent folder of the folder to create.</param>
/// <param name="folderName">The name of the folder to create.</param>
/// <param name="inheritPermissions">True if the folder should inherit the permissions of the parent folder.</param>
/// <param name="inheritSecretPolicy">True if the folder should inherit the secret policies of the parent folder.</param>
/// <returns>The Folder</returns>
public async Task<Folder> CreateFolderAsync(Folder parentFolder, string folderName, bool inheritPermissions = true, bool inheritSecretPolicy = true)
{
if (parentFolder == null)
{
return null;
}
return await CreateFolderAsync(parentFolder.ID, folderName, inheritPermissions, inheritSecretPolicy);
}
/// <summary>
/// Creates a secret server folder given a parent folder.
/// </summary>
/// <param name="parentFolderId">The parent folder ID of the folder to create.</param>
/// <param name="folderName">The name of the folder to create.</param>
/// <param name="inheritPermissions">True if the folder should inherit the permissions of the parent folder.</param>
/// <param name="inheritSecretPolicy">True if the folder should inherit the secret policies of the parent folder.</param>
/// <returns>The Folder</returns>
public async Task<Folder> CreateFolderAsync(int parentFolderId, string folderName, bool inheritPermissions = true, bool inheritSecretPolicy = true)
{
var response = await _httpClient.GetAsync($"{_apiEndpoint}/folders/stub");
var jsonResponse = await response.Content.ReadAsStringAsync();
JObject parsedResponse = JObject.Parse(jsonResponse);
parsedResponse["folderName"] = folderName;
parsedResponse["folderTypeId"] = 1;
parsedResponse["inheritPermissions"] = inheritPermissions;
parsedResponse["inheritSecretPolicy"] = inheritSecretPolicy;
parsedResponse["parentFolderId"] = parentFolderId;
var createFolderArgsJson = parsedResponse.ToString();
var createFolderContentBytes = Encoding.UTF8.GetBytes(createFolderArgsJson);
var byteContent = new ByteArrayContent(createFolderContentBytes);
byteContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var createResponse = await _httpClient.PostAsync($"{_apiEndpoint}/folders", byteContent);
var createResponseJson = await response.Content.ReadAsStringAsync();
return GetFolderByName(folderName, parentFolderId);
}
/// <summary>
/// Deletes all secrets inside the given folder. Be careful with this, really!
/// </summary>
/// <param name="folder">The folder to remove secrets in.</param>
/// <returns></returns>
public async Task DeleteSecretsInFolder(Folder folder)
{
var secrets = GetSecretsByFolder(folder, true);
foreach (var secret in secrets)
{
await DeleteSecret(secret.ID);
}
}
/// <summary>
/// Gets a folder from the secret server with a given folder path. Returns null if the folder does not exist.
/// </summary>
/// <param name="folderPath"></param>
/// <returns></returns>
public Folder GetFolder(string folderPath)
{
// Swap slashes just in case and parse apart the folder path.
folderPath = folderPath.Replace("\\", "/");
var folderSplit = folderPath.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
// Find the parent folder.
string parentFolderName = folderSplit[0];
Folder parentFolder = GetFolderByName(parentFolderName);
if (parentFolder == null)
{
throw new Exception($"Could not find parent folder named {parentFolderName}");
}
// Find, or create, the other folders up through the path.
foreach (var subfolder in folderSplit.Skip(1))
{
var folder = GetFolderByName(subfolder, parentFolder.ID);
if (folder == null)
{
return null;
}
parentFolder = folder;
}
return parentFolder;
}
/// <summary>
/// Get a folder from the secret server with a given folder path, or creates the folders if they do not exist.
/// </summary>
/// <param name="folderPath"></param>
/// <returns></returns>
public async Task<Folder> GetOrAddFolderAsync(string folderPath)
{
// Swap slashes just in case and parse apart the folder path.
folderPath = folderPath.Replace("\\", "/");
var folderSplit = folderPath.Split(new char[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
// Find the parent folder.
string parentFolderName = folderSplit[0];
Folder parentFolder = GetFolderByName(parentFolderName);
if (parentFolder == null)
{
throw new Exception($"Could not find parent folder named {parentFolderName}");
}
// Find, or create, the other folders up through the path.
foreach (var subfolder in folderSplit.Skip(1))
{
var folder = GetFolderByName(subfolder, parentFolder.ID);
if (folder == null)
{
folder = await CreateFolderAsync(parentFolder, subfolder);
}
parentFolder = folder;
}
return parentFolder;
}
/// <summary>
/// Returns a specific secret template type by name.
/// Throws an exception if the template name matches more than one template type.
/// </summary>
/// <param name="templateName">Name of the template to search for.</param>
/// <returns>The template with the given name.</returns>
public SecretTemplate GetSecretTemplateByName(string templateName)
{
var templates = GetSecretTemplatesByName(templateName);
// Limit to exact name. The secret API search is open-ended wildcard style.
templates = templates.Where(template => string.Equals(template.Name, template.Name, StringComparison.OrdinalIgnoreCase)).ToArray();
if (templates == null || templates.Length == 0)
{
return null;
}
else if (templates.Length > 1)
{
throw new Exception($"There was more than one template type with the name {templateName}");
}
else
{
return templates.First();
}
}
/// <summary>
/// Returns secret template types given a name.
/// </summary>
/// <param name="templateName">Name of the template.</param>
/// <returns></returns>
public SecretTemplate[] GetSecretTemplatesByName(string templateName)
{
var searchString = $"?filter.searchText={templateName}";
var url = $"{_apiEndpoint}/secret-templates{searchString}";
var secretTemplates = GetPageEnumerable<SecretTemplate>(url);
return secretTemplates.ToArray();
}
/// <summary>
/// Gets a list of all secrets from a given folder.
/// Secrets returned only include top-level information, and does not include fields or extended properties.
/// Use GetSecretByID() to grab all of the information for a particular secret.
/// </summary>
/// <param name="folder">Folder to pull secrets from</param>
/// <param name="includeSubfolders">True if you want the result to include secrets from subfolders.</param>
/// <returns></returns>
public Secret[] GetSecretsByFolder(Folder folder, bool includeSubfolders = false)
{
var filter = $"?filter.folderId={folder.ID}";
if (includeSubfolders)
{
filter += "&filter.includeSubFolders=True";
}
var url = $"{_apiEndpoint}/secrets/{filter}";
var secrets = GetPageEnumerable<Secret>(url);
return secrets.ToArray();
}
/// <summary>
/// Returns a Secret Server style paging enumerable that will continue to load pages as the IEnumerable is enumerated.
/// </summary>
/// <typeparam name="T">The type of the object to deserialize from the request.</typeparam>
/// <param name="url">The query URL</param>
/// <returns></returns>
private IEnumerable<T> GetPageEnumerable<T>(string url)
{
bool hasParameters = url.IndexOf('?') >= 0;
int pageCounter = 0;
int numPages = 0;
int entriesPerPage = 10;
do
{
// Build the skip/take params depending on the page.
int skip = entriesPerPage * pageCounter;
string paramString = hasParameters ? "&" : "?";
paramString += $"Skip={skip}&Take={entriesPerPage}";
string endpoint = url + paramString;
// Unfortunately async enumerables won't exist until C# 8.
// The sync enumerable is preferable to duplicating this code everywhere.
var response = _httpClient.GetAsync(endpoint, HttpCompletionOption.ResponseContentRead).GetAwaiter().GetResult();
var jsonResponse = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
JObject parsedResponse = JObject.Parse(jsonResponse);
int total = int.Parse(parsedResponse["total"].ToString());
if (total <= 0)
{
yield break;
}
// Figure out how many pages, and how many entries per page there are.
entriesPerPage = int.Parse(parsedResponse["take"].ToString());
numPages = int.Parse(parsedResponse["pageCount"].ToString());
int currentPage = int.Parse(parsedResponse["currentPage"].ToString());
var records = parsedResponse["records"];
var count = records.Count();
for (int i = 0; i < count; i++)
{
yield return records[i].ToObject<T>();
}
++pageCounter;
} while (pageCounter < numPages);
}
/// <summary>
/// Returns a secret (with full data) with the given ID.
/// </summary>
/// <param name="ID">ID of the secret.</param>
/// <returns></returns>
public async Task<Secret> GetSecretByID(int ID)
{
var response = await _httpClient.GetAsync($"{_apiEndpoint}/secrets/{ID}");
var responseContent = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<Secret>(responseContent);
return result;
}
/// <summary>
/// Looks up a single secret by name.
/// Throws an exception if there is more than one secret that matches the name.
/// </summary>
/// <param name="name">Name of the secret</param>
/// <param name="folderID">Optional parent folder ID</param>
/// <returns></returns>
public SecretSearch FindSecretByName(string name, int folderID = -1)
{
var secrets = FindSecretsByName(name, folderID)
?.Where(secret => string.Equals(secret?.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase));
int count = secrets.Count();
if (secrets == null || count == 0)
{
return null;
}
else if (count > 1)
{
throw new Exception($"Found more than one secret with name {name}");
}
return secrets.First();
}
/// <summary>
/// Looks up secrets by name.
/// </summary>
/// <param name="name">Name of the secret</param>
/// <param name="folderID">Optional parent folder ID</param>
/// <returns></returns>
public SecretSearch[] FindSecretsByName(string name, int folderID = -1)
{
var filters = $"?filter.includeRestricted=true&filter.searchtext={name}";
if (folderID > 0)
{
filters += $"&filter.folderId={folderID}";
}
var url = $"{_apiEndpoint}/secrets/lookup{filters}";
var secrets = GetPageEnumerable<SecretSearch>(url);
return secrets.ToArray();
}
/// <summary>
/// Returns a shimmed out default-value secret modeled after the provided templatID.
/// </summary>
/// <param name="templateId">Template ID to model the secret after</param>
/// <param name="folderId">Folder to place the secret in.</param>
/// <returns></returns>
public async Task<Secret> CreateSecretStubByTemplateIDAsync(int templateId, string secretName, int folderId)
{
var response = await _httpClient.GetAsync($"{_apiEndpoint}/secrets/stub?filter.secrettemplateid={templateId}&filter.folderId={folderId}");
var responseContent = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<Secret>(responseContent);
result.Name = secretName;
result.SiteID = 1;
return result;
}
/// <summary>
/// Creates a secret, given a secret object.
/// It is highly suggested that you create secrets with CreateSecretStubByTemplateID in order to construct a valid Secret object based on a template type.
/// </summary>
/// <param name="secret">The secret to create.</param>
/// <returns></returns>
public async Task<Secret> CreateSecret(Secret secret)
{
// Do some basic validation to make sure this isn't going to do something undefined.
if (string.IsNullOrWhiteSpace(secret.Name))
{
throw new Exception("Must define a secret name when creating new secrets.");
}
else if (secret.FolderID <= 0)
{
throw new Exception("Must specify a parent folder to place new secrets in.");
}
var json = JsonConvert.SerializeObject(secret);
var createFolderContentBytes = Encoding.UTF8.GetBytes(json);
var byteContent = new ByteArrayContent(createFolderContentBytes);
byteContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var createResponse = await _httpClient.PostAsync($"{_apiEndpoint}/secrets/", byteContent);
var responseContent = await createResponse.Content.ReadAsStringAsync();
if (!createResponse.IsSuccessStatusCode)
{
throw new Exception(responseContent);
}
var result = JsonConvert.DeserializeObject<Secret>(responseContent);
return result;
}
/// <summary>
/// Updates a secret.
/// </summary>
/// <param name="secret"></param>
/// <returns></returns>
public async Task<Secret> UpdateSecret(Secret secret)
{
// Do some basic validation to make sure this isn't going to do something undefined.
if (string.IsNullOrWhiteSpace(secret.Name))
{
throw new Exception("Must specify a valid secret name when updating a secret.");
}
else if (secret.FolderID <= 0)
{
throw new Exception("Secret must have a valid parent folder ID.");
}
else if (secret.ID <= 0)
{
throw new Exception("Can only update secrets with a valid secret ID");
}
var json = JsonConvert.SerializeObject(secret);
var createFolderContentBytes = Encoding.UTF8.GetBytes(json);
var byteContent = new ByteArrayContent(createFolderContentBytes);
byteContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var createResponse = await _httpClient.PutAsync($"{_apiEndpoint}/secrets/{secret.ID}", byteContent);
var responseContent = await createResponse.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<Secret>(responseContent);
return result;
}
/// <summary>
/// Uploads a file to a secret with the given field name.
/// </summary>
/// <param name="secret">The secret to upload the file to.</param>
/// <param name="field">The field name of the file on the secret</param>
/// <param name="filename">The name of the file.</param>
/// <param name="bytes">The byte data of the file.</param>
/// <returns></returns>
public async Task<bool> UploadFile(Secret secret, string fieldName, string filename, byte[] bytes)
{
var data = new
{
fileName = filename,
fileAttachment = bytes
};
// Secret Server doesn't want the field ID, or a url encoded field name where spaces turn to %20's,
// It arbitrarily wants field name spaces replaced with dashes.
// Cheers to three hours I won't get back.
fieldName = fieldName.Replace(" ", "-");
var json = JsonConvert.SerializeObject(data);
var createFolderContentBytes = Encoding.UTF8.GetBytes(json);
var byteContent = new ByteArrayContent(createFolderContentBytes);
byteContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
var createResponse = await _httpClient.PutAsync($"{_apiEndpoint}/secrets/{secret.ID}/fields/{fieldName}", byteContent);
return createResponse.IsSuccessStatusCode;
}
/// <summary>
/// Uploads a file to a secret.
/// Throws an exception if there is more than one field type on the secret that is a file.
/// </summary>
/// <param name="secret">The secret to upload the file to.</param>
/// <param name="bytes">The bytes of the file.</param>
/// <returns></returns>
public async Task<bool> UploadFile(Secret secret, string filename, byte[] bytes)
{
var fileFields = secret.Items.Where(item => item.IsFile);
if (fileFields.Count() > 1)
{
throw new Exception("There is more than one field on this secret that is a file. Please specify the field name.");
}
var field = fileFields.First();
return await UploadFile(secret, field.FieldName, filename, bytes);
}
/// <summary>
/// Uploads a file to a secret given a file path.
/// Throws an exception if there is more than one field type on the secret that is a file.
/// </summary>
/// <param name="secret">The secret to upload the file to.</param>
/// <param name="bytes">The bytes of the file.</param>
/// <returns></returns>
public async Task<bool> UploadFile(Secret secret, string filepath)
{
//Secret secret, string filename, byte[] bytes
if (!File.Exists(filepath))
{
throw new FileNotFoundException($"Could not locate file {filepath}");
}
var filename = Path.GetFileName(filepath);
var bytes = File.ReadAllBytes(filepath);
return await UploadFile(secret, filename, bytes);
}
/// <summary>
/// Downloads a file from a secret with the given field name.
/// </summary>
/// <param name="secret">The secret to upload the file to.</param>
/// <param name="field">The field name of the file on the secret</param>
/// <param name="filename">The name of the file.</param>
/// <param name="bytes">The byte data of the file.</param>
/// <returns></returns>
public async Task<byte[]> DownloadFile(Secret secret, string fieldName)
{
fieldName = fieldName.Replace(" ", "-");
var response = await _httpClient.GetAsync($"{_apiEndpoint}/secrets/{secret.ID}/fields/{fieldName}");
if (!response.IsSuccessStatusCode)
{
return null;
}
var responseContent = await response.Content.ReadAsByteArrayAsync();
return responseContent;
}
/// <summary>
/// Downloads a file from a secret.
/// Throws an exception if there is more than one field type on the secret that is a file.
/// </summary>
/// <param name="secret">The secret to upload the file to.</param>
/// <param name="bytes">The bytes of the file.</param>
/// <returns></returns>
public async Task<byte[]> DownloadFile(Secret secret)
{
var fileFields = secret?.Items.Where(item => item.IsFile);
if (fileFields == null)
{
return null;
}
else if (fileFields.Count() > 1)
{
throw new Exception("There is more than one field on this secret that is a file. Please specify the field name.");
}
var field = fileFields.First();
return await DownloadFile(secret, field.FieldName);
}
/// <summary>
/// Downloads a file from a Secret to a given file path.
/// Throws an exception if there is more than one field type on the secret that is a file.
/// </summary>
/// <param name="filepath">The local file location where the file will be stored.</param>
/// <param name="secret">The secret to download the file from.</param>
/// <returns>true if a file was successfully downloaded.</returns>
public async Task<bool> DownloadFile(string filepath, Secret secret)
{
var bytes = await DownloadFile(secret);
if (bytes == null)
{
return false;
}
File.WriteAllBytes(filepath, bytes);
return true;
}
/// <summary>
/// Deletes a secret.
/// </summary>
/// <param name="secret"></param>
/// <returns></returns>
public async Task<bool> DeleteSecret(Secret secret)
{
return await DeleteSecret(secret.ID);
}
/// <summary>
/// Deletes a secret.
/// </summary>
/// <param name="secretId"></param>
/// <returns></returns>
public async Task<bool> DeleteSecret(int secretId)
{
var response = await _httpClient.DeleteAsync($"{_apiEndpoint}/secrets/{secretId}");
return response.IsSuccessStatusCode;
}
/// <summary>
/// Compares the hash provided to the hash on the specified secret in Secret Server
/// </summary>
/// <param name="secretId">Secret to compare against</param>
/// <param name="md5hash">Local hash to use for comparison</param>
/// <returns>Returns true if the hashes *DO NOT* match as this indicates a changed secret.</returns>
public async Task<bool> DetectChanges(int secretId, string md5Hash)
{
var remoteHashResult = await _httpClient.GetAsync($"{_apiEndpoint}/secrets/{secretId}/fields/file-hash");
if (remoteHashResult.IsSuccessStatusCode)
{
var remoteHash = remoteHashResult.Content.ReadAsStringAsync().Result;
// Hashes come back wrapped in quotes. Rip them out.
remoteHash = remoteHash.Trim('"');
var comparisonResult = string.Compare(md5Hash, remoteHash, true);
if (0 == comparisonResult)
{
return false;
}
else
{
return true;
}
}
else
{
throw new HttpRequestException("Exception getting file hash from Secret Server: " + remoteHashResult.RequestMessage);
}
}
/// <summary>
/// Disposes the secret server client.
/// </summary>
public void Dispose()
{
_httpClient?.Dispose();
}
}
}