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:
- Category-specific traits — there is one trait per extension point
(
AuthProvider,NotificationChannel,StorageBackend, …) so plugins never see weakly-typedserde_json::Valuepayloads. - ABI stability via
stabby— every trait is rewritten by#[plugin_trait]into anextern "C", stabby-annotated companion trait so plugin and host can be compiled with different toolchains and still link safely. - TOML manifests +
build.rsdiscovery — every plugin ships aplugin.tomlfile. The host's build script reads a workspace-levelvaultaris.plugins.tomlindex 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:
- defining the trait in
vaultaris-plugin-sdk; - adding a variant to
PluginCategory; - 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.
| Category | Trait | What plugins implement |
|---|---|---|
auth_provider | AuthProvider | Custom credential checks, MFA, hardware tokens |
identity_provider | IdentityProvider | LDAP, SAML, OIDC, AD, social federation |
storage_backend | StorageBackend | HashiCorp Vault, AWS KMS, on-prem KV |
notification_channel | NotificationChannel | Email, SMS, push, chat, voice |
encryption_provider | EncryptionProvider | KMS / HSM-backed crypto |
policy_engine | PolicyEngine | Rego, OPA, custom ABAC |
audit_sink | AuditSink | SIEM forwarding, S3, syslog |
jwt_customizer | JwtCustomizer | Add/transform claims on issued tokens |
rate_limiter | RateLimiter | Token bucket, leaky bucket, vendor APIs |
metrics_collector | MetricsCollector | Datadog, OTel, Prometheus push |
input_validator | InputValidator | Domain rules, password strength, denylists |
lifecycle_hook | LifecycleHook | Cross-cutting observers (legacy hook style) |
webhook_handler | WebhookHandler | Inbound 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_versionmatchesvaultaris_plugin_sdk::ABI_VERSION;- the declared categories match the categories actually shipped by
vaultaris_plugin_start(); - the
idfollows 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:
| Legacy | Replacement |
|---|---|
impl VaultarisPlugin | One 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 field | plugin.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:
- Listing.
state.plugins.for_tenant(t).<cat>().registered_*()is sync inside the loop body and is safe to.awaitfrom any handler. Use it for admin endpoints that enumerate plugins. - Fan-out via a task. Move the dispatch into a
tokio::spawnso theSend-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_slugis the manifest'sslugwhen set, otherwise the full reverse-DNSid(/webhook/dev.vaultaris.notifications.twilio-sms/...also works, just less pretty).tenant_idis the tenant UUID for tenant-scoped activations or the literal stringglobalfor plugins activated underTenantScope::Global.restis matched against[[webhook]].pathas a literal prefix.path = "/sms-status"accepts both/sms-statusand/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.