vaultaris /docs

Plugin Development

Extend Vaultaris with type-safe native Rust plugins.

Vaultaris plugins are native Rust dynamic libraries that extend the platform without forking it. The plugin system is built on three pillars:

  1. Category-specific traits — there is one trait per extension point (AuthProvider, NotificationChannel, StorageBackend, …) so plugins never see weakly-typed serde_json::Value payloads.
  2. ABI stability via stabby — every trait is rewritten by #[plugin_trait] into an extern "C", stabby-annotated companion trait so plugin and host can be compiled with different toolchains and still link safely.
  3. TOML manifests + build.rs discovery — every plugin ships a plugin.toml file. The host's build script reads a workspace-level vaultaris.plugins.toml index that points to each plugin crate; the file is validated and embedded into the host binary so the loader never has to scan a directory.

Quick start

# Cargo.toml
[lib]
crate-type = ["cdylib"]

[dependencies]
vaultaris-plugin-sdk = "0.2"
// src/lib.rs
use vaultaris_plugin_sdk::prelude::*;

#[derive(Default)]
pub struct TwilioSmsChannel;

impl NotificationChannel for TwilioSmsChannel {
    fn channel_id(&self) -> stabby::string::String { "twilio-sms".into() }

    fn supports(&self, kind: NotificationKind) -> bool {
        matches!(kind, NotificationKind::Sms)
    }

    fn send(
        &self,
        _ctx: RequestContext,
        notification: Notification,
    ) -> impl core::future::Future<
        Output = stabby::abi::Result<stabby::string::String, PluginError>,
    > + Send + Sync {
        async move {
            // call Twilio…
            stabby::abi::Result::Ok(notification.recipient.clone())
        }
    }
}

vaultaris_plugin_sdk::plugin_export! {
    NotificationChannel => [TwilioSmsChannel],
}

plugin_export! reads plugin.toml from the crate root by default — pass manifest = "alt.toml"; only when shipping multiple manifests. The category name on the left of => doubles as the PluginCategory enum variant and the trait the right-hand types implement, so the older PluginCategory { Trait: Trait => [...] } form is no longer required.

For typed per-tenant configuration, declare the config type once and let the macro thread an Arc<Config> into every implementation:

use std::sync::Arc;
use serde::Deserialize;

#[derive(Clone, Default, Deserialize)]
pub struct TwilioConfig {
    #[serde(default)] pub account_sid: String,
    #[serde(default)] pub auth_token:  String,
    #[serde(default)] pub from_number: String,
}

#[derive(Clone)]
pub struct TwilioSmsChannel { cfg: Arc<TwilioConfig> }

impl FromPluginConfig<TwilioConfig> for TwilioSmsChannel {
    fn from_config(cfg: Arc<TwilioConfig>) -> Self { Self { cfg } }
}

vaultaris_plugin_sdk::plugin_export! {
    config = TwilioConfig;
    NotificationChannel => [TwilioSmsChannel],
}

Two tenants → two vaultaris_plugin_start calls → two independent Arc<TwilioConfig> chains, so a coding mistake in the plugin can never leak credentials across tenants. Plugins that need to build the registry imperatively — custom runtime setup, dynamic categories, shared connection pools — use the escape hatch lifecycle = MyLifecycle;, where MyLifecycle: PluginLifecycle owns the Config associated type and the start body.

# plugin.toml
id            = "dev.vaultaris.notifications.twilio-sms"
version       = "1.0.0"
display_name  = "Twilio SMS"
abi_version   = 3

[[categories]]
kind     = "notification_channel"
priority = 100

Plugin categories

Every category is one trait. Adding a new category to Vaultaris means:

  1. defining the trait in vaultaris-plugin-sdk;
  2. adding a variant to PluginCategory;
  3. dropping a dispatcher file in src/infrastructure/plugins/;

…and nothing else. The host's cross-category enum, registry types and loader pick up the new category automatically.

CategoryTraitWhat plugins implement
auth_providerAuthProviderCustom credential checks, MFA, hardware tokens
identity_providerIdentityProviderLDAP, SAML, OIDC, AD, social federation
storage_backendStorageBackendHashiCorp Vault, AWS KMS, on-prem KV
notification_channelNotificationChannelEmail, SMS, push, chat, voice
encryption_providerEncryptionProviderKMS / HSM-backed crypto
policy_enginePolicyEngineRego, OPA, custom ABAC
audit_sinkAuditSinkSIEM forwarding, S3, syslog
jwt_customizerJwtCustomizerAdd/transform claims on issued tokens
rate_limiterRateLimiterToken bucket, leaky bucket, vendor APIs
metrics_collectorMetricsCollectorDatadog, OTel, Prometheus push
input_validatorInputValidatorDomain rules, password strength, denylists
lifecycle_hookLifecycleHookCross-cutting observers (legacy hook style)
webhook_handlerWebhookHandlerInbound HTTP requests from providers (Twilio, Stripe, GitHub, …)

Manifest format

plugin.toml ships next to the dynamic library and is the contract between plugin and host. It uses the schema in vaultaris_plugin_sdk::manifest:

id            = "dev.example.audit.s3"
version       = "0.3.1"
display_name  = "S3 Audit Sink"
description   = "Mirror Vaultaris audit events to an S3 bucket."
author        = "Example Inc."
homepage      = "https://example.com"
license       = "Apache-2.0"
min_vaultaris_version = "0.3.0"
abi_version   = 3
permissions   = ["network.outbound:s3.amazonaws.com"]

[[categories]]
kind      = "audit_sink"
instances = 1
priority  = 50

[[config]]
name        = "bucket"
label       = "S3 bucket"
type        = "string"
required    = true
description = "Destination bucket. Must exist."

The host validates that:

  • abi_version matches vaultaris_plugin_sdk::ABI_VERSION;
  • the declared categories match the categories actually shipped by vaultaris_plugin_start();
  • the id follows reverse-DNS notation.

Build-script discovery

Operators that ship Vaultaris with a fixed set of plugins can let cargo build resolve every plugin manifest at compile time. Drop a vaultaris.plugins.toml next to the host's Cargo.toml:

library_dir = "target/release"

[[plugin]]
id           = "dev.vaultaris.notifications.twilio-sms"
path         = "examples/plugins/twilio-sms-channel"
autoload     = true

Then in build.rs:

use std::path::PathBuf;
use vaultaris_plugin_sdk::build::generate_plugin_index;

fn main() {
    let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("plugin_index.rs");
    generate_plugin_index("vaultaris.plugins.toml", out).unwrap();
}

The generated plugin_index.rs exports a PLUGIN_INDEX: &[PluginIndexEntry] slice that the host iterates at startup to load every flagged plugin.

Tenant isolation

Plugins are activated per tenant. The platform operator places the binary on disk once, but each tenant must explicitly opt-in (via the admin API) before the host registers a dispatch slot for them. The PluginHostRegistry filters every snapshot by the caller's TenantScope; a tenant therefore cannot observe — let alone invoke — a plugin slot installed by a peer tenant.

TenantScope::Global is reserved for plugins the operator approves platform-wide (encryption providers, default audit sinks, …). Global slots are visible to every tenant alongside their own.

Process-wide library cache

The dynamic library on disk is cached in PluginLibraryCache, keyed by the canonical binary path. When tenant A activates a plugin the file is dlopen-ed and the resulting Arc<libloading::Library> is stashed in the cache. When tenant B activates the same plugin we reuse the existing Arc — only the boxed trait objects minted by each tenant's own call to vaultaris_plugin_start(config) are new. Operators thus pay the filesystem-read and dlopen cost once, regardless of how many tenants opt in.

Uninstalling for a single tenant only drops that tenant's slots. The cached Arc<Library> survives until every tenant referencing the binary has uninstalled, at which point the dylib is dlclose-ed automatically.

Installing at runtime

curl -X POST http://localhost:8080/api/v1/admin/plugins \
    -H 'Authorization: Bearer ADMIN_TOKEN' \
    -H 'Content-Type: application/json' \
    -d @plugin-install.json

The handler accepts a PluginInstallManifest and copies the binary to the configured plugin directory. The next call to POST /api/v1/admin/plugins/<id>/activate runs the loader and inserts the plugin into the runtime registry.

Migrating from the legacy SDK

The pre-0.2 SDK is gone. The migration boils down to:

LegacyReplacement
impl VaultarisPluginOne trait per category (AuthProvider, NotificationChannel, …)
vaultaris_plugin!(MyPlugin)vaultaris_plugin_sdk::plugin_export! { … }
HookContext (JSON)RequestContext + category-specific structs
HookResult (JSON)stabby::abi::Result<T, PluginError>
Manual entry_point fieldplugin.toml next to the dylib

Concrete examples live in examples/plugins/.

Async dispatch — author requirements

Every async trait method in the SDK is desugared by #[plugin_trait] into an extern "C" fn(&self, ...) -> DynFuture<'static, T>. The blanket impl that bridges T: YourTrait to <YourTrait>Extern requires T: Clone + 'static: the macro clones self into the future so the boxed trait object never has to track a non-'static lifetime — exactly what stabby's Dyn wrapper expects.

In practice plugin authors derive Clone on a thin wrapper struct and park heavy state behind Arc<Inner> so the clone is essentially a refcount bump:

#[derive(Clone)]
pub struct TwilioSmsChannel {
    inner: std::sync::Arc<TwilioInner>,
}

struct TwilioInner {
    http: reqwest::Client,
    account_sid: String,
    auth_token: String,
}

Calling dispatchers from axum handlers

axum's Handler trait requires the handler future to be Send. Borrowing &<Trait>Stable across an .await inside a handler currently trips that bound — the borrow holds a reference into a stabby Dyn whose vtable does not propagate auto traits. Two patterns work today:

  1. Listing. state.plugins.for_tenant(t).<cat>().registered_*() is sync inside the loop body and is safe to .await from any handler. Use it for admin endpoints that enumerate plugins.
  2. Fan-out via a task. Move the dispatch into a tokio::spawn so the Send-sensitive borrow lives entirely inside its own future chain:
    let plugins = state.plugins.clone();
    let ctx = ContextBuilder::for_tenant(tenant).build();
    tokio::spawn(async move {
        plugins
            .for_tenant(tenant)
            .audit()
            .record(ctx, event)
            .await;
    });
    

A direct in-handler call (state.plugins.for_tenant(t).hooks().emit(...).await) will compile once the upstream stabby issue tracking auto-trait propagation through Dyn lands. The dispatchers and the registry themselves are already async-correct.

Priority and ordering

Slots are stable-sorted ascending on priority at install time, so iteration is deterministic and lower-priority plugins run first. Manifests set the priority per category in plugin.toml, so a plugin that contributes both an audit_sink and an input_validator can run early as a validator and late as an audit sink:

[[categories]]
kind     = "input_validator"
priority = 10            # runs before community validators

[[categories]]
kind     = "audit_sink"
priority = 200           # ships its data after every other sink

Two slots that share the same priority keep their install order — that order is the order tenants opt-in, so operators can rely on it.

Per-tenant configuration

Operators store every plugin's settings inside the tenant's advanced settings document under the conventional plugins.<plugin_id> key:

{
  "branding": { /* … */ },
  "plugins": {
    "dev.vaultaris.notifications.twilio-sms": {
      "account_sid": "AC...",
      "auth_token":  "secret",
      "from_number": "+15551234567"
    }
  }
}

The host carves the subtree out before crossing the FFI boundary and hands it to the plugin's start hook as PluginStartConfig.config_json. Plugins never see anything outside their own subtree, so two plugins on the same tenant cannot read each other's secrets.

The control-plane API serves the merged schema of every active plugin at:

GET /api/v1/tenants/{tenant_id}/plugins/active-schemas

so the admin UI can autocomplete plugins.<plugin_id>.<field> keys without baking plugin-specific knowledge into the frontend. When the operator saves a new advanced-settings document the backend invokes stop + start on every active plugin for the tenant, so rotated secrets take effect without a server restart.

Inbound webhooks

Plugins that need to receive HTTP from external providers (Twilio's delivery callbacks, Stripe events, GitHub deployment hooks, …) implement WebhookHandler and declare [[webhook]] entries in their manifest. The host owns the HTTP server and routes inbound requests by URL — plugins never bind a port.

# plugin.toml
slug = "twilio"          # optional; URL-safe; falls back to `id`

[[categories]]
kind = "webhook_handler"

[[webhook]]
route_id        = "sms-status"
path            = "/sms-status"
methods         = ["POST"]
description     = "Twilio MessageStatus delivery callback"
max_body_bytes  = 65536      # default 1 MiB
timeout_seconds = 5          # default 10

The host mounts every [[webhook]] entry at:

/webhook/{plugin_slug}/{tenant_id}/{rest...}
  • plugin_slug is the manifest's slug when set, otherwise the full reverse-DNS id (/webhook/dev.vaultaris.notifications.twilio-sms/... also works, just less pretty).
  • tenant_id is the tenant UUID for tenant-scoped activations or the literal string global for plugins activated under TenantScope::Global.
  • rest is matched against [[webhook]].path as a literal prefix. path = "/sms-status" accepts both /sms-status and /sms-status/anything.
use vaultaris_plugin_sdk::prelude::*;

#[derive(Clone)]
pub struct TwilioSmsStatusWebhook { cfg: Arc<TwilioConfig> }

impl FromPluginConfig<TwilioConfig> for TwilioSmsStatusWebhook {
    fn from_config(cfg: Arc<TwilioConfig>) -> Self { Self { cfg } }
}

impl WebhookHandler for TwilioSmsStatusWebhook {
    fn route_id(&self) -> stabby::string::String { "sms-status".into() }

    async fn handle(&self, ctx: WebhookContext, req: WebhookRequest)
        -> PluginResult<WebhookResponse>
    {
        // 1. Verify the provider signature (Twilio: X-Twilio-Signature
        //    HMAC-SHA1 of `full_url + sorted_form_body` with
        //    cfg.auth_token as the key).
        // 2. Parse the form-encoded body for MessageSid, MessageStatus,
        //    From, To, ErrorCode.
        // 3. Persist whatever you need through Vaultaris's storage
        //    abstractions.
        stabby::abi::Result::Ok(WebhookResponse::no_content())
    }
}

plugin_export! {
    config = TwilioConfig;
    NotificationChannel => [TwilioSmsChannel],
    WebhookHandler      => [TwilioSmsStatusWebhook],
}

Why webhooks live outside /api/v1

Webhook providers don't speak our Bearer token; they speak HMAC over a shared secret stored in the plugin's tenant config. The webhook router therefore sits at the outer router level (under /webhook/...) and bypasses the Bearer middleware. Every plugin authenticates the inbound request inside handle() by checking the provider's signature header against the request body, and returns WebhookResponse::unauthorized("...") on mismatch.

Rate limiting, the read-only guard and the request-id middleware still wrap webhook traffic — those layers sit one level above the auth one, so webhooks share the platform's traffic-shaping with the rest of the API.

Body size and timeouts

Each route declares its own max_body_bytes and timeout_seconds. The router rejects oversized bodies with 413 before crossing the FFI boundary, and abandons the in-flight future with 504 when the plugin's handle future does not resolve within the timeout. Keep both knobs as low as your provider needs — Twilio's status callbacks are a few hundred bytes and respond in milliseconds; aiming for the 1 MiB / 10 s defaults wastes resources.