Skip to main content

HTTP adapter

WIP

Back the store with a remote REST API, covering the complete wire protocol, authentication, SSRF guards, retry, and circuit breaker.

IamHttpAdapter turns the fourteen adapter methods it implements into fourteen REST calls against a service you run. It moves storage behind a service boundary, not evaluation: the engine still resolves the subject and evaluates policies locally, it just fetches them over fetch instead of from a database. The adapter ships the SRE plumbing that implies: per-request timeout, exponential-backoff retry, a circuit breaker, response size caps, and construction-time SSRF validation of the base URL. The wire protocol below is the whole of what your server has to implement.

Install


npm i @gentleduck/iam

No dependencies beyond fetch.

import { IamHttpAdapter, iamHttpAdapter } from '@gentleduck/iam/adapters/http'
import type { IamHttp } from '@gentleduck/iam/adapters/http'

Setup

import { IamEngine } from '@gentleduck/iam'
import { IamHttpAdapter } from '@gentleduck/iam/adapters/http'

const adapter = new IamHttpAdapter({
  baseUrl: 'https://iam.example.com/access',
  allowedHosts: ['iam.example.com'],
  headers: { Authorization: `Bearer ${process.env.IAM_SERVICE_TOKEN}` },
  timeoutMs: 2_000,
})

const engine = new IamEngine({ adapter })

iamHttpAdapter(config) is the same constructor behind a factory function.

Options

IamHttp.IConfig is the single constructor argument.

OptionTypeDefaultMeaning
baseUrlstringrequiredBase URL of your IAM API. Validated at construction (see Base URL validation); a single trailing / is stripped. Every path below is appended to it verbatim.
allowedHostsstring[]undefinedHost allowlist for baseUrl. Omitted, any host is accepted and a one-time console.warn fires.
allowPrivateHostsbooleanfalsePermits a baseUrl whose hostname is a private, loopback, or link-local IP literal.
headersRecord<string, string> or () => Record<string, string> | Promise<…>{}Static headers, or a function awaited before every request. This is how the adapter authenticates.
fetchtypeof globalThis.fetchglobalThis.fetchCustom fetch: a polyfill, or middleware for logging, tracing, or mTLS.
timeoutMsnumber >= 05_000Per-request abort. 0 disables it and leaves only the engine's adapterTimeoutMs.
retriesinteger >= 02Retries on a transient failure, so 3 attempts total.
backoffMsnumber >= 0100Base for exponential backoff: attempt N waits backoffMs * 2^(N-1) plus jitter.
circuitBreakerThresholdinteger >= 15Consecutive failures before the circuit opens. It cannot be disabled through configuration; set a high threshold instead.
circuitBreakerCooldownMsnumber >= 030_000How long the circuit stays open before allowing a half-open probe.
onPolicyError(err, ctx: { adapter: 'http'; rowId: string }) => voidconsole.warnCalled for every row the API returned that fails shape validation, and for a list endpoint that answers a non-array.

The numeric options are validated at construction

undefined takes the default; anything else must be a number the adapter can act on, and a value outside the bounds above throws IAM_HTTP_OPTION_INVALID immediately (meta: { field: 'retries', got: 'null', constraint: 'finite integer >= 0' }). The failures being guarded against were severe and silent. retries: NaN made the retry loop's condition false on the first evaluation, so the adapter issued no request at all and then threw the lastError it had never assigned. timeoutMs: NaN is the same shape one layer down: setTimeout(fn, NaN) fires on the next tick, so every request aborts before it can answer. NaN arrives the ordinary way, from Number(process.env.IAM_HTTP_RETRIES) on an unset variable.

Wire protocol

Fourteen endpoints, all relative to baseUrl. Every path segment interpolated from an id is encodeURIComponent-escaped. Every request carries Content-Type: application/json.

MethodPathRequest bodySuccess responseNotes
GET/policies—JSON array of AccessControl.IPolicyA non-array body is reported once and treated as [].
GET/policies/{id}—one IPolicy, or 404404 maps to null, the interface's "not found".
PUT/policiesthe full IPolicyany 2xx; body ignoredUpsert keyed on policy.id. The id is in the body, not the path.
DELETE/policies/{id}—any 2xx; body ignoredMust be idempotent: a 404 here throws, it is not treated as success.
GET/roles—JSON array of AccessControl.IRoleAs /policies.
GET/roles/{id}—one IRole, or 404404 maps to null.
PUT/rolesthe full IRoleany 2xxUpsert keyed on role.id.
DELETE/roles/{id}—any 2xxIdempotent, and your server must cascade: strip the deleted role from every subject's assignments.
GET/subjects/{id}/roles—JSON array of role-id stringsUnscoped roles only. See the contract callout below.
GET/subjects/{id}/scoped-roles—JSON array of { role, scope }Scoped roles only, both fields non-empty strings.
POST/subjects/{id}/roles{ "roleId": "...", "scope": "..." }any 2xxscope is omitted from the JSON when undefined. Must be idempotent, and must answer 4xx when roleId names no stored role - the reference server answers 422.
DELETE/subjects/{id}/roles/{roleId}—any 2xxOptional ?scope=…. Without the query param, delete every assignment of that role, at every scope.
GET/subjects/{id}/attributes—a JSON object{} for an unknown subject. An array, null, or a scalar throws.
PATCH/subjects/{id}/attributesthe partial attribute objectany 2xxMerge, do not replace. Keys absent from the body survive.

There is no endpoint for updateAssignmentScope; the adapter does not implement it, so engine.admin.updateAssignmentScope falls back to DELETE then POST on /subjects/{id}/roles. It has no withClient either, so engine.withTransaction throws the coded IAM_ENGINE_ADAPTER_NOT_TRANSACTIONAL against this adapter.

Two of these obligations are the reason the compliance suite gives this adapter a flag of its own. The operator's server owns the role catalog, so the adapter cannot phrase the unstored-role refusal itself; delegatesRoleExistence waives the wording of that refusal and nothing else. The refusal itself is still asserted, and so is the deleteRole cascade.

A cold read over the wire

Loading diagram...

The unscoped-roles contract

Reference server

The package's own compliance test wires a minimal in-memory server the adapter is held against, and it is the clearest executable specification of the protocol. The routing it implements, verbatim in behaviour:

// GET /subjects/:id/roles: unscoped only, deduplicated
json([...new Set(entries.filter((e) => e.scope == null).map((e) => e.role))])

// GET /subjects/:id/scoped-roles: scoped only
json(entries.filter((e) => e.scope != null).map((e) => ({ role: e.role, scope: e.scope })))

// POST /subjects/:id/roles: idempotent on (role, scope)
if (!entries.some((e) => e.role === roleId && e.scope === scope)) entries.push({ role: roleId, scope })

// DELETE /subjects/:id/roles/:roleId: no scope param means every scope
scope === null
  ? entries.filter((e) => e.role !== roleId)
  : entries.filter((e) => !(e.role === roleId && e.scope === scope))

// PATCH /subjects/:id/attributes: merge, never replace
attributes.set(subjectId, { ...(attributes.get(subjectId) ?? {}), ...body })

// GET /policies/:id and /roles/:id: 404 for a miss, never 200 with null
hit ? json(hit) : json({ error: 'not found' }, 404)

Note that it mounts under a path prefix (baseUrl ends in /access) and strips that prefix before routing: the adapter appends its paths to whatever baseUrl you give, so mounting the API at a subpath is supported.

Authentication

The adapter has no auth of its own; it merges whatever headers gives it into every request. Static headers for a fixed service token:

new IamHttpAdapter({
  baseUrl: process.env.IAM_API!,
  allowedHosts: ['iam.internal'],
  headers: { Authorization: `Bearer ${process.env.IAM_SERVICE_TOKEN}` },
})

A function for rotating credentials or per-request context. It is awaited before every request, including each retry attempt, so a token refreshed between attempts is picked up:

new IamHttpAdapter({
  baseUrl: process.env.IAM_API!,
  allowedHosts: ['iam.internal'],
  headers: async () => ({
    Authorization: `Bearer ${await getServiceToken()}`,
    'X-Request-Id': crypto.randomUUID(),
  }),
})

Content-Type: application/json is merged first and your headers last, so returning a Content-Type from your function overrides the default; do not, unless you mean to. There is deliberately no per-call header field: HeadersInit has three shapes, the merge handled one of them by spreading, and a Headers instance carrying Authorization spread to {}, so the write went out unauthenticated with nothing to see.

For mTLS or a proxy, supply fetch instead and do the transport work there:

new IamHttpAdapter({
  baseUrl: process.env.IAM_API!,
  allowedHosts: ['iam.internal'],
  fetch: async (url, init) => {
    const started = Date.now()
    const res = await undiciFetch(url, { ...init, dispatcher: mtlsAgent })
    metrics.timing('iam.http', Date.now() - started, { status: String(res.status) })
    return res
  },
})

Base URL validation

baseUrl is parsed and checked once, in the constructor. Each failure throws immediately rather than at first request.

Every row throws IAM_HTTP_BASE_URL_INVALID, distinguished only by meta.reason:

Conditionmeta.reasonOther meta fields
Not parseable as a URL'unparseable'baseUrl
Scheme is not http: or https:'bad-scheme'scheme
Contains a query string or fragment'has-query-or-fragment'—
Host not in a non-empty allowedHosts'host-not-allowed'host
Private or loopback IP literal without allowPrivateHosts'private-host'host

allowedHosts matching is deliberate about its edges. Comparison is case-insensitive on both sides, a trailing FQDN dot is stripped, and internationalised names are punycoded through new URL before comparing. An entry may be a bare hostname or a host:port pair: a bare entry matches the hostname at any port, so ['example.com'] accepts example.com, example.com:80, and example.com:8443; a host:port entry matches only that exact port, so example.com:8080 rejects both example.com and example.com:9090.

The private-host check covers IPv4 (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, 0.0.0.0/8), IPv6 (::1, ::, fc00::/7, fe80::/10), and the tunnelling forms that smuggle an IPv4 address inside an IPv6 literal: IPv4-mapped (::ffff:127.0.0.1 and its hex spelling ::ffff:7f00:1), IPv4-compatible (::127.0.0.1), 6to4 (2002:7f00:1::), and NAT64 (64:ff9b::127.0.0.1). DNS names are never resolved, because that would need synchronous I/O at construction; constrain those with allowedHosts.

Errors, retries, and the circuit breaker

Response handling

A non-2xx response throws IAM_HTTP_RESPONSE_ERROR (meta: { status, body }). The body is read as a stream with a 4 KiB hard ingest cap and truncated to 200 characters with a ...(truncated) suffix before it lands in meta.body, so a hostile remote cannot exhaust memory building an error message. Successful bodies go through the same treatment at a larger scale: reading stops at 4 MiB and throws IAM_HTTP_RESPONSE_TOO_LARGE (meta: { capBytes }).

404 is special only for getPolicy and getRole, which return null for it. On every other endpoint a 404 is an error.

What counts as transient

ClassTransient?Behaviour
5xxyesRetried; the body is read once for the error message.
4xxnoThrown immediately as a definitive answer, and it resets the breaker's failure counter: the server answered.
AbortError (including the adapter's own timeout)yesRetried.
TypeError (fetch's network-failure class)yesRetried.
ECONNRESET, ECONNREFUSED, ETIMEDOUT, ENOTFOUNDyesRetried.
Anything elsenoThrown, and counted as a breaker failure.

Backoff for attempt N is min(backoffMs * 2^(N-1), 60_000) plus a random jitter of up to min(backoffMs, 5000) ms. The 60-second ceiling exists so a misconfigured retries cannot push the delay past setTimeout's 32-bit limit.

Circuit breaker

Loading diagram...

Closed passes traffic. Each request that ends in a thrown error increments a consecutive-failure counter; any response at all (2xx or 4xx) resets it to zero. At circuitBreakerThreshold the circuit opens.

Open rejects immediately with IAM_HTTP_CIRCUIT_OPEN (meta: { state: 'open' }), without a fetch attempt, for circuitBreakerCooldownMs.

Half-open begins when the cooldown elapses. Exactly one probe is allowed through; concurrent callers reject fast with the same IAM_HTTP_CIRCUIT_OPEN code (meta: { state: 'half-open-busy' }) so a recovering upstream is not flooded the instant it comes back. The probe's outcome closes the circuit or re-opens it for another cooldown.

Breaker state is per adapter instance, not per process. It cannot be disabled by configuration - 0 is rejected at construction - so raise the threshold above any burst you expect instead.

Timeouts

Two timeouts stack. The adapter's own timeoutMs aborts the fetch with a transient error (so it is retried); the engine's adapterTimeoutMs aborts the whole adapter call (so it is not). Whichever fires first wins, and IReadOptions.signal from the engine is composed with the per-request timer, so an invalidation that supersedes an in-flight load cancels the HTTP request for real rather than leaving it running. This is one of the few adapters that honours the signal end to end. Set timeoutMs: 0 to rely solely on the engine's.

Input and response guards

The adapter refuses obviously bad input before it reaches the wire, and refuses obviously bad output before it reaches the engine.

SituationResult
Any id that is empty, non-string, contains / or \, is all dots, or is over 1024 charactersthrows, no request issued. Reads and writes share one guard, so a write cannot store an id a read could never fetch back. It used to return null, [] or {} instead; the {} was the sharp end, because a rule denying on attributes.suspended === true then evaluated as though the attribute were absent - a guard meant to bound a URL was quietly retiring deny rules.
assignRole with scope: '' or scope: '*', or with startsAt / expiresAt / attributesthrows, naming the option; this adapter stores none of them
setSubjectAttributes with a non-object attrsthrows IAM_ATTRIBUTES_INVALID (meta: { adapter: 'http', subjectId, reason: 'not-object', got }) - the same shared guard every adapter uses
setSubjectAttributes with a __proto__ own key in attrsthrows the same IAM_ATTRIBUTES_INVALID, but meta.reason: 'forbidden-key' - __proto__ cannot round-trip through JSON.parse intact, so it is refused rather than silently mishandled
GET /policies or /roles returns a non-arrayreported through onPolicyError with rowId set to the path, treated as []
A policy row fails parsePolicyRowreported, then the whole read throws IAM_UNREADABLE_POLICY (meta: { adapter: 'http', policyId, detail }) - a dropped policy may be the one that denies
A role row fails parseRoleRowdropped and reported; the rest of the catalog is returned. The row's own id is used as the rowId when present, else "/roles[3]"
GET /subjects/{id}/roles returns a non-arraythrows IAM_HTTP_SUBJECT_ROLES_INVALID (meta: { subjectId, reason: 'not-array', got })
An entry in it is not a non-empty stringsilently skipped
GET /subjects/{id}/scoped-roles returns a non-arraythrows IAM_HTTP_SUBJECT_SCOPED_ROLES_INVALID (meta: { subjectId, reason: 'not-array', got })
An entry lacks a non-empty string role and scopesilently skipped: a scoped role with no scope is not a global grant
GET /subjects/{id}/attributes returns an array, null, or a scalarthrows IAM_ATTRIBUTES_CORRUPT (meta: { adapter: 'http', subjectId, reason: 'not-object' }) - the same shared read-path guard every adapter uses, which also fires on a stored __proto__-keyed bag

The asymmetry is deliberate and matches the other adapters. A bad role row is dropped, because role permissions are allow-only and losing one can only cost a subject a grant. A bad policy row and bad subject data both throw, because silently returning [] or {} would strip a deny rule and change decisions with no signal. See the same rule in a different store on the Redis and Prisma pages.

Gotchas

  • Evaluation stays local. This adapter fetches authorization data; the engine still resolves subjects and evaluates policies in your process. It is not a remote-decision adapter. To evaluate on the server and consume the result on a client, expose engine.permissions() and use the client integrations instead.
  • Every cold read is a round trip. Pair it with the engine's LRU (cacheTTL, maxCacheSize), and use the Redis invalidator rather than a short TTL if you need fresh reads across nodes.
  • headers runs on every attempt. A slow token fetch is inside your timeoutMs budget.
  • The breaker is per instance. Constructing an adapter per request gives you no breaker at all, and re-fires the missing-allowedHosts warning check on a process-wide latch. Construct it once.
  • PUT sends the whole object. There is no partial update for policies or roles; the body is the full IPolicy or IRole, and your server upserts on its id.
  • A DELETE of an unknown id must succeed. The interface expects idempotent deletes, and the adapter turns a 404 on those paths into a thrown error.
  • Your server owns two guarantees the adapter cannot enforce: DELETE /roles/{id} cascades to the grants, and POST /subjects/{id}/roles refuses a role it does not hold. Both are contract; the adapter only surfaces the non-2xx as a throw.
  • The numeric options throw at construction. retries: Number(process.env.X) on an unset variable is NaN, and NaN used to mean no request was issued at all.

See also