Multi-Tenancy Architecture
Overview
ORE Studio implements a multi-tenant architecture where each tenant's data is isolated at the database level using PostgreSQL Row-Level Security (RLS). This document describes how tenant context flows through the system from user authentication to database queries. For the party hierarchy, RLS, and data ownership model see Multi-Party Architecture. For account-party rules and the login flow see Multi-Party Login Flow.
Core Concepts
Tenant Identification
Each tenant is identified by a UUID. The tenant_id wrapper type provides
type-safe handling of tenant identifiers:
namespace ores::utility::uuid { class tenant_id { public: static tenant_id system(); // Returns system tenant (max UUID) static std::optional<tenant_id> from_string(const std::string& s); static tenant_id generate(); const boost::uuids::uuid& value() const; std::string to_string() const; bool operator==(const tenant_id& other) const; auto operator<=>(const tenant_id& other) const; private: explicit tenant_id(boost::uuids::uuid id); boost::uuids::uuid id_; }; }
Tenants Table Structure
The ores_iam_tenants_tbl table stores tenant definitions. It has two UUID columns
that serve different purposes:
| Column | Purpose |
|---|---|
id |
The tenant's unique identifier (used by accounts, etc.) |
tenant_id |
Always system tenant (FFF…) - the record's owner |
This design follows a key principle: the tenants table itself is managed by the
system tenant. All tenant records have tenant_id = system because the system
owns and manages the tenant registry.
┌─────────────────────────────────────────────────────────────────────┐ │ ores_iam_tenants_tbl │ ├──────────────────────────────┬──────────────────────────────────────┤ │ id (the tenant's own ID) │ tenant_id (always system tenant) │ ├──────────────────────────────┼──────────────────────────────────────┤ │ 197fae34-...-cfa4 │ ffffffff-ffff-ffff-ffff-ffffffffffff │ │ (Example Corp) │ (system owns this record) │ ├──────────────────────────────┼──────────────────────────────────────┤ │ b7b302e6-...-c9a9 │ ffffffff-ffff-ffff-ffff-ffffffffffff │ │ (ACME Corp) │ (system owns this record) │ ├──────────────────────────────┼──────────────────────────────────────┤ │ ffffffff-ffff-ffff-ffff-fff │ ffffffff-ffff-ffff-ffff-ffffffffffff │ │ (System tenant itself) │ (system owns this record) │ └──────────────────────────────┴──────────────────────────────────────┘
When an account belongs to "Example Corp", its tenant_id column references the
tenant's id (not the tenant_id column):
-- Accounts reference tenants.id, not tenants.tenant_id SELECT a.username, t.name as tenant_name FROM ores_iam_accounts_tbl a JOIN ores_iam_tenants_tbl t ON a.tenant_id = t.id -- Note: t.id, not t.tenant_id WHERE a.valid_to = ores_utility_infinity_timestamp_fn() AND t.valid_to = ores_utility_infinity_timestamp_fn();
This pattern applies to all tenant-scoped tables: they reference tenants.id to
identify which tenant owns the record.
System Tenant
The system tenant is defined as the maximum UUID value:
ffffffff-ffff-ffff-ffff-ffffffffffff
The system tenant is used for:
- System-level operations that span all tenants
- SuperAdmin accounts
- Bootstrap and provisioning operations
- Default context when no specific tenant is configured
The max UUID was chosen because:
- It is a valid UUID that will not conflict with randomly generated UUIDs
- It is easily recognizable in logs and debugging
- It avoids issues with nil UUID (all zeros) which some databases treat as NULL
Tenant Types
Each tenant has a type that determines its operational characteristics, available
features, and the level of controls enforced. Tenant types are stored in the
ores_iam_tenant_types_tbl lookup table and validated on tenant creation.
| Type | Purpose | Controls |
|---|---|---|
system |
Platform administration and shared governance | Platform-managed |
production |
Real customer organisations | Strict |
evaluation |
Realistic environments for demos and testing | Relaxed |
automation |
Automated test infrastructure | None (programmatic) |
system
The system tenant (tenant 0, max UUID) is the platform-level tenant that owns shared governance data such as the tenant registry, system-wide permissions, and reference datasets in the data librarian. There is exactly one system tenant per deployment. Users logged into the system tenant are super administrators with cross-tenant visibility.
production
Production tenants represent real customer organisations with isolated business operations. These tenants enforce strict operational controls:
- Four-eyes authorisation for sensitive operations (e.g., counterparty onboarding, party modifications).
- KYC-gated counterparty creation with supporting documentation.
- Data librarian operations restricted to safe, auditable actions.
- No bulk import of parties or counterparties from external datasets.
evaluation
Evaluation tenants provide realistic, production-like environments for demonstrations, user acceptance testing, QA, and system evaluation. Controls are relaxed to allow rapid environment setup:
- GLEIF-based tenant onboarding available (importing root party and child parties from LEI data).
- Bulk counterparty import from LEI datasets via the data librarian.
- No four-eyes requirement for sensitive operations.
- Clearly labelled in the UI to prevent confusion with production tenants.
Evaluation tenants are suitable for pre-production testing, sales demos, training environments, and exploratory testing by QA teams.
automation
Automation tenants are created and destroyed programmatically by test harnesses for unit, integration, and load testing. They are not intended for human interaction:
- Created with random UUIDs for isolation between test runs.
- No UI-driven workflows; all operations performed via test fixtures.
- Parallel test execution without interference.
- Ephemeral; typically torn down after test completion.
Party Types and Multi-Party Architecture
Each tenant contains parties that represent business units. Parties have their
own type taxonomy and RLS-based data isolation. See the companion document
projects/ores.refdata/modeling/multi_party.org for the full multi-party
architecture, including party-level RLS, visible party sets, and the login flow
extension.
| Party Type | Purpose | Auto-created |
|---|---|---|
system |
Administrative party (party 0) | Yes |
operational |
Business entity (trades, books, KYC) | No |
Row-Level Security
PostgreSQL RLS policies enforce tenant isolation at the database level. Each connection sets a session variable that RLS policies use to filter data:
SET app.current_tenant_id = 'tenant-uuid-here';
All queries on tenant-scoped tables automatically filter by this value, preventing cross-tenant data access.
Database Context
context Class
The database::context class encapsulates database connectivity with tenant
awareness:
class context { public: explicit context(sqlgen::ConnectionPool<connection_type> connection_pool, sqlgen::postgres::Credentials credentials, utility::uuid::tenant_id tenant_id); const utility::uuid::tenant_id& tenant_id() const; // Create a new context with a different tenant (shares connection pool) context with_tenant(utility::uuid::tenant_id tenant_id) const; private: tenant_aware_pool<connection_type> connection_pool_; sqlgen::postgres::Credentials credentials_; };
Key properties:
- Tenant ID is mandatory at construction
with_tenant()creates a lightweight copy with a different tenant- The underlying connection pool is shared across contexts
tenant_aware_pool
The tenant_aware_pool wraps a connection pool and sets tenant context on each
connection acquisition:
template <class Connection> class tenant_aware_pool { public: tenant_aware_pool(sqlgen::ConnectionPool<Connection> pool, utility::uuid::tenant_id tenant_id) : pool_(std::move(pool)), tenant_id_(std::move(tenant_id)) {} const utility::uuid::tenant_id& tenant_id() const { return tenant_id_; } expected<AcquiredConnection, sqlgen::Error> acquire() { auto conn = pool_.acquire(); if (!conn) { return sqlgen::error(conn.error()); } // Set PostgreSQL session variable for RLS auto stmt = fmt::format( "SET app.current_tenant_id = '{}'", tenant_id_.to_string()); (*conn)->exec(stmt); return conn; } private: sqlgen::ConnectionPool<Connection> pool_; utility::uuid::tenant_id tenant_id_; };
Key properties:
- Tenant ID is immutable after construction
- Every
acquire()sets the PostgreSQL session variable - Multiple
tenant_aware_poolinstances share the underlying connection pool
Connection Pool Architecture
A single connection pool is shared across all tenants. Each request creates a lightweight wrapper that sets the appropriate tenant context:
┌─────────────────────────────────────┐
│ sqlgen::ConnectionPool │
│ (shared via rfl::Ref, =10 conns) │
└─────────────────┬───────────────────┘
│ shared_ptr semantics
┌─────────────────────────────┼─────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ tenant_aware_pool │ │ tenant_aware_pool │ │ tenant_aware_pool │
│ tenant_id = "AAA" │ │ tenant_id = "BBB" │ │ tenant_id = "CCC" │
│ (request 1) │ │ (request 2) │ │ (request 3) │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
│ │ │
▼ ▼ ▼
SET app.current_tenant SET app.current_tenant SET app.current_tenant
= 'AAA' = 'BBB' = 'CCC'
This architecture provides:
- Efficient connection utilization (single pool for all tenants)
- Tenant isolation through RLS (each request sets its own context)
- Thread safety (each request has its own immutable wrapper)
Session Management
Session Data
Each authenticated session stores the tenant ID resolved at login, along with party context set after the user selects a party:
struct session_info { boost::uuids::uuid account_id; utility::uuid::tenant_id tenant_id; boost::uuids::uuid party_id; std::vector<boost::uuids::uuid> visible_party_ids; std::string username; };
The tenant ID is:
- Resolved once at login time
- Immutable for the session lifetime
- Used for all subsequent requests in that session
The party_id and visible_party_ids fields are:
- Set via
update_session_party()after the user selects a party - Used by
make_request_context()to setapp.visible_party_idsin the DB session, enabling party-level RLS enforcement
Session-Tenant Binding
When a user authenticates:
- The system resolves the tenant from the principal (e.g.,
user@hostname) - The tenant ID is stored in the session
- All subsequent requests use the session's tenant context
This ensures that a user's operations are always scoped to their tenant.
Message Handler Architecture
tenant_aware_handler Base Class
Message handlers inherit from tenant_aware_handler which provides common
tenant-handling functionality:
template <class Derived> class tenant_aware_handler { protected: tenant_aware_handler(database::context ctx, std::shared_ptr<comms::service::auth_session_service> sessions) : ctx_(std::move(ctx)), sessions_(std::move(sessions)) {} // Validate authentication and return session data std::expected<comms::service::session_data, utility::serialization::error_code> require_authentication(const std::string& remote_address, const std::string& operation); // Create per-request context using session's tenant database::context make_request_context( const comms::service::session_data& session); database::context ctx_; std::shared_ptr<comms::service::auth_session_service> sessions_; };
Per-Request Context Creation
The make_request_context() method creates a context bound to the session's
tenant and, when a party is set, also configures party context:
database::context make_request_context( const comms::service::session_info& auth) { auto ctx = ctx_.with_tenant(auth.tenant_id); if (!boost::uuids::uuid_is_nil(auth.party_id)) ctx = ctx.with_party(auth.party_id, auth.visible_party_ids); return ctx; }
This ensures:
- Each request operates in the correct tenant context
- The underlying connection pool is shared (lightweight)
- Tenant context is immutable for the request lifetime
- Party context sets
app.visible_party_idsin the DB session for RLS
Request Processing Flow
Authenticated Requests
For requests from authenticated users:
┌─────────────────────┐
│ Incoming Request │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ require_ │
│ authentication() │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Session found with │
│ tenant_id │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ make_request_ │
│ context(session) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Execute operation │
│ in tenant context │
└─────────────────────┘
Example:
auto auth = require_authentication(remote_address, "Get accounts"); if (!auth) { co_return std::unexpected(auth.error()); } auto ctx = make_request_context(*auth); repository::accounts_repository repo; auto accounts = repo.list(ctx);
Pre-Authentication Requests
For requests before authentication (login, signup, account creation):
┌─────────────────────┐
│ Incoming Request │
│ (Login/Signup) │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Parse principal │
│ user@hostname │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ Has hostname? │
└──────────┬──────────┘
│
┌──────┴──────┐
│ Yes │ No
▼ ▼
┌─────────┐ ┌───────────┐
│ Lookup │ │ Use │
│ tenant │ │ handler's │
│ by host │ │ tenant │
└────┬────┘ └─────┬─────┘
│ │
└──────┬──────┘
│
▼
┌─────────────────────┐
│ Execute in resolved │
│ tenant context │
└─────────────────────┘
Example:
database::context operation_ctx = [&]() { if (!hostname.empty()) { const auto tenant_id = database::service::tenant_context::lookup_by_hostname( ctx_, hostname); return database::service::tenant_context::with_tenant( ctx_, tenant_id.to_string()); } else { return ctx_.with_tenant(ctx_.tenant_id()); } }();
Tenant Resolution
By Hostname
Tenants can be associated with hostnames. When a principal includes a hostname
(e.g., john@acme.example.com), the system looks up the tenant by that
hostname:
tenant_id lookup_by_hostname(const database::context& ctx, const std::string& hostname);
This enables:
- Multi-tenant SaaS deployments where each customer has their own subdomain
- Automatic tenant resolution without explicit tenant selection
Default Tenant
When no hostname is provided in the principal:
- In production: uses system tenant for administrative operations
- In testing: uses the test fixture's tenant for isolation
Performance Characteristics
Per-Request Overhead
| Operation | Cost |
|---|---|
| Copy shared_ptr (pool reference) | =10 ns |
| Copy tenant_id string (=36 chars) | =50 ns |
| Create context wrapper | =20 ns |
| SET app.current_tenant_id | Part of query |
| Total additional overhead | < 1 μs |
Memory Per Request
| Component | Size |
|---|---|
| tenant_aware_pool | =40 bytes |
| database::context | =48 bytes |
| Total (stack allocated) | < 100 bytes |
All request-scoped objects are stack-allocated and freed when the request completes.
Connection Pool
- Single pool shared across all tenants
- Pool size configured at startup (typically 10-20 connections)
- No per-tenant connection overhead
- RLS handles isolation at the query level
Tenant Isolation Guarantees
Data Isolation
- All tenant-scoped tables have RLS policies
- Queries automatically filter by
app.current_tenant_id - Cross-tenant data access is impossible at the SQL level
Session Isolation
- Each session is bound to exactly one tenant
- Tenant binding is immutable for the session lifetime
- Session hijacking cannot change tenant context
Request Isolation
- Each request creates its own tenant context
- Contexts are immutable and stack-allocated
- Concurrent requests cannot interfere with each other's tenant context
Configuration
Production
In production, handlers are configured with the system tenant:
// context_factory creates context with system tenant by default utility::uuid::tenant_id tenant_id = utility::uuid::tenant_id::system(); if (!cfg.database_options.tenant.empty()) { auto parsed = utility::uuid::tenant_id::from_string( cfg.database_options.tenant); if (parsed) { tenant_id = *parsed; } } return context(pool, credentials, tenant_id);
Testing
In tests, handlers receive an isolated test tenant:
// Test fixture creates context with test tenant auto test_tenant = utility::uuid::tenant_id::generate(); database::context ctx(pool, credentials, test_tenant); accounts_message_handler handler(ctx, sessions);
This provides:
- Isolation between test runs
- Parallel test execution without interference
- Clean tenant context for each test
Related Components
| Component | File | Purpose |
|---|---|---|
| utility | include/ores.utility/uuid/tenant_id.hpp | Tenant ID wrapper type |
| database | include/ores.database/domain/tenant_aware_pool.hpp | Connection pool with tenant context |
| database | include/ores.database/domain/context.hpp | Database context with tenant |
| database | src/service/context_factory.cpp | Context creation from config |
| comms | include/ores.comms/messaging/tenant_aware_handler.hpp | Base class for handlers |
| iam | src/messaging/accounts_message_handler.cpp | Account operations with tenancy |