MAQPNADocs

Approval and alert notifications

Send approval requests, decisions, revocations and budget alerts to a signed webhook, Slack, Microsoft Teams or Matrix, and let approvers approve or deny from chat.

When a rule holds a tool call for approval, someone has to notice. The MAQPNA gateway can tell them: it publishes governance events to notifiers, which deliver them to a signed webhook, Slack, Microsoft Teams or Matrix. In Slack and Teams, approvers can approve or deny from the message itself. Delivery is at least once, from an on-disk outbox that survives restarts.

Email is not a built-in channel. To send email, point a webhook notifier at a small relay that turns the signed JSON event into a message.

sequenceDiagram
    participant A as Agent
    participant G as Gateway
    participant O as Outbox
    participant N as Slack / Teams / webhook
    participant P as Approver
    A->>G: tools/call delete_resource
    G->>G: rule says require_approval
    G->>O: approval.created
    O->>N: deliver (retry with backoff)
    N->>P: message with Approve / Deny
    P->>N: clicks Approve
    N->>G: signed callback /v1/approvals/{id}/callback/{notifier}
    G->>G: map chat user to approver, check roles
    G-->>A: call runs, result returned
    G->>O: approval.decided

When to use it#

  • Approvers should not have to watch the console.
  • You want a chat trail of decisions next to the audit ledger.
  • Revocations and budget alerts should reach the on-call channel.

Events#

Event When
approval.created A call is held for approval
approval.partial One of several required approvals was recorded (four-eyes)
approval.decided The approval was approved or denied
approval.expired Nobody decided in time
revocation.created A kill-switch revocation was created
budget.threshold Spend crossed an alertAtPercent threshold below 100 %
budget.exceeded A budget limit was reached
audit.forward_refused A collector refused an edge gateway's ledger segment

A notifier gets every event unless you list some in events.

Channels#

type Destination Secret settings Approve from chat
webhook Any HTTPS endpoint; JSON event body url, hmacSecretFile (signs the body) No (call the admin API)
slack Slack incoming webhook; Block Kit message with Approve, Deny and Open buttons webhookURLFile, signingSecretFile (Slack app signing secret, for callbacks) Yes
teams Teams incoming webhook or workflow; Adaptive Card with Action.Execute approve and deny webhookURLFile, plus callbackOIDC (Bot Framework token) or hmacSecretFile (signed relay) for callbacks Yes, through a bot or relay
matrix A Matrix room (PUT /_matrix/client/v3/rooms/{room}/send/...) homeserver, roomId, accessTokenFile No

Incoming-webhook URLs embed a credential, so Slack and Teams read them from a file. The gateway never logs a notifier URL.

Prerequisites#

  • MAQPNA installed with the Helm chart, and at least one require_approval rule (Human approvals).
  • For approve-from-chat: OIDC admin auth, so that chat users map to real approvers (Keycloak and OIDC).
  • With sovereignty.allowedEgressHosts set: the notifier hosts in that list. The gateway checks every notifier endpoint at start-up.

Step 1: Create the notifier Secret#

All notifier secrets go in one Secret, mounted at /etc/maqpna/notify:

kubectl create secret generic maqpna-notify -n maqpna-system \
  --from-literal=hmac='<random-32-byte-hex>' \
  --from-literal=slack-url='https://hooks.slack.com/services/<your-webhook-path>' \
  --from-literal=slack-signing='<slack-signing-secret>'

Step 2: Configure the notifiers#

approvals:
  consoleURL: https://console.example.eu       # linked from every message
  notifierSecret: maqpna-notify
  outboxPath: /var/lib/maqpna/audit/notify-outbox.jsonl   # the default
  notifiers:
    - name: ops
      type: webhook
      url: https://hooks.example.eu/maqpna
      hmacSecretFile: /etc/maqpna/notify/hmac
    - name: approvers
      type: slack
      webhookURLFile: /etc/maqpna/notify/slack-url
      signingSecretFile: /etc/maqpna/notify/slack-signing
      events: [approval.created, approval.decided, approval.expired]
      userMap:                                  # Slack user ID -> approver
        U012ABCDEF: [email protected]
        U034GHIJKL: {user: [email protected], groups: [maqpna-approvers], namespaces: [team-a]}
  preview:
    mode: redacted      # none | hash | redacted | full
    maxBytes: 4096

approvals.preview.mode controls how much of the tool-call arguments approvers see in the message. redacted masks obvious secrets; raw arguments are only stored in full mode.

Step 3: Enable approve-from-chat (Slack)#

In your Slack app, turn on Interactivity and set the request URL to:

https://gateway.example.eu/v1/notify/callback/approvers

The gateway checks Slack's X-Slack-Signature and X-Slack-Request-Timestamp with the signing secret, rejects requests outside a 5-minute replay window, maps the Slack user through userMap, and applies the same rules as the admin API: the approver must hold the approver role for the namespace, cannot approve their own calls, and cannot vote twice. The decision is recorded with issuer urn:maqpna:notifier:<name>.

For Teams, configure callbackOIDC (issuer, audience, optional jwksURL or discoveryURL) so the gateway verifies the Bot Framework token on Action.Execute invokes.

Step 4: Receive signed webhooks#

Each webhook delivery is a POST with the JSON event and these headers:

Header Value
X-Maqpna-Event The event type, for example approval.created
X-Maqpna-Delivery <event id>.<notifier>; stable across retries, use it to de-duplicate
X-Maqpna-Signature t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + body)>

Verify the signature and reject old timestamps:

import hashlib, hmac, time

def verify(secret: bytes, header: str, body: bytes, window: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t, sig = parts.get("t"), parts.get("v1")
    if not t or not sig or abs(time.time() - int(t)) > window:
        return False
    want = hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, sig)
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: Buffer, header: string, body: Buffer, window = 300): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
  const t = parts["t"], sig = parts["v1"];
  if (!t || !sig || Math.abs(Date.now() / 1000 - Number(t)) > window) return false;
  const want = createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
  return want.length === sig.length && timingSafeEqual(Buffer.from(want), Buffer.from(sig));
}

The payload has id, type, time, consoleUrl, and for approval events an approval object (id, status, namespace, agent, session, user, server, tool, argsPreview, policy, rule, reason, minApprovers, approvers, decidedBy, createdAt, expiresAt). Other events carry data.

Delivery and retries#

Deliveries are queued in the outbox (approvals.outboxPath, mode 0600) and retried with exponential backoff from 1 second up to 5 minutes, at most 12 attempts (about one hour). Deliveries older than 24 hours are dropped. With the Postgres state backend, replicas share the outbox state, so an expiry is announced once.

Verify#

  1. Make a call that a require_approval rule holds (see Your first governed agent).
  2. Check that the message arrives. The gateway logs approval notifiers configured at start-up with each notifier's name and type.
  3. Approve from Slack and check the decision: maqpna approvals get <id>.

Troubleshooting#

Symptom Cause Fix
Gateway does not start: notifiers[<name>]: webhookURLFile: ... The Secret key is missing or empty Check approvals.notifierSecret and the file name under /etc/maqpna/notify
Start-up refuses a notifier endpoint Host not allowed by the sovereignty policy Add the host to sovereignty.allowedEgressHosts
Chat reply "You are not registered as a MAQPNA approver." Chat user not in userMap Add the user ID
Chat reply "You are not an approver for namespace ..." The mapped user lacks the approver role there Fix adminAuth.roles or namespaceRoles, or userMap groups
401 invalid callback signature Wrong Slack signing secret, or clock skew over 5 minutes Check signingSecretFile; sync clocks
409 replayed callback The same callback was sent twice Nothing to do; the first one counted
404 no interactive callback configured No signing secret (Slack) or callbackOIDC / HMAC (Teams) Configure callback verification

Next steps#