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
| Element | Description |
|---|---|
| Tenant list | Name, logo, and the user's role in each tenant |
| "Remember my choice" | Checkbox — saves lastTenantId per user; auto-selects on future logins |
| Workspace switcher | Sidebar 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
| RPC | Description |
|---|---|
IsDescendant(parentId, childId) | Returns bool — true if childId is a descendant of parentId in the hierarchy tree. O(1) — closure table lookup in PostgreSQL. |
GetTenantStatus(tenantId) | Returns enum — active, 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).
| Method | Endpoint | Permission required | Description |
|---|---|---|---|
POST | /api/v1/tenants | tenants.create | Create a tenant (name, domain, plan) |
GET | /api/v1/tenants | tenants.list | List all tenants (paginated) |
GET | /api/v1/tenants/:id | tenants.list | Tenant details |
PATCH | /api/v1/tenants/:id | tenants.update | Update name, domain, settings |
PATCH | /api/v1/tenants/:id/block | tenants.block | Block — all users lose access immediately |
PATCH | /api/v1/tenants/:id/unblock | tenants.block | Restore access |
DELETE | /api/v1/tenants/:id | tenants.delete | Soft delete — data preserved |
PATCH | /api/v1/tenants/:id/restore | tenants.delete | Restore soft-deleted tenant |
GET | /api/v1/tenants/:id/usage | tenants.list | Resource consumption metrics |
GET | /api/v1/tenants/:id/modules | tenants.list | Installed 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.
| Step | Action | Timing |
|---|---|---|
| 1 | Owner calls POST /tenants/:id/transfer-ownership with password + TOTP | Immediately |
| 2 | Email confirmation sent to current Owner ("Confirm the transfer") | Owner clicks within 24 h |
| 3 | Second confirmation email sent ("Are you sure? This is irreversible.") | 24 h after step 2 |
| 4 | 72-hour cooling period begins — all Admins notified | 72 h |
| 5 | New Owner receives email and confirms with password + TOTP | After cooling period |
| 6 | Transfer activates: new user becomes Owner, former Owner becomes Member | Immediately |
| 7 | Full audit trail recorded at every step | Ongoing |
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
| Scenario | HTTP | type URI suffix |
|---|---|---|
| User not a member of target tenant | 403 | forbidden |
| Target tenant blocked | 402 | tenant-suspended |
| Target tenant deleted | 404 | not-found |
| Session token expired (selector) | 401 | token-expired |
| Transfer already in progress | 409 | conflict |
| New Owner not a tenant member | 400 | validation-error |