Appearance
🛡️ adminadmin@example.com · Domain admin in Acme Corp
Chapter 4 — Write into a tenant
Create a Project
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}'Note --sub-range-cidr. A Project is not just a folder — it carves a slice out of the Domain's mesh range, and the Nodes it later owns get addresses from that slice.
Chapter 13 watches those addresses get handed out.
Invite a user
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}'Read the field name: external_subject_pseudonym.
The email is not stored in the clear on the invitation. And the user is not a principal yet — an invitation is a pending intent. Identity materialises on acceptance, not on send.
Build a Group
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}'--source manual says a human maintains this membership. The alternative is a Group synced from an identity provider — same object, different authority over who is in it.
Add yourself to it
bash
plexctl group member add \
--group "$GROUP_ID" \
--principal "$USER_ID" \
--kind user \
--source manualA Group with no members grants nothing to nobody.
Right now this membership also grants nothing — the Group holds no relation on anything yet. Chapter 5 is where that changes, and it is the single most important idea in the tenancy model.
Read your own writes
bash
plexctl audit entries list --domain "$DOMAIN_ID" --alltext
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-edd3de241975Four commands, four rows, in order, at the tail.
Every row reads REASON = granted — the log does not record what happened, it records that an authorization decision was made and what it decided.
Confirm the membership is live
bash
plexctl rebac check \
--subject "user:$USER_ID" \
--relation member \
--resource "group:$GROUP_ID"text
DECISION REASON CORRELATION_ID
allowed 39cc730d-2c62-44b9-ad65-6e1ec1b8e8d7The same check the platform runs for itself before every privileged call. You can ask it the same questions it asks internally.
If this comes back
deniedimmediately after the previous slide, the write is still propagating to the authorization backend. Wait a beat and retry.
Verify the chain
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 — every row from seq 1 to the head recomputes cleanly, including the four you just appended.
On divergence the command exits 1 and fills DIVERGENT_SEQ with the first row that does not match.
You do not have to trust the audit log. You can recompute it.