Skip to content

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:dev image (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-public

IdP 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_id sets the subject. The tuple is always written for user:<user_id>, and the value must equal a domain_admins[].user_id in the same manifest. The pass writes tuples and never creates a user, so a user_id no admin entry pins fails the run.
  • relation is the relation the tuple carries, as schema/authz.zed declares 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.
  • object addresses 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=1

A 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_id names the user the token authenticates as. As with grants:, it must equal a domain_admins[].user_id in the same manifest.
  • plaintext carries the full bearer string, psk_<env>_<token-id-base32>_<random-base32>. Produce one with tokens.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.
  • ttl is a Go duration string, greater than zero and at most tokens.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: 24h

The 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=0

Both 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.yaml

Run 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=0

In-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 ​