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_pool instances 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 set app.visible_party_ids in the DB session, enabling party-level RLS enforcement

Session-Tenant Binding

When a user authenticates:

  1. The system resolves the tenant from the principal (e.g., user@hostname)
  2. The tenant ID is stored in the session
  3. 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_ids in 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