Skip to content

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

dotnet publish -c Release -o ./publish

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:

  1. The .rmod package
  2. A description of what the module does
  3. List of all outbound network connections the module makes
  4. 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:

  1. Navigate to ModulesInstall Module
  2. Upload the signed .rmod file
  3. RP-PAM validates the signature and license fingerprint
  4. Configure the module (API URL, credentials, etc.)
  5. The module's setup validation runs (connectivity test, auth check)
  6. 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:

  1. Pre-requisite checklist: What does the admin need before configuring? (e.g., "Inventory API must be reachable on port 443")
  2. 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


RP-PAM v1.0.0 — Copyright 2026 Ravenphyre. All rights reserved.