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
| Tier | Role | Created by | Can manage |
|---|---|---|---|
| Platform Owner | Single operator. Wildcard * permission over platform operations. | CLI bootstrap (kernel-cli setup) — never via public API | All tenants, billing plans, modules, infrastructure |
| Partner | White-label reseller. Has its own admin panel, branding, and client base. | Platform Owner via POST /api/v1/tenants | Its own Client tenants, modules installed for Partners |
| Client | End-business customer of a Partner. Fully isolated. | Platform Owner or Partner admin | Its own users, modules, data |
The Platform Owner account can only be created via the server-side CLI on first deployment. Public registration never issues platform-level permissions.
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:
| Layer | Mechanism | Scope |
|---|---|---|
| PostgreSQL RLS | CREATE 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 rows | Row-level |
| ClickHouse Row Policy | CREATE ROW POLICY tenant_policy ON {table} USING (tenant_id = {jwt_tenant_id}) + application-level WHERE tenant_id = ? (dual enforcement) | Row-level |
JWT tenant_id claim | The API Gateway injects tenant_id from the validated JWT into every downstream call. A module cannot change this value. | Request-level |
| Kafka / WebSocket / S3 | SDK-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;
| Query | SQL complexity | Use case |
|---|---|---|
| Is B a descendant of A? | O(1) — single primary-key lookup | Gateway delegation middleware permission check |
| All descendants of A | O(1) — single ancestor index scan | Platform Owner billing reports |
| Ancestors of C | O(1) — single descendant index scan | Billing 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:
- Sets
app.tenant_idas a PostgreSQL session variable — RLS policies activate automatically. - Injects
tenant_idinto every gRPC metadata header. - Prefixes all Valkey keys with
{tenant_id}:. - 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"
}
| Constraint | Value |
|---|---|
| Returned token TTL | 1 hour |
| Minimum reason length | 20 characters (IMPERSONATE_MIN_REASON_LENGTH) |
| Reason categories | security_investigation, support_request, compliance_audit, data_export_request |
| Audit log | Every impersonation is immutably recorded in Audit Log |
| Tenant visibility | GET /api/v1/tenants/:id/impersonations — tenant admin can review all impersonation events |
Tenant Lifecycle
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:
usersrow — the new Owner account.tenantsrow — the new organisation.tenant_closurerows — self-reference + all ancestor rows up to Platform Owner root.user_rolesrow — Owner role (*) assigned to the new user.walletsrow — system wallet (type = 'system') with default currencyUSD.
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
| Endpoint | Permission required | Description |
|---|---|---|
POST /tenants | tenants.create (Platform Owner) | Create tenant manually |
GET /tenants | tenants.list (Platform Owner) | List all tenants (paginated) |
GET /tenants/:id | tenants.list | Tenant details |
PATCH /tenants/:id | tenants.update | Update name, domain, settings |
PATCH /tenants/:id/block | tenants.block | Block tenant |
PATCH /tenants/:id/unblock | tenants.block | Unblock tenant |
DELETE /tenants/:id | tenants.delete | Soft delete |
PATCH /tenants/:id/restore | tenants.delete | Restore soft-deleted tenant |
POST /tenants/:id/transfer-ownership | Owner only | Initiate ownership transfer (72 h cooling period) |
GET /tenants/:id/usage | usage.view | Resource consumption |
GET /tenants/:id/impersonations | Owner of that tenant | Audit log of impersonation events |