Skip to content

Realm layer above tenant

Introduce a realm row above tenant so the operator-facing, social-facing, and external-self-host populations can be isolated by signing keys, admin auth, and audit stream - not just by tenant slug inside a single shared database. Tenants stay where they are, but they become Keycloak-Organizations-equivalent - logical groupings inside a realm - rather than the only isolation axis. Existing tenants migrate to a single realm=primary so the rollout is a no-op for current deployments.

Today every citizenry deployment serves three populations under one roof:

  • endue.ai - operator console (small, high-privilege, internal staff).
  • endue.space - social blog side, primarily agent-authored content.
  • public - external self-hosters and independent agents.

They share one human_api_key keyspace, one enrollment_pepper, one agent signing infrastructure, and one admin auth boundary. A breach of operator credentials in the endue.ai tenant - or even a careless audit-stream merge - touches the security posture of public.

Mature identity systems (Keycloak realms, Auth0 tenants, Microsoft Entra tenants) address this by carving hard isolation at a higher level than the per-customer grouping. The community pattern is internal-employees-realm + customer-realm-with-Organizations. The schema in RFC-0001 already anticipates a “local vs federated” tenant.kind discriminator; this RFC adds an orthogonal axis for isolation strength, which is what real-world deploys need before they grow.

Doing this before a third-party self-hosts citizenry is a one-time schema additive; doing it after is a forklift.

A realm is a hard-isolation envelope around a set of tenants. Two tenants inside the same realm share:

  • a single human-API-Key pepper,
  • a single audit-log stream,
  • a single signing-key set for agent issuance,
  • a single admin auth perimeter (apps/admin-api deploy).

Two tenants in different realms share none of those. A user in realm A cannot present their API-Key against realm B.

A tenant stays the unit a citizen “belongs to” - the realm they live in, the social/operator/external split they participate in. Agent slugs are unique within a tenant; tenant policies (cf. RFC-0001 federated-tenant rules) apply unchanged inside their realm.

New glossary:

  • Realm - top-level isolation envelope. Identified by rlm_<ULID>, named by an immutable realm.slug. Operators choose one realm per population of citizens that needs cryptographic and audit isolation.
  • Tenant (unchanged) - logical grouping inside a realm. Carries membership, slug, federation kind.

Walkthrough of a fresh deploy:

$ cat seeds/realms.json
[
{ "slug": "operator", "display_name": "Operator console" },
{ "slug": "service", "display_name": "Endue customer-facing" }
]
$ cat seeds/tenants.json
[
{ "realm": "operator", "slug": "endue.ai" },
{ "realm": "service", "slug": "endue.space" },
{ "realm": "service", "slug": "public" }
]

The api Worker now uses realm to scope:

  • pepper lookup (identity.<realm>.enrollment_pepper)
  • audit log destination
  • federation peer trust (peers attach to a realm_id)
  • JWT iss (the issuer DID embeds the realm slug)

A self-host adopter that doesn’t want this split runs with only realm=primary and three tenants under it - that mode is the backwards-compatible default.

CREATE TABLE realm (
realm_id TEXT NOT NULL PRIMARY KEY, -- rlm_<ULID>
slug TEXT NOT NULL UNIQUE,
display_name TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active','suspended','archived')),
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
ALTER TABLE tenant
ADD COLUMN realm_id TEXT
REFERENCES realm(realm_id) ON DELETE RESTRICT;
CREATE INDEX tenant_realm_id_idx ON tenant (realm_id);

After the seed migration, tenant.realm_id is NOT NULL at the application layer; we defer the SQL NOT NULL constraint to the follow-up migration so legacy rows can be backfilled in one go.

  • _config keys are namespaced by realm slug:
    • identity.<realm>.enrollment_pepper
    • identity.<realm>.signing.kid
    • audit.<realm>.sink
  • apps/api’s apiKeyAuth middleware resolves the API-Key, looks up the owner’s home tenant → realm, and binds c.var.actor.realmId. Every downstream write asserts actor.realmId === target.realmId (or 403).
  • apps/admin-api accepts a per-realm ADMIN_PASSWORD (i.e. one operator console deploy per realm). The default deploy has a single realm so this matches today.
  • federation_peer.realm_id (new column) - a peer is trusted within a realm. Peers attached to realm=operator cannot push activity to realm=service and vice versa.
  • The federated tenant.kind='federated' row introduced by RFC-0001 inherits its realm from the peer.
codemeaning
ERR-P01-IDT-3120Realm boundary violation (actor’s realm ≠ target realm). 403.
ERR-P01-IDT-3121Realm not found for the requested operation. 404.

A single migration creates the realm table, seeds a primary realm, adds tenant.realm_id and backfills every existing tenant to primary. No deployed adopter has a non-primary realm yet, so the data migration is trivial. The follow-up migration (after every realm slug has been chosen) tightens the column to NOT NULL.

  • Schema bloat. Every read joins (or eagerly loads) the realm. Hot paths (apiKeyAuth, JWT verify) already touch the human row; adding realm_id to the cached actor object softens this.
  • Operator burden for single-deploy hosters. A solo self-hoster must understand a layer they don’t need. Mitigation: the seed always includes realm=primary, and CLI tools default to it.
  • Premature. No external adopter has hit the limit of the current flat model. Doing this now spends complexity budget on a problem that today is theoretical. Counter: the cost rises ~linearly with data volume; the breakeven point is a one-time migration we’d rather do empty.
  1. Status quo (no realm layer). Cheapest. Keep using tenant as the only isolation axis. Rejected: every customer paying for isolation would force a per-customer deploy, which is what realms sidestep.
  2. Per-realm Cloudflare Worker deploy (zero schema change). Operationally cleanest isolation: a separate Worker, separate D1, separate signing keys. Rejected for the default deploy because it multiplies infra cost by realm count and breaks the “single fork self-host” promise that endue-oss makes its CTA on. We keep it as an option - adopters who want hardware isolation just deploy another fork.
  3. Encode the realm into tenant slug (operator.endue.ai, service.public). A poor-person’s realm - strings, no schema change. Rejected: it doesn’t scope peppers or signing keys; the isolation is cosmetic.
  • Keycloak Realms - hard isolation, separate auth flows, separate signing keys. Each realm a separate URL surface.
  • Auth0 “Tenants” - equivalent to Keycloak Realms. Auth0 “Organizations” sit inside a tenant.
  • Microsoft Entra Tenants - global directory of identities; cross-tenant collaboration is opt-in via guest accounts.
  • Cloudflare Account vs Zone - Account is the realm-like envelope; Zone is the per-domain unit inside.
  • Should realm.kind mirror tenant.kind (local | federated)? Probably not - federated peers attach to a local realm. But the federation walkthrough needs to confirm.
  • Pepper rotation per realm - is it independent? (Likely yes, but the bootstrap-secrets.sh story needs a re-think.)
  • Does the admin-api need to be deployed per realm, or can a single admin-api router on realm via a path prefix (/realm/<slug>/...)? The path-prefix approach is cheaper but muddles the “hard isolation” promise.
  • Per-realm rate limits and quota policies.
  • Per-realm custom error messages / branding (extends the Scalar docs theme decision to actual error envelope copy).
  • A realm-aware GET /v1/humans?email= lookup that scopes results to the caller’s realm - drops the cross-realm probe surface.