Current section

Files

Jump to
ash_hooks documentation tutorials tenancy-adoption-checklist.md
Raw

documentation/tutorials/tenancy-adoption-checklist.md

# Multi-tenant adoption checklist

ash_hooks supports Ash attribute multitenancy: every dispatch, ingest,
delivery run, sweep, and reconciliation threads an explicit tenant, and the
package fails closed with a named error when a multitenant resource is
touched without one. This checklist walks one app — two tenants or two
thousand — through the same steps. The reasoning behind the floor is
[ADR-0011](https://github.com/baselabs/ash_hooks/tree/main/docs/adr).

## The contract

Declare the SAME attribute strategy on the resources a given operation
touches — for the full outbound machine that is your Subscription, your
Endpoint, and your OutboundDelivery ledger; for inbound, your
InboundDelivery ledger. (An inbound-only app declares tenancy on its
ledger alone — each operation verifies the set it actually reads.)

```elixir
attributes do
  # ...the extension's injected fields...
  attribute :org_id, :string, allow_nil?: false
end

multitenancy do
  strategy :attribute
  attribute :org_id
end
```

Two declarations are rejected outright with `{:error, :tenancy_mismatch}`:

- `global?: true` — it looks consistent while silently allowing
  tenant-less reads, which would disable every guarantee below.
- non-`:attribute` strategies — attribute is the only strategy that
  works across every data layer the package supports.

## The ordered steps

If you are also upgrading from 1.x to 2.0, complete the schema and declaration/route
backfill in [UPGRADING](../../UPGRADING.md) before enabling the new runtime.

**0. Drain in-flight Oban jobs from the 1.1.x shape** (skip if you have
no queued deliveries). The moment your resources declare multitenancy,
jobs enqueued by the old version — whose args carry no tenant — start
failing closed with `{:error, :tenant_required}` when they run. That is
the correct, safe behavior, but it is noisy: drain or complete the old
queue BEFORE deploying the version whose resources declare tenancy.

**1. Add the tenant columns, then backfill them.** First the columns
(in the adopter's migration):

```sql
ALTER TABLE webhook_endpoints ADD COLUMN org_id TEXT;
-- ...likewise for webhook_subscriptions, inbound_deliveries,
--    outbound_deliveries
```

Then backfill — every legacy row must get its tenant value:

```sql
-- your mapping from existing rows to their owning tenant
UPDATE webhook_endpoints SET org_id = <derived per row>;
UPDATE webhook_subscriptions SET org_id = <derived per row>;
UPDATE inbound_deliveries SET org_id = <derived per row>;
UPDATE outbound_deliveries SET org_id = <derived per row>;
```

Verify zero NULL tenants remain:

```sql
-- must return 0 for every table
SELECT COUNT(*) FROM outbound_deliveries WHERE org_id IS NULL;
```

A NULL-tenant row is a quiet trap: the unique identities become
tenant-scoped in step 2, and NULL never equals a tenant value — so a new
delivery creates a separate row instead of matching the old one and may repeat
its network effect. NULL-tenant rows are also invisible to every
tenant-scoped sweep. Backfill before proceeding; the count above is the
gate.

**2. Regenerate migrations so the unique identities include the tenant.**
The unique identities (`unique_delivery` on endpoint+event,
`unique_ingest` on provider+external id + scope) become tenant-prefixed
unique indexes — run your `mix ash.codegen` / migration generator after
adding the declarations. Dedup is per-tenant by construction from here:
the same provider event id in two tenants is two rows, both processed.

**3. Deploy, and thread the tenant through your call sites.**

```elixir
# outbound — every call
AshHooks.dispatch(Order, :order_paid, event, tenant: org.id)

# inbound — the tenant rides the request context
AshHooks.Ingress.ingest(WebhookLedger, :stripe, raw_body, %{signature: sig, tenant: org.id})

# the worker (unchanged) — the enqueue serializes the row's tenant into
# job args; Delivery.run/2 recovers it after any restart
```

**4. Adopt the per-tenant operations surface.**

- Sweeps are per-tenant: `reap/2` and both `prune/2`s take `:tenant`;
  `AshHooks.reap_all/2` and `AshHooks.prune_all/2` sweep a list of
  tenants sequentially — one tenant's failure never stops the others —
  and return each tenant's result plus a total:
  `{:ok, %{results: %{"org_a" => {:ok, 3}, ...}, total: 3}}`.
- Reconciliation of stale pending rows, enqueue failures, due retries, expired
  sends, and pending endpoint disables:
  `AshHooks.reconcile_pending(Order, :order_paid, tenant: org.id,
  enqueue: {MyWorker, :enqueue})`. The delivery resource needs
  `timestamps()` (`inserted_at` drives the staleness cutoff — the
  default is five minutes). Use the same declaration and durable queue route.
  Concurrent reconcilers use separate expiring enqueue claims; the enqueue callback
  receives the event reconstructed from the row (id, type, payload —
  its metadata map is empty), so write custom callbacks accordingly.
- Tenant-aware secrets (all optional): the worker macro's
  `tenant_aware_secrets: true` (resolver becomes `f(ref, tenant)`), the
  inbound `secret fn tenant -> {:ok, secret} end`, and the provider
  `webhook_signing_secret(connection, tenant)` override for
  per-Organization connection custody.

## The isolation guarantees (what you may rely on)

- A dispatch for tenant A cannot create a delivery row for tenant B's
  endpoint; a subscription pointing at another tenant's endpoint_id
  resolves to not-found and is skipped; the 410-disable cannot be aimed
  cross-tenant.
- The inbound dedup identity is per-tenant; the claim fence, marks,
  renewal, and redaction are per-tenant.
- A tenant-less call against a multitenant set returns
  `{:error, :tenant_required}` before any data access.
- Workers recover full tenant context from job args alone (string
  tenants are the supported shape; a custom `parse_attribute` pairs
  with `tenant_from_attribute`, which the worker inverts through).
- Recovery is fenced to the row's declaration, route, tenant, and enqueue token.
  A crash after queue admission can still invoke a custom enqueue callback again;
  make that callback idempotent. The generated Oban worker requires a persisted
  matching runnable job before reporting successful admission.

## Single-tenant apps

Leave multitenancy undeclared and omit `:tenant`. The same entry points work
with a single-tenant ledger; the 2.0 schema upgrade still applies.