Appearance
Build in your first Domain
In the previous lesson you walked the seeded Acme Corp Domain without changing anything. This lesson is the opposite: you will build inside it. By the end you will have created a Project, invited a user, made a Group and added a member, defined and assigned a Label, and then found every one of those changes recorded in the Domain's audit log — your own work, written into the hash-chained trail you only read before.
When you want to run this lesson again from a clean slate, the Tear down your local plexsphere lesson resets the whole stack so the Domain returns to its seeded state.
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. You should also have done Explore your first Domain — this lesson assumes you recognise a Domain, a Project, an identity, and the audit log. If make dev is not currently up, complete the set-up lesson first and come back.
You also need jq on your $PATH to read object UUIDs out of the JSON responses into the shell variables later steps refer back to.
Recreate the shell environment from the previous lessons so the commands below work in a fresh terminal, and capture the id of the principal you are authenticated as while you are at it:
bash
export PATH="$PWD/bin:$PATH"
export PLEXSPHERE_URL=http://localhost:8080
DOMAIN_ID=$(plexctl domain get acme-corp --output json | jq -r '.id')
USER_ID=$(plexctl whoami --output json | jq -r '.subject')$DOMAIN_ID holds the UUID of the Acme Corp demo Domain and $USER_ID holds the principal you are authenticated as, resolved straight from plexctl whoami. Deriving it this way — rather than picking a row out of identity list — guarantees that the relations you grant yourself in Step 4 target exactly the subject your later commands run as; a stale or mismatched id there is the most common cause of a later request denied by ReBAC on an act you believe you authorized. In the seeded demo that principal is admin@example.com. Everything you build below lives inside Acme Corp, because — as the previous lesson showed — nothing in plexsphere exists outside a Domain.
Step 1 — Create a Project
A Project is the workload grouping inside a Domain. Create one, and capture its id so the later steps can attach things to it:
bash
PROJECT=$(plexctl project create \
--domain "$DOMAIN_ID" \
--slug build-demo \
--display-name "Build Demo" \
--sub-range-cidr 10.50.0.0/28 \
--output json)
PROJECT_ID=$(echo "$PROJECT" | jq -r '.id')
echo "$PROJECT" | jq '{id, domain_id, slug, name}'create runs once: it returns the new Project as JSON. You keep the whole response in $PROJECT, pull its id into $PROJECT_ID for the later steps, and print the fields that matter:
json
{
"id": "019ecc66-3f7a-7b21-9c84-2d1e5a8f0b34",
"domain_id": "019ecc65-8622-7a7f-bfe1-0344c7a22dbd",
"slug": "build-demo",
"name": "Build Demo"
}Capture the id from
project create, not fromproject list. A Project becomes visible toproject listonly once the authorization layer has caught up with its creation — a brief, eventually-consistent lag — but--output jsononcreatereturns the new id immediately. The--sub-range-cidris optional; if you supply one it must sit inside Acme Corp's mesh-CIDR10.50.0.0/24.
Step 2 — Invite a user
Invite an external subject into the Domain:
bash
INVITATION=$(plexctl identity invite \
--domain "$DOMAIN_ID" \
--email ada@example.com \
--output json)
INVITATION_ID=$(echo "$INVITATION" | jq -r '.id')
echo "$INVITATION" | jq '{id, external_subject_pseudonym, expires_at}'json
{
"id": "019ecc66-5a9c-7d42-8e15-6f2b3c4d5e60",
"external_subject_pseudonym": "b3d8f1a2c4e5079bd6a8f0c2e4b6d8091a3c5e7f8b0d2a4c6e8f0a1b3d5c7e9f",
"expires_at": "2026-06-22T17:50:18Z"
}Two things to notice. The email never appears in the response — the Domain stores a pseudonym of the external subject, not the raw address. And there is no invite URL: minting the link a real invitee would click is a surface plexsphere does not expose yet, so this lesson does not promise one.
The invitation is pending. Acceptance — not invitation — materialises the user as a principal, so ada@example.com is not yet something you can add to a Group. That is why the Group member you add in the next step is yourself ($USER_ID) — the principal you are authenticated as, who already exists.
Step 3 — Create a Group and add a member
Create a Group with a fresh slug, capture its id, then add the seeded admin as a member:
bash
GROUP=$(plexctl group create \
--domain "$DOMAIN_ID" \
--slug build-demo-admins \
--display-name "Build Demo Admins" \
--source manual \
--output json)
GROUP_ID=$(echo "$GROUP" | jq -r '.id')
echo "$GROUP" | jq '{id, domain_id, slug, display_name, source}'json
{
"id": "019ecc66-7b1d-7e53-9f26-7a3c4d5e6f71",
"domain_id": "019ecc65-8622-7a7f-bfe1-0344c7a22dbd",
"slug": "build-demo-admins",
"display_name": "Build Demo Admins",
"source": "manual"
}The Group exists, and it does nothing. A Group is a container of principals, not a permission — it confers access only once someone grants the Group a relation on an object, which is the whole subject of Grant access through a Group. What it needs first is a member.
That member has to be you. $USER_ID is the principal you are authenticated as, and it is the only one available: the ada@example.com invitation from Step 2 is still pending, and a pending invitation has no principal to add yet.
Two flags carry meaning beyond the ids. --kind user picks the principal family — a Group can also hold a service_identity or another group, and the discriminator tells the server which column to write. --source manual says you are managing this membership by hand rather than syncing it from an identity provider; it must match the parent Group's own source, and the server rejects the pair if it does not.
bash
plexctl group member add \
--group "$GROUP_ID" \
--principal "$USER_ID" \
--kind user \
--source manualtext
GROUP PRINCIPAL KIND SOURCE
019ecc66-7b1d-7e53-9f26-7a3c4d5e6f71 019ecc65-9f3a-7c14-8d2b-5f3e0a7b1c40 user manualThe row that comes back is the membership: a Group, a principal, and the two discriminators that qualify it. That single tuple is what the authorization engine will walk in the next lesson when it decides whether you reach an object through this Group.
Step 4 — Read your work in the audit log
In the previous lesson you read this same audit log without having written anything to it. Read it again — now it contains your work:
bash
plexctl audit entries list --domain "$DOMAIN_ID" --allThe trail is per-Domain and hash-chained, ordered oldest-first, so your new rows are at the bottom. Scroll to the tail and you will recognise the steps you just ran:
text
SEQ OCCURRED_AT REASON RELATION OBJECT_TYPE OBJECT_ID CORRELATION_ID
...
7 2026-06-15T17:50:02Z granted project.create domain 019ecc65-8622-7a7f-bfe1-0344c7a22dbd bb836c4a-548f-46fe-bbeb-4e999ad4b1e0
8 2026-06-15T17:50:18Z granted invitation.create domain 019ecc65-8622-7a7f-bfe1-0344c7a22dbd 5afeeb41-82bd-446e-8b45-4ae3b00b7063
9 2026-06-15T17:50:31Z granted group.create domain 019ecc65-8622-7a7f-bfe1-0344c7a22dbd f14c2f39-9bb0-4ca1-a1d5-4faf972af4fc
10 2026-06-15T17:50:44Z granted group.member.add domain 019ecc65-8622-7a7f-bfe1-0344c7a22dbd 4976d14f-a0c6-41a3-86f4-edd3de241975Every row carries REASON = granted: the platform recorded that each write was authorized and accepted. The RELATION column names what you did — project.create, invitation.create, group.create, group.member.add — one row per state-changing call you made. That is the same chain you read as a visitor in the previous lesson, now viewed from the side that records decisions.
This is the payoff of the two lessons together: before, you read this log as a visitor; now every line near the tail is something you put there.
Step 5 — Verify your work
You wrote rows and a membership; now confirm the platform agrees. Two checks close the loop.
First, ask the authorization engine whether the membership you added in Step 3 actually landed — the same rebac check the platform runs for itself before every privileged act:
bash
plexctl rebac check \
--subject "user:$USER_ID" \
--relation member \
--resource "group:$GROUP_ID"text
DECISION REASON CORRELATION_ID
allowed 39cc730d-2c62-44b9-ad65-6e1ec1b8e8d7allowed — the membership is live. (If it comes back denied right after Step 3, the write is still propagating to the authorization backend; wait a moment and retry.) Run the same check for a relation you never granted and it comes back denied with a non-zero exit.
Second, verify the audit chain itself. Every row you read in Step 5 is hash-linked to the one before it; audit verify recomputes the whole chain and tells you whether a single byte has shifted:
bash
plexctl audit verify --domain "$DOMAIN_ID"text
VALID SEGMENT_FROM SEGMENT_TO DIVERGENT_SEQ EXPECTED_HASH OBSERVED_HASH
valid=true 1 10 <unknown>valid=true means the chain — including the rows your own writes just appended — recomputes cleanly from seq 1 to the head. The command exits 0 on a clean chain and 1 on divergence, with DIVERGENT_SEQ and the two hash columns populated to point at the first row that does not match. That is what makes the log tamper-evident: you do not have to trust it, you can recompute it.
What you learned
- A Domain is something you build inside, not just read. Projects, Groups, and invitations are all created relative to exactly one Domain.
- Identity materialises on acceptance. An invitation is a pending intent; the user becomes a principal you can act on only once the invitation is accepted.
- Every state change is recorded as an authorization decision, in the per-Domain hash-chained audit log, with
REASON = grantedfor the writes you just made. - You can verify, not just trust.
rebac checkanswers whether a grant is live, andaudit verifyrecomputes the hash-chain end to end — the platform's integrity surfaces are queryable, not faith-based.
Where to go next
- Keep learning by doing — Grant access through a Group takes the Group you just built and uses it to delegate access: grant the Group a relation on a Project and watch every member inherit it.
Or pick the quadrant that matches what you need now:
- You have a real job to do — the how-to guides give you the shortest correct path: manage Projects, invite an operator, manage Groups, manage Label definitions, and assign object Labels. The full index is the how-to guides.
- You need an exact contract — a
plexctlflag, the wire shape of an endpoint: theplexctlreference. - You want to understand why tenancy, identity, Labels, and audit are shaped this way: the bounded-context explanations and the architecture overview.
- Done with the stack, or want a clean slate — Tear down your local plexsphere removes the cluster and returns the Domain to its seeded state.