Plugins#
Requirements: EXT-014 (plugin lifecycle), with EXT-013, EXT-018, SEC-020,
AZ-006. Code: internal/plugin (package verification, review,
install/upgrade/rollback, enable/disable), internal/httpapi/plugins.go,
internal/configuration/extensions.go (trusted publisher keys), migration
0071_plugins.sql. Contracts:
api/plugins/package.v1.json and
api/plugins/manifest.v1.json. Tests:
TestPluginLifecycle, TestPluginSingleAdminReview (tests/e2e), internal/plugin,
TestPluginPublisherKeys (internal/configuration).
A plugin is a signed, versioned configuration bundle: a set of pre-action hooks, webhook subscriptions and lifecycle workflows that a vendor or an internal team ships together, which a realm reviews once and installs, upgrades, rolls back, disables or removes as a unit. A plugin contains no code and can configure nothing the administration API cannot (ADR 0021); the logic stays in the hook and webhook endpoints the plugin points to.
Trust: publisher keys#
Only packages signed by a key the operator trusts can be uploaded or installed. The keys are deployment configuration (configuration.md):
extensions:
plugin_publishers:
- key_id: acme-2026
publisher: acme
public_key: "base64 of the raw 32-byte Ed25519 public key"
not_after: "2027-06-30T00:00:00Z" # optional, RFC 3339key_idandpublishermatch^[a-z0-9][a-z0-9.-]{0,62}$; key IDs are unique, and one publisher may have several keys (for rotation).public_keyis standard base64 of exactly 32 bytes;not_afteris an RFC 3339 time. Startup fails on any violation; anot_afteralready in the past is accepted at startup and refused when the key is used.- There is no unsigned or development mode, and no download from URLs or a marketplace: packages are uploaded, and only signatures by listed keys are accepted.
- A plugin name belongs, in each realm, to the publisher of its first upload
(
409for another publisher), permanently: plugin rows and versions are never deleted, even when the only version was rejected. - Removing a key, or passing its
not_after, stops new uploads, installations, rollbacks and enables of packages signed with it (403): enabling a plugin re-checks the signature, the key, contract compatibility and the enabling administrator's permissions like an installation. It does not stop what is already running: an enabled plugin's parts keep running until an administrator disables or uninstalls it. After a key compromise, disable or uninstall the plugins it signed.
Package format#
POST /plugins takes the package
(api/plugins/package.v1.json):
{"manifest": "<base64 of the manifest bytes>",
"payload": "<base64 of the payload bytes>",
"signature": {"key_id": "acme-2026", "algorithm": "Ed25519", "value": "<base64>"}}- Signature: Ed25519 over
"proudauth-plugin-v1\n"followed by the manifest bytes (domain-separated). The manifest binds the payload throughpayload_sha256, the hex SHA-256 of the payload bytes. Both parts travel as base64 of their exact bytes, so JSON tooling on the way cannot break the signature. - Manifest (
manifest_version1, api/plugins/manifest.v1.json):name(^[a-z0-9][a-z0-9-]{1,62}$),version(strictMAJOR.MINOR.PATCH),publisher(must equal the signing key's publisher),description,permissions(1–50 known built-in permissions),requires(contract name → acceptable versions),dependencies(at most 10, each{name, min_version}) andpayload_sha256. - Payload:
{"hooks": [...], "webhooks": [...], "workflows": [{name, description?, definition}]}, at most 20 of each. Hooks and subscriptions use the request bodies ofPOST /hooksandPOST /webhooks. Hooks may not setowner_user_id(the installer owns the parts it creates; parts that already exist keep their owner on upgrades); subscriptions are realm-wide (noorganization_id). The payload has no JSON Schema of its own:package.v1.jsondescribes it in text, and the parts are validated by the API's own rules. A workflow'snameis checked only at installation, so an invalid name passes upload and review. - Compatibility: each
requiresentry must name a contract the server supports with at least one acceptable version, otherwise422withincompatible: true; an unknown contract name means the server is too old. When given, a hook'scontract_versionmust be amongrequires.hook_contract, a subscription'sevent_versionamongrequires.webhook_events, and a workflow'sschema_versionamongrequires.workflow_definition(extension-contracts.md). - Limits: manifest 16 KiB, payload 160 KiB, and the whole request within
limits.max_body_bytes(256 KiB by default, enough for a package at both maximums once base64-encoded). The manifest and payload are decoded strictly: an unknown member (for examplescripts) is a422. Duplicate members inside them are not rejected; an unknown member of the outer request body is a400. - Order of checks: sizes and base64, algorithm (
422); key, expiry and signature (403);manifest_versionand the manifest (422); publisher and payload digest (403); manifest rules, payload and part counts (422). A package that is not signed by a trusted key therefore never reaches the version or content checks. - Digest: the server stores the exact signed bytes and a
package_digest, the SHA-256 of the manifest bytes, a zero byte and the payload bytes. The API shows reviewers the manifest (as stored JSON) and the payload, but not the signature or the raw manifest bytes, so a reviewer cannot re-verify the signature through the API; compare the package file you were given instead.
Lifecycle#
- Upload (
POST /plugins,realm.plugins.manage, recent authentication; a user, not a service principal). The server checks the signature, digest, manifest and compatibility, and validates every part with the API's own validation (hooks, webhook subscriptions, workflow definitions with their references); errors point into the payload (for examplepayload.hooks[0].kind; at installationpayload.hooks.<member>, and workflows by name,payload.workflows[<name>]). The manifest must declare every permission the parts need:realm.hooks.writefor hooks,realm.webhooks.writefor subscriptions, andrealm.workflows.write,realm.workflows.publishand each step's permission for workflows (422lists what is missing). The uploader must hold every declared permission (403 grant_ceiling_exceeded). The version is stored with its exact signed bytes aspending_review, or asapprovedwhen the realm policy isextensions.plugin_review: single_admin(recorded as reviewed by the uploader, with the policy noted in the audit event). The status follows the policy at upload time: a version uploaded undersingle_adminstays approved after the realm returns tosecond_admin. Uploading a version that exists is a409. - Review (
realm.plugins.approve).GET /plugins/{id}/versions/{version}shows the manifest and payload.POST …/versions/{version}/approve(recent authentication) or/reject, each with an optional note of at most 500 characters. Onlypending_reviewversions can be reviewed (409otherwise). The uploader cannot review their own version (403), and the approver must hold every declared permission. - Install, upgrade or downgrade (
POST …/versions/{version}/install,realm.plugins.manage, recent authentication; a user), for approved versions and versions installed before (403while pending review,409for a rejected version). Signature, key validity, compatibility, part validation and the installer's permissions are checked again. Dependencies must be installed, enabled and at leastmin_version(409withmissing_dependency). In one transaction the parts are reconciled by name: existing parts are updated and keep their IDs and signing secrets, so endpoints keep verifying across upgrades and rollbacks; new parts are created and their secrets are returned once insigning_secrets; parts the version no longer contains are removed (workflows that never ran are deleted; workflows that ran stay, disabled and retired, as evidence: see Data). A part whose name collides with a hook, subscription or workflow the plugin does not manage is a409. Workflows are published with the installer as the accountable publisher (ceiling checked) and the reviewer recorded as the revision's approver; a workflow whose definition and publisher did not change keeps its revision. The plugin becomesenabled, the versioninstalled(the one before itsuperseded), and the previous version is kept asprevious_version. Each part gets the state its version declares (a hook the payload declaresdisabledstays disabled); a hook or subscription an administrator switched off with an emergency control stays off until they turn it back on; the parts of a disabled plugin come back with the installation. Installing a version below themin_versionan enabled dependent plugin requires (a downgrade or a rollback) is refused (409, withdependentnaming the plugin). - Rollback (
POST /plugins/{id}/rollback,realm.plugins.manage, recent authentication) reinstallsprevious_versionwithout a new review (409when there is none); trust, compatibility, the installer's permissions and dependencies are checked again.previous_versionthen becomes the version rolled back from, so a second rollback goes forward again. - Disable and enable (
POST /plugins/{id}/disable,/enable,realm.plugins.manage, optional{"reason"}of at most 64 characters, recorded as the audit reason code). Every hook, subscription and workflow of the plugin changes state in one transaction. Disabling is an emergency control: audited in categoryemergency, which raisessecurity.alert. Disabled hooks are not called (a disabled security hook stops restricting anything, as with the emergency control); disabled subscriptions keep their queued deliveries but get no deliveries for events published while they are disabled; disabled workflows neither fire nor advance. Disabling is refused (409) while an enabled plugin depends on this one, and enabling needs the dependencies enabled. A plugin that is not installed is a409; setting the state it already has does nothing and records nothing. The parts' own state changes are not audited one by one; theplugin.enabledorplugin.disabledevent covers them. - Uninstall (
DELETE /plugins/{id},realm.plugins.manage, recent authentication) deletes the plugin's hooks and subscriptions (queued deliveries of those subscriptions are deleted with them); workflows that never ran are deleted, workflows that ran stay, disabled. The plugin becomesinactive, its versions stay stored, andprevious_versionnames the removed version, so a rollback reinstalls it. Refused while enabled plugins depend on it.
States: a plugin is inactive (uploaded but never installed, or
uninstalled), enabled or disabled; a version is pending_review,
approved, rejected, installed or superseded.
Parts never drift from the reviewed package. Hooks, subscriptions and
workflows a plugin manages carry its plugin_id, and ordinary changes to them
answer 409: PUT and DELETE /hooks/{id}, PATCH and DELETE /webhooks/{id} (also a state-only PATCH that pauses a subscription), and
workflow revisions, approval, publication, rollback, enable, disable and
deletion. The emergency controls (POST /emergency/controls, for hooks and
subscriptions) and webhook secret rotation still apply to them; there is no
emergency control for a single workflow, so stop a plugin's workflows by
disabling the plugin. A part switched off by an emergency control stays off
through plugin upgrades, rollbacks and enables until the emergency control
turns it back on.
API#
All paths are under /api/v1/realms/{realm}, with an admin-audience token
from an MFA session. {id} is the plugin's ID or its name.
| Method and path | Permission | Notes |
|---|---|---|
GET /plugins | realm.plugins.read | Installed and uploaded plugins |
POST /plugins | realm.plugins.manage, recent authentication | Upload a version (lifecycle) |
GET /plugins/{id} | realm.plugins.read | State, installed and previous version, parts |
DELETE /plugins/{id} | realm.plugins.manage, recent authentication | Uninstall |
GET /plugins/{id}/versions | realm.plugins.read | Every stored version with its review status |
GET /plugins/{id}/versions/{version} | realm.plugins.read | Manifest and payload for review |
POST /plugins/{id}/versions/{version}/approve | realm.plugins.approve, recent authentication | Not the uploader |
POST /plugins/{id}/versions/{version}/reject | realm.plugins.approve | Not the uploader |
POST /plugins/{id}/versions/{version}/install | realm.plugins.manage, recent authentication | Install, upgrade or downgrade; returns new parts' signing_secrets once |
POST /plugins/{id}/rollback | realm.plugins.manage, recent authentication | Reinstall previous_version |
POST /plugins/{id}/enable, /disable | realm.plugins.manage | Optional reason; disable is an emergency control |
Recent authentication applies to users only (service principals have none).
Upload and installation also refuse service principals (403).
Permissions: realm.plugins.read (normal), realm.plugins.manage
(privileged), realm.plugins.approve (privileged). The built-in
realm-admin role holds them through realm.*; realm-auditor holds
realm.plugins.read.
Realm policy: extensions.plugin_review is second_admin (default: a second
administrator approves every version) or single_admin (an upload is
approved by its uploader; the choice is recorded with every installation).
Audit events: plugin.version.uploaded, plugin.version.approved,
plugin.version.rejected, plugin.installed, plugin.upgraded,
plugin.downgraded, plugin.rolled_back, plugin.enabled,
plugin.disabled, plugin.uninstalled, and the parts' own hook.*,
webhook.* and workflow.* events, which carry plugin_id in their
metadata for plugin parts. Part state changes made by installing, enabling
or disabling a plugin have no per-part event. To find
everything a plugin did, combine GET /audit-events filtered by the
plugin's events with GET /plugins/{id} (its current parts).
Not offered#
- Code in plugins of any kind (ADR 0021).
- Parts other than hooks, webhook subscriptions and workflows (roles, clients, claim mappings, resource types, policies). These create or shape authority and need their own ceiling and review rules before a package may carry them; reconciliation by name would extend to them.
- Unsigned plugins, and fetching plugins from URLs or a marketplace. No extra egress or supply-chain path: uploads only, signed by keys the operator trusts.
- Configuration export or import and realm data export of plugins. Plugin versions are stored with their exact signed bytes; re-upload the package in the target realm.
Data#
- Workflows a new version drops, or that an uninstall leaves, are kept disabled as evidence when they have runs, marked retired: enabling, upgrading or rolling back the plugin does not bring them back unless a version names them again.
- No retention or erasure job covers the plugin tables: uploaded versions, with the user IDs of uploader and reviewer and the review notes, are kept (data-inventory.md).
Residual risks#
- With
extensions.plugin_review: single_admin, a realm gives up the second review by its own choice; the choice is audited with every installation. - The endpoints a plugin's hooks and subscriptions point to are outside the signature: the package fixes their URLs, not what runs there.
- A publisher key that leaks lets its holder sign packages until the operator removes it; what it signed keeps running, and can be enabled again, until an administrator disables or uninstalls it.