Skip to main content

Multi-Tenancy & B2B2B Hierarchy

SeptemCore is designed for white-label B2B2B deployments — scenarios where the operator of the platform (the Platform Owner) resells access to business customers (Partners), who in turn serve their own end clients (Clients). Every layer of this tree is called a tenant.


The Three-Tier Hierarchy

TierRoleCreated byCan manage
Platform OwnerSingle operator. Wildcard * permission over platform operations.CLI bootstrap (kernel-cli setup) — never via public APIAll tenants, billing plans, modules, infrastructure
PartnerWhite-label reseller. Has its own admin panel, branding, and client base.Platform Owner via POST /api/v1/tenantsIts own Client tenants, modules installed for Partners
ClientEnd-business customer of a Partner. Fully isolated.Platform Owner or Partner adminIts own users, modules, data
important

The Platform Owner account can only be created via the server-side CLI on first deployment. Public registration never issues platform-level permissions.

kernel-cli setup --email [email protected] --password <strong-password>

Tenant Isolation Layers

Isolation is enforced at four independent layers, not just one. All four must be bypassed simultaneously for a data leak to occur:

LayerMechanismScope
PostgreSQL RLSCREATE POLICY tenant_isolation ON {table} USING (tenant_id = current_setting('app.tenant_id')::uuid) — every table has this policy; queries silently return only the caller's rowsRow-level
ClickHouse Row PolicyCREATE ROW POLICY tenant_policy ON {table} USING (tenant_id = {jwt_tenant_id}) + application-level WHERE tenant_id = ? (dual enforcement)Row-level
JWT tenant_id claimThe API Gateway injects tenant_id from the validated JWT into every downstream call. A module cannot change this value.Request-level
Kafka / WebSocket / S3SDK-level tenantId filtering on Kafka consumer; WebSocket channel namespacing {tenantId}:{channel}; S3 path prefix {tenantId}/{bucket}/; Valkey key prefix {tenantId}:{key}Message / object level

Closure Table: O(1) Ancestry Queries

The tenant hierarchy is stored in PostgreSQL using a closure table pattern. This allows ancestry and descendant queries in O(1) time regardless of tree depth, without recursive CTEs.

-- Table: tenant_closure
-- Every ancestor-descendant pair (including self-reference) is a row.

CREATE TABLE tenant_closure (
ancestor_id UUID NOT NULL REFERENCES tenants(id),
descendant_id UUID NOT NULL REFERENCES tenants(id),
depth INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (ancestor_id, descendant_id)
);

-- Self-reference (depth = 0) inserted on every tenant creation
INSERT INTO tenant_closure VALUES (tenant_id, tenant_id, 0);

-- Children of Platform Owner (depth = 1 = Partners)
SELECT descendant_id FROM tenant_closure
WHERE ancestor_id = :platformOwnerId AND depth = 1;

-- All descendants of Partner A at any depth (O(1) index scan)
SELECT descendant_id FROM tenant_closure
WHERE ancestor_id = :partnerAId;
QuerySQL complexityUse case
Is B a descendant of A?O(1) — single primary-key lookupGateway delegation middleware permission check
All descendants of AO(1) — single ancestor index scanPlatform Owner billing reports
Ancestors of CO(1) — single descendant index scanBilling plan inheritance

TenantHierarchyService (gRPC)

The IAM service exposes the TenantHierarchyService gRPC contract, used by the API Gateway's delegation middleware to validate cross-tenant operations.

// proto/platform/iam/v1/iam_service.proto

service TenantHierarchyService {
// Returns true if `descendantId` is in the subtree rooted at `ancestorId`.
rpc IsDescendant(IsDescendantRequest) returns (IsDescendantResponse);

// Returns the status of a tenant (active / blocked / deleted).
rpc GetTenantStatus(GetTenantStatusRequest) returns (GetTenantStatusResponse);
}

message IsDescendantRequest {
string ancestor_id = 1;
string descendant_id = 2;
}

message IsDescendantResponse {
bool is_descendant = 1;
}

The Gateway delegation middleware calls IsDescendant on every request that crosses a tenant boundary (e.g., a Partner Admin acting on behalf of a Client tenant). If the check fails, the Gateway returns 403 Forbidden before the request reaches any domain service.


JWT Claims and Tenant Context

Every JWT issued by the IAM service carries a tenant_id claim that identifies the active tenant for the session. When a user belongs to multiple tenants (e.g., a Platform Owner account), they call POST /api/v1/auth/select-tenant to switch context — a new JWT is issued with the target tenant_id.

// Decoded JWT payload example
{
"sub": "01963baf-0e2a-7c4a-b1b8-3d5e9f2a1c07",
"tenant_id": "01963baf-1122-7c00-a2a3-bd7e8c9d0e11",
"roles": ["crm.*", "billing.view"],
"iat": 1745827200,
"exp": 1745828100
}

The API Gateway extracts tenant_id and:

  1. Sets app.tenant_id as a PostgreSQL session variable — RLS policies activate automatically.
  2. Injects tenant_id into every gRPC metadata header.
  3. Prefixes all Valkey keys with {tenant_id}:.
  4. Validates the module routing table (modules:active:{tenant_id} in Valkey) to reject requests for modules not installed by this tenant.

Platform Owner Impersonation

The Platform Owner has wildcard * permission over platform operations (managing tenants, billing, modules, infrastructure). However, * does not bypass RLS for tenant business data (contacts, wallets, files). To access a specific tenant's data, the Platform Owner must use the impersonation flow:

POST https://api.septemcore.com/v1/auth/impersonate
Content-Type: application/json

{
"targetTenantId": "01963baf-1122-7c00-a2a3-bd7e8c9d0e11",
"reason": "security_investigation",
"reasonDetail": "Investigating suspicious transaction pattern reported by tenant admin"
}
ConstraintValue
Returned token TTL1 hour
Minimum reason length20 characters (IMPERSONATE_MIN_REASON_LENGTH)
Reason categoriessecurity_investigation, support_request, compliance_audit, data_export_request
Audit logEvery impersonation is immutably recorded in Audit Log
Tenant visibilityGET /api/v1/tenants/:id/impersonations — tenant admin can review all impersonation events

Tenant Lifecycle

caution

Blocking a tenant immediately revokes JWT access for all users of that tenant. Billing escalation follows the timeline: Grace (0–7 d) → Suspended / read-only (8–37 d) → Terminated / soft-deleted (38+ d).


Tenant Provisioning: Atomic Transaction

POST /api/v1/auth/register (self-signup) or POST /api/v1/tenants (Platform Owner) creates all of the following in a single PostgreSQL transaction:

  1. users row — the new Owner account.
  2. tenants row — the new organisation.
  3. tenant_closure rows — self-reference + all ancestor rows up to Platform Owner root.
  4. user_roles row — Owner role (*) assigned to the new user.
  5. wallets row — system wallet (type = 'system') with default currency USD.

If any step fails, all are rolled back. There is never a tenant without an Owner or a wallet.


Module Isolation Within a Tenant

Each module installed by a tenant stores its data in a dedicated PostgreSQL schema:

-- Schema: module_{moduleSlug}
-- Example for the CRM module:
CREATE SCHEMA module_crm;

CREATE TABLE module_crm.contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
email TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);

-- RLS policy applied automatically by the kernel
CREATE POLICY tenant_isolation_contacts ON module_crm.contacts
USING (tenant_id = current_setting('app.tenant_id')::uuid);

Two modules can have a table named contacts — they live in module_crm.contacts and module_igaming.contacts respectively. Schema namespacing prevents collisions entirely.

An advisory lock is acquired per-schema during migrations to prevent conflicts when two modules are installed concurrently for the same tenant.


Tenant REST API Reference

All endpoints require a valid JWT. Tenant-scoped endpoints apply RLS automatically.

Base URL: https://api.septemcore.com/v1

EndpointPermission requiredDescription
POST /tenantstenants.create (Platform Owner)Create tenant manually
GET /tenantstenants.list (Platform Owner)List all tenants (paginated)
GET /tenants/:idtenants.listTenant details
PATCH /tenants/:idtenants.updateUpdate name, domain, settings
PATCH /tenants/:id/blocktenants.blockBlock tenant
PATCH /tenants/:id/unblocktenants.blockUnblock tenant
DELETE /tenants/:idtenants.deleteSoft delete
PATCH /tenants/:id/restoretenants.deleteRestore soft-deleted tenant
POST /tenants/:id/transfer-ownershipOwner onlyInitiate ownership transfer (72 h cooling period)
GET /tenants/:id/usageusage.viewResource consumption
GET /tenants/:id/impersonationsOwner of that tenantAudit log of impersonation events