Actions and resources
The compute:* actions and compute-* resource types Better Compute adds to Better IAM, the method-to-action table, list filtering and cross-tenant refusal.
Every protected method asks Better IAM one question: may this caller perform this action on this
resource in this ? Actions are written compute:<area>:<verb>, such as
compute:reservations:create. Resources are written <type>/<id>, such as compute-pool/{poolId}. Both come
from compute's catalog, which the bridge installs into every Better IAM host through bridge.plugin(), and they
are the names you write policies against.
Resource types
All of compute's resource types are app-owned: nothing is ever registered in Better IAM. When IAM needs a
resource, it calls compute's , which answers from the memory-resident
. The prefix is compute- by default; typePrefix changes it
(it must match ^[a-z][a-z0-9-]{0,31}$) and must be the same in betterCompute() and createComputeIamBridge().
| Type | What it is | Parent | Relations | Declared attributes |
|---|---|---|---|---|
compute-fleet | The platform fleet. A scope type whose only id is global. | |||
compute-tenant | A tenant's compute scope. A scope type whose id is the tenant id. | |||
compute-project | A | member, admin | ||
compute-site | A boot and failure domain | region, iamRegion, kind | ||
compute-pool | A scheduling and policy domain | viewer, user, operator, admin | region, class, gpuModel, gpuMemoryGb, sharing | |
compute-node | A machine | compute-pool | operator | region, gpuModel, gpuCount, lifecycle, trust, dedicatedTo |
compute-reservation | A GPU reservation, decided while it is still a | compute-pool | region, gpuModel, gpuCount, durationHours, binding, projectId, spot | |
compute-instance | A VPS instance | compute-pool | vcpu, memoryGiB, gpus, imageId, imageVerified, projectId | |
compute-image | An OS or ramdisk image | visibility, verified | ||
compute-profile | A provisioning profile | |||
compute-provider-account | An account with an external capacity provider | |||
compute-sandbox | A sandbox (v0.5): a VPS instance with a template and a lifetime, decided while it is still a draft | compute-pool | templateId, vcpu, memoryGiB, gpus, ttlSeconds, warm, and fromSnapshot on a restore | |
compute-sandbox-template | A tenant's sandbox template (v0.5): pool, flavor, image, runtime, TTL bounds, warm pool | |||
compute-sandbox-snapshot | A sandbox's snapshot (v0.5): disk and memory, tenant-private, owned by its sandbox's owner whoever took it; may outlive its sandbox | sandboxId, templateId, includeMemory | ||
compute-sandbox-volume | A tenant's persistent sandbox volume (v1.1, agent memory): a disk on one host of a pool that its sandboxes attach across runs, owned by whoever created it; decided before it exists on create | compute-pool | sizeGiB (pending on resize) |
Besides a row's attributes, every resolved resource carries ownerId when the row has an owner, and the prefixed
parentType and parentId of its parent. Better IAM turns the parent into resource.parentRelations, so a
policy on a reservation can test the caller's relations on its pool. The attributes each row actually carries
today are listed under Resource attributes.
Scope types
Three types exist only as scopes, so a decision about "everything in this tenant" or "the whole fleet" has a resource to name:
compute-fleet/globalresolves only in the root tenant. Fleet-wide methods are decided in the root tenant, whatevertenantIdtheir input names.compute-tenant/{t}resolves only when its id is the decision's tenant.compute-project/{id}resolves in the project's owner tenant.
How the resolver answers
The resolver runs inside Better IAM's locked decision transaction, so a hit must do no I/O:
- It echoes
{ tenantId, type, id }exactly. Better IAM refuses anything else asRESOURCE_MISMATCH. - A row missing from memory costs one primary-key read, bounded to 20 ms. A row that does not exist is
NOT_FOUND, which Better IAM does not audit and which aborts a wholeauthorizeManybatch. - A lookup that times out, or a projection that is not loaded yet, denies (
ACCESS_DENIED). - Types without compute's prefix go to the
hostFallbackyou passed tobridge.resolveResource(), or fail withRESOURCE_RESOLVER_REQUIRED.
Each process loads the projection at start and then tails authz_changes about once a second. Staleness can
cause a spurious denial, never a wrong mutation, because compute re-validates inside its own transaction.
Admissions never decide on a stale copy. After an admission's first transaction commits the draft (or the changed
row, as in reservations.extend), the envelope calls the optional IamPort.refreshResource(tenantId, resource)
before iam.require. The embedded port from bridge.embedded() implements it with AuthzProjection.refresh(type, id, tenantId), which re-reads that one row; on a miss or a failed read it drops the row, so the decision takes the
bounded miss path above and fails closed. Before this, reservations.extend decided on the row cached at creation,
so a BoundedExtend-style policy capped at 72 hours could be passed by one extra hour within the tail interval.
tests/server-example-policies.test.ts runs the example policies against real drafts to hold this.
In remote mode the IAM host decides on its replica's copy, so pass refresh: { url, secret, sql? } to
bridge.remote() (or remoteIam(), which it wraps):
the port then posts a signed { tenantId, type, id, lsn } to the IAM host's refresh handler
(computeIamHost().refreshHandler), which waits at most 2 seconds until its replica has replayed compute's WAL
position from after transaction 1 and re-reads the row. Any failure fails the admission with UNAVAILABLE (fail
closed). Without refresh the remote port has no refreshResource, remote admissions can decide on a row up to the
replica lag plus 1 second old, and better-compute doctor warns iam.refresh.
The remote port forwards only identity and client-context headers with the caller's credential (authorization,
cookie when there is no bearer, origin, user-agent, x-request-id), never the client's own
x-forwarded-for, x-real-ip or forwarded (e7092bf, security review 14 L3): behind a proxy that appends to them,
a caller could choose the request.sourceIp its policy conditions see. The address IAM sees is
Credential.sourceIp, which the host verified and the port sends as x-forwarded-for. compute.nodeHandler sets it
from the socket's peer address; a fetch host passes it as compute.handler(request, { peer: { address } })
(d269738), from the socket or from the hop a proxy it trusts reported. Without it IAM sees no source address. See
hosting the API.
Actions
The core catalog declares 56 actions. Most areas have a read action and a manage, create or finer verb.
Where an action appears on a scope type as well as on an item type, the scope form gates list and create calls
and the item form gates calls on one resource:
| Action | Resource types that carry it |
|---|---|
compute:fleet:admin | compute-fleet |
compute:sites:read | compute-tenant, compute-site |
compute:sites:manage | compute-fleet, compute-tenant, compute-site |
compute:pools:read | compute-tenant, compute-pool |
compute:pools:create | compute-fleet, compute-tenant |
compute:pools:manage | compute-pool |
compute:pools:share | compute-pool |
compute:capacity:read | compute-tenant, compute-pool |
compute:quotas:read | compute-tenant |
compute:quotas:manage | compute-fleet |
compute:join-tokens:create, compute:join-tokens:read, compute:join-tokens:revoke | compute-pool |
compute:flavors:read, compute:flavors:manage | compute-pool |
compute:nodes:read | compute-tenant, compute-node |
compute:nodes:provision | compute-pool, compute-node |
compute:nodes:update, compute:nodes:cordon, compute:nodes:drain, compute:nodes:power, compute:nodes:exec, compute:nodes:console, compute:nodes:ssh-root, compute:nodes:delete, compute:nodes:revoke-identity | compute-node |
compute:workflows:read, compute:workflows:manage | compute-node |
compute:reservations:read | compute-tenant, compute-reservation |
compute:reservations:create, compute:reservations:extend, compute:reservations:release | compute-reservation |
compute:instances:read | compute-tenant, compute-instance |
compute:instances:create, compute:instances:update, compute:instances:exec, compute:instances:delete | compute-instance |
compute:projects:read, compute:projects:manage | compute-tenant, compute-project |
compute:usage:read | compute-tenant, compute-project |
compute:events:read, compute:webhooks:manage | compute-tenant |
compute:secrets:manage | compute-fleet, compute-tenant |
compute:drivers:read | compute-fleet, compute-tenant |
compute:profiles:read, compute:profiles:manage | compute-tenant, compute-profile |
compute:images:read | compute-tenant, compute-image |
compute:images:publish | compute-fleet, compute-tenant, compute-image |
compute:providers:read | compute-tenant, compute-provider-account |
compute:providers:manage | compute-fleet, compute-tenant, compute-provider-account |
compute:sandboxes:read, compute:sandboxes:manage | compute-tenant, compute-sandbox-template, compute-sandbox-volume (read also on compute-sandbox and compute-sandbox-snapshot) |
compute:sandboxes:create, compute:sandboxes:update, compute:sandboxes:exec, compute:sandboxes:delete | compute-sandbox (delete also on compute-sandbox-snapshot) |
compute:batches:read, compute:batches:create, compute:batches:approve | compute-tenant (v1.0 destructive-batch quorum) |
Adding actions
Modules and plugins extend the catalog with a catalog fragment, which createComputeIamBridge() merges into the
types above. The built-in modules add none: the core catalog covers all of their methods. The merge enforces
Better IAM's limits and fails with INVALID_CONFIG on a breach:
- An action matches
^compute:[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)+$. A plugin's actions must start withcompute:<pluginId>:. - A type has at most 64 attributes and 32 relations, and its name at most 64 characters. Attribute types are
string,numberorboolean. - Two fragments may not disagree on a type's parent or on an attribute's type. A parent must be in the catalog.
import { definePlugin } from 'better-compute/core';
export const acmeAutoinstall = () =>
definePlugin({
id: 'acme-autoinstall',
catalog: {
// Merged into compute-node; plugin actions live under compute:<id>:.
resourceTypes: { node: { actions: ['compute:acme-autoinstall:render'] } },
},
// api(context) { ...methods that declare action 'compute:acme-autoinstall:render' }
});Pass the plugin to both betterCompute({ plugins }) and createComputeIamBridge({ plugins }), so the API and
every IAM host agree. initialize() checks the method registry against the merged catalog: a method whose action
is missing (uncatalogued) or that disagrees with its method-table row (mismatch) fails with INVALID_CONFIG,
and a non-plugin method with no row (undeclared) is a catalog.undeclared warning in better-compute doctor.
See Modules and Methods.
Method-to-action table
This table is rendered from the live method registry. The normative source is PLAN.md §9.2, kept as apiSurface
in @better-compute/core: scripts/authz-matrix.mjs generates one test per row, and
tests/iam-bridge-matrix.test.ts checks every action and type pair against a real Better IAM carrying the
bridge, allowing each only when granted. It decides through compute's own IamPort: bridge.embedded under
pnpm test, and bridge.remote over the IAM's HTTP API under pnpm test:iam-remote.
- Decided on is the resource type of the one decision. Its id comes from the input: the pool for
compute-pool, the tenant forcompute-tenant,globalforcompute-fleet. - or means the method decides on one of two resources depending on its input. For example,
sites.createdecides oncompute-fleetforsharedanddedicatedsites and oncompute-tenantfor a tenant'scustomsite, andusage.listdecides oncompute-projectwhen the input names a project. - + items marks a list that follows the list recipe.
- planned marks a method this release does not serve yet; it arrives in the phase shown.
Platform and tenancy
| Method | Action | Decided on | Phase |
|---|---|---|---|
system.whoami | none (public) | nothing | v0.1 |
system.version | none (public) | nothing | v0.1 |
system.selfCheck | compute:fleet:admin | compute-fleet | v0.1 |
projects.create | compute:projects:manage | compute-tenant | v0.1 |
projects.get | compute:projects:read | compute-project | v0.1 |
projects.list | compute:projects:read | compute-tenant + items | v0.1 |
projects.archive | compute:projects:manage | compute-project | v0.2 |
sites.list | compute:sites:read | compute-tenant + items | v0.1 |
sites.get | compute:sites:read | compute-site | v0.1 |
sites.create | compute:sites:manage | compute-fleet or compute-tenant | v0.1 |
sites.update | compute:sites:manage | compute-site | v0.2 |
sites.delete | compute:sites:manage | compute-site | v0.2 |
siteTokens.create | compute:sites:manage | compute-site | v0.2 |
siteTokens.revoke | compute:sites:manage | compute-site | v0.2 |
secrets.put | compute:secrets:manage | compute-tenant or compute-fleet | v0.2 |
secrets.delete | compute:secrets:manage | compute-tenant or compute-fleet | v0.2 |
drivers.list | compute:drivers:read | compute-tenant | v0.2 |
Nodes and access
| Method | Action | Decided on | Phase |
|---|---|---|---|
joinTokens.create | compute:join-tokens:create | compute-pool | v0.1 |
joinTokens.list | compute:join-tokens:read | compute-pool | v0.1 |
joinTokens.revoke | compute:join-tokens:revoke | compute-pool | v0.1 |
joinRequests.list | compute:join-tokens:read | compute-pool | v0.2 |
joinRequests.approve | compute:join-tokens:create | compute-pool | v0.2 |
joinRequests.reject | compute:join-tokens:create | compute-pool | v0.2 |
nodes.list | compute:nodes:read | compute-tenant + items | v0.1 |
nodes.get | compute:nodes:read | compute-node | v0.1 |
nodes.events | compute:nodes:read | compute-node | v0.1 |
nodes.cordon | compute:nodes:cordon | compute-node | v0.1 |
nodes.uncordon | compute:nodes:cordon | compute-node | v0.1 |
nodes.drain | compute:nodes:drain | compute-node | v0.1 |
nodes.label | compute:nodes:update | compute-node | v0.1 |
nodes.taint | compute:nodes:update | compute-node | v0.1 |
nodes.adopt | compute:nodes:provision | compute-node | v0.1 |
nodes.revokeIdentity | compute:nodes:revoke-identity | compute-node | v0.1 |
nodes.delete | compute:nodes:delete | compute-node | v0.2 |
nodes.issueSshCert | compute:nodes:ssh-root | compute-node | v0.3 |
nodes.revokeSshCert | compute:nodes:ssh-root | compute-node | v0.3 |
nodes.listSshCerts | compute:nodes:read | compute-node | v0.3 |
inventory.get | compute:nodes:read | compute-node | v0.1 |
inventory.history | compute:nodes:read | compute-node | v0.2 |
inventory.diff | compute:nodes:read | compute-node | v0.2 |
commands.run | compute:nodes:exec | compute-node | v0.1 |
commands.get | compute:nodes:read | compute-node | v0.1 |
commands.list | compute:nodes:read | compute-node | v0.1 |
commands.cancel | compute:nodes:exec | compute-node | v0.2 |
commandBatches.create | compute:batches:create | compute-tenant | v1.0 |
commandBatches.get, list, scope | compute:batches:read | compute-tenant | v1.0 |
commandBatches.approve, cancel | compute:batches:approve (on top of the approver signatures, never instead of them) | compute-tenant | v1.0 |
tunnels.open | compute:instances:exec | compute-instance or compute:nodes:console on compute-node | v0.3 |
Capacity
| Method | Action | Decided on | Phase |
|---|---|---|---|
pools.list | compute:pools:read | compute-tenant + items | v0.1 |
pools.get | compute:pools:read | compute-pool | v0.1 |
pools.create | compute:pools:create | compute-fleet or compute-tenant | v0.1 |
pools.capacity | compute:capacity:read | compute-pool | v0.1 |
pools.offer | compute:pools:share | compute-pool | v0.1 |
pools.unoffer | compute:pools:share | compute-pool | v0.1 |
pools.update | compute:pools:manage | compute-pool | v0.2 |
pools.archive | compute:pools:manage | compute-pool | v0.2 |
capacity.summary | compute:capacity:read | compute-tenant | v0.1 |
capacity.byPool | compute:capacity:read | compute-pool | v0.2 |
capacity.preview | compute:capacity:read | compute-pool | v0.2 |
capacity.timeline | compute:capacity:read | compute-pool | v0.2 |
reservations.create | compute:reservations:create | compute-reservation (the draft) | v0.1 |
reservations.get | compute:reservations:read | compute-reservation | v0.1 |
reservations.list | compute:reservations:read | compute-tenant + items | v0.1 |
reservations.release | compute:reservations:release | compute-reservation | v0.1 |
reservations.cancel | compute:reservations:release | compute-reservation | v0.2 |
reservations.extend | compute:reservations:extend | compute-reservation | v0.1 |
reservations.renew | compute:reservations:extend | compute-reservation | v0.1 |
quotas.get | compute:quotas:read | compute-tenant | v0.1 |
quotas.set | compute:quotas:manage | compute-fleet | v0.2 |
Usage and events
| Method | Action | Decided on | Phase |
|---|---|---|---|
usage.list | compute:usage:read | compute-tenant or compute-project | v0.1 |
usage.export | compute:usage:read | compute-tenant or compute-project | v0.2 |
events.list | compute:events:read | compute-tenant | v0.1 |
webhooks.create | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.list | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.get | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.update | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.rotateSecret | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.delete | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.ping | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.deliveries | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.redeliver | compute:webhooks:manage | compute-tenant | v0.3 |
webhooks.eventTypes | compute:webhooks:manage | compute-tenant | served, no §9.2 row |
Provisioning and bare metal
| Method | Action | Decided on | Phase |
|---|---|---|---|
profiles.create | compute:profiles:manage | compute-tenant | v0.2 |
profiles.get | compute:profiles:read | compute-profile | v0.2 |
profiles.validate | compute:profiles:read | compute-profile | v0.2 |
profiles.render | compute:profiles:read | compute-profile | v0.2 |
profiles.list | compute:profiles:read | compute-tenant + items | v0.2 |
profiles.update | compute:profiles:manage | compute-profile | v0.2 |
profiles.delete | compute:profiles:manage | compute-profile | v0.2 |
images.register | compute:images:publish | compute-tenant or compute-fleet | v0.2 |
images.get | compute:images:read | compute-image | v0.2 |
images.list | compute:images:read | compute-tenant + items | v0.2 |
images.promote | compute:images:publish | compute-image | v0.2 |
workflows.get | compute:workflows:read | compute-node | v0.2 |
workflows.list | compute:workflows:read | compute-node | v0.2 |
workflows.abort | compute:workflows:manage | compute-node | v0.2 |
workflows.retry | compute:workflows:manage | compute-node | v0.2 |
workflows.pause | compute:workflows:manage | compute-node | v0.2 |
workflows.resume | compute:workflows:manage | compute-node | v0.2 |
metal.enroll | compute:nodes:provision | compute-pool | v0.2 |
metal.manage | compute:nodes:provision | compute-node | v0.2 |
metal.inspect | compute:nodes:provision | compute-node | v0.2 |
metal.provide | compute:nodes:provision | compute-node | v0.2 |
metal.deploy | compute:nodes:provision | compute-node | v0.2 |
metal.undeploy | compute:nodes:provision | compute-node | v0.2 |
metal.rescue | compute:nodes:provision | compute-node | v0.2 |
metal.unrescue | compute:nodes:provision | compute-node | v0.2 |
metal.abort | compute:nodes:provision | compute-node | v0.2 |
metal.recover | compute:nodes:provision | compute-node | v0.2 |
metal.retire | compute:nodes:provision | compute-node | v0.2 |
metal.power | compute:nodes:power | compute-node | v0.2 |
metal.bulk | compute:nodes:provision | compute-pool, then each node | v1.0 |
metal.discovered | compute:sites:read | compute-site (the site's owning tenant only) | v1.0 |
metal.confirmMac | compute:nodes:provision | compute-node | v1.0 |
VPS
| Method | Action | Decided on | Phase |
|---|---|---|---|
instances.create | compute:instances:create, and compute:secrets:manage when userData is a secret:// reference | compute-instance (the draft); the secrets decision on compute-tenant, plus compute-fleet in the root tenant | v0.3 |
instances.list | compute:instances:read | compute-tenant + items | v0.3 |
instances.get | compute:instances:read | compute-instance | v0.3 |
instances.start | compute:instances:update | compute-instance | v0.3 |
instances.stop | compute:instances:update | compute-instance | v0.3 |
instances.resize | compute:instances:update | compute-instance | v0.3 |
instances.delete | compute:instances:delete | compute-instance | v0.3 |
flavors.list | compute:flavors:read | compute-pool | v0.3 |
flavors.create | compute:flavors:manage | compute-pool | v0.3 |
flavors.update | compute:flavors:manage | compute-pool | v0.3 |
flavors.delete | compute:flavors:manage | compute-pool | v0.3 |
providerAccounts.create | compute:providers:manage | compute-fleet or compute-tenant | v0.3 |
providerAccounts.list | compute:providers:read | compute-tenant + items | v0.3 |
providerAccounts.get | compute:providers:read | compute-provider-account | v0.3 |
providerAccounts.offers | compute:providers:read | compute-provider-account | v0.3 |
providerAccounts.update | compute:providers:manage | compute-provider-account | v0.3 |
providerAccounts.delete | compute:providers:manage | compute-provider-account | v0.3 |
sandboxes.create | compute:sandboxes:create | compute-sandbox (the draft) | v0.5 |
sandboxes.list | compute:sandboxes:read | compute-tenant + items | v0.5 |
sandboxes.get | compute:sandboxes:read | compute-sandbox | v0.5 |
sandboxes.extend | compute:sandboxes:update | compute-sandbox | v0.5 |
sandboxes.snapshot | compute:sandboxes:update | compute-sandbox | v0.5 |
sandboxes.exec (hosted by the tunnels module) | compute:sandboxes:exec | compute-sandbox | v0.5 |
sandboxes.delete | compute:sandboxes:delete | compute-sandbox | v0.5 |
sandboxTemplates.create | compute:sandboxes:manage, and compute:secrets:manage when it names secrets; for each volume it names, compute:sandboxes:manage (read-write mount) or compute:sandboxes:read (read-only) | compute-tenant (the secrets decision also on compute-fleet in the root tenant; the volume decisions on each compute-sandbox-volume, 77bf9f5) | v0.5 |
sandboxTemplates.list | compute:sandboxes:read | compute-tenant + items | v0.5 |
sandboxTemplates.get | compute:sandboxes:read | compute-sandbox-template | v0.5 |
sandboxTemplates.update | compute:sandboxes:manage, and compute:secrets:manage when the resulting template names secrets and the update changes them or what the guest runs; the per-volume decisions for a new volumes list, or for the current one when the update changes what the guest runs | compute-sandbox-template (the secrets decision on compute-tenant, plus compute-fleet in the root tenant; the volume decisions on each compute-sandbox-volume) | v0.5 |
sandboxTemplates.delete | compute:sandboxes:manage | compute-sandbox-template | v0.5 |
sandboxSnapshots.list | compute:sandboxes:read | compute-tenant + items | v0.5 |
sandboxSnapshots.get | compute:sandboxes:read | compute-sandbox-snapshot | v0.5 |
sandboxSnapshots.delete | compute:sandboxes:delete | compute-sandbox-snapshot | v0.5 |
sandboxVolumes.create | compute:sandboxes:manage, with the requested sizeGiB | compute-sandbox-volume (the volume it would create, under its pool; 6cb30e9, was compute-tenant) | v1.0 |
sandboxVolumes.list | compute:sandboxes:read | compute-tenant (scope mine by default, or all) | v1.0 |
sandboxVolumes.get | compute:sandboxes:read | compute-sandbox-volume | v1.0 |
sandboxVolumes.resize | compute:sandboxes:manage, with the new sizeGiB pending | compute-sandbox-volume | v1.0 |
sandboxVolumes.delete | compute:sandboxes:manage | compute-sandbox-volume | v1.0 |
Fleet and rollouts
| Method | Action | Decided on | Phase |
|---|---|---|---|
fleet.export | compute:fleet:admin | compute-fleet | v0.2 |
fleet.plan | compute:fleet:admin | compute-fleet | v0.2 |
fleet.apply | compute:fleet:admin | compute-fleet | v0.2 |
fleet.status | compute:fleet:admin | compute-fleet | v0.2 |
releases.register | compute:fleet:admin | compute-fleet | v0.4 |
releases.list | compute:fleet:admin | compute-fleet | v0.4 |
rollouts.create | compute:fleet:admin | compute-fleet | v0.4 |
rollouts.list | compute:fleet:admin | compute-fleet | v0.4 |
rollouts.get | compute:fleet:admin | compute-fleet | v0.4 |
rollouts.pause | compute:fleet:admin | compute-fleet | v0.4 |
rollouts.resume | compute:fleet:admin | compute-fleet | v0.4 |
rollouts.abort | compute:fleet:admin | compute-fleet | v0.4 |
How a call is decided
The envelope in @better-compute/server runs the same steps for every method, over HTTP
(POST /api/compute/<group>/<method>), the typed client and compute.api.<group>.<method>(credential, input):
- Public methods (
system.whoami,system.version) validate their input and run. - A call without a credential is
UNAUTHENTICATED, with no IAM call. - The input is validated (
INVALID_INPUT) before any IAM call. IamPort.authenticateidentifies the caller. The answer is cached for 30 seconds.- The tenant pre-check below runs.
- Methods with an admission (request-as-resource creates,
reservations.extend, and creates that take an idempotency key) runpreparein a first transaction: the idempotency lookup and the draft and projection rows, never an IAM call. A replay by the same caller returns the stored result without a decision. Otherwise the one decision isrequireon the resourcepreparereturned. - Every other mutation makes one
require, never cached. A read makes oneauthorize, whose allows are cached for 10 seconds per session. - The handler runs in one transaction, read-only for reads. An allowed mutation also writes an
audit_eventsrow with its admission context in that transaction.
IAM is never called inside a compute transaction. Denials are opaque: Better IAM reports every denial as
ACCESS_DENIED, including a host without the plugin answering UNKNOWN_ACTION, and compute adds nothing to it.
Cross-tenant refusal
A caller whose tenant is neither the input's tenantId nor the root tenant gets ACCESS_DENIED before compute
reads or writes its database and before any IAM decision. For fleet-wide methods, the decision tenant is the
root tenant, so only root-tenant callers pass.
The check was added because Better IAM 0.1 resolved the resource before it checked the tenant: without it, an
outsider probing ids got NOT_FOUND for ids that do not exist and ACCESS_DENIED for ids that do, an existence
oracle, and every probe appended a denial to the victim tenant's audit chain. Better IAM 0.2, which Better Compute
now requires, checks the tenant first itself: a foreign tenant is denied (TENANT_MISMATCH, masked as
ACCESS_DENIED) before any resolver runs, absent and present ids get the same answer, and the denial is audited on
the caller's own tenant with the target in targetTenantId (pinned by tests/iam-contract.test.ts). Compute keeps
its own check anyway: the answer is the same in every case, and an outsider costs no database access and no IAM
decision.
tests/server-admission.test.ts asserts that such a call makes one IAM call (authenticate) and no database
access at all.
Root-tenant callers continue to IAM, which allows them into another tenant only through ROOT_OVERRIDE: a
root-tenant person with root authority, in a user session with MFA. Better IAM audits every override. Inside your
own tenant, an id that is not in your view resolves to NOT_FOUND, which reveals nothing beyond the tenant.
List methods and item filtering
The methods marked + items return only the rows the caller may read, without one IAM round trip per row:
- Coarse gate. The envelope's one decision on the scope,
compute-tenant/{t}orcompute-project/{id}. - SQL pre-filter. The query is scoped to the tenant and to rows the projection holds in that tenant's view.
- Owner-scoped kinds.
reservations.listandinstances.listtakescope: 'mine' | 'all', andmineis the default: the coarse decision plusowner_identity_id = callerin SQL, with no per-item checks. - Everything else, including
scope: 'all',pools.list,nodes.list,sites.list,projects.list,profiles.list,images.listandproviderAccounts.list: after the page is read, every item is decided with the method's action on its own resource. Allows already in the read-decision cache are reused; the rest go inauthorizeManybatches of at most 50.
// The caller's own reservations: one decision on compute-tenant, no per-item checks.
const mine = await compute.api.reservations.list(credential, { tenantId });
// Every reservation the caller may read in the tenant: per-item checks in batches of 50.
const all = await compute.api.reservations.list(credential, { tenantId, scope: 'all' });A batch never degrades into a partial list. When the resolver does not know an item yet (NOT_FOUND aborts the
whole batch), compute waits 100 ms for the projection to catch up, reloads the page and retries once. Any other
batch error fails the request, and an answer whose length does not match the batch is INTERNAL_ERROR.
Every denied item costs an audit write: Better IAM appends a denial to the tenant's audit chain, under its global
lock. Write policies so that list pages mostly allow, and prefer scope: 'mine' where it answers the question.
Caches
| Cache | Key | Lifetime | Holds |
|---|---|---|---|
| Authentications | sha256 of the token, the Authorization header and the IAM cookie | 30 s | The Caller |
| Read decisions | Session, tenant, action, type and id | 10 s | Allows only |
Both caches hold at most 10 000 entries, least recently used first out. Mutations are never cached. The server
exposes invalidation as compute.caches: dropIdentity(identityId), dropSession(sessionId),
dropDecisions(), clear() and stats(). Every drop also discards answers that were in flight when it
happened, so a revocation cannot be overwritten by an older allow. Wire these to IAM events as shown in
IAM integration.
Better Compute is created by Sean Filimon
Last updated
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.
Policies
Write Better IAM policies for Better Compute, from the gpu-user and agent-guardrails examples to pool relations, resource attributes and conditions.