Skip to main content
Version: v5.7.0

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, or ssh-ca for short-lived CA-signed session certificates), connection facts (username, auth method, port), and a credential_ref — a secret-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_REQUIRED refusal naming packs:rpc when 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; the provisioned and destroyed states 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 -J form). Unknown *_ref keys 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:

ActionPurpose
infrastructure:host_execrun a command on the referenced host — streamed logs, typed outputs
infrastructure:host_put / host_getfile or whole-tree transfer to/from the host (recursive=true)
infrastructure:host_synctree sync, direction up or down, with guarded deletes
infrastructure:host_factsgather native host facts and record them through the facts hand-over
infrastructure:host_register / host_deregisterthe 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 an ssh-ca session 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_exec runs on a runner with a one-shot session in a masked variable; nothing credential-shaped ever reaches a log or a property.
  • info:transport:session and the warn: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).