Multi-tenancy
How Febasi Auth isolates tenants — logically, physically, and atomically — without leaking identity across organizations.
The Auth is multi-tenant from the database up. Every user, role,
permission, refresh token, and audit log carries a tenant_id, and every
authenticated request is scoped to exactly one tenant.
There are two isolation strategies, and they coexist:
Logical isolation (default)
Single shared database. Tenant boundaries enforced by composite
foreign keys and (tenant_id, …) unique indexes.
Physical isolation (opt-in)
Each tenant has its own dedicated PostgreSQL database, registered in the central catalog with an encrypted connection string.
Tenants
A tenant is identified by a unique code — a short, URL-safe slug like
febasi, acme-corp, oboi. The code is what clients send during login.
| Field | Type | Notes |
|---|---|---|
id | UUID | Internal primary key. |
code | string | Globally unique. Used at login. |
name | string | Display name. |
status | enum | ACTIVE or INACTIVE. |
auth_config | JSONB | Password policy, token expirations, etc. |
jwt_secret_* | varies | Optional per-tenant JWT signing material. |
Tenants are managed under /api/v1/tenants/... — see
Endpoints for the full list.
Logical isolation
Without any extra setup, every tenant is isolated through the tenant_id
column on every user-facing table:
Two safety nets back this up:
- Composite unique indexes. A user's
email,username, andcpf_cnpjare unique per tenant, never globally. Tenant A and tenant B can both have ajoao@febasi.com.br. - Cross-tenant access guard. Every controller checks that the resource
it's about to read or modify belongs to the caller's tenant. Crossing
that boundary returns
CROSS_TENANT_ACCESS(HTTP 403) — Febasi Auth never silently leaks rows.
Physical isolation
For tenants that need full data residency (regulatory requirements, large isolation guarantees, or a "noisy neighbor" they want to escape), the Auth API supports per-tenant dedicated databases.
Register a database
PUT /api/v1/tenants/{tenantId}/database{
"connectionString": "postgresql://user:pass@host:5432/tenant_acme",
"databaseType": "postgres",
"poolSize": 10,
"idleTimeoutSeconds": 30,
"connectionTimeoutSeconds": 5
}The connection string is encrypted with AES-256-GCM before being stored,
using the service-wide ENCRYPTION_KEY. It is never returned in plaintext.
Wait for validation
The new entry lands as pending_validation. The service connects, runs a
schema check, and either flips it to active or failed with the error
recorded on the row.
Health checks
Active databases are pinged periodically. Three consecutive failures mark the
tenant database inactive and the central database is used as a fallback for
read-only metadata. The /health endpoint reports activePools and
cachedConfigs so you can monitor the state of the routing layer.
How tenant context is resolved
| Auth method | Source of tenant_id |
|---|---|
| JWT | The tenantId and tenantCode claims inside the access token. |
| Client Key | The X-Tenant-Code header sent alongside the API key. |
| Login | The tenantCode field in the request body. |
A global middleware validates the resolved tenant on every request. If the
tenant is INACTIVE or unknown, the request is rejected with
TENANT_NOT_FOUND before any business logic runs.
What this means for clients
- You will only ever receive data from your tenant — no filters needed client-side.
- If you build an admin UI that switches tenants, you must obtain a fresh token per tenant. Refresh tokens are tenant-scoped.
- Per-tenant JWT secrets are independent. A token from tenant A cannot be validated as a token from tenant B even if you somehow swap the secret.
Want even stronger isolation?
Combine physical isolation with per-tenant JWT secrets. The same cryptographic blast radius as having one Auth per tenant — at a fraction of the operational cost.