Skip to content

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 first 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. This lesson captures the UUID of each object it creates into a shell variable with jq, so later steps can refer back to it. If you would rather not install jq, every command still works — you just copy the ID column out of the printed table by hand instead.

Recreate the shell environment from the previous lessons so the commands below work in a fresh terminal, and capture the seeded admin user's id while you are at it:

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

DOMAIN_ID=$(kubectl exec statefulset/postgres -- \
  env PGPASSWORD=plexsphere psql -U plexsphere -d plexsphere -tAc \
  "SELECT id FROM plexsphere.domains WHERE slug='acme-corp'")

USER_ID=$(plexctl identity list --domain "$DOMAIN_ID" --output json \
  | jq -r '.items[0].id')

$DOMAIN_ID holds the UUID of the Acme Corp demo Domain and $USER_ID holds the seeded admin@example.com user — the principal you are acting as. 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 payments \
  --display-name "Payments" \
  --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": "<project-uuid>",
  "domain_id": "<acme-corp-uuid>",
  "slug": "payments",
  "name": "Payments"
}

Capture the id from project create, not from project list. A Project becomes visible to project list only once the authorization layer has caught up with its creation — a brief, eventually-consistent lag — but --output json on create returns the new id immediately. The --sub-range-cidr is optional; if you supply one it must sit inside Acme Corp's mesh-CIDR 10.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": "<invitation-uuid>",
  "external_subject_pseudonym": "<pseudonym>",
  "expires_at": "<timestamp>"
}

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 the seeded admin ($USER_ID), 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 payments-oncall \
  --display-name "Payments On-Call" \
  --source manual \
  --output json)
GROUP_ID=$(echo "$GROUP" | jq -r '.id')
echo "$GROUP" | jq '{id, domain_id, slug, display_name, source}'
json
{
  "id": "<group-uuid>",
  "domain_id": "<acme-corp-uuid>",
  "slug": "payments-oncall",
  "display_name": "Payments On-Call",
  "source": "manual"
}
bash
plexctl group member add \
  --group "$GROUP_ID" \
  --principal "$USER_ID" \
  --kind user \
  --source manual
text
GROUP                                 PRINCIPAL                             KIND  SOURCE
<group-uuid>                          <seed-user-uuid>                      user  manual

--source manual says you are managing this membership by hand rather than syncing it from an identity provider; it must match the Group's own source.

Step 4 — Define, authorize, and assign a Label

Labels are plexsphere's selectors — key/value tags you attach to objects. Defining a Label and assigning it are two distinct acts, and they have a deliberate twist worth understanding.

First define a Label in the Domain. --on-delete cascade means that deleting the definition later removes it and its assignments in one move:

bash
DEF=$(plexctl label define create \
  --scope domain --scope-id "$DOMAIN_ID" \
  --key env --type enum --enum-values production,staging \
  --applicable-kinds project --on-delete cascade \
  --output json)
DEF_ID=$(echo "$DEF" | jq -r '.id')
echo "$DEF" | jq '{id, scope, qualified_key, applicable_kinds, on_delete}'
json
{
  "id": "<def-uuid>",
  "scope": "domain",
  "qualified_key": "acme-corp/env",
  "applicable_kinds": [
    "project"
  ],
  "on_delete": "cascade"
}

Note the QUALIFIED_KEY: the key you assign and delete by is acme-corp/env — the scope's slug joined to your key — not the bare env.

Now the twist. Assigning a Label is a privileged act: plexsphere deliberately does not let a Domain admin assign Labels just because they administer the Domain. Assignment needs two explicit relations — maintainer on the target Project and assigner on the Label definition — and neither is granted by the admin relation you hold. Grant them to yourself explicitly:

bash
plexctl rebac tuple add \
  --project "$PROJECT_ID" \
  --resource "project:$PROJECT_ID" \
  --relation maintainer \
  --subject "user:$USER_ID"

plexctl rebac tuple add \
  --project "$PROJECT_ID" \
  --resource "labeldefinition:$DEF_ID" \
  --relation assigner \
  --subject "user:$USER_ID"
text
ID                                    SUBJECT                                    RELATION    RESOURCE                                      CREATED_AT
<tuple-uuid>                          user:<seed-user-uuid>                      maintainer  project:<project-uuid>                        <timestamp>
ID                                    SUBJECT                                    RELATION  RESOURCE                                              CREATED_AT
<tuple-uuid>                          user:<seed-user-uuid>                      assigner  labeldefinition:<def-uuid>                            <timestamp>

If a grant returns Permission Denied on the first try, wait a couple of seconds and run it again. The Project you created in Step 1 takes a moment to propagate to the authorization backend, and it is that Project's manage permission that authorizes the write.

The --project flag names the Project whose manage permission authorizes you to write the tuple — your Domain admin relation already grants that. This is the platform's authorization model made visible: a Label is not a permission, and holding one — or being able to administer a Domain — never silently confers the right to attach Labels to things.

With both relations granted, assign the Label to the Project:

bash
plexctl label object set "project:$PROJECT_ID" \
  --definition-id "$DEF_ID" \
  --value production
text
OBJECT_KIND  OBJECT_ID                             QUALIFIED_KEY  VALUE       ASSIGNED_BY
project      <project-uuid>                        acme-corp/env  production  user

If this returns request denied by ReBAC (reason=insufficient_relation) on the first try, wait a couple of seconds and run it again. The grants you just wrote take a moment to propagate to the authorization backend; the retry succeeds once they have.

Step 5 — 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" --all

The 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
...
<seq> <timestamp>          granted  project.create     domain       <acme-corp-uuid>  <correlation-id>
<seq> <timestamp>          granted  invitation.create  domain       <acme-corp-uuid>  <correlation-id>
<seq> <timestamp>          granted  group.create       domain       <acme-corp-uuid>  <correlation-id>
<seq> <timestamp>          granted  group.member.add   domain       <acme-corp-uuid>  <correlation-id>
<seq> <timestamp>          granted  manage             domain       <acme-corp-uuid>  <correlation-id>
<seq> <timestamp>          granted  assign             domain       <acme-corp-uuid>  <correlation-id>

Every 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. The Label work surfaces under the underlying authorization relations rather than a label.* verb: the rebac tuple add grants and the assignment appear as manage and assign. That is the same chain, 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 6 — Verify your work

You wrote permissions and rows; now confirm the platform agrees. Two checks close the loop.

First, ask the authorization engine whether the maintainer grant you wrote in Step 4 actually landed — the same rebac check the platform runs for itself before every privileged act:

bash
plexctl rebac check \
  --subject "user:$USER_ID" \
  --relation maintainer \
  --resource "project:$PROJECT_ID"
text
DECISION  REASON  CORRELATION_ID
allowed           <correlation-id>

allowed — the grant is live. (If it comes back denied right after Step 4, the grant is still propagating to the authorization backend; wait a moment and retry.) Run the same check with a relation you never granted, say --relation owner, 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             <head-seq>  <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, Labels, 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.
  • Labels are selectors, not permissions. Assigning a Label is a privileged act gated by explicit maintainer + assigner relations that Domain administration does not confer — so attaching a Label can never quietly escalate access.
  • Every state change is recorded as an authorization decision, in the per-Domain hash-chained audit log, with REASON = granted for the writes you just made.
  • You can verify, not just trust. rebac check answers whether a grant is live, and audit verify recomputes the hash-chain end to end — the platform's integrity surfaces are queryable, not faith-based.

Where to go next

  • Keep learning by doingGrant 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: