Tenancy Model — Clients, Environments & Infra
Console models a customer's world as a three-level hierarchy. Getting the levels right is what lets one customer run several isolated infrastructures while still being billed, authorized, and notified as a single tenant.
The three levels
| Level | Table | Boundary for | Key columns |
|---|---|---|---|
| Client | clients | Tenancy — one customer: billing, access root, notify-routing root, one CDM customer | id, name, slug |
| Environment | environments | Infrastructure isolation — a distinct infra estate under the customer | client_id, name, slug, UNIQUE(client_id, slug) |
| Infra | hosts, clusters, k8s_nodes, k8s_namespaces, k8s_workloads, credentials, install_tokens, webhook_sources, pull_sources, … | the actual machines / clusters / secrets | varies — see below |
Infra tables do not share one tenancy column. Which column a table carries decides how far a grant reaches, so it is worth being exact:
| Table | client_id | environment_id | Effective scope |
|---|---|---|---|
hosts | — | NOT NULL | Environment-only |
clusters | NOT NULL | NOT NULL | Both, always set |
credentials | NOT NULL | nullable | Client, optionally narrowed to an environment |
install_tokens | NOT NULL | nullable | Client, optionally narrowed to an environment |
webhook_sources | NOT NULL | nullable | Client, optionally narrowed to an environment |
pull_sources | NOT NULL | nullable | Client, optionally narrowed to an environment |
Two consequences that a "everything is per-environment" reading would get backwards:
- A credential, install token, webhook source or pull source with a
NULLenvironment_idis client-wide. It is visible to, and usable across, every environment under that client. Narrowing one to a single environment is opt-in, not the default. assetscarries neither column. It is a generic identity layer keyed by(asset_type, ref_id)pointing at the real row (hosts.id,clusters.id, …); tenancy is resolved by following that reference, never by reading a column onassetsitself.
There is no k8s_inventory table — the Kubernetes inventory lives in clusters, k8s_nodes,
k8s_namespaces and k8s_workloads.
Client vs. environment — which do I create?
A new client is a new tenant. A new environment is new infra under the same tenant.
- Same customer, different infrastructures → one client, multiple environments. The common case for a customer running separable estates (a core product + a payments product + an FP project).
- Genuinely different customers (separate billing / access / legal entity) → separate clients.
Do not create a second client just to separate infra — you would fragment the customer's billing, RBAC root, and notification routing, and lose the single-tenant view. The environment layer exists precisely so you don't have to.
Worked example — Global Solutions / Global Pay
Global Solutions is one customer (CDM CDM-105) running three separable estates. It is one Console client with three environments:
client: Global Solutions (slug globalsolutions, jira_customer_key CDM-105)
├── environment: global-solutions → GS core infra
├── environment: global-pay → Global Pay infra (isolated)
└── environment: global-solutions-fp → FP infra
Global Pay's hosts, clusters, credentials and agents live under the global-pay environment and are fully isolated from GS core — yet Global Solutions stays one tenant for billing, access, and notifications. Spinning up a separate "Global Pay" client would have been the wrong move.
Two orthogonal axes — don't conflate them
A client has two independent sub-structures. Both hang off the client; they are not the same thing:
| Axis | Sub-entity | Isolates | Keyed by |
|---|---|---|---|
| Infra / monitoring | environment | hosts, clusters, credentials, agents, alerts | environments.id → environment_id on infra |
| Service desk / notify | org (JSM organization) | Jira ticket → Telegram routing | service_desk_org_mappings.jira_organization_id |
So "Global Pay" is simultaneously:
- a service-desk org (
jira_organization_id = 1213) for ticket-notification routing — see Routing Model, and - (should be) an environment for infra isolation.
The two are configured independently: a service_desk_org_mappings row does not create an environment, and creating an environment does not route tickets.
Creating & scoping environments
- Create:
POST /api/v1/clients/{client_id}/environmentswith{name, slug}(unique per client), or the Console UI → client → Environments → Add. - Enroll infra: each environment issues its own install token; an agent enrolls into exactly one environment, which pins its hosts/inventory to that estate.
- Scope access: RBAC grants can be environment-scoped —
user_client_environments,team_client_environments, and a service account'senvironment_scope. Grant an engineer or team access to only theglobal-payenvironment without exposing the rest of Global Solutions.
See Tenant Isolation & Authorization for how these scopes are enforced on every endpoint.