Building Custom Modules¶
Section: Reference | Article 61
Audience: Developers, System Integrators
Last Updated: 2026-04-08
Overview¶
RP-PAM supports custom modules that extend its integration capabilities. If you need to manage privileged access to a system that RP-PAM doesn't have a built-in module for, you can build your own.
Custom modules use the same SDK as RP-PAM's built-in modules (Active Directory, SSH, Database, etc.). They run in an isolated process and communicate with RP-PAM via a secure IPC channel.
What a Module Can Do¶
| Capability | Description | Example |
|---|---|---|
| Grant Access | Add a user to a system when access is approved | Add user to an application role |
| Revoke Access | Remove a user when the grant expires or is revoked | Remove user from an application role |
| User Lookup | Check if a user exists in the target system | Verify user account exists |
| Health Check | Verify connectivity to the target system | Test API endpoint reachability |
Module SDK Interface¶
Every module implements the IModuleIntegration interface:
public interface IModuleIntegration
{
// Unique ID matching the module manifest
string ModuleId { get; }
// Called once when the module loads — set up connections, read config
Task InitializeAsync(IModuleSdkContext context, CancellationToken ct);
// Called for each operation (grant, revoke, lookup)
Task<ModuleOperationResult> ExecuteOperationAsync(
ModuleOperationRequest request, CancellationToken ct);
// Called periodically — return health status
Task<ModuleHealthResult> CheckHealthAsync(CancellationToken ct);
// Called on shutdown — clean up connections
Task ShutdownAsync(CancellationToken ct);
}
The SDK provides:
| Service | Interface | What It Does |
|---|---|---|
| Configuration | IModuleConfigReader |
Read your module's config (stored encrypted in RP-PAM) |
| Logging | IModuleLogger |
Write to the module operation log (visible in the portal) |
Complete Example — Inventory Access Module¶
This example builds a module that manages access to a fictional "Acme Inventory" REST API. When access is granted, the module creates an API token for the user. When revoked, the token is deleted.
Note: This example uses a fictional system. Replace the API calls with your real target system's API.
Project Structure¶
MyCompany.RpPam.Module.InventoryAccess/
├── MyCompany.RpPam.Module.InventoryAccess.csproj
├── InventoryAccessModule.cs
├── InventoryConfig.cs
└── manifest.json
manifest.json¶
{
"moduleId": "inventory-access",
"displayName": "Acme Inventory Access",
"version": "1.0.0",
"author": "My Company",
"publisherType": "private",
"capabilities": ["grant_access", "revoke_access", "user_lookup"],
"minCoreVersion": "1.0.0",
"description": "Manages API token access to the Acme Inventory system."
}
InventoryConfig.cs¶
using System.Text.Json;
namespace MyCompany.RpPam.Module.InventoryAccess;
public sealed class InventoryConfig
{
/// <summary>Base URL of the Inventory API.</summary>
public string ApiBaseUrl { get; init; } = "https://inventory.example.com/api";
/// <summary>Admin API key for managing user tokens.</summary>
public string AdminApiKey { get; init; } = "";
/// <summary>Connection timeout in seconds.</summary>
public int TimeoutSeconds { get; init; } = 15;
public static InventoryConfig Parse(string json)
{
try { return JsonSerializer.Deserialize<InventoryConfig>(json) ?? new(); }
catch { return new(); }
}
}
InventoryAccessModule.cs¶
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Ravenphyre.RpPam.Services;
namespace MyCompany.RpPam.Module.InventoryAccess;
public sealed class InventoryAccessModule : IModuleIntegration
{
public string ModuleId => "inventory-access";
private InventoryConfig? _config;
private HttpClient? _http;
private IModuleLogger? _logger;
private bool _initialized;
// ─── Lifecycle ──────────────────────────────────────────────
public async Task InitializeAsync(IModuleSdkContext context, CancellationToken ct)
{
// Read configuration (stored encrypted in RP-PAM's database)
string configJson = await context.Config.GetConfigJsonAsync(ct);
_config = InventoryConfig.Parse(configJson);
_logger = context.Logger;
// Validate required config
if (string.IsNullOrEmpty(_config.AdminApiKey))
{
_logger.Error("Admin API key is not configured.");
throw new InvalidOperationException(
"adminApiKey is required in the module configuration.");
}
// Set up HTTP client for the Inventory API
_http = new HttpClient
{
BaseAddress = new Uri(_config.ApiBaseUrl),
Timeout = TimeSpan.FromSeconds(_config.TimeoutSeconds),
};
_http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", _config.AdminApiKey);
_initialized = true;
_logger.Info($"Inventory module initialized. API: {_config.ApiBaseUrl}");
}
// ─── Operations ─────────────────────────────────────────────
public async Task<ModuleOperationResult> ExecuteOperationAsync(
ModuleOperationRequest request, CancellationToken ct)
{
if (!_initialized || _http is null)
return ModuleOperationResult.Fail("Module not initialized.");
return request.OperationType switch
{
"ModuleOpGrantAccess" => await GrantAccessAsync(request, ct),
"ModuleOpRevokeAccess" => await RevokeAccessAsync(request, ct),
"ModuleOpUserLookup" => await UserLookupAsync(request, ct),
_ => ModuleOperationResult.Fail(
$"Unsupported operation: {request.OperationType}"),
};
}
private async Task<ModuleOperationResult> GrantAccessAsync(
ModuleOperationRequest request, CancellationToken ct)
{
// Create an API token for this user in the Inventory system
var body = new
{
userId = request.UserId,
grantId = request.GrantId,
role = "inventory_read_write",
};
var content = new StringContent(
JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
try
{
var response = await _http!.PostAsync("/tokens", content, ct);
string responseBody = await response.Content.ReadAsStringAsync(ct);
if (response.IsSuccessStatusCode)
{
_logger?.Info(
$"Inventory access granted for user {request.UserId}",
grantId: request.GrantId);
return ModuleOperationResult.Ok(
$"API token created for user {request.UserId}",
responseBody);
}
else
{
return ModuleOperationResult.Fail(
$"API returned {response.StatusCode}: {responseBody}",
retryable: (int)response.StatusCode >= 500);
}
}
catch (HttpRequestException ex)
{
_logger?.Error($"Connection failed: {ex.Message}",
grantId: request.GrantId);
return ModuleOperationResult.Fail(
$"Connection failed: {ex.Message}", retryable: true);
}
}
private async Task<ModuleOperationResult> RevokeAccessAsync(
ModuleOperationRequest request, CancellationToken ct)
{
try
{
// Delete the token by grant ID
var response = await _http!.DeleteAsync(
$"/tokens?grantId={request.GrantId}", ct);
if (response.IsSuccessStatusCode)
{
_logger?.Info(
$"Inventory access revoked for grant {request.GrantId}",
grantId: request.GrantId);
return ModuleOperationResult.Ok(
$"API token revoked for grant {request.GrantId}");
}
else
{
string body = await response.Content.ReadAsStringAsync(ct);
return ModuleOperationResult.Fail(
$"Revoke failed: {response.StatusCode} — {body}");
}
}
catch (HttpRequestException ex)
{
return ModuleOperationResult.Fail(ex.Message, retryable: true);
}
}
private async Task<ModuleOperationResult> UserLookupAsync(
ModuleOperationRequest request, CancellationToken ct)
{
try
{
var response = await _http!.GetAsync(
$"/users/{request.UserId}", ct);
bool exists = response.IsSuccessStatusCode;
return ModuleOperationResult.Ok(
$"User {request.UserId}: {(exists ? "found" : "not found")}",
JsonSerializer.Serialize(new { userId = request.UserId, exists }));
}
catch
{
return ModuleOperationResult.Ok(
$"User lookup failed (connectivity issue)",
JsonSerializer.Serialize(new { userId = request.UserId, exists = false }));
}
}
// ─── Health Check ───────────────────────────────────────────
public async Task<ModuleHealthResult> CheckHealthAsync(CancellationToken ct)
{
if (!_initialized || _http is null)
return ModuleHealthResult.Failed("Not initialized");
try
{
var response = await _http.GetAsync("/health", ct);
return response.IsSuccessStatusCode
? ModuleHealthResult.Healthy(
$"Inventory API reachable at {_config!.ApiBaseUrl}")
: ModuleHealthResult.Degraded(
$"API returned {response.StatusCode}");
}
catch (Exception ex)
{
return ModuleHealthResult.Failed(
$"API unreachable: {ex.Message}");
}
}
// ─── Shutdown ───────────────────────────────────────────────
public Task ShutdownAsync(CancellationToken ct)
{
_http?.Dispose();
_initialized = false;
return Task.CompletedTask;
}
}
Module Configuration¶
When you install the module and configure it in the portal, the configuration is stored encrypted in RP-PAM's database. Example configuration:
{
"apiBaseUrl": "https://inventory.yourcompany.com/api",
"adminApiKey": "inv-admin-key-abc123",
"timeoutSeconds": 15
}
The adminApiKey is encrypted at rest. Your module reads it via context.Config.GetConfigJsonAsync() — the SDK handles decryption automatically.
Building and Packaging¶
Step 1 — Build¶
Step 2 — Create Module Package¶
Package your module as an .rmod file (a ZIP archive with a specific structure):
inventory-access-1.0.0.rmod
├── manifest.json
├── MyCompany.RpPam.Module.InventoryAccess.dll
└── (any additional dependency DLLs)
Step 3 — Submit for Review¶
Custom modules must be reviewed by Ravenphyre before they can be installed. Submit:
- The
.rmodpackage - A description of what the module does
- List of all outbound network connections the module makes
- Your RP-PAM license fingerprint (locks the module to your installation)
Step 4 — Installation¶
After approval, Ravenphyre co-signs the module with a Private Publisher Certificate. Install via the portal:
- Navigate to Modules → Install Module
- Upload the signed
.rmodfile - RP-PAM validates the signature and license fingerprint
- Configure the module (API URL, credentials, etc.)
- The module's setup validation runs (connectivity test, auth check)
- Module is active
Development Best Practices¶
| Practice | Why |
|---|---|
| Always handle exceptions in ExecuteOperationAsync | Unhandled exceptions crash the module process |
Set retryable: true for transient failures |
RP-PAM will retry via the outbox pattern |
| Keep CheckHealthAsync under 1 second | It runs every 30 seconds — slow checks degrade monitoring |
| Use IModuleLogger, not Console.Write | Logger entries appear in the portal's module log viewer |
| Don't store secrets in code | Use the module configuration — RP-PAM encrypts it at rest |
| Declare all network access in the manifest | Undisclosed network access will be rejected during review |
| Test with the SDK test harness | dotnet test with the RP-PAM module test utilities |
Setup Validation¶
Custom modules should implement the same setup validation as built-in modules. When an admin configures your module in the portal, RP-PAM runs:
- Pre-requisite checklist: What does the admin need before configuring? (e.g., "Inventory API must be reachable on port 443")
- Post-config validation: After the admin fills in the config, test connectivity and permissions
The SDK provides IModuleSetupValidator for this. If your module doesn't implement validation, it will be flagged during review.
Next Steps¶
- Module Signing and Lifecycle — Dev signing, dual-signing, submission, and community modules
- Lab Environment Setup — Set up a test lab for module development
- Review the Configuration Reference for module config fields
- Contact
support@ravenphyre.netto begin the review process
RP-PAM v1.0.0 — Copyright 2026 Ravenphyre. All rights reserved.