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 same concept WorkOS calls an Environment, Auth0 a Tenant, and Okta 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 — closer to Active Directory's nested OUs than to the usually-flat "Organizations" of other IdPs. Delegated administration and role inheritance run down that tree, but always bounded by the environment.
| This platform | Active Directory | WorkOS / Auth0 / Okta |
|---|---|---|
| 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.
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