ADR-036: Multi-Tenant RAG Guardrails

<!-- MADR 3.0 Template - Markdown Any Decision Records --> <!-- Reference: https://adr.github.io/madr/ -->

Status

Proposed

Date

2025-12-08

Deciders

Context and Problem Statement

The RAG system (ADR-021) currently stores embeddings without tenant isolation. In a multi-tenant iDempiere environment:

  1. Data Leakage Risk: Tenant A's K_Entry articles could be retrieved for Tenant B's queries
  2. MCP User Routing: Different MCP users should be routed to their tenant's knowledge context
  3. Shared vs Private: System knowledge (AD_Client_ID=0) should be shared, tenant knowledge should be isolated
  4. Compliance: Some deployments require strict data separation between tenants

iDempiere's security model uses AD_Client_ID (tenant) and AD_Org_ID (organization) for data isolation. The RAG guardrails must respect this model.

Decision Drivers

Considered Options

  1. Metadata-Based Filtering - Store ad_client_id in metadata, filter at query time
  2. Table-Per-Tenant - Separate pgvector tables per AD_Client_ID
  3. Hybrid Approach - Shared table for system content, tenant tables for private content

Decision Outcome

Chosen option: "Metadata-Based Filtering", because:

Confirmation

The decision is confirmed when:

Pros and Cons of the Options

Option 1: Metadata-Based Filtering (chosen)

Store ad_client_id in embedding metadata, apply filter at query time.

Option 2: Table-Per-Tenant

Separate pgvector tables: cli_embeddings_{client_id}

Option 3: Hybrid Approach

Shared table for system, tenant tables for private.

Architecture

Tenant Security Model

┌─────────────────────────────────────────────────────────────────┐
│                     iDempiere Security Model                     │
├─────────────────────────────────────────────────────────────────┤
│  AD_Client_ID = 0        │  System (AD metadata, shared)        │
│  AD_Client_ID = 1000014  │  CloudEmpiere (shared knowledge KB)  │
│  AD_Client_ID = 1000015  │  Tenant A (private content)          │
│  AD_Client_ID = 1000016  │  Tenant B (private content)          │
└─────────────────────────────────────────────────────────────────┘

CloudEmpiere (1000014) is a SPECIAL tenant that holds shared knowledge:
- K_Entry articles for all customers
- Best practices documentation
- Common troubleshooting guides
- Training materials

This content is accessible to ALL tenants, similar to System (0).

RAG Guardrail Flow

┌─────────────────────────────────────────────────────────────────┐
│                        MCP/CLI Request                           │
│  User: CloudEmpiere (AD_Client_ID = 1000014)                    │
│  Query: &quot;how to create invoice&quot;                                  │
└─────────────────────────────┬───────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Tenant Guardrail Layer                       │
│  ┌─────────────────────────────────────────────────────────────┐│
│  │  1. Extract tenant context from MCP session/CLI config      ││
│  │  2. Build allowed clients list:                             ││
│  │     - System (0) - AD metadata                              ││
│  │     - CloudEmpiere (1000014) - shared KB                    ││
│  │     - User&#39;s tenant (1000015) - private content             ││
│  │  3. Apply metadata filter to embedding search               ││
│  └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────┬───────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     PGVector Search                              │
│  SELECT * FROM cli_embeddings                                    │
│  WHERE embedding &lt;=&gt; query_embedding &lt; threshold                 │
│    AND (metadata-&gt;&gt;&#39;ad_client_id&#39;)::int IN (0, 1000014, 1000015)│
│  ORDER BY embedding &lt;=&gt; query_embedding                          │
│  LIMIT 5;                                                        │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Filtered Results                             │
│  - AD Process &quot;Complete Document&quot; (client=0, system)            │
│  - K_Entry &quot;Invoice Workflow&quot; (client=1000014, shared KB)       │
│  - K_Entry &quot;Tenant A Invoice SOP&quot; (client=1000015, private)     │
│  - Wiki &quot;Creating Invoices&quot; (client=0, public)                  │
│  ✗ K_Entry &quot;Tenant B Internal&quot; (client=1000016, blocked)        │
└─────────────────────────────────────────────────────────────────┘

Embedding Metadata Schema

Current Schema (ADR-021)

{
  &quot;source_type&quot;: &quot;k_entry&quot;,
  &quot;source_id&quot;: &quot;1000123&quot;,
  &quot;language&quot;: &quot;en_US&quot;
}

Extended Schema (with tenant guardrails)

{
  &quot;source_type&quot;: &quot;k_entry&quot;,
  &quot;source_id&quot;: &quot;1000123&quot;,
  &quot;language&quot;: &quot;en_US&quot;,
  &quot;ad_client_id&quot;: &quot;1000014&quot;,
  &quot;ad_org_id&quot;: &quot;0&quot;
}

Tenant Assignment by Source Type

Source Type AD_Client_ID Notes
wiki 0 Public documentation, shared
k_entry 1000014 CloudEmpiere shared KB (default)
k_entry Other Tenant-specific private KB
ad_table 0 System metadata, shared
ad_window 0 System metadata, shared
ad_process 0 System metadata, shared
ad_tab 0 System metadata, shared
ad_field 0 System metadata, shared
ad_infowindow 0 System metadata, shared
custom_docs From upload context Future: user-uploaded docs

Shared Knowledge Hierarchy

┌─────────────────────────────────────────────────────────────────┐
│  Layer 1: System (AD_Client_ID = 0)                             │
│  - AD Tables, Windows, Processes, Tabs, Fields                  │
│  - Wiki documentation                                           │
│  - Accessible to ALL tenants                                    │
├─────────────────────────────────────────────────────────────────┤
│  Layer 2: CloudEmpiere Shared KB (AD_Client_ID = 1000014)       │
│  - K_Entry articles for all customers                           │
│  - Best practices, troubleshooting, training                    │
│  - Accessible to ALL tenants (shared knowledge provider)        │
├─────────────────────────────────────────────────────────────────┤
│  Layer 3: Tenant Private (AD_Client_ID = tenant)                │
│  - Tenant-specific K_Entry articles                             │
│  - Custom documentation                                         │
│  - Accessible ONLY to that tenant                               │
└─────────────────────────────────────────────────────────────────┘

Implementation

1. TenantContext Interface

/**
 * Provides tenant context for RAG guardrails.
 */
public interface TenantContext {

    /** CloudEmpiere shared KB tenant ID */
    int CLOUDEMPIERE_CLIENT_ID = 1000014;

    /** Primary tenant ID (AD_Client_ID) */
    int getClientId();

    /** Allowed organization IDs (empty = all orgs) */
    List&lt;Integer&gt; getOrgIds();

    /** Whether to include system content (client=0) */
    default boolean includeSystemContent() {
        return true;
    }

    /** Whether to include CloudEmpiere shared KB (client=1000014) */
    default boolean includeSharedKnowledge() {
        return true;
    }

    /** Build list of allowed client IDs for filtering */
    default List&lt;Integer&gt; getAllowedClientIds() {
        List&lt;Integer&gt; allowed = new ArrayList&lt;&gt;();
        if (includeSystemContent()) {
            allowed.add(0);  // System content (AD metadata, wiki)
        }
        if (includeSharedKnowledge()) {
            allowed.add(CLOUDEMPIERE_CLIENT_ID);  // Shared KB
        }
        if (getClientId() != 0 &amp;&amp; getClientId() != CLOUDEMPIERE_CLIENT_ID) {
            allowed.add(getClientId());  // Tenant private content
        }
        return allowed;
    }
}

2. MCP Tenant Provider

@ApplicationScoped
public class McpTenantContext implements TenantContext {

    @ConfigProperty(name = &quot;idempiere.mcp.tenant.client-id&quot;, defaultValue = &quot;0&quot;)
    int defaultClientId;

    @ConfigProperty(name = &quot;idempiere.mcp.tenant.org-ids&quot;)
    Optional&lt;List&lt;Integer&gt;&gt; orgIds;

    // Set from MCP session authentication
    private ThreadLocal&lt;Integer&gt; sessionClientId = new ThreadLocal&lt;&gt;();

    @Override
    public int getClientId() {
        Integer session = sessionClientId.get();
        return session != null ? session : defaultClientId;
    }

    @Override
    public List&lt;Integer&gt; getOrgIds() {
        return orgIds.orElse(List.of());
    }

    public void setSessionClientId(int clientId) {
        sessionClientId.set(clientId);
    }
}
public ToolResult search(String query, String sourceType, TenantContext tenant) {
    // ... existing code ...

    // Build filter with tenant guardrail
    Filter filter = buildTenantFilter(sourceType, tenant);

    EmbeddingSearchRequest request = EmbeddingSearchRequest.builder()
            .queryEmbedding(queryEmbedding)
            .maxResults(ragConfig.getMaxResults())
            .minScore(ragConfig.getMinScore())
            .filter(filter)
            .build();

    // ... existing code ...
}

private Filter buildTenantFilter(String sourceType, TenantContext tenant) {
    List&lt;Filter&gt; filters = new ArrayList&lt;&gt;();

    // Source type filter
    if (sourceType != null &amp;&amp; !sourceType.isBlank()) {
        filters.add(metadataKey(&quot;source_type&quot;).isEqualTo(sourceType));
    }

    // Tenant filter (guardrail)
    List&lt;Integer&gt; allowedClients = tenant.getAllowedClientIds();
    filters.add(metadataKey(&quot;ad_client_id&quot;).isIn(
        allowedClients.stream().map(String::valueOf).toList()
    ));

    // Org filter (optional, if configured)
    List&lt;Integer&gt; orgIds = tenant.getOrgIds();
    if (!orgIds.isEmpty()) {
        filters.add(metadataKey(&quot;ad_org_id&quot;).isIn(
            orgIds.stream().map(String::valueOf).toList()
        ));
    }

    return Filter.and(filters);
}

4. Enhanced Ingestion (add client_id metadata)

// In ADMetadataIngestor - add client_id=0 for system content
TextSegment segment = TextSegment.from(content.toString(),
    Metadata.from(&quot;source_type&quot;, SOURCE_TYPE_PROCESS)
        .put(&quot;source_id&quot;, String.valueOf(processId))
        .put(&quot;language&quot;, language)
        .put(&quot;ad_client_id&quot;, &quot;0&quot;)       // System content
        .put(&quot;ad_org_id&quot;, &quot;0&quot;));        // All orgs

// In KEntryIngestor - already has client_id from query
TextSegment segment = TextSegment.from(content,
    Metadata.from(&quot;source_type&quot;, SOURCE_TYPE)
        .put(&quot;source_id&quot;, sourceId)
        .put(&quot;language&quot;, language)
        .put(&quot;ad_client_id&quot;, String.valueOf(ragConfig.getKEntryClientId()))
        .put(&quot;ad_org_id&quot;, &quot;0&quot;));

Configuration

# ==================== Tenant Guardrails ====================

# CloudEmpiere shared knowledge base client ID
# This tenant&#39;s K_Entry content is shared with ALL tenants
idempiere.cli.rag.guardrails.shared-kb-client-id=1000014

# Default tenant for CLI operations (when not specified)
idempiere.cli.tenant.default-client-id=0

# MCP tenant configuration (set per MCP user/connection)
idempiere.mcp.tenant.client-id=1000015
idempiere.mcp.tenant.org-ids=1000000,1000001

# Include system content (AD_Client_ID=0) in all searches
idempiere.cli.rag.guardrails.include-system=true

# Include CloudEmpiere shared KB in all searches
idempiere.cli.rag.guardrails.include-shared-kb=true

# Enable strict tenant isolation (error if no tenant context)
idempiere.cli.rag.guardrails.strict-mode=false

Database Index Optimization

To optimize metadata filtering, add a GIN index:

-- Index for tenant filtering
CREATE INDEX idx_cli_embeddings_client
    ON cli_embeddings ((metadata-&gt;&gt;&#39;ad_client_id&#39;));

-- Composite index for common query patterns
CREATE INDEX idx_cli_embeddings_tenant_source
    ON cli_embeddings (
        (metadata-&gt;&gt;&#39;ad_client_id&#39;),
        (metadata-&gt;&gt;&#39;source_type&#39;)
    );

Migration

For existing embeddings without ad_client_id:

-- Set default ad_client_id=0 for existing system content
UPDATE cli_embeddings
SET metadata = metadata || &#39;{&quot;ad_client_id&quot;: &quot;0&quot;, &quot;ad_org_id&quot;: &quot;0&quot;}&#39;
WHERE metadata-&gt;&gt;&#39;ad_client_id&#39; IS NULL;

Security Considerations

  1. Default Deny: If no tenant context, only system content (client=0) is returned
  2. Strict Mode: Optional mode that errors if search is attempted without tenant context
  3. Audit Logging: Log tenant-filtered searches for compliance
  4. MCP Authentication: Tenant context should come from authenticated MCP session
  5. CLI Override: CLI --client-id flag should require elevated permissions

Future Enhancements

  1. Role-Based Access: Integrate with AD_Role for finer-grained control
  2. Document-Level Security: Per-document access control
  3. Cross-Tenant Sharing: Explicit document sharing between tenants
  4. Tenant Admin: Tenant admins can manage their knowledge base

Path: /docs/developers/architecture/idempiere-hub/036-multi-tenant-rag-guardrails