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. Supply it
inline (never on the command line) and the server holds it encrypted
at rest on the record — no secrets provider needed — or pin a
secret-vault:// reference with --credential-ref:
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
--subject sam resolves to sam's account and the grant stores the
principal id — a grant admits an identity, never a name, so a
federated account that happens to share the username never inherits it.
Where two accounts hold the name, spell the one you mean as
<provider>:<username> (local:sam, ldap:corp:sam — the provider
value opschain admin users list shows); the same spellings work for
--approvers.
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).