better/compute
IAM integration

IAM integration

How Better Compute uses Better IAM as its only identity provider, policy engine, billing ledger and flag store, and how the IAM bridge wires the two.

Better Compute has no user accounts, no roles and no policy language of its own. Every person, service account and AI agent that calls the API is an identity in Better IAM, and every protected call is decided by Better IAM. Better Compute keeps only what an IAM system cannot hold for it: the capacity data it decides about, and the machine PKI that identifies its daemons.

Better IAM plays four roles for compute:

  • Identity provider. Sessions, API keys, role sessions, delegated sessions for AI agents and session JWTs all come from Better IAM. Compute never issues a session.
  • Policy decision point. Each protected method makes one decision on a compute:* action and a compute-* resource. Policies, roles, bindings, relationships and conditions live in Better IAM.
  • Billing ledger. Compute measures reserved time and records hourly usage on Better IAM meters. Prices, budgets and invoices are Better IAM's.
  • Flag store. Tenant feature flags such as compute.h100 gate features inside decisions and, through a cache, in code.

Installing Better IAM

Better IAM is on npm. Better Compute 0.1.1 works with Better IAM >=0.2.0 <0.4.0 (0.3 is current): install better-iam next to better-compute (npm install better-compute better-iam). @better-compute/iam needs @better-iam/core and optionally @better-iam/server, @better-iam/client and @better-iam/adapter-sqlite, all of which better-iam brings. See Installation.

What each side owns

ConcernBetter IAMBetter Compute
IdentitiesPeople, service accounts, agents, sessions, keys, delegationsNone. Daemons, site agents and gateways use compute's own mTLS certificates and never hold an IAM credential.
DecisionsPolicies, roles, bindings, relationships, conditionsDeclares the action and resource of every method, and refuses callers from other tenants before asking
ResourcesNothing registeredEvery compute-* type is app-owned: compute resolves it from its own
AuditDenials and root overrides, in the tenant's audit chainAllowed mutations, in audit_events, with the
MoneyMeters, prices, budgets, invoicesHourly , sent with idempotency keys
LimitsSpend budgets per tenant and pool
MachinesNothingNode certificates, join tokens, attestors

Modes

The bridge supports two topologies. Embedded is the default and the only one supported from v0.1 to v0.3: Better IAM runs inside compute's own processes. Remote is the v0.4 topology for a Better IAM deployment that compute does not run.

Embedded (default)Remote (v0.4)
Compute's portbridge.embedded(iam, options)bridge.remote({ baseURL, serviceToken, rootTenantId, iamDatabase })
Authenticationiam.authenticate, one IAM lock acquisitionsts.getCallerIdentity over HTTP, two acquisitions
Decisionsiam.require, iam.authorize and iam.authorizeMany in processThe same calls over HTTP, each with the caller's own credential forwarded
ResolverIn process, reading compute's projection from memoryIn the IAM host (computeIamHost), reading a replica of compute's projection
EventsThe plugin's afterAudit writes iam_inbox in compute's databaseThe plugin writes compute_inbox in the IAM database (postgresInbox) or posts signed batches (httpInbox)
Usagebilling.recordMany, up to 200 events per callHTTP billing.recordMany as the root service account, up to 100 per call

The remote pieces (bridge.remote, remoteIam, and computeIamHost, replicaProjection, postgresInbox, httpInbox and createInboxHandler from better-compute/iam/plugin) already exist and are tested against a real Better IAM: pnpm test:iam-remote, part of pnpm check, reruns the IAM-dependent suites with bridge.remote, the authz matrix included (see testing). Remote mode belongs to the v0.4 milestone on the roadmap. Pointing Better IAM's own console at compute's IAM database needs an upstream Better IAM change (U0) first, in either mode.

How the pieces fit

createComputeIamBridge(options) builds every piece from one merged catalog. Pass it the same modules and plugins you pass to betterCompute(), and the same typePrefix if you change it.

PieceWhat it does
bridge.plugin()The IamPlugin for betterIam({ plugins }): compute's resource types and actions, and an afterAudit hook that forwards the events compute reacts to into iam_inbox. The hook never throws.
bridge.resolveResource(hostFallback?)Better IAM's resolveResource. It answers compute-* types from memory and hands every other type to your own resolver.
bridge.embedded(iam, options)The compute uses, over the Better IAM instance in the same process. Call embedded() or remote() once per bridge; a second call is INVALID_STATE.
bridge.modulesThe iam module: projection and inbox lifecycle, the migration iam/0001_successors and the reconcilers listed below.
bridge.catalog, bridge.projection, bridge.flagsThe merged catalog, the memory-resident projection and the flag cache, for tests and diagnostics.

Wire embedded mode

Every process that constructs betterIam() installs compute's plugin and resolver, and compute receives the port and the iam module:

compute.server.ts
import { postgresAdapter } from '@better-iam/adapter-postgres';
import { betterIam } from '@better-iam/server';
import { createComputeIamBridge } from 'better-compute/iam';
import { metal } from 'better-compute/metal';
import { postgres } from 'better-compute/postgres';
import { betterCompute } from 'better-compute/server';

const modules = [metal()];
const bridge = createComputeIamBridge({ modules }); // the same modules compute registers

export const iam = betterIam({
  database: postgresAdapter({ connectionString: process.env.IAM_DATABASE_URL! }),
  secret: process.env.IAM_SECRET!,
  baseURL: 'https://iam.example.com',
  plugins: [bridge.plugin()], // catalog + afterAudit into iam_inbox
  resolveResource: bridge.resolveResource(), // compute-* types from memory
  // Optional: session JWTs whose audience includes compute.
  sts: { jwt: { signingKeys: [iamSigningKey], audiences: ['https://compute.example.com'] } },
});

export const compute = betterCompute({
  database: postgres({ url: process.env.BETTER_COMPUTE_DATABASE_URL! }),
  secret: process.env.BETTER_COMPUTE_SECRET!,
  baseURL: 'https://compute.example.com',
  modules: [...modules, ...bridge.modules],
  iam: bridge.embedded(iam, { rootTenantId, runIamJobs: true }),
});

await compute.initialize(); // catalog check, migrations, then the iam module loads the projection
  • rootTenantId is the id of Better IAM's root tenant, which compute treats as the platform. You can also pass it to betterCompute({ rootTenantId }); when both are set they must agree.
  • initialize() fails with INVALID_CONFIG when a method's action is missing from the merged catalog. See Actions and resources.
  • Until the iam module has loaded the projection, every compute decision is denied and inbox writes spill into memory. A host that constructs Better IAM outside compute calls bridge.bind({ sql }) to do the same.
  • runIamJobs: true makes the iam module run Better IAM's scheduler jobs (below). It needs a real betterIam() instance, or it throws INVALID_CONFIG.

For local development without configured IAM, better-compute dev --sim N starts an in-memory Better IAM with compute's plugin and resolver installed and prints its root administrator's token. Plain better-compute dev without IAM answers only public methods. See Local development.

Credentials compute accepts

Callers authenticate to compute with Better IAM credentials only:

  • IAM bearer tokens in the Authorization header: user sessions, API keys, role sessions and delegated sessions (biam_…).
  • Session JWTs from Better IAM's sts.getSessionToken({ format: 'jwt', audience: [iamIssuer, computeAudience] }). Better IAM mints them only when sts.jwt.audiences contains compute's audience and the caller holds iam:session-tokens:create and iam:assertions:create; the gpu-user example policy grants both.
  • The IAM session cookie, only when betterCompute({ acceptIamCookie: true }) is set for a console on the same origin. Otherwise compute drops the cookie before calling IAM.

Authentication yields a Caller: identityId, identityKind (user, service or agent), tenantId (the acting tenant; a role session acts in the role's tenant), sessionId, sessionKind (user, role, api-key, session-token or delegated), mfa, and agentId and delegationId for agents. Any other identity or session kind is refused with UNAUTHENTICATED.

Fleet paths never call IAM

Every Better IAM call takes a turn on one serialized lane: its PostgreSQL adapter takes one advisory lock per transaction. Compute therefore keeps IAM off every path that scales with the fleet. Heartbeats, inventory reports, command traffic, joins, gateway jobs and reconcilers make no IAM call. Only API requests do:

RequestIAM cost
A mutation or admission (require)2 lock acquisitions (Better IAM authenticates again inside require), 3 when the 30-second authentication cache misses
A read whose allow is cachedNone for 10 seconds
A list pageOne coarse decision, then at most one authorizeMany per 50 items
A feature flagOne features.isEnabled per tenant and key on a cache miss, never one per admission

A resolver hit inside IAM's locked transaction does no I/O. A reservation or instance admission costs one more call, billingCheck, when its pool's policy sets admission.budgetCheck: 'meter' or the tenant's offer reacts to a spent budget with stop-new or drain (see Billing and usage).

tests/sim/iam-negative.test.ts enforces the rule: it wraps the port in spyIamPort, tags fleet code paths with spy.path(name, fn), runs ten simulated minutes of joins, heartbeats, inventory, commands and a reservation, and asserts that every recorded IAM call came from an API admission. spyIamPort is exported for your own tests.

Rules for every IAM host

Better IAM's action catalog and event subscribers are process-local, so every process that constructs betterIam() on compute's IAM database must install compute's plugin and resolver. Two failure modes follow when one does not:

  • No plugin. That host knows no compute:* action. Its decisions come back as UNKNOWN_ACTION, which every caller sees as ACCESS_DENIED. policies.create rejects compute:* actions with INVALID_ACTION, relationships.create on compute-pool fails, and audit events recorded there never reach compute's inbox.
  • Plugin but no resolver. Compute decisions fail with RESOURCE_RESOLVER_REQUIRED.

Until upstream change U0 lands, the supported topology is that compute's own api and worker processes are the only IAM hosts on that database, with runIamJobs: true so that someone dispatches Better IAM's audit hooks. Administrators author policies with a Better IAM CLI whose configuration also installs computeIamHost, so that compute:* actions validate.

The plugin's afterAudit must never throw: Better IAM awaits every plugin's hook in one loop without a guard, so one throw would stall the host's whole event pipeline. A write to the inbox that fails or takes longer than 2 seconds spills the event into a bounded in-memory buffer (10 000 events, oldest dropped first), which is flushed after the next successful write. timeoutMs and spill on bridge.plugin() change both limits.

IAM events compute reacts to

The plugin forwards only allowed audit events whose action compute reacts to, and the inbox dedupes them on the event id, because Better IAM delivers at least once:

EventsReaction
identity:offboard, identity:expire, identity:delete, iam:identities:delete, iam:identities:updateidentity: close streams, reassign work; the successor of an offboarded identity is remembered for usage
auth:session:revoke, auth:session:revoke-others, identity:revoke-sessions, tenant:revoke-sessions, iam:roles:revoke-sessionssessions: recheck streams, drop cached authentications
agent:suspend, agent:suspend-all, delegation:revokeagent: close the agent's streams, cancel its unplaced work
binding:activate, binding:deactivate, iam:bindings:activatebinding: cap or close break-glass sessions
iam:tenants:update, iam:tenants:deletetenant: refuse a suspended tenant's mutations and a deleted tenant's calls, tear the deleted one down
Policy, role, binding and relationship changes, feature:*authz: drop read-decision and flag caches
billing:budget-alertbudget: apply the offering's budget policy

The iam.inbox reconciler drains the inbox per tenant in Better IAM's sequence order, without waiting for gaps, and every reaction is level-triggered: it re-reads current state instead of trusting event order. Two reactions are built in: remembering successorId from identity:offboard, and invalidating the flag cache. The rest are hooks.

better-compute serve installs default handlers when the configuration's host sets host.iam: { bridge } (installIamHandlers), each running in the inbox transaction:

ReactionDefault
agentCloses the agent's or delegation's tunnels on every live gateway within the 2-second inbox drain (agentStreamCloser(gatewayStreamCloser(...))), drops its cached authentications, and cancels the reservations it created that have not started (pending, placed, awaiting-confirmation) with capacity's withdrawAgentReservations
identityDrops the identity's cached authentications. For an owner gone for good (offboarded, expired, deleted, or found so by the IamReconcile backstop), identityEndOf finds the successor (from the event, or the one remembered in iam_successors) and reassignReservations hands its live reservations to it (reservation.reassigned); without a successor its unstarted reservations are cancelled. Its break-glass SSH certificates are revoked in every tenant, then its streams and tunnels close
sessionsClears the authentication cache. When an identity's own sessions are revoked, it revokes the identity's break-glass SSH certificates (the event does not say which session ended) and re-authorizes its tunnels at once
authzDrops read-decision and flag caches
bindingOn binding:deactivate, revokes the holder's live SSH certificates in every tenant, including child tenants reached through an inherited binding (node.ssh-cert.revoked, the serial joins the node's KRL), and has every gateway re-check the holder's tunnels. The holder is the actor only when they ended their own activation; a revocation must name its holder in metadata.identityId, and one that does not is reported through onError and left to the per-minute re-authorization, so an administrator's own certificates are never taken for the holder's
budgetbudgetAlertHandler(applyBudgetPolicy): for an exceeded budget, each offer to the tenant publishes quota.budget-exceeded, and drain also cancels the tenant's unstarted reservations in that pool
tenantThe server's tenantStateHandler: mirrors suspended and deleted tenants into compute_tenant_states, publishes tenant.state-changed, reloads the envelope's tenant gate and schedules a deleted tenant's teardown. With host.iam.readTenants a suspension or deletion cascades to the subtree, read by the tenancy reconciler outside the inbox transaction and retried while IAM is down; without it only a deletion of the named tenant applies. See tenancy

host.iam.handlers replace the defaults one by one. A failed successor lookup fails the reaction, so the inbox retries it rather than cancelling as if there were no successor.

Remote IAM without its database. When compute cannot read the IAM database's inbox, Better IAM can deliver the same events as signed webhooks (v0.4 fallback intake). host.iam.webhook: { secret, path? } makes serve mount createIamWebhookHandler({ secret, inbox }) at POST /iam/events (or path); it verifies X-Better-IAM-Signature (HMAC-SHA256 over <timestamp>.<body>, 300-second tolerance), writes the events compute reacts to into iam_inbox, and dedupes redeliveries on the audit event id. The IAM root subscribes once with webhooks.create({ scope: 'subtree', events: computeIamEventPatterns, outcomes: ['allow'], url }), and secret is what that call returned. Tampered, stale and unsigned deliveries get 401. See tunnels. If you build the server yourself, wire the ones you need:

compute.server.ts
import { agentStreamCloser, gatewayStreamCloser } from 'better-compute/iam';

bridge.setHandlers({
  // gatewayClients: gatewayControlClient(...) for each gateway's internal control API
  agent: agentStreamCloser(gatewayStreamCloser(() => gatewayClients)),
  async identity(_tx, event) {
    if (event.subjectId) compute.caches.dropIdentity(event.subjectId);
  },
  async sessions() {
    compute.caches.clear(); // rare; costs only re-authentication
  },
  async authz() {
    compute.caches.dropDecisions();
  },
  onError(error, event, reaction) {
    console.warn('IAM reaction failed', reaction, event.action, error);
  },
});

Each handler runs inside the drain transaction under its own savepoint. A handler that fails is reported to onError, and the event is marked processed anyway: iam.reconcile repairs state, not events.

Jobs the iam module runs

The iam module's reconcilers are singletons (shardBy: 'none'): exactly one worker running the controllers role runs each one at a time, under its own lease.

ReconcilerEveryWhat it does
iam.inbox2 sClaims up to 500 unprocessed iam_inbox rows with FOR UPDATE SKIP LOCKED, runs the reactions, and deletes processed rows after 7 days
iam.reconcile5 minRe-reads from IAM every identity that owns a non-terminal reservation, and hands inactive ones to the identity handler. Embedded mode only.
iam.usage1 hThe usage sink: sends closed usage windows to Better IAM billing
iam.pull1 sRemote mode with iamDatabase: moves rows from the IAM database's compute_inbox into iam_inbox
iam.jobs.audit-hooks1 sWith runIamJobs: Better IAM's events.dispatch(), which delivers audit events to plugins, compute's inbox included
iam.jobs.auth-outbox5 sWith runIamJobs: auth.dispatchOutbox()
iam.jobs.budgets1 hWith runIamJobs: billing.checkBudgets()
iam.jobs.close-period1 dWith runIamJobs: billing.closePeriod()
iam.jobs.sweep1 hWith runIamJobs: sweepExpired()

betterCompute({ iamJobs }) is an alternative for hosts that bring their own job functions: those run under the iam worker role, each under a singleton:iam:<name> lease.

Set up and check

better-compute iam setup prints the example policies for organization administrators to apply, and with --meters creates the root billing meters. See Policies and Billing and usage.

better-compute doctor runs compute.selfCheck(), which includes the iam module's own checks:

FindingSeverityMeaning
iam.not-configuredwarningThe configuration uses unconfiguredIam(): no credential authenticates and only system.version and system.whoami answer
iam.projectionerrorThe authz projection is not loaded, so every compute decision is denied
iam.inbox-spillwarning, or error once events were droppedIAM events are spilled in memory because the inbox could not be written
iam.inbox-backlogwarningMore than 10 000 events wait in iam_inbox: no worker runs iam.inbox

The CLI cannot see the IAM hosts themselves. On each process that constructs betterIam(), call checkIamHost(iam, { catalog: bridge.catalog, credential, rootTenantId, expectSessionJwts }) with a root administrator's credential. It returns findings instead of throwing: iam.catalog and iam.resource-types (the plugin is missing), iam.resolver (a synthetic decision on compute-fleet/global failed, typically with RESOURCE_RESOLVER_REQUIRED) and iam.session-jwt (session JWTs are expected but sts.jwt is not configured). PLAN.md lists further host checks, such as the inbox heartbeat age and a synthetic session JWT with doctor --token, that are not implemented yet.

In this section

Was this page helpful?

Better Compute is created by Sean Filimon

Last updated

On this page