Event Delivery

SaaSLayer Permissions emits durable events for access-request and delegation lifecycle changes. A separate event-delivery sidecar service ships those events to customer-configured destinations (HTTPS webhooks today, EventBridge next, more adapters later) with retries, dead-letter handling, and a query fallback so workflows never strand on a dropped delivery.

Event delivery is intentionally out of the authorization hot path. The authorizer never blocks on a notification.

What gets delivered

Event typeWhen it fires
accessRequest.createdA new access request — including principal_delegation — is written.
accessRequest.approvedThe approval threshold is reached and the entitlement change applies.
accessRequest.deniedAn approver denies the request.
accessRequest.expiredA pending request exceeds its TTL without approval.
delegatedGrant.createdA durable delegated grant is written from an approved request or standing policy.
delegatedGrant.revokedA delegated grant is revoked.
delegated_session.mintedA delegated session is minted from a durable grant.
standingDelegation.createdA user-owned or admin-managed standing delegation policy is written.
standingDelegation.revokedA standing delegation policy is revoked.
session.revokedA SESSION row is explicitly revoked (not just expired).

Additional event types will be added as features land. The set of attributes inside each event follows a stable, additive contract.

Why a sidecar

The events are written to an outbox in the same atomic operation as the RBAC state change. A separate delivery worker picks them up and ships them. This separation matters:

  • The authorizer never makes outbound HTTP calls; revocation latency stays predictable.
  • A flapping webhook receiver cannot back-pressure RBAC writes.
  • Adapter failures are isolated to the sidecar's retry/DLQ machinery.
  • Customers can subscribe multiple destinations to the same event without RBAC fanning out synchronously.

Reliability contract

For events that are part of an RBAC state-change contract (access request created, approved, denied; durable grant created, revoked; etc.):

  • The outbox row is written inside the same DDB transaction as the state change. If the state change commits, the event is durable; if it rolls back, no event leaks.
  • Delivery is at-least-once. Receivers must be idempotent on id.
  • Failed deliveries retry with exponential backoff.
  • After max attempts the event moves to dead_letter and a structured failure record is queryable.

Best-effort emissions (e.g., legacy hook callbacks for optional integrations) are marked best_effort on the outbox row and are explicitly not part of the reliability contract.

Subscriptions

A subscription says "deliver these event types for this scope to this transport."

Create a webhook subscription

POST /notification-subscriptions
Content-Type: application/json

{
  "subscriber": { "type": "USER", "id": "alice" },
  "scope": "tn_acme",
  "eventTypes": ["accessRequest.created", "accessRequest.approved"],
  "transport": {
    "type": "webhook",
    "url":  "https://app.example.com/rbac-events"
  },
  "enabled": true
}

Successful response includes a one-time signingSecret (and only the one time):

{
  "data": {
    "type": "notification-subscription",
    "id": "sub_01J...",
    "attributes": {
      "subscriber": { "type": "USER", "id": "alice" },
      "scope": "tn_acme",
      "eventTypes": ["accessRequest.created", "accessRequest.approved"],
      "transport": {
        "type": "webhook",
        "url":  "https://app.example.com/rbac-events"
      },
      "secretHint":      "...ab12",
      "secretRotatedAt": "2026-05-17T18:00:00.000Z",
      "enabled": true
    },
    "meta": {
      "signingSecret": "whsec_..."
    }
  }
}

Store the signingSecret immediately. Subsequent reads expose only secretHint (last four characters) and secretRotatedAt.

Rotate a signing secret

POST /notification-subscriptions/sub_01J.../rotate-secret

Rotation returns a new secret once and signs every delivered payload with both the old and the new secret for a configurable overlap window (default 24h). Receivers can flip to the new secret at any point during the overlap.

EventBridge subscription

POST /notification-subscriptions
Content-Type: application/json

{
  "subscriber": { "type": "TENANT", "id": "tn_acme" },
  "scope": "tn_acme",
  "eventTypes": ["accessRequest.*", "delegatedGrant.*"],
  "transport": {
    "type": "eventbridge",
    "busName": "saaslayer-events",
    "region":  "us-west-1",
    "accountId": "123456789012"
  },
  "enabled": true
}

The sidecar publishes:

Source       = saaslayer.rbac
DetailType   = <eventType>
Detail       = <redacted-payload-json>
EventBusName = <configured>

Cross-account targets require the bus policy to allow the sidecar's IAM principal to call events:PutEvents.

Webhook payload and signature

The delivered payload is JSON:

{
  "id":   "evt_01J...",
  "type": "accessRequest.created",
  "createdAt": "2026-05-17T18:00:00.000Z",
  "recipient": { "type": "USER", "id": "alice" },
  "data": {
    "requestId": "req_01J...",
    "requester": { "type": "AGENT", "id": "support-bot" },
    "grantor":   { "type": "USER",  "id": "alice" },
    "summary":   "support-bot requests invoice read for ticket TKT-123"
  }
}

Headers:

Content-Type: application/json
X-SaaSLayer-Event-Id:   evt_01J...
X-SaaSLayer-Event-Type: accessRequest.created
X-SaaSLayer-Signature:  t=<unix-ts>,v1=<hex-hmac>

Verify the signature

The signature is HMAC-SHA256 over <timestamp>.<raw-body> using your subscription's signing secret.

const crypto = require('crypto')

function verify(rawBody, headerValue, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    headerValue.split(',').map(p => p.split('=', 2))
  )
  const timestamp = Number(parts.t)
  const signature = parts.v1

  if (!timestamp || !signature) return false
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  // constant-time compare
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(signature, 'hex')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

Reject any request where the signature does not verify, where the timestamp is outside the tolerance window, or where headers are missing.

About the signing secret

The HMAC secret is a SaaSLayer-generated outbound signing key. SaaSLayer needs to know it because SaaSLayer signs every webhook delivery with it; you need it because your receiver verifies with the same key.

This does not make SaaSLayer a secrets manager. SaaSLayer does not store customer-owned third-party credentials (database passwords, vendor API tokens, certificates). The webhook secret exists to sign messages SaaSLayer itself sends — analogous to Stripe's whsec_… keys or GitHub's webhook secrets — and is the only way to prove a webhook actually came from RBAC.

The secret is stored encrypted with a KMS key in the subscription row. It is never logged, never included in outbox payloads, never returned in normal GET responses.

URL validation and SSRF protection

Webhook URLs are validated at create time and re-validated at every delivery (DNS rebinding is real).

Rules:

  • Scheme must be https. http is rejected unless the deployment explicitly sets PERMISSIONS_ALLOW_INSECURE_WEBHOOKS=true (intended only for local development).
  • The hostname is resolved at registration and at each delivery. Resolution to any of these is rejected:
    • RFC1918 ranges: 10/8, 172.16/12, 192.168/16
    • Loopback: 127/8, ::1
    • Link-local: 169.254/16 (including the AWS IMDS address 169.254.169.254)
    • Unique-local IPv6: fc00::/7
    • 0.0.0.0
  • Hostnames that resolve to known cloud-provider metadata endpoints are rejected even when they sit outside the standard private ranges.
  • Customers who genuinely need to deliver to a private network can set allowPrivateNetwork: true plus tenant-level confirmation; the override is loudly audited.

Without these guards, a misconfigured or malicious subscription could point a webhook at http://169.254.169.254/latest/meta-data/... and harvest the sidecar's IAM credentials. This is a real exploit class, not a hypothetical.

Retries and dead-letter

OutcomeBehavior
HTTP 2xx within 5 secondsMarked delivered, recorded with response code.
HTTP 3xxTreated as misconfiguration. Recorded, marked failed, no auto-follow.
HTTP 4xx (except 429)Marked failed_terminal. No further retries (the receiver said "do not retry").
HTTP 429 or network errorRetried with exponential backoff.
HTTP 5xxRetried with exponential backoff.
Max attempts exceededMoved to dead_letter.

Default retry schedule: 30s, 2m, 10m, 1h, 6h, 24h. Tunable per deployment.

EventBridge failures retry against the same schedule and classify errors by AWS exception category (Throttling* retries; misconfigured bus/account terminates).

Query fallback

Notifications are convenience. The authoritative state is always queryable:

GET /access-requests/req_01J...
GET /access-requests/req_01J.../timeline
GET /delegated-grants/dgrant_01J...
GET /outbox-events?status=pending&recipientType=USER&recipientId=alice
GET /notifications?recipientType=USER&recipientId=alice

Agents that cannot rely on webhook delivery — for example, sandboxed environments without inbound HTTP — should poll the relevant request or grant resource.

Retry an outbox event

When a delivery dead-letters, an operator can replay it after fixing the receiver:

POST /outbox-events/evt_01J.../retry

The event is requeued with attempts reset. Subscriptions evaluate again at retry time, so a disabled subscription will not receive replayed events.

Operational guidance

  • Subscribe a dedicated tenant or admin recipient to accessRequest.* so compliance has a complete trail even if user-level subscriptions are misconfigured.
  • Treat the signing secret like a customer-facing credential: rotate on suspected compromise, do not commit to source control.
  • Make webhook receivers idempotent on event id. At-least-once delivery means duplicates happen.
  • Watch the dead-letter queue. A spike usually indicates a downstream outage, not an RBAC problem.
  • Do not delete subscriptions you no longer want — disable them. Disabled subscriptions stop receiving deliveries but preserve the audit trail.