Multi-Tenancy

Multi-Tenancy

Nexus GSLB supports multiple isolated tenants on a single instance. Each tenant has its own pools, members, services, health checks, and geo rules; API calls from one tenant cannot read or modify another tenant's resources.

License: Creating additional tenants requires the enterprise tier. All installations ship with one default tenant usable on any tier. Contact licensing@gslb.nexus to upgrade.

Concepts

Term Description
System admin The operator who controls the Nexus GSLB instance. Either the holder of the GSLB_API_KEY env var, or a logged-in user with the tenant_admin role on the "default" tenant. Can manage the tenant list and has access to all "default" tenant resources.
Tenant An isolated account. Identified by a randomly-generated API key. Cannot see or modify other tenants' resources.
Default tenant When multi-tenancy is not in use, all resources belong to "default". Existing single-tenant deployments continue to work unchanged.

Authentication flow

Request → authMiddleware
  ├─ /api/v1/health → exempt (no auth required)
  ├─ GSLB_API_KEY unset → treated as default-tenant admin (system admin)
  ├─ Bearer <token> == GSLB_API_KEY → system admin, default tenant
  ├─ session (cookie/Bearer) → user's tenant + role; tenant_admin on the
  │    "default" tenant is promoted to system admin
  ├─ SHA-256(Bearer <token>) matches tenants.api_key_hash → that tenant (operator perms)
  └─ anything else → 401 Unauthorized

System-admin status is stored in request context (isSystemAdmin(tenantID, role) == tenantID=="default" && role=="tenant_admin") and checked in tenant-management endpoints. It is also surfaced on GET /api/v1/auth/me as isSystemAdmin so the WebUI can gate the Tenants page.

Tenant management (system-admin-only)

All three endpoints require system admin: either a Bearer token matching GSLB_API_KEY, or a logged-in tenant_admin on the "default" tenant. A tenant_admin on any other tenant is scoped to its own tenant and cannot manage the tenant list (it receives 403 admin access required).

Create a tenant

POST /api/v1/tenants
Authorization: Bearer <admin-key>
Content-Type: application/json

{
  "name": "acme",
  "rpsLimit": 500
}

Response (201):

{
  "tenant": { "id": "abc123", "name": "acme", "rpsLimit": 500, "createdAt": 1714000000 },
  "apiKey": "a1b2c3d4...64hexchars"
}

apiKey is shown once only — it is not stored. The SHA-256 hash of the key is stored in the tenants table. If the key is lost, delete and re-create the tenant.

List tenants

GET /api/v1/tenants
Authorization: Bearer <admin-key>

Returns the tenant list without API keys (keys are never retrievable after creation).

Delete a tenant

DELETE /api/v1/tenants/{id}
Authorization: Bearer <admin-key>

Deletes the tenant record. The tenant's pools, services, etc. remain in the database (the tenant_id column references are not cascade-deleted). Prune orphaned resources manually if needed.

Using a tenant API key

Pass the tenant's API key as a Bearer token. All CRUD operations are automatically scoped to that tenant:

GET /api/v1/pools
Authorization: Bearer a1b2c3d4...64hexchars

Tenant A will never see pools, members, services, or geo rules created by Tenant B, even if they share the same pool name.

Isolation model

Layer Scope
pools Filtered by tenant_id on all reads, writes, and deletes.
services Filtered by tenant_id on all reads, writes, and deletes.
members No tenant_id column; scoped via JOIN with pools on ownership checks.
health_checks Scoped via pool-ownership pre-check.
geo_rules Scoped via pool-ownership pre-check on create/list; subquery on delete.
health_status Internal table keyed by pool_id — indirectly isolated since pool IDs are UUIDs.
DNS resolution GetServiceByDomain queries across all tenants (domains are globally unique).

Known limitations (v1)

  • Pool and service names are globally unique — the UNIQUE(name) constraint on pools and services is not yet scoped per-tenant. Two tenants creating a pool named "web-prod" will get a conflict error. A future migration will change this to UNIQUE(name, tenant_id). Workaround: prefix names with a tenant identifier (e.g. "acme-web-prod").
  • Deleting a tenant does not cascade-delete its resources — pool/service rows remain with their tenant_id set but no corresponding tenant record. This is intentional (allows recovery by recreating the tenant with the same ID), but operators should manually clean up if the tenant is permanently deleted.
  • DNS is not per-tenant — the DNS server serves all tenants' domains from a shared namespace. Domain names must be globally unique across tenants (enforced by the idx_services_domain unique index).
  • No per-tenant rate limiting — the rpsLimit field is stored but not yet plumbed into the DNS RPS limiter. Per-tenant RPS enforcement is planned.

Database schema

CREATE TABLE tenants (
    id           TEXT    PRIMARY KEY,
    name         TEXT    NOT NULL UNIQUE,
    api_key_hash TEXT    NOT NULL UNIQUE,  -- SHA-256 hex of the bearer token
    rps_limit    INTEGER NOT NULL DEFAULT 1000,
    created_at   INTEGER NOT NULL
);

-- pools and services gain tenant_id via additive migration:
ALTER TABLE pools    ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE services ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default';

Existing rows default to 'default', preserving all data for single-tenant deployments upgrading to a multi-tenant release.

Security notes

  • API keys are 32 random bytes (256 bits), hex-encoded to 64 characters — brute force is not feasible.
  • Only the SHA-256 hash is stored; a DB compromise does not expose live keys.
  • The admin key (GSLB_API_KEY) is never stored in the database; it is read from the environment at request time.
  • Each mutating API call is recorded in the audit log with the tenant field set to the caller's tenant ID.

Code references

  • internal/storage/tenant.go: Tenant struct, GenerateAPIKey, HashAPIKey, TenantDB wrapper with all scoped CRUD methods.
  • internal/api/server.go: authMiddleware — tenant resolution and context injection; tenantFromCtx, isAdminFromCtx.
  • internal/api/handlers.go: tdb(r) helper — all handlers obtain a *TenantDB scoped to the request's tenant.

Was this article helpful?
© 2026