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
- Development Team
Context and Problem Statement
The RAG system (ADR-021) currently stores embeddings without tenant isolation. In a multi-tenant iDempiere environment:
- Data Leakage Risk: Tenant A's K_Entry articles could be retrieved for Tenant B's queries
- MCP User Routing: Different MCP users should be routed to their tenant's knowledge context
- Shared vs Private: System knowledge (AD_Client_ID=0) should be shared, tenant knowledge should be isolated
- 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
- Security: Prevent cross-tenant data access in RAG retrieval
- iDempiere Compatibility: Align with existing AD_Client_ID/AD_Org_ID security model
- MCP Integration: Support per-tenant MCP connections (ADR-010)
- Performance: Filtering should not significantly impact retrieval latency
- Simplicity: Single embedding table with metadata filtering preferred over multiple tables
- System Knowledge: AD metadata (client=0) should be accessible to all tenants
Considered Options
- Metadata-Based Filtering - Store ad_client_id in metadata, filter at query time
- Table-Per-Tenant - Separate pgvector tables per AD_Client_ID
- Hybrid Approach - Shared table for system content, tenant tables for private content
Decision Outcome
Chosen option: "Metadata-Based Filtering", because:
- Single table simplifies management and indexing
- pgvector supports efficient metadata filtering
- Aligns with K_Entry's existing ad_client_id metadata pattern
- Allows flexible access policies (e.g., shared + tenant-specific)
Confirmation
The decision is confirmed when:
- All ingested embeddings include
ad_client_idmetadata - RAG searches apply tenant filter automatically
- MCP users are routed to correct tenant context
- System knowledge (client=0) is accessible to all
Pros and Cons of the Options
Option 1: Metadata-Based Filtering (chosen)
Store ad_client_id in embedding metadata, apply filter at query time.
- Good, because single table is simpler to manage
- Good, because pgvector has efficient JSON metadata filtering
- Good, because flexible access control (include client=0 always)
- Good, because K_Entry already uses this pattern
- Neutral, because all tenants share same vector index
- Bad, because filter overhead on large datasets
Option 2: Table-Per-Tenant
Separate pgvector tables: cli_embeddings_{client_id}
- Good, because complete data isolation
- Good, because independent indexing per tenant
- Good, because simpler queries (no filter needed)
- Bad, because schema management complexity
- Bad, because duplicate system content
- Bad, because more tables to maintain
Option 3: Hybrid Approach
Shared table for system, tenant tables for private.
- Good, because shared system knowledge in one place
- Good, because strict tenant isolation for private content
- Bad, because query complexity (join across tables)
- Bad, because mixed architecture
- Bad, because still have table management overhead
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: "how to create invoice" │
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 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's tenant (1000015) - private content ││
│ │ 3. Apply metadata filter to embedding search ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ PGVector Search │
│ SELECT * FROM cli_embeddings │
│ WHERE embedding <=> query_embedding < threshold │
│ AND (metadata->>'ad_client_id')::int IN (0, 1000014, 1000015)│
│ ORDER BY embedding <=> query_embedding │
│ LIMIT 5; │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Filtered Results │
│ - AD Process "Complete Document" (client=0, system) │
│ - K_Entry "Invoice Workflow" (client=1000014, shared KB) │
│ - K_Entry "Tenant A Invoice SOP" (client=1000015, private) │
│ - Wiki "Creating Invoices" (client=0, public) │
│ ✗ K_Entry "Tenant B Internal" (client=1000016, blocked) │
└─────────────────────────────────────────────────────────────────┘
Embedding Metadata Schema
Current Schema (ADR-021)
{
"source_type": "k_entry",
"source_id": "1000123",
"language": "en_US"
}
Extended Schema (with tenant guardrails)
{
"source_type": "k_entry",
"source_id": "1000123",
"language": "en_US",
"ad_client_id": "1000014",
"ad_org_id": "0"
}
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<Integer> 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<Integer> getAllowedClientIds() {
List<Integer> allowed = new ArrayList<>();
if (includeSystemContent()) {
allowed.add(0); // System content (AD metadata, wiki)
}
if (includeSharedKnowledge()) {
allowed.add(CLOUDEMPIERE_CLIENT_ID); // Shared KB
}
if (getClientId() != 0 && getClientId() != CLOUDEMPIERE_CLIENT_ID) {
allowed.add(getClientId()); // Tenant private content
}
return allowed;
}
}
2. MCP Tenant Provider
@ApplicationScoped
public class McpTenantContext implements TenantContext {
@ConfigProperty(name = "idempiere.mcp.tenant.client-id", defaultValue = "0")
int defaultClientId;
@ConfigProperty(name = "idempiere.mcp.tenant.org-ids")
Optional<List<Integer>> orgIds;
// Set from MCP session authentication
private ThreadLocal<Integer> sessionClientId = new ThreadLocal<>();
@Override
public int getClientId() {
Integer session = sessionClientId.get();
return session != null ? session : defaultClientId;
}
@Override
public List<Integer> getOrgIds() {
return orgIds.orElse(List.of());
}
public void setSessionClientId(int clientId) {
sessionClientId.set(clientId);
}
}
3. Enhanced RagService Search
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<Filter> filters = new ArrayList<>();
// Source type filter
if (sourceType != null && !sourceType.isBlank()) {
filters.add(metadataKey("source_type").isEqualTo(sourceType));
}
// Tenant filter (guardrail)
List<Integer> allowedClients = tenant.getAllowedClientIds();
filters.add(metadataKey("ad_client_id").isIn(
allowedClients.stream().map(String::valueOf).toList()
));
// Org filter (optional, if configured)
List<Integer> orgIds = tenant.getOrgIds();
if (!orgIds.isEmpty()) {
filters.add(metadataKey("ad_org_id").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("source_type", SOURCE_TYPE_PROCESS)
.put("source_id", String.valueOf(processId))
.put("language", language)
.put("ad_client_id", "0") // System content
.put("ad_org_id", "0")); // All orgs
// In KEntryIngestor - already has client_id from query
TextSegment segment = TextSegment.from(content,
Metadata.from("source_type", SOURCE_TYPE)
.put("source_id", sourceId)
.put("language", language)
.put("ad_client_id", String.valueOf(ragConfig.getKEntryClientId()))
.put("ad_org_id", "0"));
Configuration
# ==================== Tenant Guardrails ====================
# CloudEmpiere shared knowledge base client ID
# This tenant'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->>'ad_client_id'));
-- Composite index for common query patterns
CREATE INDEX idx_cli_embeddings_tenant_source
ON cli_embeddings (
(metadata->>'ad_client_id'),
(metadata->>'source_type')
);
Migration
For existing embeddings without ad_client_id:
-- Set default ad_client_id=0 for existing system content
UPDATE cli_embeddings
SET metadata = metadata || '{"ad_client_id": "0", "ad_org_id": "0"}'
WHERE metadata->>'ad_client_id' IS NULL;
Security Considerations
- Default Deny: If no tenant context, only system content (client=0) is returned
- Strict Mode: Optional mode that errors if search is attempted without tenant context
- Audit Logging: Log tenant-filtered searches for compliance
- MCP Authentication: Tenant context should come from authenticated MCP session
- CLI Override: CLI
--client-idflag should require elevated permissions
Future Enhancements
- Role-Based Access: Integrate with AD_Role for finer-grained control
- Document-Level Security: Per-document access control
- Cross-Tenant Sharing: Explicit document sharing between tenants
- Tenant Admin: Tenant admins can manage their knowledge base