ADR-0061: Scoped API keys and the management API
| Status | Accepted |
| Date | 2026-10-13 |
| Issue | #51 |
| Pull request | #164 |
| Related | ADR-0012, ADR-0050, ADR-0053, ADR-0059 |
Context and problem statement
Channels, tenants and portal users could only be created in the LiveView UIs. An integrator could not provision a
channel from their own onboarding flow, and could not keep Converger's configuration in infrastructure as code. A
tenant had exactly one API key (tenants.api_key_hash), and it could do everything: a read-only dashboard or a
bot that only posts messages had to hold a key that could also delete routing rules. Only routing rules and
webhook subscriptions had REST endpoints.
Issue #51 asks for a management API for the tenant's configuration and the platform's tenants, scoped API keys
read, write and manage, an audit entry for every management call with the key as the actor, and
Terraform-friendly semantics: stable ids, idempotent PUT, If-Match ETags.
Decision drivers
- No existing integration may break: SDKs, the load harness, the chaos suite and every tenant hold a single key.
- Least privilege: a key should carry only what its holder needs, and a key must not be able to mint a broader one.
- One way to do each thing: no resource reachable under two URLs with two operation ids in the OpenAPI document.
- Platform operations (creating tenants) must not be reachable with any tenant credential.
- Every change is audited in the same transaction as the change, as the rest of the code base does.
Considered options
- Replace
tenants.api_key_hashwith anapi_keystable. Migrate every tenant's key into it and drop the tenant columns. - Add
api_keysfor scoped keys and keep the tenant's key as a primary key with every scope. - Scopes on the tenant row (one key, configurable scopes).
For the URL layout:
- A. Move or duplicate routing rules, webhook subscriptions and pause/resume under
/api/v1/manage. - B. Put only the new configuration resources under
/api/v1/manage, and gate the existing configuration routes with themanagescope.
Pros and cons of the options
Option 1: replace the tenant key
- Good, because there is one credential model.
- Bad, because the admin panel's create and rotate flows, the rotation grace period (
previous_api_key_hash), the fixtures of hundreds of tests, the load and chaos harnesses and the SDK conformance suite would all change in the same PR, for no behaviour gain.
Option 2: primary key plus scoped keys
- Good, because existing keys keep working unchanged, as keys with every scope.
- Good, because scoped keys get their own lifecycle (name, expiry, revocation,
last_used_at) without touching the tenant row. - Bad, because there are two kinds of key to explain.
Option 3: scopes on the tenant row
- Bad, because one key per tenant cannot serve a dashboard and a bot with different rights.
Option A: everything under /manage
- Good, because the namespace matches the issue's sketch.
- Bad, because duplicated routes yield duplicate OpenAPI operations, and moved routes break clients.
Option B: new resources under /manage, scope on the rest
- Good, because no route moves and every operation keeps one id.
- Bad, because configuration is spread over two prefixes. The docs list both.
Decision
Chosen: option 2 with layout B.
api_keystable:tenant_id,name,key_hash(SHA-256, unique),prefix(display),scopes(subset ofread,write,manage),expires_at,last_used_at,revoked_at,created_by. Keys arecvg_live_...like the primary key, so secret scanners match both. They are revoked, never deleted, so an audit entry'sactor_idalways resolves.- The primary key (
tenants.api_key_hash) has every scope. The deprecated channel token keeps every scope on the routes that accept it. - Scope enforcement in
ConvergerWeb.Plugs.TenantAuth, so it fails closed. Every action needs a scope:readforGET/HEAD,writeotherwise. A controller can override this per action (scopes:) or for all actions (scope:). A missing scope is403 insufficient_scope, withdetails.required. The scopes are separate:managedoes not includereadorwrite. - What
managecovers: everything under/api/v1/manage(channels withrotate_secret, portal users, settings, API keys), changes to routing rules and webhook subscriptions, and channel pause/resume (a channel change). The routing dry-run only reads, so it needsread. - No escalation: a key can only issue keys whose scopes are a subset of its own.
/api/v1/managetakes API keys only, not channel tokens (403 forbidden).- The admin API (
/api/v1/admin: tenants CRUD,rotate_api_key, API key issuance) authenticates withADMIN_API_TOKEN(bearer, at least 32 characters, compared as SHA-256 digests in constant time), behind the same IP allowlist as the admin panel. Unset, it answers401to everything. It is a separate pipeline, so no tenant credential reaches it. Personal access tokens per admin user are left for later. - Audit: management calls go through the context functions that already write the audit entry in the same
transaction. The actor is
tenant_apiwith the credential's id (the scoped key id, the tenant id for the primary key, the channel id for a channel token), oradmin_apiwith idadmin_api_token. New vocabulary: actor typeadmin_api, resourceapi_key, actionsrevokeandrotate_secret. - Terraform semantics: UUIDs are stable.
PUTandPATCHare the same partial update, so a repeatedPUTis a no-op. Responses carry a strongETagderived from the id andupdated_at.PUT,PATCHandDELETEhonourIf-Matchand answer412 precondition_failed(withdetails.current_etag) when it does not match. - Secrets: a channel's config is returned with secret fields as
[REDACTED](the adapter'ssecret: truefields and every keyConverger.Secretstreats as sensitive). A[REDACTED]value sent back keeps the stored value, so read-modify-write does not wipe credentials. The channel secret, the tenant's primary key and a scoped key's key are returned only by the call that creates or rotates them.
Consequences
Positive
- Integrators can provision a tenant, its channels, routing rules and webhook subscriptions with only the API. The acceptance test does exactly that.
- Least-privilege keys: a
readkey cannot post activities, and awritekey cannot change configuration. - No existing integration changes behaviour.
Negative and trade-offs
- Two kinds of tenant key. The primary key cannot be scoped down. A tenant that wants least privilege everywhere issues scoped keys and keeps the primary key in a vault.
last_used_atcosts one conditionalUPDATEper key per minute.- A single shared
ADMIN_API_TOKENcannot attribute admin API calls to a person. The audit log recordsadmin_api. PUTis not a full replacement: omitted fields are kept. This is simpler for clients, but aPUTcannot reset an omitted field to its default.
Follow-ups
- Portal and admin panel pages to issue and revoke scoped keys (the API exists).
- Personal access tokens for admin users, so admin API calls are attributable.
- Creating a resource with a client-chosen id (
PUTto a new id) if Terraform providers need it.
Implementation
- Migration
priv/repo/migrations/20261013175100_create_api_keys.exs. SchemaConverger.ApiKeys.ApiKey. ContextConverger.ApiKeys(authenticate/1,create_api_key/3,revoke_api_key/2).Converger.ApiKeys.Credential. ConvergerWeb.Plugs.TenantAuth(scopes,actor/1),ConvergerWeb.Plugs.AdminApiAuth,ConvergerWeb.ETag.- Controllers
ConvergerWeb.Manage.{ChannelController, TenantUserController, SettingsController, ApiKeyController}andConvergerWeb.AdminAPI.{TenantController, ApiKeyController}. Scope overrides onRoutingRuleController,WebhookSubscriptionControllerandChannelDeliveryController. Converger.Channels.rotate_secret/2,Converger.Tenants.update_tenant_and_limits/3, andConverger.Accounts.get_tenant_user/2.- New error codes
insufficient_scope(403) andprecondition_failed(412).ConvergerWeb.ApiSpecadds a403response to every operation behind an API key, plus theadminTokensecurity scheme. ADMIN_API_TOKENinconfig/runtime.exs.- Tests:
test/converger_web/controllers/management_api_test.exs(acceptance script, scopes, escalation, ETags, redaction, admin API) and the management and admin sections oftest/converger_web/api_contract_test.exs.