Context:
The Endpoint Credential Manager (ECM) Software Development Kit for BeyondTrust Privileged Remote Access (PRA) and Remote Support (RS) allows developers to leverage a Credential Provider other than the one included (Vault) to allow Users to inject credentials when for accessing a Jump Client, a Web Jump, etc. This guide covers a specific example: Delinea Secret Server.
Important: There is already an official Delinea Secret Server ECM Plugin, and this tutorial’s objective is not to position this Plugin example as a replacement. The official Secret Server Plugin is based on the Secret Server SOAP API and Impersonation. This tutorial is about creating a simple ECM Plugin based on the Secret Server REST API, for which Impersonation is not available.
This guide also shows how to create a Custom Source to manage the configuration and credentials needed for the Plugin to authenticate to PRA and RS on one side, and to Secret Server on the other side.

Note: Secure Remote Access (SRA) is used to refer to both PRA and RS.
Disclaimer
The Endpoint Credential Manager (ECM) Software Development Kit
Allows developers to create Custom ECM Plugins. The SDK comes with a Plugin example, which has been used as a starting point to create a new Plugin.
Any sample or proof of concept code (“Code”) provided on the Community is provided “as is” and without any express or implied warranties. This means that we do not promise that it will work for your specific needs or that it is error-free. Such Code is community supported and not supported directly by BeyondTrust, and it is not intended to be used in a production environment. BeyondTrust and its contributors are not liable for any damage you or others might experience from using the Code, including but not limited to, loss of data, loss of profits, or any interruptions to your business, no matter what the cause is, even if advised of the possibility of such damage.
Capabilities
- List secrets from Delinea Secrets Server
- Filter secrets list based on user permissions (direct and indirect)
- Inject the selected secret into a Jump session
- Support for SRA Local and LDAP/AD Service Providers
- Optional: Use Environment Variables for the Configuration for a deployment via Jenkins or another CI/CD automation solution.
Configure Delinea Secret Server
We need to create Folders and Secrets. The types tested are:
- Windows Account – SRA hostname must match Machine
- Unix Account (SSH) – SRA hostname must match Machine
- Web Password - SRA hostname must be included in Secret Server URL
We need to assign Folder Permissions to test Users (Local or AD) either directly or via Groups.
We need to create an API Service Account for the ECM Plugin with the following Role Permissions:
- View Groups
- View Secret
- View Users
The API Service Account needs access (View) for the Folders.
We need to enable Remote Password Changing.
Obtain and deploy the ECM Runtime and ECM SDK Example Plugin
The documentation for the ECM SDK is available here:
https://www.beyondtrust.com/docs/privileged-remote-access/how-to/integrations/ecm-sdk/index.htm



Here is a quick list of packages that need to be added:
- Json.NET (Newtonsoft.Json);
- Microsoft.Bcl.AsyncInterfaces;
- Microsoft.Bcl.Cryptography;
- System.Formats.Asn1;
- System.ComponentModel.Composition;
- Microsoft.Extensions.Hosting;
- Microsoft.Extensions.Hosting.Abstractions;
- Microsoft.Extensions.Hosting.WindowsServices;
- Microsoft.Extensions.Logging;
- System.Configuration.ConfigurationManager;
- System.DirectoryServices;
- System.Runtime.Caching;
- System.Security.Cryptography.Pkcs;
- System.Security.Cryptography.ProtectedData;
- System.ServiceModel.Primitives;
- System.ServiceProcess.ServiceController .

namespace MyCompany.Integrations.ExamplePlugin
{
/// <summary>
/// Represents a simple POCO that defines the config values necessary for the plugin to operate
/// These are simply examples of the types of items which might be required for your implementation
/// </summary>
public sealed record ExamplePluginConfig
{
public required string ApiBaseUrl { get; init; }
public required string username { get; init; }
public required string password { get; init; }
public bool EnableSpecialCase { get; init; } = false;
}
}

{ {
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ECMConfig": {
"SRASiteHostname": "",
"SRAClientId": "",
"SRAClientSecret": ""
},
"ExamplePluginConfig": {
"ApiBaseUrl": "https://test.mycompany.com/path/to/api",
"username": "MyClientId-12345-asdfasdf",
"password": "super-secret-client-secret",
"EnableSpecialCase": true
}
}

Note: While it is still possible to leverage appsettings.json for config values, the option to use Environment Variables is covered in this guide, with the use of a CI/CD Solution like Jenkins in mind, to deploy the plugin as a Container, e.g. using for example a Microsoft provided Docker image.
See for example: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/net-core-net-framework-containers/official-net-docker-images

//***** Custom Source - Environment Variables - BEGIN
ExamplePluginConfig ExamplePluginConfig = new ExamplePluginConfig
{
ApiBaseUrl = Environment.GetEnvironmentVariable("ApiBaseUrl"),
username = Environment.GetEnvironmentVariable("username"),
password = Environment.GetEnvironmentVariable("password")
};
builder.Services.Configure<ExamplePluginConfig>(builder.Configuration);
//***** Custom Source - Environment Variables - END

//***** Custom Source - Environment Variables - BEGIN
ECMConfig ECMConfig = new ECMConfig {
SRASiteHostname = Environment.GetEnvironmentVariable("SRASiteHostName"),
SRAClientId = Environment.GetEnvironmentVariable("SRAClientId"),
SRAClientSecret = Environment.GetEnvironmentVariable("SRAClientSecret")
};
builder.Services.Configure<ECMConfig>(builder.Configuration);
//***** Custom Source - Environment Variables – END

// Custom Code - START
private static async Task<string> GetAuthToken(string ApiUrl, string USERNAME, string SECRET)
{
try
{
var httpClient = new HttpClient
{
DefaultRequestHeaders = {
{ "User-Agent", "BeyondTrust Secure Remote Access" }
}
};
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{"grant_type", "password"} ,
{"username", USERNAME} ,
{"password", SECRET}
}
);
HttpResponseMessage message = httpClient.PostAsync(ApiUrl + "/oauth2/token", content).Result;
string result = await message.Content.ReadAsStringAsync();
if (message.IsSuccessStatusCode)
{
return result;
}
else
{
throw new Exception("Failed to Authenticate " + result);
}
}
catch (Exception ex)
{
Console.Write("####### Error in method = " + ex.ToString());
throw new Exception("Failed to Authenticate " + ex.ToString());
}
}
private static async Task<string> GetUser(string ApiUrl, string BEARER_TOKEN, string USERNAME)
{
try
{
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", BEARER_TOKEN);
HttpResponseMessage message = httpClient.GetAsync(ApiUrl + "/api/v1/users?filter.searchField=name&filter.searchText=" + USERNAME).Result;
string result = await message.Content.ReadAsStringAsync();
if (message.IsSuccessStatusCode)
{
return result;
}
else
{
throw new Exception("Failed to Find User with USERNAME " + result);
}
}
catch (Exception ex)
{
Console.Write("####### Error in method = " + ex.ToString());
throw new Exception("Failed to Find User with USERNAME " + ex.ToString());
}
}
private static async Task<string> GetUserGroups(string ApiUrl, string BEARER_TOKEN, string UserId)
{
try
{
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", BEARER_TOKEN);
HttpResponseMessage message = httpClient.GetAsync(ApiUrl + "/api/v1/users/" + UserId + "/Groups").Result;
string result = await message.Content.ReadAsStringAsync();
if (message.IsSuccessStatusCode)
{
return result;
}
else
{
throw new Exception("Failed to Get User Groups " + result);
}
}
catch (Exception ex)
{
Console.Write("####### Error in method = " + ex.ToString());
throw new Exception("Failed to Get User Groups " + ex.ToString());
}
}
private static async Task<string> GetFolderPermissions(string ApiUrl, string BEARER_TOKEN, string GroupId, string entity)
{
try
{
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", BEARER_TOKEN);
HttpResponseMessage message = httpClient.GetAsync(ApiUrl + "/api/v1/folder-permissions?filter." + entity + "Id=" + GroupId).Result;
string result = await message.Content.ReadAsStringAsync();
if (message.IsSuccessStatusCode)
{
return result;
}
else
{
throw new Exception("Failed to Get Folder Permissions " + result);
}
}
catch (Exception ex)
{
Console.Write("####### Error in method = " + ex.ToString());
throw new Exception("Failed to Get Folder Permissions " + ex.ToString());
}
}
private static async Task<string> GetFolderSecrets(string ApiUrl, string BEARER_TOKEN, string FolderId, string Machine, string searchField)
{
try
{
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", BEARER_TOKEN);
HttpResponseMessage message = httpClient.GetAsync(ApiUrl + "/api/v1/secrets?filter.searchField=" + searchField + "&filter.searchText=" + Machine + "&filter.folderId=" + FolderId).Result;
string result = await message.Content.ReadAsStringAsync();
if (message.IsSuccessStatusCode)
{
return result;
}
else
{
throw new Exception("Failed to Get Folder Secrets " + result);
}
}
catch (Exception ex)
{
Console.Write("####### Error in method = " + ex.ToString());
throw new Exception("Failed to Get Folder Secrets " + ex.ToString());
}
}
private static async Task<string> GetSecret(string ApiUrl, string BEARER_TOKEN, string SecretId)
{
try
{
var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", BEARER_TOKEN);
HttpResponseMessage message = httpClient.GetAsync(ApiUrl + "/api/v1/secrets/" + SecretId).Result;
string result = await message.Content.ReadAsStringAsync();
if (message.IsSuccessStatusCode)
{
return result;
}
else
{
throw new Exception("Failed to Get Secret " + result);
}
}
catch (Exception ex)
{
Console.Write("####### Error in method = " + ex.ToString());
throw new Exception("Failed to Get Secret " + ex.ToString());
}
}
// Custom Code - END

private readonly ExamplePluginConfig examplePluginConfig;
public ExamplePlugin(IOptions<ExamplePluginConfig> config)
{
// Possible tasks to handle when the service is instantiated:
// - validate configuration
// - initialize any sort of API client or other types that will be required for servicing requests
// - pre-load any information that may be required for servicing requests (files, API / product version info, etc.)
examplePluginConfig = config.Value;
}

public async Task<ActionResult<IList<CredentialSummary>>> FindCredentialsForSessionAsync(SRASession session)
{
// At this point the user has chosen a Jump Item in the console and attempted to initiate a session to the target system.
// The plugin should retrieve a list of credentials that are either queried or filtered based on the supplied
// information about the user and system to which the user is connecting.
// NOTE: While the operation is async, the SRA site will only wait for a certain period of time (20 seconds by default)
// for the list to be returned before it proceeds without presenting any creds for selection.
try
{
// GET Secrets List
// Authenticate - GET Bearer Token
var token = await GetAuthToken(examplePluginConfig.ApiBaseUrl, examplePluginConfig.username, examplePluginConfig.password);
dynamic jsonString_token = JsonConvert.DeserializeObject(token);
string access_token = jsonString_token.access_token;
Console.Write("We got access token \n");
// Get User
var user = await GetUser(examplePluginConfig.ApiBaseUrl, access_token, session.User.Value.Username);
Console.Write("We got User for Username = " + session.User.Value.Username + "\n");
dynamic jsonString_user = JsonConvert.DeserializeObject(user);
string userId = "";
foreach (var i in jsonString_user.records)
{
Console.Write("####### Discovered User = " + i.displayName + " id = " + i.id + " userName = " + i.userName + "\n");
userId = i.id;
}
// Get User Groups
var userGroups = await GetUserGroups(examplePluginConfig.ApiBaseUrl, access_token, userId);
Console.Write("We got Groups for Username = " + session.User.Value.Username + "\n");
dynamic jsonString_userGroups = JsonConvert.DeserializeObject(userGroups);
List<CredentialSummary> credentials = new List<CredentialSummary>();
foreach (var i in jsonString_userGroups.records)
{
if (i.groupName != "Everyone") {
Console.Write("####### Discovered User Group = " + i.groupName + " id = " + i.groupId + " groupDomainName = " + i.groupDomainName + "\n");
// Get Folders
string groupId = i.groupId;
//var folders = await GetFolderPermissions(examplePluginConfig.ApiBaseUrl, access_token, groupId, "group");
var folders = await GetFolderPermissions(examplePluginConfig.ApiBaseUrl, access_token, userId, "user");
Console.Write("We got Folders for Group = " + i.groupName + "\n");
dynamic jsonString_folders = JsonConvert.DeserializeObject(folders);
Console.Write("####### About to GET Secrets for Machine = " + session.JumpItem.Value.ComputerName + "\n");
Console.Write("####### About to GET Secrets for JumpItem = " + session.JumpItem.Value + "\n");
foreach (var j in jsonString_folders.records)
{
Console.Write("####### Discovered Folder folderId = " + j.folderId + "\n");
// Get Secrets
string folderId = j.folderId;
// GET Secrets for searchField = Machine
var secrets = await GetFolderSecrets(examplePluginConfig.ApiBaseUrl, access_token, folderId, session.JumpItem.Value.ComputerName, "Machine");
Console.Write("We got Secrets for searchField = Machine and for Folder = " + j.folderId + "\n");
dynamic jsonString_secrets = JsonConvert.DeserializeObject(secrets);
foreach (var k in jsonString_secrets.records)
{
Console.Write("####### Discovered Secret for Machine = " + k.name + " id = " + k.id + " secretTemplateName = " + k.secretTemplateName + "\n");
string credId = k.id;
CredentialSummary credential = new CredentialSummary { CredentialId = credId, DisplayValue = "Secret Server : " + k.name };
credentials.Add(credential);
}
// GET Secrets for searchField = Machine
var secrets2 = await GetFolderSecrets(examplePluginConfig.ApiBaseUrl, access_token, folderId, session.JumpItem.Value.ComputerName, "URL");
Console.Write("We got Secrets for searchField = URL and for Folder = " + j.folderId + "\n");
dynamic jsonString_secrets2 = JsonConvert.DeserializeObject(secrets2);
foreach (var k in jsonString_secrets2.records)
{
Console.Write("####### Discovered Secret for URL = " + k.name + " id = " + k.id + " secretTemplateName = " + k.secretTemplateName + "\n");
string credId = k.id;
CredentialSummary credential = new CredentialSummary { CredentialId = credId, DisplayValue = "Secret Server : " + k.name };
credentials.Add(credential);
}
}
}
}
// Then put that all in an ActionResult object to be returned
return new ActionResult<IList<CredentialSummary>> { ResultValue = credentials, IsSuccess = true };
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get list of Credentials");
return new ActionResult<IList<CredentialSummary>> { ResultValue = null, FailureReason = ex.Message, IsSuccess = false };
}
// COMMENTED - throw new NotImplementedException($"The {Name} definition must implement {nameof(FindCredentialsForSessionAsync)} as defined by {nameof(ICredentialActions)}");
}

public async Task<ActionResult<CredentialPackage>> GetCredentialForInjectionAsync(SRASession session, string credentialId)
{
// The user has been presented with a list of credentials (supplied by FindCredentialsForSessionAsync above) and has
// selected one from the list. The plugin should use the information supplied about the session as well as the ID of
// the selected credential to attempt to retrieve / check-out that credential and return it for injection into the session.
// NOTE: As above, it is important to note that while the operation is async, the same timeout applies.
try
{
// Authenticate - GET Bearer Token
var token = await GetAuthToken(examplePluginConfig.ApiBaseUrl, examplePluginConfig.username, examplePluginConfig.password);
dynamic jsonString_token = JsonConvert.DeserializeObject(token);
string access_token = jsonString_token.access_token;
Console.Write("We got access token \n");
// Get Secret
Console.Write("####### Get secret id = " + credentialId + "\n");
var secret = await GetSecret(examplePluginConfig.ApiBaseUrl, access_token, credentialId);
dynamic jsonString_secret = JsonConvert.DeserializeObject(secret);
string secretValue = "";
string userName = "";
foreach (var i in jsonString_secret.items)
{
if (i.slug == "username")
{
Console.Write("####### Secret Username = " + i.itemValue + "\n");
userName = i.itemValue;
}
if (i.fieldName == "Password") {
Console.Write("####### Secret Password found ! " + "\n");
secretValue = i.itemValue;
}
}
var result = CredentialPackage.BuildUsernamePasswordPackage(credentialId, userName, secretValue);
return new ActionResult<CredentialPackage> { ResultValue = result, IsSuccess = true };
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to inject Credentials");
return new ActionResult<CredentialPackage> { ResultValue = null, FailureReason = ex.Message, IsSuccess = false };
}
// COMMENTED - throw new NotImplementedException($"The {Name} definition must implement {nameof(GetCredentialForInjectionAsync)} as defined by {nameof(ICredentialActions)}");
}

public async Task<ActionResult> ReleaseCredentialFromSessionAsync(SRASession session, string credentialId, long sessionEndedTimestamp)
{
// And at this point the session has ended and the SRA site is notifying the ECM / plugin in case any clean-up or check-in actions
// need to be performed in order to release the credential so other users can access it.
// NOTE: Any responses that return an ActionResult indicating failure will result in the SRA site retrying the request until it is met
// with a successful response or the request eventually times out of the queue. If it is unlikely that a given error scenario will
// ever result in success, then it is best to return a successful response here but adequately log anything that went wrong.
return new ActionResult { IsSuccess = true };
//throw new NotImplementedException($"The {Name} definition must implement {nameof(ReleaseCredentialFromSessionAsync)} as defined by {nameof(ICredentialActions)}");
}

Build and Debug


Next Steps
Once we can successfully Build and test the Plugin App via Debug in Visual Studio, the App and all its dependencies can be deployed. It is not recommended to use appsettings.json to store sensitive configuration information including credentials, so another config Source should be used. Several examples are available on the web, and covering them is out of scope for this guide. However, this guide does include an example using Environment Variables, which can make sense for when the Plugin App is containerized and a CI/CD solution like Jenkins is used to deploy the container to Docker or Kubernetes.
We can build a Docker image leveraging the Microsoft image described here: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/net-core-net-framework-containers/official-net-docker-images



<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-ExamplePlugin-a620ada8-83c6-4c7a-8df7-edda1c5e47af</UserSecretsId>
<RootNamespace>MyCompany.Integrations.ExamplePlugin</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Bcl.Cryptography" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.ComponentModel.Composition" Version="9.0.1" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="9.0.1" />
<PackageReference Include="System.DirectoryServices" Version="9.0.1" />
<PackageReference Include="System.Formats.Asn1" Version="9.0.1" />
<PackageReference Include="System.Runtime.Caching" Version="9.0.1" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.1" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.1" />
<PackageReference Include="System.ServiceModel.Primitives" Version="8.1.1" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="9.0.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="lib\" />
</ItemGroup>
<ItemGroup>
<Reference Include="BeyondTrustECMSDK">
<HintPath>lib/BeyondTrustECMSDK.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Reference Include="BeyondTrustECMService">
<HintPath>lib/BeyondTrustECMService.dll</HintPath>
</Reference>
</ItemGroup>
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
<ItemGroup>
<DataFiles Include="$(ProjectDir)..\lib\*.*"/>
</ItemGroup>
<Copy
SourceFiles="@(DataFiles)"
DestinationFolder="$(TargetDir)\lib\"
SkipUnchangedFiles="true"/>
</Target>
</Project>

FROM mcr.microsoft.com/dotnet/sdk:8.0@sha256:35792ea4ad1db051981f62b313f1be3b46b1f45cadbaa3c288cd0d3056eefb83 AS build-env
WORKDIR /SRA_Delinea
# Copy everything
COPY . ./
COPY /lib/*.dll ./lib/
COPY S"/lib/BeyondTrustECMSDK.dll", "/lib"]
COPY Y"/lib/BeyondTrustECMService.dll", "/lib"]
# Restore as distinct layers
RUN dotnet restore
# Build and publish a release
RUN dotnet publish -c Release -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:6c4df091e4e531bb93bdbfe7e7f0998e7ced344f54426b7e874116a3dc3233ff
ENV ApiBaseUrl="https://mySubdomain.com/SecretServer"
ENV username="12345"
ENV password="asdfgh"
ENV SRASiteHostname="myInstance.beyondtrustcloud.com"
ENV SRAClientId="12345"
ENV SRAClientSecret="abcde"
WORKDIR /SRA_Delinea
COPY --from=build-env /SRA_Delinea/out .
ENTRYPOINT b"dotnet", "ExamplePlugin.dll"]
Now we can build our Docker image:
docker build -t sra-ecm-delinea -f Dockerfile .
We need to create an env file for our configuration:
ApiBaseUrl=http://mySecretServer/SecretServer
username=svc_sra_rest_api
password=P@ssw0rd123!!
SRASiteHostname=myInstance.beyondtrustcloud.com
SRAClientId=12345
SRAClientSecret=asdfgh



