Skip to content

Explore your first Domain

In this lesson you will walk the demo tenant that make dev seeded for you. By the end you will have read a Domain, its Project list, its identities, and its audit log from the command line — and, crucially, watched the tenancy boundary stop you when you reach for a neighbour's data. You will finish with a working mental model of how plexsphere's tenancy fits together.

You will not change any tenant data: no Project, no user, no Node. The one action with a side effect comes near the end, when you sign in a second time, to a second tenant — and that sign-in is precisely how you prove to yourself why the other tenants were hidden. This is exactly the right thing to do before you start running how-to guides that do change things.

This lesson takes about twenty minutes.

Before you start

You need the result of the set-up lesson, Set up your local plexsphere: a running plexsphere kind cluster and a plexctl that is built, on $PATH, and logged in. If make dev is not currently up, complete that lesson first and come back. You also need jq on your $PATH to read fields out of the JSON responses.

Recreate the shell environment from that lesson so the commands below work in a fresh terminal:

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

eval "$(make -s dev-ids)"

$DOMAIN_ID now holds the UUID of the Acme Corp demo Domain you would re-authenticate through; plexctl login resolves its active IdP binding for you. Acme Corp is your home tenant for this lesson. Beta LLC and Gamma Cooperative are structurally identical tenants that make dev also seeded — but, as you are about to see, they are invisible to you until you sign in to them, because you logged in as a principal in Acme Corp and nowhere else.

Step 1 — Confirm you are signed in

You signed in during Set up your local plexsphere and confirmed your principal there with plexctl whoami — the seeded admin, scoped to Acme Corp. Device-code tokens are short-lived, so if any command below answers Unauthenticated: token expired, your session has lapsed. $DOMAIN_ID is already in your shell from the step above, so just sign in again:

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

Complete the browser prompt with the seeded Dex credentials — username admin@example.com, password password — exactly as in that lesson.

This is the single most important idea in plexsphere: identity is per-Domain. The same human signing into Beta LLC would be a different principal there, with a different subject UUID. Hold on to that — every step below is a consequence of it.

Step 2 — List the Domains you can see

bash
plexctl domain list

Only one Domain comes back — Acme Corp — even though make dev seeded three (Acme Corp, Beta LLC, Gamma Cooperative):

text
ID                                    SLUG       DISPLAY_NAME  CREATED_AT
019ecc65-8622-7a7f-bfe1-0344c7a22dbd  acme-corp  Acme Corp     2026-06-15T17:47:46Z

That is not a bug — it is the direct consequence of Step 1. domain list does not return every Domain on the platform; it returns every Domain your principal can read. The API lists the Domains, then drops every row your principal holds no read relation on before it answers — the same per-Domain permission check that gates everything else. You are signed in as a principal in Acme Corp only, so Acme Corp is all you see. Beta LLC and Gamma Cooperative are right there in the database; they are simply invisible to this identity.

A Domain is the top of the tenancy tree: nothing — no Project, no identity, no Node — exists outside one. You got exactly one readable Domain, not an empty list and not a permission error. That is the seed working as designed. If you got an empty list or an error, the stack is unhealthy — return to Set up your local plexsphere and run the reset recipe.

Step 3 — Look inside the Domain

A Domain contains Projects and identities. Look at each in turn, always scoped to Acme Corp with --domain "$DOMAIN_ID". First its Projects:

bash
plexctl project list --domain "$DOMAIN_ID"

Exactly one Project comes back:

text
ID                                    DOMAIN                                SLUG       DISPLAY_NAME  CREATED_AT
019100c0-0d0c-7000-8000-000000000d01  019ecc65-8622-7a7f-bfe1-0344c7a22dbd  plexd-dev  Plexd Dev     2026-06-15T17:47:46Z

plexd-dev is stack furniture, not something you built. The bootstrap Job seeds it from deploy/local/base/plexsphere-bootstrap/configmap.yaml so the dev stack's plexd agents have a Project to register their Nodes into — Run your first agent is the lesson that uses it. Its id is pinned in that manifest rather than generated, so it reads the same on every make dev; the Domain id in the second column is minted per stack, which is why yours differs from the one printed here. You create your own first Project in the next lesson.

Now the Domain's identities:

bash
plexctl identity list --domain "$DOMAIN_ID"
text
ID                                    EXTERNAL_SUBJECT_PSEUDONYM                                        KIND  DOMAIN                                DISPLAY_NAME          CREATED_AT
019fb940-fd65-7079-8149-26d0897afb64  d11d0de6a8a285122cb10dac699d70d31ba8e744be4c66a2df0f7fa65302dc02  user  019ecc65-8622-7a7f-bfe1-0344c7a22dbd  member@example.com    2026-06-15T17:47:46Z
019100c0-0d0c-7000-8000-000000000001  ce9137dfffb4030480640020e2ffd83e2562c338ebf2c373a6049a2856c7452c  user  019ecc65-8622-7a7f-bfe1-0344c7a22dbd  operator@example.com  2026-06-15T17:47:46Z
019ecc65-9f3a-7c14-8d2b-5f3e0a7b1c40  af8020befdf6611e8c864659b172d46e65733be23c020b297c6b5f1354a7027b  user  019ecc65-8622-7a7f-bfe1-0344c7a22dbd  admin@example.com     2026-06-15T17:47:46Z

Acme Corp seeds three human identities. admin@example.com is you — the principal plexctl whoami reported when you signed in, now seen from the Domain's side rather than the caller's. operator@example.com is the platform operator's tenant hat — a plain Domain auditor here; the platform authority you use in Create a Cloud rides its separate Domain-less platform sign-in. member@example.com is an ordinary member holding no authoring grant at all; Register a Blueprint signs in as it to show what a plain tenant may read. plexsphere never stores the external subject in plaintext: the EXTERNAL_SUBJECT_PSEUDONYM column is a one-way hash of it, derived under the Domain's own pepper. It is therefore stable for a principal within a Domain but freshly minted on every seeded stack — your three values will not match the ones above even though the accounts are. You are looking at one fact from two directions: who am I (whoami) and who is in this Domain (identity list).

Notice that both commands needed --domain "$DOMAIN_ID". That is not boilerplate — it is the tenancy boundary asserting itself. There is no "list all Projects everywhere" call, because a Project only has meaning relative to its Domain.

Step 4 — Read the audit log

Every state-changing call — and sensitive reads such as listing identities — is recorded in a per-Domain, hash-chained audit log. Read Acme Corp's:

bash
plexctl audit entries list --domain "$DOMAIN_ID"
text
SEQ  OCCURRED_AT           REASON   RELATION                      OBJECT_TYPE  OBJECT_ID                             CORRELATION_ID
1    2026-06-15T17:50:14Z  granted  node_reachability.transition  domain       019ecc65-8622-7a7f-bfe1-0344c7a22dbd
2    2026-06-15T17:50:34Z  granted  node_reachability.transition  domain       019ecc65-8622-7a7f-bfe1-0344c7a22dbd
3    2026-06-15T17:52:13Z  granted  identity.list                 domain       019ecc65-8622-7a7f-bfe1-0344c7a22dbd  392b25f7-50d9-4246-8ae1-e8bf130f8dd9

Only the last row is yours. identity.list is the plexctl identity list you ran in Step 3 — the log records authorization decisions as they happen, and it carries the CORRELATION_ID of the request that caused it.

The two node_reachability.transition rows are the dev stack talking to itself. make dev runs two plexd agents against that plexd-dev Project, and a per-Domain evaluator sweeps their liveness on a timer: a Node whose last heartbeat is older than 90 s is marked stale, older than 300 s unreachable, and every such state change is audited. Their subject is a node:, not you, and they carry no correlation id because no request triggered them — a clock did. Expect the count to differ from the listing above: it depends on how long your stack has been up and whether the agents are still beating. plexctl mesh topology --domain "$DOMAIN_ID" shows the state each Node is in right now.

What you will not find is the seeding itself. The bootstrap wrote the Domain straight to the database, outside the /v1 API, so it left no entries here; a Domain created through the API would be audited. The audit log is per-Domain for the same reason identity is: one tenant can never read another tenant's history — as you are about to confirm directly.

Step 5 — Reach across a tenancy boundary, and get stopped

You have read Acme Corp from every angle. Now try to read its neighbour. You cannot discover Beta LLC through plexctl — the API only lists the Domains you are allowed to see — so read its id straight from the database, then point an identity-list call at it while still signed in as your Acme Corp principal:

bash
DOMAIN_ID_BETA=$(kubectl exec statefulset/postgres -- \
  env PGPASSWORD=plexsphere psql -U plexsphere -d plexsphere -tAc \
  "SELECT id FROM plexsphere.domains WHERE slug='beta-llc'")
plexctl identity list --domain "$DOMAIN_ID_BETA"

This does not show you Beta LLC's identities. It fails, and exits non-zero:

text
plexctl: Permission Denied: identities ListIdentities: caller lacks domain:read on the addressed Domain

Swapping --domain changed the target of the call, but it did not change who you are: your token is still bound to your Acme Corp principal, and that principal holds no relation in Beta LLC. The boundary stopped you. This is the per-Domain identity from Step 1 — "the same human signing into Beta LLC would be a different principal there" — now enforced on the wire.

Not every surface refuses you the same way. Try the Projects list against the same Domain:

bash
plexctl project list --domain "$DOMAIN_ID_BETA"
text
ID  DOMAIN  SLUG  DISPLAY_NAME  CREATED_AT

A header row and nothing under it: this one returns an empty page and exits 0, rather than erroring — the Projects surface filters row by row and simply shows you nothing it is allowed to show, instead of confirming the Domain even exists. Beta LLC happens to hold no Projects of its own either, so this one page has two causes at once — nothing to show you, and nothing you would be allowed to see. Keep that in mind: the last step of this lesson shows how the JSON output tells those two apart. Different signal, same guarantee: in neither case do you see another tenant's data. A script that automates plexsphere has to treat "empty" and "denied" as the same wall.

Want to see the rule itself, not just its effect? rebac check asks the authorization engine a single yes/no question — the very check the domain list filter and the identity-list gate run for you internally. Capture your own subject id with plexctl whoami, then ask the engine directly about each Domain:

bash
USER_ID=$(plexctl whoami --output json | jq -r '.subject')

plexctl rebac check \
  --subject "user:$USER_ID" \
  --relation read \
  --resource "domain:$DOMAIN_ID"

For Acme Corp the answer is allowed — the REASON column stays empty, because the engine only fills it in to explain a denial:

text
DECISION  REASON  CORRELATION_ID
allowed           c9663519-57c6-4e68-92f5-cad6e81f9b2a

Ask the same question about Beta LLC and the engine says no — the command fails with a non-zero exit:

bash
plexctl rebac check \
  --subject "user:$USER_ID" \
  --relation read \
  --resource "domain:$DOMAIN_ID_BETA"
text
plexctl: rebac check: denied (reason=insufficient_relation, correlation_id=e64fa71c-870e-4fac-b868-a41e5af60ea8)

insufficient_relation is the same reason behind the empty list and the 403: your principal simply holds no read-granting relation on domain:019ecc65-9012-7b3c-a1d4-7e2f5a8b0c11. The boundary is not a convention layered on top — it is this ReBAC decision, and every read you have run went through it.

Step 6 — See Beta LLC for real, as a second principal

So where are Beta LLC and Gamma Cooperative? Exactly where Acme Corp is — you just have to sign in to them. Each Domain has its own active IdP binding; logging in with Beta's domain_id mints a token bound to your Beta LLC principal. Save it under a separate profile with --profile-name so your Acme Corp session stays put:

Approve this one in a private browser window. Your earlier approval left a plexsphere_session cookie behind, and the approval page pins whoever that cookie says you are onto the pending authorization — it does not check that the cookie's Domain matches the Domain the device code asked for. Approve from the same window and you will mint an Acme Corp token and save it under the beta profile, which makes the rest of this step read backwards. Sign out first — open the Console at http://localhost:8080/ and pick Sign out from the user menu — or just approve in a private window. Either way the page has no live session to reuse and drives a fresh sign-in against Beta's own IdP binding, which is what you want.

bash
plexctl login --domain-id "$DOMAIN_ID_BETA" --profile-name beta

Complete the device prompt in that private window with the same credentials as before — username admin@example.com, password password:

text
To complete sign-in, visit:
  http://localhost:8080/v1/device?user_code=DD2QSJZH

Logged in. Saved profile "beta".

Reusing that account is deliberate, not a shortcut. The seed grants the one Dex user admin@example.com a Domain-admin row in Acme Corp and in Beta LLC, and plexsphere keys its user rows on (domain_id, external_subject) — so the same human arrives as two separate principals with two different subject UUIDs. Run plexctl whoami --profile beta and compare the SUBJECT column with the one from Step 1 if you want to see it.

Now list Domains as your Beta principal by selecting that profile with --profile:

bash
plexctl domain list --profile beta

Beta LLC appears — and Acme Corp, this time, does not:

text
ID                                    SLUG      DISPLAY_NAME  CREATED_AT
019ecc65-9012-7b3c-a1d4-7e2f5a8b0c11  beta-llc  Beta LLC      2026-06-15T17:47:46Z

If you get acme-corp here instead, the approval reused your old browser session and the beta profile is holding an Acme Corp token. Sign out in the Console (or open a private window) and run the login above again.

Your default profile is untouched: plexctl domain list with no --profile still answers as Acme Corp. Gamma Cooperative behaves identically — repeat this step with slug='gamma-coop' if you want all three.

That is the concrete answer to "what about Beta and Gamma": they are full tenants, equal to Acme Corp, each reachable only by a principal that holds a relation inside it. One login is one tenant. There is deliberately no cross-tenant super-admin in this model — the Domain is the tenancy root, and identity lives strictly inside it.

When you are done, you can drop the extra session and keep only your Acme Corp login:

bash
plexctl logout --profile beta
text
Logged out. Cleared profile "beta".

That revokes the Beta token server-side and drops the profile entry; your default Acme Corp profile is untouched. It is also the way out if the approval above bound the wrong Domain — clear the profile, then log in again.

Step 7 — Ask for machine-readable output

Everything you have run prints a human table by default. Add --output json (long form only — plexctl does not bind it to -o) to get a structured payload instead:

bash
plexctl domain list --output json
json
{
  "items": [
    {
      "created_at": "2026-06-15T17:47:46.512083Z",
      "description": "Demo tenant seeded by the dev-stack bootstrap Job.",
      "id": "019ecc65-8622-7a7f-bfe1-0344c7a22dbd",
      "mesh_cidr": "10.50.0.0/24",
      "name": "Acme Corp",
      "reachability": {
        "heartbeat_interval": "30s",
        "stale_after": "1m30s",
        "unreachable_after": "5m0s"
      },
      "slug": "acme-corp",
      "updated_at": "2026-06-15T17:47:46.512083Z"
    }
  ]
}

The shape is always { "items": [ ... ] }. This is the form you would script against — and the bridge from "I am learning plexsphere" to "I am automating plexsphere". It also speaks to the ambiguity from Step 5: when a list is empty because every row was hidden from you, the JSON carries empty_reason: no_rebac_membership; when it is empty because nothing exists, the field is simply absent. Its presence, not its value, is the signal.

What you learned

You now have a working mental model:

  • A Domain is the tenancy boundary. Projects, identities, and audit history all live inside exactly one Domain and never leak across.
  • Identity is per-Domain — and so is visibility. domain list shows only the Domains your principal can read; reaching into another Domain is refused outright (identity list, audit) or returns nothing (project list). Either way you never see another tenant's data.
  • One login is one tenant. To work in a different tenant you sign in again as a principal there; there is no platform-wide super-admin, because the Domain is the tenancy root.
  • The CLI and the /v1 API are one system. plexctl is a window onto the same data and the same permission checks the API enforces, not a separate store.
  • Everything that changes state is audited, per-Domain and hash-chained.

Where to go next

You read this Domain without changing it. The next lesson has you build inside it:

  • Keep learning by doingBuild in your first Domain creates a Project, invites a user, builds a Group, defines and assigns a Label, then reads your own writes back out of this same audit log.

Or pick the quadrant that matches what you need now: