Environments & the isolation model
Environments & the isolation model
An environment is the platform's hard isolation boundary: its own user pool, signing keys, issuer and organization tree. It is the layer above the organization (tenant) — the boundary other identity providers variously call an environment, a tenant, or an org. Use it to separate staging from production, or to give a product / white-label reseller a fully isolated plane.
The hierarchy
Environment ── hard boundary: own users, signing keys, issuer, discovery, branding
├── Users ── the user pool, shared within the environment
├── Organizations ── a closure-tree of ANY depth (company → division → dept → team)
│ └── Memberships ── user ↔ org node + role
└── Clients ── your OAuth apps / products
The organization layer is an arbitrary-depth tree, because that is the shape real customers actually have: a group owns companies, a company has divisions, a division has teams. Modelling that directly — rather than flattening it into one list and pushing the hierarchy into naming conventions or custom attributes — is what lets delegated administration and role inheritance run down the tree, always bounded by the environment.
If you are coming from a directory service or another IdP, the layers map like this:
| This platform | Active Directory | Common IdP vocabulary |
|---|---|---|
| Environment | Forest / Domain | Environment / Tenant / Org |
| Organization (closure-tree) | OU tree | Organizations |
| User (via Membership) | User in an OU | User |
| Client | app | Application |
Two topologies — chosen by placement, not code
- Shared identity across products. Put several products in the same environment: a user signs up once and gets SSO across all of them, and a customer org's SSO connection serves every product in that environment.
- Isolated per product / white-label. Put a product (or reseller) in its own environment: separate user pool, keys, issuer and branding — a standalone "IdP in a box".
Resolution — how a request finds its environment
Every API request resolves its environment from the host before anything
else runs (the ResolveEnvironment middleware, backed by an EnvironmentResolver):
- an exact custom-domain match (
environments.domain), else - the leading DNS label as an environment slug (
id.staging.acme.com→ thestagingenvironment).
For a single-tenant / on-prem deployment, set cbox-id.environments.default to
your one environment key and every host resolves to it. In a multi-tenant
deployment, a host that maps to no environment is refused (404) rather than
served the wrong plane. Swap the bound EnvironmentResolver to resolve by API
key or header instead.
Resolution is cached
The bound EnvironmentResolver is a CachedEnvironmentResolver wrapping the database
one. Uncached, the lookup above is 2–3 queries on every request — the custom-domain
match, then the slug match, then the owning account's liveness — against a table that
changes approximately never, so the result is cached for
cbox-id.environments.resolution_cache_ttl
(CBOX_ID_ENVIRONMENT_RESOLUTION_CACHE_TTL), 60 seconds by default. Set it to 0 to
bypass the cache entirely and resolve live on every request.
Entries are two-level, and only positive results are cached:
host:{sha256(host)}→ the environment key that host resolves toenv:{key}→ the resolved, liveness-gated environment
The split is what makes the off-switch exact: suspending an environment (or its owning
account) drops the env: entry only, so the next request still finds the host mapping,
misses the environment and falls through to a full live resolution — which now refuses.
Because a refusal is never cached, reactivating restores service just as promptly.
forKey() is deliberately not cached — it is a primary-key lookup off the request path
that intentionally does not gate liveness.
Operationally, invalidation is explicit rather than TTL-driven for everything the
model can see. Saving or deleting an Environment forgets its resolved entry, its current
and previous custom domain, and the {slug}.{base} host for its previous and
current slug across every configured base_domain — so a rename or a re-pointed custom
domain is visible on the next request. Account suspension and reactivation invalidate
explicitly too (the environment rows are untouched by that write, so their model events do
not fire).
The TTL is what bounds the cases that invalidation cannot reach — up to
resolution_cache_ttl seconds of staleness after:
- a write that fires no model events (a mass
update(), raw SQL, a direct database edit), or - a slug rename where the serving host sits under a base domain that is not listed in
cbox-id.environments.base_domains, since that is exactly the list the slug-derived host keys are enumerated from.
There is no flush command. Invalidate from code —
app(Cbox\Id\Organization\EnvironmentResolutionCache::class) exposes
forgetEnvironment($environmentKey) (which also drops the cached default) and
forgetHost($host) — or clear the application cache.
Isolation guarantees — and how they're proven
The environment boundary is deny-by-default and load-bearing: a query with no
environment in context returns nothing, never another environment's rows. Each
guarantee below is proven by a dedicated test in the suite (--group=isolation);
if any ever passes while a leak exists, the platform's core promise is void.
| Guarantee | Proven by |
|---|---|
The org-level escape hatch (withoutScope) and roll-up never cross an environment |
EnvironmentIsolationTest |
| An organization (and its whole closure subtree) is invisible from another environment | OrganizationEnvironmentTest |
| A token signed in one environment never verifies in another (distinct keys/JWKS) | CryptoEnvironmentIsolationTest |
| The same email is a distinct user per environment; sessions never cross; a federated identity resolves only within its environment | IdentityEnvironmentTest |
| A client / connection / directory / opaque code is unusable from another environment | OAuthEnvironmentTest |
| A request's environment is resolved from its host; an unknown host is refused | EnvironmentResolutionTest |
Run them alone with:
vendor/bin/pest --group=isolation
Making a model environment-owned
Any tenant-owned model that must be partitioned by environment implements
EnvironmentOwned and uses BelongsToEnvironment — it then auto-stamps
environment_id on create and is scoped on every read. It composes with
BelongsToTenant: environment is the hard outer wall, organization the inner,
roll-up-able one.
final class Thing extends Model implements EnvironmentOwned, TenantOwned
{
use BelongsToEnvironment;
use BelongsToTenant;
}
In tests, act as an environment exactly like a tenant:
uses(Cbox\Id\Kernel\Tenancy\Testing\InteractsWithTenancy::class);
$this->actingAsEnvironment('env_a'); // pin the hard boundary
$this->runAsEnvironment('env_b', fn () => ...); // scoped, then restored
Custom domains
An environment can publish its issuer on a custom host (id.acme.com) instead of
the default {slug}.{base_domain}. The flow is self-serve and proves domain control
before anything goes live:
$domains = app(Cbox\Id\Organization\Contracts\EnvironmentDomains::class);
// 1. Request — returns the DNS TXT record the admin must publish. Nothing changes yet.
$challenge = $domains->request($environmentKey, 'id.acme.com');
// $challenge->recordName === '_cbox-id-challenge.id.acme.com'
// $challenge->recordValue === 'cbox-id-domain-verification=<token>'
// 2. Verify — once the TXT record resolves, the domain is promoted to the
// environment's issuer host (read by the per-environment issuer resolver).
$result = $domains->verify($environmentKey); // $result->verified === true
// 3. Clear — drop back to the {slug}.{base_domain} / configured issuer.
$domains->clear($environmentKey);
Verification reads TXT records through the injected Federation\Contracts\DnsResolver
(the deployable app swaps in an authoritative resolver so a just-published record
is seen immediately). A domain is refused if it is malformed, a bare IP, a platform
base domain (or a subdomain of one), or already claimed by another environment.
TLS is the operator's responsibility (by design)
The package proves domain control and records the host — it does not issue TLS certificates, and it never talks to your cluster. This keeps it portable across every deployment shape. Once a domain verifies, terminate TLS for it however your ingress already does:
- cert-manager — create a
Certificate(or an ingress annotation) for the new host; ACME/Let's Encrypt issues it. Trigger this from your own reconciler off the verified-domains list. - On-demand TLS (Caddy, Traefik) — issue on the first TLS handshake, gated by an "is this host verified?" check the host app exposes, so only verified domains get a certificate.
The verified domain is available on the Environment (->domain), so a deployment
can enumerate the hosts that need certificates without reaching into this package.