Appearance
Seed tenancy Domains
plexsphere-bootstrap is the one-shot binary that seeds the initial Domain aggregates and, optionally, their IdPBinding aggregates from an operator-supplied manifest. It is not a plexctl command — it runs as a Kubernetes Job (or a local binary) ahead of first use.
Prerequisites
- A reachable Postgres, with the migrate Job already applied.
- The
plexsphere-bootstrap:devimage (side-loaded by the kind dev stack) or a local build:go build -o ./bin/plexsphere-bootstrap ./cmd/plexsphere-bootstrap.
Steps
Author the manifest
yaml
domains:
- slug: acme-corp
display_name: Acme Corp
mesh_cidr: 10.64.0.0/12
idp_bindings:
- domain_slug: acme-corp
issuer: https://dex.example.com
client_id: plexsphere
client_secret_ref: secret://dev/plexsphere-dex-publicIdP bindings (idp_bindings:)
The optional top-level idp_bindings: block binds each seeded Domain to an OIDC IdP at seed time. On the dev overlay the dev-only secret reference is the sentinel secret://dev/plexsphere-dex-public.
IdPBinding seeding (post-manifest). Bindings not in the manifest are created afterwards via
plexctl domain-idp create— see Manage IdP bindings.
Relations (grants:)
The optional top-level grants: block writes ReBAC relationships for users the same manifest seeds, so an operator holds authority the moment the Job exits. Each entry takes three fields:
user_idsets the subject. The tuple is always written foruser:<user_id>, and the value must equal adomain_admins[].user_idin the same manifest. The pass writes tuples and never creates a user, so auser_idno admin entry pins fails the run.relationis the relation the tuple carries, asschema/authz.zeddeclares it on the addressed object (admin,owner,auditor, …). The pass checks only that it is non-empty; SpiceDB refuses a relation the object definition does not know.objectaddresses what the relation is written on.
object accepts exactly four forms:
yaml
grants:
# The platform singleton, spelled out in full. Dev deployments only.
- user_id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0f0"
relation: auditor
object: "platform:plexsphere"
# A Domain, by the slug its `domains[]` entry declares.
- user_id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0f0"
relation: admin
object: "domain:acme-corp"
# A Project, by the slug its `projects[]` entry declares.
- user_id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0f0"
relation: maintainer
object: "project:acme-prod"
# A Resource, by its Project slug and its `external_ref` handle,
# split on the FIRST `/`.
- user_id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0f0"
relation: owner
object: "resource:acme-prod/edge-01"The three slug forms resolve against what the earlier passes seeded, so a grant can only address an object this manifest created. A platform: prefix carrying any id other than the anchor is a manifest error.
The anchor form is the one exception to "anywhere": it is accepted only where PLEXSPHERE_ENV is dev, and exits 2 before the write anywhere else. A relation on the singleton is platform-wide authority, so it would let anyone who can write the manifest ConfigMap grant themselves more than that write already carries. A production deployment seeds its first platform operator from its own configuration instead — set PLEXSPHERE_PLATFORM_OPERATOR_* on the plexsphere binary, as Sign in as a Platform Operator describes.
Because the subject must come from domain_admins[], an operator that needs no authority on its home Domain still carries an entry there; relations: [member] is the weakest the schema offers, and the real authority then traces to the grants: entries rather than to Domain membership.
The pass resolves every entry before it writes anything and hands the whole batch to one SpiceDB TOUCH, so a typo in the last entry fails the run with nothing planted:
shell
# grant-written user_id=0190a8b8-… relation=owner object=resource:0190a8b8-…
# grant-seed-complete written=1A user_id no admin entry declares, an empty relation, or an object that resolves to nothing exits 2 before the write. A failing SpiceDB write exits 1, which is the retryable class the Job's backoff covers.
Dev-only API tokens (api_tokens:)
The optional top-level api_tokens: block plants a live plexsphere.api_tokens row per entry, so a test or a demo script can drive the API as a seeded operator without an interactive sign-in. Each entry takes three fields:
user_idnames the user the token authenticates as. As withgrants:, it must equal adomain_admins[].user_idin the same manifest.plaintextcarries the full bearer string,psk_<env>_<token-id-base32>_<random-base32>. Produce one withtokens.FormatPlaintext(env, id, random), which takes the environment prefix, the token's own UUIDv7 and at least 12 random bytes. The pass parses it with the production parser, so the middle segment decides the id the row is persisted under. Only the Argon2id hash is stored.ttlis a Go duration string, greater than zero and at mosttokens.MaxTTL(90 days).
The pass runs only where PLEXSPHERE_ENV is dev and exits 2 before touching the database anywhere else. A committed plaintext is a reusable operator credential: anyone who can read the manifest can present it and be authenticated as the user it names, for the whole of its TTL. A throwaway dev stack is the only place that trade is acceptable.
A worked manifest that seeds a Domain, its operator, a Project, a Resource, the operator's authority over that Resource, and a token to present it with:
yaml
domains:
- name: Acme Corp
slug: acme-corp
mesh_cidr: 10.64.0.0/12
idp_bindings:
- domain_slug: acme-corp
issuer: https://dex.example.com
client_id: plexsphere
client_secret_ref: secret://dev/plexsphere-dex-public
discovery_url: https://dex.example.com/.well-known/openid-configuration
domain_admins:
- domain_slug: acme-corp
external_subject: acme-operator
email: operator@acme.example
email_verified: true
relations: [member]
user_id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0f0"
projects:
- domain_slug: acme-corp
name: acme-prod
slug: acme-prod
id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0c1"
resources:
- project_slug: acme-prod
external_ref: edge-01
kind: bridge
origin: Adopted
id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0d2"
grants:
- user_id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0f0"
relation: owner
object: "resource:acme-prod/edge-01"
api_tokens:
- user_id: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0f0"
plaintext: psk_dev_agikrofayb5avcqkucqkbifa6e_muzgkllpobsxeylun5zc243fmvsa
ttl: 24hThe admin pass resolves an active IdPBinding for the Domain before it upserts the operator row, so idp_bindings: carries an entry for every Domain that domain_admins: seeds into. The seed never dials the issuer.
A re-run is idempotent. A row that still verifies against the plaintext and is still acceptable is skipped; an expired one is deleted and planted again. A REVOKED row is never re-armed: re-planting it would reverse an operator revocation, so the pass fails instead and the remedy is to rotate the committed plaintext. A stored row whose hash or owner disagrees with the manifest fails the same way.
shell
# api-token-created user_id=0190a8b8-… id=0190a8b8-… expires_at=2026-09-22T10:00:00Z
# api-token-skipped id=0190a8b8-… reason=already-live
# api-token-rearmed id=0190a8b8-…
# api-token-seed-complete created=1 skipped=0 rearmed=0Both passes need more than DATABASE_URL: grants: writes through SpiceDB, so the Job also reads SPICEDB_ENDPOINT, SPICEDB_PRESHARED_KEY and SPICEDB_PRESHARED_KEY_INSECURE, and api_tokens: reads PLEXSPHERE_ENV.
shell
DATABASE_URL="${DATABASE_URL}" \
PLEXSPHERE_ENV=dev \
SPICEDB_ENDPOINT=spicedb.plexsphere.svc:50051 \
SPICEDB_PRESHARED_KEY="${SPICEDB_PRESHARED_KEY}" \
SPICEDB_PRESHARED_KEY_INSECURE=true \
plexsphere-bootstrap --config ./bootstrap.yamlRun the binary
shell
plexsphere-bootstrap --manifest ./bootstrap.yaml --database-url "${DATABASE_URL}"
# domain-created slug=acme-corp id=… mesh_cidr=10.64.0.0/12
# idp-binding-created domain_slug=acme-corp issuer=https://dex.example.com binding_id=…
# seed-complete domains_created=1 domains_skipped=0
# binding-seed-complete bindings_created=1 bindings_skipped=0In-cluster this is the bootstrap Job; it is idempotent and safe to re-run — a re-run reports domain-skipped … reason=already-exists instead of domain-created.
Read the seeded UUIDs
The seeded Domain and IdP-binding UUIDs live in Postgres; read them directly when you need them, for example to drive plexctl login. On the kind dev stack the bootstrap Job seeds acme-corp, beta-llc, and gamma-coop; read their UUIDs from the dev Postgres StatefulSet:
shell
kubectl exec statefulset/postgres -- \
env PGPASSWORD=plexsphere psql -U plexsphere -d plexsphere -tAc \
"SELECT d.slug, d.id, b.id
FROM plexsphere.domains d
JOIN plexsphere.idp_bindings b
ON b.domain_id = d.id AND b.status = 'active'
ORDER BY d.slug"
# acme-corp|0192f2f6-…|0192f2f7-…
# beta-llc|0192f2f8-…|0192f2f9-…
# gamma-coop|0192f2fa-…|0192f2fb-…The three columns are the Domain slug, the domain_id, and the active idp_binding_id.
Verification
shell
psql "${DATABASE_URL}" -c "SELECT slug FROM domains ORDER BY slug;"
# slug
# -----------
# acme-corp
# (1 row)See also
- Manage IdP bindings — post-manifest bindings.
../../reference/cli/index.md— the operator CLI surface.