Skip to content
🛡️ 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 manual

A 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" --all
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-edd3de241975

Four 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-6e1ec1b8e8d7

The 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 denied immediately 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.