Skip to main content

Tenant Management

SeptemCore is a multi-tenant platform where one user can belong to multiple organisations. Tenant management covers how users move between organisations, how the platform resolves which tenant a request belongs to, and how the kernel enforces B2B2B hierarchical relationships between parent and child tenants.


One Account, Many Tenants

A user's identity is global across the platform. Auth providers (Google, MetaMask, etc.) are bound to the user, not to any specific tenant. A single Google login can give access to every organisation the user belongs to.

[email protected] ──┬── Acme Corp (Owner)
├── Beta Ltd (Admin)
└── Gamma LLC (Member)

Multi-Tenant Login Algorithm

POST /auth/login { email, password } (or OAuth callback)

IAM finds userId from credentials
IAM queries: how many tenants does userId belong to?

├─ 0 tenants
│ └─ First-time user
│ → create new tenant + assign Owner role
│ → issue JWT with new tenantId

├─ 1 tenant
│ └─ auto-select
│ → issue JWT with that tenantId

└─ 2+ tenants
├─ lastTenantId saved for this user?
│ └─ auto-select (skip selector)
│ → issue JWT with lastTenantId
└─ no lastTenantId → PARTIAL AUTH
→ return temporary session token (NOT a JWT, TTL 5 min):
{
"requires_tenant_selection": true,
"session_token": "tmp_01j9p9...",
"tenants": [
{ "id": "...", "name": "Acme Corp", "role": "owner", "logo_url": "..." },
{ "id": "...", "name": "Beta Ltd", "role": "admin" }
]
}
→ UI shows tenant selector
→ POST /auth/select-tenant { session_token, tenant_id }
→ full JWT issued

Tenant selector UI elements

ElementDescription
Tenant listName, logo, and the user's role in each tenant
"Remember my choice"Checkbox — saves lastTenantId per user; auto-selects on future logins
Workspace switcherSidebar avatar dropdown → "Switch organisation" (Slack workspace pattern)

Select Tenant (POST)

Used during login when requires_tenant_selection: true is returned:

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

{
"session_token": "tmp_01j9p9...",
"tenant_id": "01j9p3kz5f00000000000000"
}

Response 200 OK:

{
"access_token": "eyJhbGciOiJFUzI1NiJ9...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "01j9p3kx2e00000000000000",
"tenant_id": "01j9p3kz5f00000000000000",
"roles": ["admin"]
}
}

Switch Tenant (No Re-Login)

Once authenticated, users switch tenants without re-entering credentials:

POST https://api.septemcore.com/v1/auth/switch-tenant
Authorization: Bearer <access_token>
Content-Type: application/json

{
"tenant_id": "01j9p4ma7600000000000000"
}

IAM verifies that userId ∈ tenant_id (the user actually belongs to the requested tenant), then issues a new JWT pair scoped to the new tenant:

{
"access_token": "eyJhbGciOiJFUzI1NiJ9...",
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "01j9p3kx2e00000000000000",
"tenant_id": "01j9p4ma7600000000000000",
"roles": ["member"]
}
}

The previous refresh token is immediately revoked. Refresh tokens are tenant-scoped — a stolen token for tenant A does not give access to tenant B.


List User's Tenants

GET https://api.septemcore.com/v1/auth/tenants
Authorization: Bearer <access_token>
{
"data": [
{
"id": "01j9p3kz5f00000000000000",
"name": "Acme Corp",
"role": "owner",
"logo_url": "https://cdn.septemcore.com/t/acme/logo.png",
"status": "active"
},
{
"id": "01j9p4ma7600000000000000",
"name": "Beta Ltd",
"role": "admin",
"logo_url": null,
"status": "active"
}
]
}

B2B2B Delegation — gRPC TenantHierarchyService

When Platform Owners build reseller networks or white-label products, they create a parent → child tenant hierarchy. The Gateway uses the TenantHierarchyService gRPC service to enforce delegation rules — ensuring a parent-tenant admin can only access tenants that are actual descendants of their own tenant.

gRPC Methods

RPCDescription
IsDescendant(parentId, childId)Returns booltrue if childId is a descendant of parentId in the hierarchy tree. O(1) — closure table lookup in PostgreSQL.
GetTenantStatus(tenantId)Returns enumactive, blocked, deleted. Used by Gateway to reject requests from blocked or deleted tenants before any auth check.

Why O(1) IsDescendant

The hierarchy uses a closure table pattern in PostgreSQL. Every ancestor–descendant pair is stored as a row with a depth column. IsDescendant is a single indexed lookup:

SELECT EXISTS (
SELECT 1
FROM tenant_closures
WHERE ancestor_id = $1 -- parentId
AND descendant_id = $2 -- childId
);

This is O(1) regardless of tree depth. No recursive CTEs, no tree walks at query time.

Delegation middleware in the Gateway

1. Delegated request arrives: JWT has tenantId = "reseller-A"
X-Act-As-Tenant: "customer-B"

2. Gateway calls TenantHierarchyService.IsDescendant(
parentId: "reseller-A",
childId: "customer-B"
)
→ true → allowed → forward with tenantId = "customer-B"
→ false → 403 Forbidden

3. Gateway calls TenantHierarchyService.GetTenantStatus("customer-B")
→ active → proceed
→ blocked → 402 Payment Required (tenant suspended)
→ deleted → 404 Not Found

Tenant CRUD (Platform Admin)

Tenant management endpoints are restricted to users with tenants.* permissions (Platform Admin or delegated reseller).

MethodEndpointPermission requiredDescription
POST/api/v1/tenantstenants.createCreate a tenant (name, domain, plan)
GET/api/v1/tenantstenants.listList all tenants (paginated)
GET/api/v1/tenants/:idtenants.listTenant details
PATCH/api/v1/tenants/:idtenants.updateUpdate name, domain, settings
PATCH/api/v1/tenants/:id/blocktenants.blockBlock — all users lose access immediately
PATCH/api/v1/tenants/:id/unblocktenants.blockRestore access
DELETE/api/v1/tenants/:idtenants.deleteSoft delete — data preserved
PATCH/api/v1/tenants/:id/restoretenants.deleteRestore soft-deleted tenant
GET/api/v1/tenants/:id/usagetenants.listResource consumption metrics
GET/api/v1/tenants/:id/modulestenants.listInstalled modules

Ownership Transfer

Transferring tenant ownership is a deliberate, multi-step process designed to prevent accidental or forced transfer. The total minimum duration is ~4 days.

StepActionTiming
1Owner calls POST /tenants/:id/transfer-ownership with password + TOTPImmediately
2Email confirmation sent to current Owner ("Confirm the transfer")Owner clicks within 24 h
3Second confirmation email sent ("Are you sure? This is irreversible.")24 h after step 2
472-hour cooling period begins — all Admins notified72 h
5New Owner receives email and confirms with password + TOTPAfter cooling period
6Transfer activates: new user becomes Owner, former Owner becomes MemberImmediately
7Full audit trail recorded at every stepOngoing

Cancel any step with POST /tenants/:id/cancel-transfer.

After activation: the former Owner is immediately demoted to Member (read-only). The new Owner decides what role to assign them next. The platform is not an arbiter of ownership disputes (per Terms of Service).


SDK

import { useAuth } from '@platform/sdk-auth';
import { kernel } from '@platform/sdk-core';

const { user } = useAuth();

// List tenants the current user belongs to
const tenants = await kernel.auth().listTenants();

// Switch to another tenant (re-issues JWT pair)
await kernel.auth().switchTenant('01j9p4ma7600000000000000');
// The SDK automatically updates the in-memory access token
// and the HttpOnly refresh cookie

Error Reference

ScenarioHTTPtype URI suffix
User not a member of target tenant403forbidden
Target tenant blocked402tenant-suspended
Target tenant deleted404not-found
Session token expired (selector)401token-expired
Transfer already in progress409conflict
New Owner not a tenant member400validation-error