Multi-Party Architecture

Overview

ORE Studio implements party-level data isolation within tenants. While tenant isolation separates organisations from each other (see Multi-Tenancy Architecture), party isolation separates business units within an organisation. This document describes how party context flows through the system from user authentication to database queries.

This is a companion document to projects/ores.iam/modeling/multi_tenancy.org. The multi-tenancy architecture provides the foundation; multi-party adds a second layer of isolation within each tenant.

Core Concepts

Party Hierarchy

Parties within a tenant form an arbitrarily nested tree. The tree structure represents a corporate group's organisational hierarchy:

                   ┌────────────────────┐
                   │   System Party     │  (party 0, auto-created)
                   │   type: system     │
                   └────────┬───────────┘
                            │
                   ┌────────┴───────────┐
                   │   ACME Group       │  (root operational party)
                   │   type: operational│
                   └────────┬───────────┘
                            │
         ┌──────────────────┼──────────────────┐
         │                  │                  │
┌────────┴─────────┐  ┌────┴───────────┐  ┌───┴────────────┐
│ ACME Europe      │  │ ACME Americas  │  │ ACME Asia-Pac  │
│ type: operational│  │ type: operational│  │ type: operational│
└────────┬─────────┘  └────────────────┘  └────────────────┘
         │
┌────────┴─────────┐
│ ACME London      │
│ type: operational│
└──────────────────┘

Party Types

Each party has a type that determines its role within the tenant.

Type Purpose Auto-created
system Administrative party (party 0) Yes
operational Business entity (trades, books, KYC) No

System Party

Every tenant has exactly one system party, created automatically during tenant provisioning. The system party:

  • Is the administrative home for tenant admin accounts.
  • Has full visibility over all parties in the tenant.
  • Owns party-scoped data that is administrative rather than business in nature.
  • In tenant 0 (system tenant), it is the only party.

The system party is analogous to the system tenant: it is an infrastructure construct, not a business entity. It serves a similar role to the max UUID system tenant but at the party level.

Operational Parties

Operational parties represent real business entities: legal entities, branches, desks, subsidiaries. They:

  • Are created by users during normal business operations.
  • Form an arbitrarily nested hierarchy via parent_party_id.
  • Have tree-scoped visibility: a party can see its own data and all descendant party data.
  • Own business data: counterparty relationships, books, portfolios, trades.

Data Ownership Patterns

Data within a tenant falls into two categories based on ownership scope.

Tenant-Scoped Data (Shared Reference Data)

Reference data defined once at the tenant level, sourced from standards bodies (ISO, FpML). All parties within the tenant share a common definition.

┌──────────────────────────────────────────────────────────────┐
│                     Tenant: ACME Corp                        │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │          Shared Reference Data (tenant-scoped)         │  │
│  │   Currencies: USD, EUR, GBP, JPY                       │  │
│  │   Countries: US, GB, DE, JP, FR                        │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  Party A can use: USD, EUR, GBP   (via junction table)       │
│  Party B can use: USD, JPY        (via junction table)       │
│  Party C can use: EUR, GBP        (via junction table)       │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Entities in this category:

Entity Source Party Relationship
Currencies ISO 4217 Visibility scoped via junction
Countries ISO 3166-1 Visibility scoped via junction
Business centres ORE Visibility scoped via junction
Counterparty identities GLEIF Shared golden source

Party-level visibility is controlled through junction tables (e.g. party_currencies) that define which currencies a given party can see and use. The underlying currency definitions remain shared.

Counterparty Identity vs Relationship

Counterparties are modelled as a two-tier structure that separates identity (factual, shared) from relationship (party-specific, isolated).

Counterparty Identity (Tenant-Scoped)

The identity of a counterparty – its LEI, legal name, jurisdiction, registered address, entity status, parent hierarchy – is factual, public data sourced from registries such as GLEIF. There is no reason for Party A and Party B within the same group to maintain separate copies of "Goldman Sachs Group Inc, LEI 784F5XWPLTWKTBV3E584".

Counterparty identity records live in ores_refdata_counterparties_tbl and are scoped to the tenant, not to any individual party. All parties within the tenant share a single golden source of counterparty reference data, imported from GLEIF LEI data via the data librarian.

┌──────────────────────────────────────────────────────────────┐
│                     Tenant: ACME Corp                        │
│                                                              │
│  ┌────────────────────────────────────────────────────────┐  │
│  │     Counterparty Identity (tenant-scoped, shared)      │  │
│  │   Deutsche Bank AG    LEI: 7LTWFZYICNSX8D621K86        │  │
│  │   Goldman Sachs Inc   LEI: 784F5XWPLTWKTBV3E584        │  │
│  │   BNP Paribas SA      LEI: R0MUWSFPU8MPRO8K5P83        │  │
│  └────────────────────────────────────────────────────────┘  │
│                                                              │
│  All parties see the same counterparty identities.           │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Counterparty Relationship (Party-Scoped, Future)

The business relationship between a party and a counterparty – KYC/AML status, onboarding date, credit limit, internal rating, netting agreements, ISDA/CSA details, trading permissions – is party-specific. Each regulated entity within a group has independent legal obligations:

  • KYC/AML: A legal obligation on each regulated entity individually; it cannot be delegated up the group hierarchy.
  • Credit limits and netting: Agreed per legal entity pair, not at group level.
  • Ring-fencing regulations (post-2008): Require separation between e.g. a retail bank and its investment arm.
  • Jurisdictional requirements: Different subsidiaries in different jurisdictions face different due diligence rules.

Even in a highly automated system where LLMs perform the KYC checks, the legal accountability for the outcome remains with each individual regulated entity. Automation makes the process fast but does not eliminate the per-party scoping requirement.

The counterparty relationship table (future ores_refdata_counterparty_relationships_tbl) will be party-scoped, referencing the shared identity record:

┌──────────────────────────────────────────────────────────────┐
│                     Tenant: ACME Corp                        │
│                                                              │
│  ┌────────────────────┐    ┌────────────────────┐            │
│  │    ACME London     │    │   ACME New York    │            │
│  │    (party A)       │    │   (party B)        │            │
│  ├────────────────────┤    ├────────────────────┤            │
│  │ Relationships:     │    │ Relationships:     │            │
│  │  → Deutsche Bank   │    │  → Deutsche Bank   │  ← separate│
│  │    KYC: Approved   │    │    KYC: Pending    │    records │
│  │    Limit: €50M     │    │    Limit: $30M     │            │
│  │  → BNP Paribas     │    │  → Goldman Sachs   │            │
│  │    KYC: Approved   │    │    KYC: Approved   │            │
│  ├────────────────────┤    ├────────────────────┤            │
│  │ Books:             │    │ Books:             │            │
│  │  - Main Trading    │    │  - Main Trading    │  ← separate│
│  │  - Hedge Book      │    │  - Prop Book       │    records │
│  └────────────────────┘    └────────────────────┘            │
│                                                              │
│  Identity is shared. Relationships are isolated.             │
│  Party A CANNOT see Party B's relationships.                 │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Party-Scoped Data (Isolated Operational Data)

Operational data owned by a specific party. Each party maintains its own records independently.

Entities in this category:

Entity Isolation Mechanism Notes
Counterparty relationships party_id column + RLS KYC, credit limits, agreements
Books party_id column + RLS Trading books
Portfolios party_id column + RLS Position aggregations
Trades party_id column + RLS Individual transactions

Note: Counterparty identity (LEI, legal name, hierarchy) is tenant-scoped shared reference data; only the party-specific relationship overlay is party-scoped. See the section above for details.

Party-Level Row-Level Security

Party isolation mirrors the tenant RLS pattern but adds a second layer of filtering. Both layers are enforced simultaneously.

Session Variables

At login, three values are set on the database session:

SET app.current_tenant_id = 'tenant-uuid';          -- existing (tenant RLS)
SET app.current_party_id  = 'party-uuid';           -- new (user's party)
SET app.visible_party_ids = '{uuid1,uuid2,...}';     -- new (subtree set)

The visible_party_ids array contains the user's party plus all its descendants, computed at login via a recursive CTE.

Visible Party Set Computation

At login, after the user selects their party, the server computes the full set of visible party IDs:

WITH RECURSIVE party_tree AS (
    -- Start with the user's party
    SELECT id FROM ores_refdata_parties_tbl
    WHERE id = $user_party_id
      AND tenant_id = $tenant_id
      AND valid_to = ores_utility_infinity_timestamp_fn()

    UNION ALL

    -- Add all descendants
    SELECT p.id FROM ores_refdata_parties_tbl p
    JOIN party_tree pt ON p.parent_party_id = pt.id
    WHERE p.tenant_id = $tenant_id
      AND p.valid_to = ores_utility_infinity_timestamp_fn()
)
SELECT array_agg(id) FROM party_tree;

The result is stored as the app.visible_party_ids session variable. For the system party, this query returns all parties in the tenant.

RLS Policy Pattern

Party-scoped tables use a restrictive RLS policy (ANDed with the permissive tenant policy). The policy is strict: when no party context is set, ores_iam_visible_party_ids_fn() returns NULL, so x = ANY(NULL) evaluates to NULL (falsy) and no rows are visible. There is no null pass-through. Users must have a party assigned.

CREATE POLICY party_isolation ON ores_refdata_books_tbl
  AS RESTRICTIVE
  FOR ALL USING (
    party_id = ANY(ores_iam_visible_party_ids_fn())
  )
  WITH CHECK (
    party_id = ANY(ores_iam_visible_party_ids_fn())
  );

This covers all visibility scenarios:

┌──────────────────────────────┬──────────────────────────────────────┐
│ User's Party                 │ Visible Data                         │
├──────────────────────────────┼──────────────────────────────────────┤
│ System party                 │ All parties in tenant                │
│ ACME Group (root operational)│ All operational parties              │
│ ACME Europe (mid-level)      │ ACME Europe + ACME London            │
│ ACME London (leaf)           │ ACME London only                     │
└──────────────────────────────┴──────────────────────────────────────┘

Dual RLS Enforcement

Tenant and party RLS are enforced simultaneously. A query on a party-scoped table is filtered by both:

-- Effective filter on any party-scoped table:
WHERE tenant_id = current_setting('app.current_tenant_id')::uuid     -- tenant RLS
  AND party_id = ANY(current_setting('app.visible_party_ids')::uuid[])  -- party RLS

This provides defence in depth: even if party RLS were misconfigured, tenant RLS prevents cross-tenant data access.

Database Context

Extended context Class

The database context gains party awareness alongside 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;

    // Create a new context with tenant + party (shares connection pool)
    context with_party(utility::uuid::tenant_id tenant_id,
                       boost::uuids::uuid party_id,
                       std::vector<boost::uuids::uuid> visible_party_ids) const;

private:
    tenant_aware_pool<connection_type> connection_pool_;   // existing
    sqlgen::postgres::Credentials credentials_;
};

Extended tenant_aware_pool

The pool wrapper sets all session variables on connection acquisition:

template <class Connection>
class tenant_aware_pool {
public:
    expected<AcquiredConnection, sqlgen::Error> acquire() {
        auto conn = pool_.acquire();
        if (!conn) {
            return sqlgen::error(conn.error());
        }

        // Set tenant context (existing)
        (*conn)->exec(fmt::format(
            "SET app.current_tenant_id = '{}'",
            tenant_id_.to_string()));

        // Set party context (new)
        if (party_id_) {
            (*conn)->exec(fmt::format(
                "SET app.current_party_id = '{}'",
                boost::uuids::to_string(*party_id_)));

            (*conn)->exec(fmt::format(
                "SET app.visible_party_ids = '{{{}}}'",
                format_uuid_array(visible_party_ids_)));
        }

        return conn;
    }

private:
    sqlgen::ConnectionPool<Connection> pool_;
    utility::uuid::tenant_id tenant_id_;
    std::optional<boost::uuids::uuid> party_id_;           // new
    std::vector<boost::uuids::uuid> visible_party_ids_;     // new
};

Key properties:

  • Party context is optional (not all operations require party scope).
  • When set, both tenant and party session variables are configured.
  • The visible_party_ids array is formatted as a PostgreSQL array literal.

Session Management

Extended Session Data

Each authenticated session stores party context alongside tenant context:

struct session_data {
    boost::uuids::uuid id;
    boost::uuids::uuid account_id;
    utility::uuid::tenant_id tenant_id;
    boost::uuids::uuid party_id;                            // new
    std::vector<boost::uuids::uuid> visible_party_ids;      // new
    std::string username;
    std::chrono::system_clock::time_point created_at;
    std::chrono::system_clock::time_point last_activity;
};

Session-Party Binding

Party binding follows the same principles as tenant binding:

  • Resolved at login time (after party selection).
  • Immutable for the session lifetime.
  • Used for all subsequent requests in that session.

Login Flow

Authentication and Party Selection

┌──────────────────────┐
│  Login Request       │
│  user@hostname       │
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│ Resolve tenant       │   (existing)
│ from hostname        │
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│ Authenticate user    │   (existing)
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│ Lookup parties from  │   (new)
│ account_parties      │
└──────────┬───────────┘
           │
    ┌──────┴──────┐
    │ 1 party     │ N parties
    ▼             ▼
┌─────────┐  ┌───────────────┐
│ Auto-   │  │ Return party  │
│ select  │  │ list to client│
└────┬────┘  └───────┬───────┘
     │               │
     │          ┌────┴────────┐
     │          │ Client shows│
     │          │ party picker│
     │          └────┬────────┘
     │               │
     └───────┬───────┘
             │
             ▼
┌──────────────────────┐
│ Compute visible      │   (new)
│ party set via        │
│ recursive CTE        │
└──────────┬───────────┘
           │
           ▼
┌──────────────────────┐
│ Create session with  │
│ tenant + party +     │
│ visible set          │
└──────────────────────┘

Per-Request Context Creation

The make_request_context() method gains party awareness:

database::context make_request_context(
    const comms::service::session_data& session) {
    return ctx_.with_party(
        session.tenant_id,
        session.party_id,
        session.visible_party_ids);
}

UI Considerations

Party Context Display

The active party context is displayed as a styled chip label in the application status bar so the user always knows their active party. Rules:

State Display Style Data visible?
System party active Party: <name> [System] Normal chip All party data
Operational party active Party: <name> Normal chip Party-scoped data
No party (misconfigured) Party: [No Party] Warning chip Nothing (RLS)

The party chip is always visible when connected. A red-background warning chip is shown when no party context is available; a QMessageBox::warning is also shown at login to alert the user to the misconfiguration. With strict RLS enforcement, a missing party context means all books, portfolios, and trades are hidden — not just a cosmetic warning.

The current party category is supplied by the server in the party_summary struct (see party_summary::party_category below). The Qt client uses ClientManager::isSystemParty() to check if the active party is the system party.

Login Response and Party Name

The login_response always includes the selected party in available_parties regardless of whether there is one party or many:

  • Single party: selected_party_id is set; available_parties contains the one party with its name and category. The client reads the name from available_parties.front().
  • Multiple parties: selected_party_id is nil; available_parties lists all parties. The client shows a picker; after selection the server binds the session via select_party_request.
  • Zero parties: login is rejected server-side.

Party info is fetched using ores_refdata_get_party_info_fn to avoid inline SQL in C++ handler code.

Multi-Party Table Views

When a user's visible set spans multiple parties, table views adapt:

User Context Party Column Party Filter
Single party Hidden Not shown
Multiple parties Visible Available

The party column shows the owning party for each record. A filter dropdown allows scoping the view to a specific party or subtree.

Intercompany Considerations

When viewing data across multiple parties, some records may appear to be duplicates (e.g. both Party A and Party B have a relationship with "Deutsche Bank"). These are distinct relationship records with independent lifecycles – different KYC status, credit limits, and trading agreements. The party column disambiguates them. The underlying counterparty identity (LEI, legal name) is shared and appears only once.

For aggregated views (group-level risk, consolidated P&L), intercompany positions between parties in the same group require special handling (elimination) which is a reporting concern, not a data isolation concern.

Performance Characteristics

Per-Login Overhead

Operation Cost
Recursive CTE for visible party set ~1ms for typical hierarchies
Array formatting for session variable Negligible

Per-Request Overhead

Operation Cost
SET app.current_party_id Part of query
SET app.visible_party_ids Part of query
ANY(uuid[]) check in RLS policy O(n), n = parties

For realistic hierarchies (up to a few hundred parties per tenant), the performance impact is negligible.

Memory Per Session

Component Size
party_id (UUID) 16 bytes
visible_party_ids (vector) 16 bytes * party count

For a tenant with 100 parties, visible_party_ids adds ~1.6 KB per session.

Relationship to Tenant Isolation

Party isolation is a strict refinement of tenant isolation:

┌─────────────────────────────────────────────────────────────┐
│                    PostgreSQL Database                       │
│                                                             │
│  ┌───────────────────────────────────────────────────────┐  │
│  │ Tenant RLS Layer (app.current_tenant_id)              │  │
│  │   Isolates tenants from each other                    │  │
│  │                                                       │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │ Party RLS Layer (app.visible_party_ids)         │  │  │
│  │  │   Isolates parties within a tenant              │  │  │
│  │  │                                                 │  │  │
│  │  │   Only applies to party-scoped tables           │  │  │
│  │  │   (counterparty relationships, books, trades)   │  │  │
│  │  │                                                 │  │  │
│  │  │   Tenant-scoped tables (currencies, countries)  │  │  │
│  │  │   are filtered by tenant RLS only               │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
│                                                             │
└─────────────────────────────────────────────────────────────┘
  • Tenant RLS applies to all tenant-scoped tables.
  • Party RLS applies only to party-scoped tables (those with a party_id column).
  • Both layers are enforced simultaneously on party-scoped tables.

Related Components