6.4 Hosts without a runner: brokered access
packs:infrastructurepacks:rpc
After this you can: register a host node, create and test a transport provider, bind it, mint and approve a transport grant, run a host-targeted action and read its audit trail.
Before you start — tick each one:
- Lesson 6.3 — Fleets, groups and placement (
opschain learn 6.3) - Lesson 3.5 — Secrets and the vault (
opschain learn 3.5)
In the GUI: the Estate sidebar's Infrastructure entry lists the host nodes; the Transport providers panel in the Operations area holds the providers, their bindings and the grants, and a held grant waits in Reviews & approvals.
Lesson 6.1's runners execute steps on machines you install OpsChain on.
This lesson covers the opposite case: work that must run on a
machine you install nothing on — a jump-locked production box, an
appliance, a VM a provisioning step made thirty seconds ago. OpsChain
models the machine as an infrastructure node, keeps the SSH
credential in a transport provider (the platform's custody, never
the node's), and admits every session under an approved transport
grant — the step still executes on a runner, which dials the host
with a one-shot brokered credential that never touches your workflow,
your properties or your logs. You need a server target and an admin
login (lesson 0.1), an estate (lesson 1.1) and a runner (lesson 6.1).
The transport catalog, bindings and grants require the packs:rpc
entitlement, and the host-targeted infrastructure:* actions ship in
an action pack requiring packs:infrastructure — a pack minted with
packs:rpc alongside, and whose grant satisfies every packs:rpc gate
(opschain docs entitlements). The estate registry itself — the
opschain infrastructure verbs — is base platform.
1. The shape of brokered access
Three records carry the whole mechanism, and keeping them separate is the point:
- The infrastructure node is the machine's estate identity: path, type, address, connection profile, discovered facts, lifecycle. It never holds credential material — credential-shaped property keys refuse by name at write time.
- The transport provider is the credential's home: a kind
(
ssh-static, orssh-cafor short-lived CA-signed session certificates), connection facts (username, auth method, port), and acredential_ref— asecret-vault://reference, never a value. - The transport grant is the authority: who may use which provider, where in the estate, for what actions, how often.
At step dispatch the engine resolves the provider governing the
host — the host node's own rpc.provider_ref property, else the
deepest-wins scope binding at the host's path, else the single enabled
provider — enforces the caller's transport grant against the host
node's path, and mints a one-shot credentials document into the masked
OPSCHAIN_TRANSPORT_SESSION environment variable, piped to the dial
engine on stdin. Never argv, never node properties, never a log line.
2. What refuses without the packs
Worth knowing before you start, because every refusal is named:
- Creating a transport provider (or enabling a disabled one), creating
or directly approving a transport grant, and declaring a scope
binding each answer the typed
ERROR_REASON_FEATURE_ENTITLEMENT_REQUIREDrefusal namingpacks:rpcwhen the licence lacks it. - Creating a run that uses the infrastructure pack refuses with the
pack-use error:
action pack "…" requires the "packs:infrastructure" entitlement — the <tier> licence does not include it; apply the licence pack that grants it (opschain admin licence apply / PUT /api/licence). - Lapse is polite, never destructive: stored providers and grants keep serving dispatch, reads and the test verb stay up, and reject/revoke/disable/delete stay open so a lapsed estate can always unwind authority — only the create/enable/approve writes gate.
Apply a licence carrying the packs (lesson 1.5) and everything below opens up.
3. Register a host node
A host you operate by name belongs in the estate. Register it under a
project, environment or asset — the node lands at
<parent>/infrastructure/<code>:
opschain infrastructure register \
--parent /projects/acme/environments/prod \
--code jump01 --name "Jump Host 1" --type host \
--address 10.1.9.4 --fqdn jump01.acme.example \
--properties '{"os_family":"linux","rpc":{"target":"deploy@10.1.9.4"}}'
/projects/acme/environments/prod/infrastructure/jump01 (host) lifecycle registered
- Types form a closed registry:
host,hypervisor,network_device,link. The manual lane asserts estate membership only —registered↔deregistered; theprovisionedanddestroyedstates are reserved for the integration that performs those operations, and no manual verb writes them. - The properties document is the connection profile, validated
against the type's reference vocabulary:
rpc.target(user@host[:port]— what the dialing actions default to),rpc.provider_ref(a transport-provider code, pinning the host to a provider),rpc.hops(OpenSSH-Jform). Unknown*_refkeys and credential material (passwords, private keys, tokens) refuse by name. - A duplicate address within the environment warns and never
refuses — real estates have NAT, VIPs and migrations; the list
view flags the collision (
DUP_ADDR yes) instead of blocking it.
Inspect what you made:
opschain infrastructure list --scope /projects/acme --type host
PATH TYPE NAME ADDRESS FQDN LIFECYCLE DUP_ADDR
/projects/acme/environments/prod/infrastructure/jump01 host Jump Host 1 10.1.9.4 jump01.acme.example registered no
opschain infrastructure show <node-path> adds the properties document
and facts freshness. Facts are verb-driven, never ambient — nothing
polls your hosts. Record what discovery observed, and every read
renders it last-observed:
opschain infrastructure facts /projects/acme/environments/prod/infrastructure/jump01 \
--facts '{"os_family":"linux","kernel":"6.8"}'
opschain infrastructure show /projects/acme/environments/prod/infrastructure/jump01
Path: /projects/acme/environments/prod/infrastructure/jump01
ID: 019a0d7e-2f41-7c1b-9d02-6e7b8a4c1d20
Type: host
Name: Jump Host 1
Description: -
Address: 10.1.9.4
FQDN: jump01.acme.example
Lifecycle: registered
Archived: no
Facts: last collected 2026-11-03T09:14:02Z (2m0s)
Properties: {"facts":{"kernel":"6.8","os_family":"linux"},"os_family":"linux","rpc":{"target":"deploy@10.1.9.4"}}
Facts may not carry credential material either — refused by name.
Fleets register in batches with
opschain infrastructure bulk-upsert (a YAML file of rows — the sync
lane for inventories).
4. Create a transport provider — and test it
The provider holds the credential the platform dials with. The record only ever stores a reference; supply the credential inline (never on the command line) and the server vaults it for you:
SSH_KEY=$(cat ~/.ssh/deploy_key) opschain admin transport-providers create prod-ssh \
--config '{"username":"deploy"}' --credential-env SSH_KEY
Created transport provider prod-ssh (credential_ref secret-vault://transport-providers/prod-ssh/credential)
Already keep the key in a vault (lesson 3.5)? Pin the reference instead — it is resolved as it is set, so a record can never point at a reference that does not resolve:
opschain admin transport-providers create prod-ssh \
--config '{"username":"deploy"}' \
--credential-ref secret-vault://customer-vault@infra/prod/ssh_key
The ssh-static config carries connection facts only — username,
auth_method (key, the default, or password), port,
connect_timeout_seconds; unknown keys refuse, because credential
material never belongs in config. The stronger posture is --kind ssh-ca: the referenced credential is a CA signing key that never
leaves the platform — each admitted session mints an ephemeral keypair
and a short-lived certificate signed by it (principals,
validity_seconds, default 300), so no standing key rests on any host
path.
Validate the record without dialling any host — the config
re-parses, the credential resolves and parses as what the config says
it is, and the outcome lands as the record's health (last_test_at,
surfaced by list and show):
opschain admin transport-providers test prod-ssh
Transport provider prod-ssh: record validation succeeded
A failing validation is a successful command (exit 0) that names the reason — the reference, never a value.
5. Bind it to the subtree
With exactly one enabled provider, resolution needs no configuration —
it is the default. The moment a second provider exists, declare which
provider serves which scope (/, a project, or an environment;
deepest-wins):
opschain admin transport-providers bindings set /projects/acme --provider prod-ssh
Bound /projects/acme to transport provider "prod-ssh"
And ask the server for selection provenance whenever you are unsure — several enabled providers with no covering binding refuse by name rather than guessing:
opschain admin transport-providers bindings resolve /projects/acme/environments/prod
Provider: prod-ssh (ssh-static)
Decided by: binding /projects/acme
Health: ok
A host node's own rpc.provider_ref property outranks any binding —
"this appliance always dials through dc-ssh" travels with the host.
6. Mint a transport grant — and approve it
No session is minted without a covering active grant. A grant names
a subject (user, group, service_account or agent_group — humans
and agents gate identically), a provider (a code, or a glob like
nonprod-*), the node-path scope glob sessions may target, the rpc
action patterns (exec, put, get, sync, facts, ensure;
omit for everything), and optional caps:
opschain admin transport-grants create \
--subject-kind user --subject sam \
--provider prod-ssh \
--scope '/projects/acme/environments/prod/**' \
--actions exec,put --require-step-up \
--rate-per-minute 6 --sessions-per-day 40 \
--approvers priya --note "prod host operations" \
--expires-in 720h
Created transport grant 019a0d7e-10c2-7f7a-8b53-3b1c9a4f2e01 (pending) for user:sam on provider prod-ssh
The grant is held by the approvals funnel - approve it with: opschain approvals approve 019a0d7e-10c2-7f7a-8b53-3b1c9a4f2e01
Authority is a ceremony, not an edit. On an estate carrying the
approval fabric the grant lands pending and opens a strict checklist
in the same inbox every other held subject enters — --approvers is
required (a governed grant is never approver-less), and the named
approver releases it with opschain approvals approve <id>; rejection
and quorum-TTL expiry revoke it. On an estate without the fabric the
grant activates directly at create (the base posture), and the direct
opschain admin transport-grants approve <id> verb serves any pending
grant — it refuses while a funnel ceremony is open, so there is never a
side door past an open checklist.
opschain admin transport-grants list
SUBJECT PROVIDER SCOPE ACTIONS STATUS STEP_UP ID
user:sam prod-ssh /projects/acme/environments/prod/** exec,put active yes 019a0d7e-10c2-7f7a-8b53-3b1c9a4f2e01
Note the scope glob covers the host node's path — enforcement at
dispatch is against where the host lives, not where the change was
created. --require-step-up demands a stepped-up session from the
requesting principal at session minting (lesson 7.4), and the caps
bound the loop: over-cap requests refuse loudly and land the audited
warn:transport:cap_refused event.
7. Run a host-targeted action
The infrastructure:* actions ship in the infrastructure action pack.
Declare it in your template repository's .opschain/packs.yaml
(lesson 2.4) and the actions mount like any other pack's:
packs:
- name: infrastructure
remote: opschain-pack-infrastructure # a git remote configured on the install
rev: <tag>
Then run a command on the registered host — host_ref is the host
node's path, and the step's transport session is brokered exactly as
sections 4–6 configured:
opschain runs create change acme/environments/prod/assets/ops \
--action infrastructure:host_exec \
-i host_ref=/projects/acme/environments/prod/infrastructure/jump01 \
-i command='uname -a' --follow
created change 019a0d7e-63fe-70e7-8d24-daca5d8db348 (infrastructure:host_exec on acme/environments/prod/assets/ops) — pending
--follow polls to the terminal status; opschain logs <run-id>
streams the remote command's output (lesson 3.1). The step executes on
a runner, which resolves jump01's provider, checks your grant against
the host's path, receives the one-shot session in the masked
OPSCHAIN_TRANSPORT_SESSION variable, and dials deploy@10.1.9.4.
The action publishes typed outputs — exit_code, stdout, stderr,
duration_ms — and a non-zero remote exit fails the step unless
allow_failure=true. The dial target defaults from the host node's
rpc.target property, so workflows carry no user@host strings;
sudo_user/sudo_method escalate on the remote side, and
collect_facts=true piggybacks a facts collection over the same
admitted session after the command — recorded on the host node,
never failing the primary.
The resolution refuses by name, never silently: an unknown node, a non-host node, a deregistered host, a host with no resolvable provider, and a session with no covering active grant each fail the step loudly.
One action family covers the host lifecycle end to end:
| Action | Purpose |
|---|---|
infrastructure:host_exec | run a command on the referenced host — streamed logs, typed outputs |
infrastructure:host_put / host_get | file or whole-tree transfer to/from the host (recursive=true) |
infrastructure:host_sync | tree sync, direction up or down, with guarded deletes |
infrastructure:host_facts | gather native host facts and record them through the facts hand-over |
infrastructure:host_register / host_deregister | the membership hand-over — what a provisioning step calls so the machine it made exists as estate content, and the close of that membership |
That last row is the loop-closer: any action that makes a machine exist
ends by registering it, so the new host is immediately visible in the
infrastructure screens, referenceable by host_ref and governed by
path RBAC — and a whole-estate patch fans out over registered hosts
with a for_each over the estate enumeration (lesson 6.5 builds one).
8. Read the audit trail
Every leg of the flow lands in the events feed (lesson 5.1):
opschain events --filter type=info:transport:* --since 1h
opschain events --filter type=warn:transport:* --since 24h
info:transport:session— every minted session; for anssh-casession it carries the minted certificate's fingerprint and TTL — identifiers, never material.warn:transport:refused— a session with no covering active grant, named.warn:transport:cap_refused— an over-cap request against the grant's rate or daily session cap.api:transport_grants:*— every grant write, audited under the actor who made it;api:infrastructure:*— the registry writes.
The provider test verb's outcome is audited too (success flag only), and no read, audit row or error ever returns credential material.
Check yourself
- What refuses when the transport grant is missing, and which event records every brokered session?
What you learned
- A host node is estate identity without credentials; the transport provider holds the credential reference; the grant is the authority.
- The provider is tested without dialling, and resolves per host: the node's own
rpc.provider_ref, else the deepest binding, else the single enabled provider. - A grant is a ceremony — held for approval where the funnel exists, enforced at the host node's path, capped and audited.
infrastructure:host_execruns on a runner with a one-shot session in a masked variable; nothing credential-shaped ever reaches a log or a property.info:transport:sessionand thewarn:transport:*events are the audit trail.
Next: lesson 6.5 — Model a fleet properly (opschain learn 6.5), or back to your track (opschain learn).