Skip to content

Emitting Audit Events from Plugins

Plugins can emit audit events during command handler execution. These events flow through the same audit.Logger the ABAC engine uses, gaining WAL durability, mode routing, and operator-visible metrics — without any extra ops configuration.

Use this when your handler makes an authorization-relevant decision that the ABAC policy engine didn’t catch. Examples: membership / ban / mute checks in a channels plugin, ownership checks in a building plugin, rate-limit denials in a communication plugin.

ScenarioEmit an event?
Your handler denies a command because of handler-side stateYesaudit.deny
Your handler allows a command that involved a non-trivial state checkYes, optionallyaudit.allow if the decision is operator-interesting
ABAC policy engine denied the command before your handler ranNo — the engine already emitted an event
Your handler returned an internal error (DB down, invalid state)No — that’s a system failure, not an authorization decision

Call pluginsdk.Audit(ctx) from your HandleCommand method. The returned recorder accumulates hints on the request context; the SDK serializes them into the response when your handler returns.

import (
"context"
pluginsdk "github.com/holomush/holomush/pkg/plugin"
)
func (h *handler) HandleCommand(ctx context.Context, req pluginsdk.CommandRequest) (*pluginsdk.CommandResponse, error) {
isMember, err := h.store.IsMember(channelID, req.PlayerID)
if err != nil {
return pluginsdk.Failuref("channel store unavailable"), nil
}
if !isMember {
pluginsdk.Audit(ctx).Deny(
"not_member",
"player not in channel members",
pluginsdk.AuditAttrs{"channel.type": "public"},
)
return pluginsdk.Errorf("You must join #%s before speaking there.", channelName), nil
}
// ... happy path ...
}
MethodUse
Audit(ctx).Deny(id, message, attrs)Record a deny decision
Audit(ctx).Allow(id, message, attrs)Record an allow decision
FieldWhat to put here
idPlugin-internal rule slug. Short, kebab-or-snake-case, stable. Example: "not_member", "banned", "archived"
messagePer-firing description. Should mirror what the user sees in the error, or add operator-only context. Example: "player X not in channel Y members"
attrsFree-form context. Keys should be namespaced ("channel.type" not "type") to avoid collision with host-merged keys

Call audit.deny(...) or audit.allow(...) in your Lua handler. The audit global is available in any plugin that declares holomush.plugin.v1.AuditService in its manifest requires: list.

plugin.yaml
requires:
- holomush.plugin.v1.AuditService
-- main.lua
function handle_command(cmd)
if not is_member(cmd.channel_id, cmd.player_id) then
audit.deny(
"not_member",
"player not in channel members",
{["channel.type"] = "public"}
)
return {
status = "error",
output = "You must join #" .. cmd.channel_name .. " before speaking there."
}
end
-- ... happy path ...
end
audit.deny(id: string, message: string, attrs: table?)
audit.allow(id: string, message: string, attrs: table?)

The third argument is optional; omit it if no extra context is needed.

You do not set — and cannot set — the following fields. The dispatcher stamps them on every hint for anti-spoofing:

FieldSource
SubjectDispatcher’s dispatch context (the character running your command)
ActionCommand name (dispatcher-known) + your optional qualifier
SourceAlways plugin for hints you emit
ComponentYour plugin’s name (from the authenticated plugin identity)
TimestampHost clock at flush time

Audit write failures never fail your command. The dispatcher logs the failure, bumps a Prometheus counter (abac_audit_plugin_failures_total), and returns your command response to the user unchanged. Your handler does not need to check for audit errors.