Skip to content

Reach your Resource

Provision a cloud Resource stood up an S3 bucket — a real cloud object, but a standalone one with nothing to log in to. This lesson provisions something you can reach: a Node from the kubernetes-cloudless-node Blueprint, the recipe whose substrate stands up a plexd Node that enrols into your Domain's mesh. Then you reach it.

plexsphere never hands you a standing SSH key or a long-lived kubeconfig; instead it brokers mediated sessions — each one short-lived, scoped to a single Resource, gated by ReBAC, and recorded in the audit log. You ask for access, the platform issues a one-time credential bound to a listener it controls, and that credential expires on its own.

This lesson is in two parts: first you provision a Node (reusing the demo AWS Cloud and Credential from the provisioning track), then you reach it.

This lesson takes about twenty-five minutes.

A note on the lean dev stack. The kubernetes-cloudless-node Blueprint provisions a simulator Node — there is no real sshd, kube-apiserver, or TCP service behind it, and it enrols once rather than staying resident. So the session issuance below is real and audited (a signed token comes back every time), but the final connect has nothing to land on locally. The listener endpoint is reported by the target Node, never minted at issuance, and this Node reports none, so each connect command below waits for a report that never lands and then exits 1. Pass --listener-timeout 0 to get that answer at once instead of a minute later. Against a real cloud Node the same commands drop you at a shell, serve the Kubernetes API, or tunnel a port. The skill you are learning — provisioning a Node and issuing mediated sessions against it — is identical either way.

Before you start

This lesson builds on the provisioning track: it reuses the demo AWS Cloud and its Credential from Create a Cloud and Issue a Cloud Credential, so complete at least those two first. It does not reuse the aws-s3-bucket Blueprint — a bucket is not reachable — so you register the node Blueprint here. Beyond the demo Cloud and Credential you need a running, logged-in stack from Set up your local plexsphere, plus jq and kubectl on your $PATH.

Like the provisioning lesson, this one is played by two identities — the platform operator (operator@example.com, profile operator, signed in platform-wide with --platform) who owns the demo Cloud and Credential and approves the assignment, and the project owner (admin@example.com, your default profile) who curates the Acme Corp catalog, provisions, and reaches. Both sign in with the dev password password; see the tutorials overview for the full cast.

Recreate the shell environment and read the Acme Corp Domain id — you will switch identities, so keep it in hand:

bash
export PATH="$PWD/bin:$PATH"
export PLEXSPHERE_URL=http://localhost:8080

eval "$(make -s dev-ids)"
echo "domain=$DOMAIN_ID"

Step 1 — Import the node Blueprint and recover the catalog ids

The kubernetes-cloudless-node Blueprint comes from the Acme Corp-scoped catalog you registered in Register a Blueprint, so importing from it is the Domain admin's job (domain#manage), not the platform operator's. Sign in as the project owner — your default profile — completing sign-in as admin@example.com with the password password:

bash
plexctl login --domain-id "$DOMAIN_ID"

If you took the catalog lesson the source already exists; recover its id by name and import the node recipe:

bash
CATALOG_ID=$(plexctl blueprint catalog list --output json \
  | jq -r '.items[] | select(.name == "Official Blueprint catalog") | .id')
plexctl blueprint import "$CATALOG_ID" kubernetes-cloudless-node
text
SLUG                       STATUS    VERSION   REASON
kubernetes-cloudless-node  imported  v1alpha1

status: imported with an empty REASON means the bundle passed signature verification and its first version is published. If you already imported this recipe, the status reads skipped instead and the import is a no-op — either way the Blueprint is in your catalog when the command returns.

The cloudless node Blueprint is a node (mesh-enrolling) recipe and declares no parameters — the node it stands up needs no operator input. Recover its BlueprintVersion id exactly as Register a Blueprint did: resolve the Blueprint by slug, then read the id out of the versions array that blueprint get embeds.

bash
NODE_BLUEPRINT_ID=$(plexctl blueprint list --all --output json \
  | jq -r '.items[] | select(.slug == "kubernetes-cloudless-node") | .id')
NODE_VERSION_ID=$(plexctl blueprint get "$NODE_BLUEPRINT_ID" --output json \
  | jq -r '.versions[] | select(.version == "v1alpha1") | .id')
echo "$NODE_VERSION_ID"
text
019fb9df-0c2d-7a73-9e28-63aed04a5168

The demo Cloud and its Credential belong to the platform operator, so switch hats to read their ids. Sign in under the operator profile with the Domain-independent --platform flag, completing sign-in in the browser as operator@example.com:

bash
plexctl login --profile-name operator --platform
bash
CLOUD_ID=$(plexctl cloud list --all --profile operator --output json \
  | jq -r '.items[] | select(.slug == "demo-cloud") | .id')
CREDENTIAL_ID=$(plexctl cloud credential list --cloud-id "$CLOUD_ID" --profile operator --output json \
  | jq -r 'first(.items[] | select(.display_name == "demo-credential" and .status == "active") | .id)')
echo "credential=$CREDENTIAL_ID version=$NODE_VERSION_ID"

Step 2 — As the project owner, request a credential assignment

Switch back to the project owner — your default profile, already signed in from Step 1 (drop --profile).

Create a Project to hold the Node (or reuse the provision-demo Project from the provisioning lesson), then request the credential assignment for it — the same two-party governance you exercised in Assign a Cloud Credential.

Note that $CREDENTIAL_ID is the same Cloud Credential that lesson bound to provision-demo. An assignment binds a credential to one Project, but it neither consumes nor locks the credential: one Cloud Credential can be assigned to any number of Projects, each binding requested and approved on its own. The operator's demo Credential is about to pay for its second Project — and reuse never skips the two-party handshake, so the next step finds a fresh row in the approval queue.

Create the Project first, on its own:

bash
PROJECT_ID=$(plexctl project create \
  --domain "$DOMAIN_ID" \
  --slug reach-demo \
  --display-name "Reach demo" \
  --output json | jq -r '.id')
echo "project=$PROJECT_ID"
text
project=019fb9e2-3285-7b81-8d24-f5487b39ec23

Give the grant a moment to land. Creating a Project grants you admin on it, but that grant reaches the authorization mirror shortly after the Project itself exists. Request the assignment too quickly and the request is refused:

text
plexctl: Permission Denied: credentialassignments RequestCredentialAssignment: caller lacks deploy permission (project admin, maintainer, or domain admin) on the owning Project

Wait a second and run the request again. Run only the request: a Project slug is unique per Domain, so creating reach-demo a second time collides instead of helping.

Now request the assignment against the credential the operator owns. The operator reads the assignment id back out of the approval queue in the next step, so this one stays out of a shell variable:

bash
plexctl credential assignment request \
  --project-id "$PROJECT_ID" \
  --cloud-credential-id "$CREDENTIAL_ID"
text
ID                                    PROJECT_ID                            CLOUD_CREDENTIAL_ID                   STATE      MATERIALISED  CREATED_AT
019fb9e2-8e02-7d41-b6f0-4c7a91d2e857  019fb9e2-3285-7b81-8d24-f5487b39ec23  019fb9c7-fe03-7dcf-b671-382f2b307d6f  requested  false         2026-07-31T14:58:31Z

The assignment opens in requested with MATERIALISED false — nothing is granted yet. Both flip only when the operator approves it in the next step.

Step 3 — As the platform operator, approve the assignment

A principal may never approve its own request, so switch back to the operator with --profile operator (no second sign-in). The operator picks the request up from the approval queue, the single inbox for everything awaiting a decision — it shows this row because the operator holds assign on the Cloud Credential the request spends:

bash
plexctl approval list --profile operator --status pending-approval --output json \
  | jq '.items[] | {id, kind, state, target_resource}'
json
{
  "id": "019fb9e2-8f14-7c05-9c1a-2a6b0c4d3e91",
  "kind": "credential_assignment",
  "state": "pending-approval",
  "target_resource": "cloudcredential:019fb9c7-fe03-7dcf-b671-382f2b307d6f"
}

Capture that row's id — narrowed to the credential the operator owns, which target_resource names — and approve it:

bash
APPROVAL_ID=$(plexctl approval list --profile operator --status pending-approval --output json \
  | jq -r --arg cred "$CREDENTIAL_ID" \
      'first(.items[] | select(.kind == "credential_assignment" and .target_resource == "cloudcredential:" + $cred) | .id)')

plexctl approval approve "$APPROVAL_ID" \
  --profile operator --output json | jq '{state, materialised}'
json
{
  "state": "approved",
  "materialised": true
}

Step 4 — As the project owner, provision the Node

Switch back to the project owner — drop --profile. Declare the Node from the cloudless blueprint version. Unlike the bucket, this Resource is a node, so it passes an empty parameter object and the broker carries it through an extra Enrolling phase while the node registers:

bash
RESOURCE_ID=$(plexctl resource create \
  --project-id "$PROJECT_ID" \
  --kind node \
  --blueprint-version-id "$NODE_VERSION_ID" \
  --cloud-credential-id "$CREDENTIAL_ID" \
  --parameters '{}' \
  --output json | jq -r '.id')
echo "$RESOURCE_ID"

Another grant to land. If resource create returns Permission Denied: project not authorised to use the named credential, the approval's use grant has not reached the authorization mirror yet; wait a second and re-run it.

The phase is a single resource get away — run it by hand whenever you want a snapshot:

bash
plexctl resource get "$RESOURCE_ID" --output json | jq -r '.provisioning.phase'
text
Provisioning

Poll the same read until the Node is Ready. A node walks the full arc — the Enrolling phase the bucket skipped is where the broker waits for the node to redeem its bootstrap token and register as a live mesh peer:

bash
while true; do
  PHASE=$(plexctl resource get "$RESOURCE_ID" --output json | jq -r '.provisioning.phase')
  echo "$(date +%T) $PHASE"
  case "$PHASE" in Ready|Failed) break ;; esac
  sleep 10
done
text
15:02:02 Pending
15:02:12 Provisioning
15:02:42 Enrolling
15:04:12 Ready

You now hold a Ready Node in $RESOURCE_ID. The rest of the lesson reaches it.

Step 5 — Access is a grant, not a key

Reaching a Resource needs the act permission on it — resource#act. You hold it here without doing anything: a Domain admin's authority walks down the tenancy tree (Domain → Project → Resource), so the seeded admin@example.com already has act on every Resource in Acme Corp.

A teammate who is not a Domain admin needs that access written out. You do not write act itself, though: act is a permission, which the schema computes, and a tuple is only ever written against a relation. On a Resource the relations that add up to act are owner, maintainer, and operator — so operator is the smallest one that gets a teammate in, and it carries observe along with it but not manage.

Acme Corp seeds exactly such a teammate: member@example.com, an ordinary member holding no authoring grant. Resolve its identity id, then grant it operator on the Node with the same rebac tuple add you used for Groups and Labels:

bash
MEMBER_ID=$(plexctl identity list --domain "$DOMAIN_ID" --output json \
  | jq -r 'first(.items[] | select(.display_name == "member@example.com") | .id)')

plexctl rebac tuple add \
  --project "$PROJECT_ID" \
  --resource "resource:$RESOURCE_ID" \
  --relation operator \
  --subject "user:$MEMBER_ID"

The listing carries no plaintext email — DISPLAY_NAME is what the seed put there, and for the demo accounts that happens to be their address.

Without act, issuing a session is refused with 403 and reason=rebac_denied — access is a relation you hold, never a key you keep. Everything below goes through this gate.

Step 6 — Emit a kubeconfig

The cleanest session to issue is a Kubernetes one. kubeconfig asks the platform for a mediated k8s session, waits for the target Node to report the listener that session runs over, and writes a ready-to-use kubeconfig. Impersonate a cluster user and write it to a file — it embeds a bearer token, so it lands at mode 0600:

bash
plexctl kubeconfig \
  --project-id "$PROJECT_ID" \
  --resource-id "$RESOURCE_ID" \
  --impersonate-user ops \
  --output-file ./resource.kubeconfig \
  --listener-timeout 0

The session is issued, signed, and audited, and then the wait ends empty-handed: the simulator Node reports no listener, so no kubeconfig is written and the command exits 1. Drop --listener-timeout 0 and you get the same line a minute later.

text
plexctl: kubeconfig: session issued but the target node has not reported a listener endpoint

Against a Node that does report one, the file points kubectl at that listener, with a one-time bearer token in place of any cluster credential. The server endpoint is the coordinate the Node reported and the token is minted fresh on every issuance:

yaml
apiVersion: v1
kind: Config
clusters:
- name: plexsphere
  cluster:
    server: https://<session-listener-endpoint>
users:
- name: plexsphere
  user:
    token: <one-time-session-jwt>
contexts:
- name: plexsphere
  context:
    cluster: plexsphere
    user: plexsphere
current-context: plexsphere

kubectl --kubeconfig ./resource.kubeconfig get pods would then route through the mediated session. On the dev-stack simulator there is no kubeconfig to point it at, and no API server behind the listener even if there were.

Step 7 — Open a TCP forward

For anything that is not SSH or Kubernetes, tcp-forward issues a generic mediated tunnel to a host and port reachable from the Node. It does not proxy bytes itself — it issues the session and prints the coordinates for you to point your own tunnel client at:

bash
plexctl tcp-forward \
  --project-id "$PROJECT_ID" \
  --resource-id "$RESOURCE_ID" \
  --host localhost \
  --port 5432 \
  --listener-timeout 0

Same shape as the kubeconfig: the session is issued and audited, the listener report never arrives, and nothing is printed.

text
plexctl: tcp-forward: session issued but the target node has not reported a listener endpoint

Against a Node that reports its listener, the command prints both coordinates instead:

text
local address: 127.0.0.1:54213
listener endpoint: <session-listener-endpoint>

The local address is where your tunnel client binds; the listener endpoint is the mediated entry point the Node opened and reported.

Step 8 — Open an SSH session

ssh issues a mediated SSH session and hands control straight to your local ssh client, passing the one-time token in the environment rather than on the command line:

bash
plexctl ssh \
  --project-id "$PROJECT_ID" \
  --resource-id "$RESOURCE_ID" \
  --login-user ops \
  --listener-timeout 0

On a Node that reports an SSH listener this drops you at a shell as ops, every command in the session recorded against the audit log. On the dev-stack simulator the session is issued and audited, and the ssh client is never reached, because there is no reported endpoint to exec it against:

text
plexctl: ssh: session issued but the target node has not reported a listener endpoint

Issuing the session is as far as the lean stack goes.

Step 9 — Sessions are short-lived and audited

None of these grants is a credential you keep. Each session carries a TTL — 30 minutes by default, clamped to a 4-hour ceiling (request a shorter one with --ttl-seconds) — and an idle timeout of 15 minutes. A background sweeper reclaims sessions the moment they expire or go idle, and a revoked token is refused for the rest of its lifetime. Every issuance is written to the per-Domain audit log under the access.issue relation, the same hash-chained trail you read in the earlier lessons — so who reached what, when is a query, not a guess.

Stronger sessions can demand a stronger sign-in: a Domain can require step-up for a session kind, in which case issuance is refused with 401 and reason=step_up_required unless your token carries a sufficient acr and a recent auth_time. Re-authenticate and retry.

Step 10 — Leave the Node running for the next lesson

Watch your Domain reads the observability surfaces of a Domain that is carrying load — and the Node you just provisioned is that load. Leave it running and take that lesson next.

When you are done with both, tear the Node down the way you tore down the bucket — resource delete drains the node from the mesh first (the Deregistering phase a standalone resource skips), then deletes the substrate:

bash
plexctl resource delete "$RESOURCE_ID" --yes

Do this in the Console

Reaching the Node has a browser equivalent in the Console. Open the Nodes page under the Mesh group in the sidebar — the Domain-scoped roster of Nodes the cluster knows about — and click a row's Open link to reach the Node detail. The detail shows the Node's identity and a Reachability card (State, Last heartbeat, Changed) — the browser face of the heartbeat-driven health view.

Heads-up — reachability and sessions in the dev/Floci build. The mesh reachability path and the access-orchestrator are wired in a later slice, so against the local dev stack the Reachability card shows a "not provisioned in this build" state and issuing a session returns the same deferred state rather than a live connection. The surfaces are built and testable today; they light up unchanged once those paths land.

To issue a session, open the Project detail page (Projects → Open) and use the Issue session action in the Sessions panel. Pick the kind — k8s for a kubeconfig, tcp for a forward, or ssh — fill the per-kind target, and submit. On success the dialog reveals the signed token exactly once alongside a per-kind connection artifact (a kubeconfig fragment, a forward hint, or an ssh connection string). The artifact's Listener endpoint field polls the session read the way plexctl does, showing "Waiting for the node to report its listener endpoint…" until the coordinate settles and, after about a minute without one, saying the Node has not reported an endpoint yet. The revealed token stays valid either way. The in-browser SSH terminal is not yet available — connect with the revealed details.

What you learned

  • Reachability is a property of the Blueprint. A standalone resource like the S3 bucket has nothing to log in to; a node Blueprint such as kubernetes-cloudless-node stands up a plexd Node that enrols into the mesh, and that is what you reach. The provisioning flow is the same one you drove for the bucket — only the mesh role differs.
  • A Cloud Credential is not spent by assignment. The same demo Credential that pays for provision-demo now also pays for reach-demo — one credential can be assigned to any number of Projects, but every binding passes the two-party request-and-approve handshake on its own.
  • Access is mediated, never standing. You never hold an SSH key or a durable kubeconfig; the platform issues a one-time, Resource-scoped credential bound to a listener it controls, and it expires on its own.
  • Access is a relation, gated by resource#act. A Domain admin inherits it down the tenancy tree; anyone else needs an explicit act grant on the Resource, and without it issuance is rebac_denied.
  • One door, three shapes. kubeconfig, tcp-forward, and ssh all issue the same kind of short-lived session against the same Resource id.
  • Every reach is auditable. Issuance lands in the per-Domain audit log under access.issue, and step-up lets a Domain demand a fresh, strong sign-in before high-stakes access.

Where to go next

  • Keep learning by doingWatch your Domain reads the capacity, metrics, logs, and alert surfaces of the Domain the Node you just provisioned is now part of.

Or pick the quadrant that matches what you need now:

  • You want the exact contract — the plexctl ssh, plexctl kubeconfig, and plexctl tcp-forward references document every flag, exit code, and output shape.
  • You want to understand why mediated access is shaped this way — the access bounded context explains the session aggregate, the resource#act gate, the step-up contract, and the revocation sweeper.