ADR-016: MCP Security and Multi-Tenant Routing

Status

Proposed

Date

2025-12-06

Deciders

Context and Problem Statement

The iDempiere CLI exposes AI tools via MCP (Model Context Protocol) and LangChain4j for integration with Claude Code, Claude Desktop, and other AI clients. A critical security requirement is ensuring MCP users are properly authenticated and routed to the correct iDempiere tenant data.

iDempiere is a multi-tenant ERP system where tenant isolation is enforced through:

Current State

The CLI currently has two data access paths:

  1. Direct Database (JDBC): Used by RegistryToolLogic, QueryToolLogic

    • Configured via environment variables: IDEMPIERE_DB_*
    • Context from application.properties: idempiere.defaults.ad-client-id
    • No authentication - relies on database credentials
    • No tenant isolation - queries see all data unless SQL filtered
  2. REST API: Used by GeneratedOpenApiFactory (ADR-009)

    • Requires running iDempiere server with REST plugin
    • JWT tokens with embedded role/client context
    • Tenant isolation enforced by iDempiere server

Two Main Use Cases

Use Case User Type Access Scope Security
1. End User Customer/External Strictly scoped to ONE tenant (AD_Client_ID) Must be isolated - cannot see other tenants
2. Internal Staff Employee/Admin Various levels - from "All" to role-based Access Rights determine visibility

Critical Note: iDempiere database has NO built-in tenant isolation. All data lives in the same tables. Tenant isolation is enforced only at the Java application layer via Env.getCtx() context. MCP/CLI must replicate this security model.

┌─────────────────────────────────────────────────────────────────────────────┐
│                         Database (NO isolation)                              │
│  ┌─────────────────────────────────────────────────────────────────────────┐│
│  │ C_Order: AD_Client_ID=0 | AD_Client_ID=11 | AD_Client_ID=12 | ...      ││
│  │          (System)       | (GardenWorld)   | (Customer X)    |          ││
│  └─────────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────────┘
                                    │
                    ┌───────────────┴───────────────┐
                    ▼                               ▼
┌───────────────────────────────────┐ ┌───────────────────────────────────────┐
│     iDempiere Server (Java)       │ │        idempiere-cli (MCP)            │
│  ┌─────────────────────────────┐  │ │  ┌─────────────────────────────────┐  │
│  │ Env.getCtx() enforces:      │  │ │  │ SecurityGuard must enforce:     │  │
│  │  • AD_Client_ID             │  │ │  │  • AD_Client_ID from token      │  │
│  │  • AD_Org_ID                │  │ │  │  • AD_Org_ID from token         │  │
│  │  • AD_Role_ID               │  │ │  │  • Query filter injection       │  │
│  └─────────────────────────────┘  │ │  └─────────────────────────────────┘  │
└───────────────────────────────────┘ └───────────────────────────────────────┘

Use Case 1: End User (Strict Tenant Scope)

External customers or tenant-specific users:

Use Case 2: Internal Staff (Variable Access)

Employees with role-based access:

Problem

When an MCP client (Claude Desktop, Claude Code) connects:

  1. How is the user authenticated?
  2. How is their tenant context (AD_Client_ID/AD_Org_ID) established?
  3. How is data isolation enforced?
  4. How do we prevent cross-tenant data access?

Decision Drivers

Considered Options

  1. Static Configuration - Single tenant per CLI instance
  2. Token-Based Multi-Tenant - REST API tokens determine tenant context
  3. Hybrid Approach - Static for direct DB, tokens for REST API
  4. Reuse iDempiere Core Security Classes - Adapt MRole, MUser in Quarkus

Decision Outcome

Chosen option: "Hybrid Approach" - different security models for different data access paths.

Security Model

┌─────────────────────────────────────────────────────────────────────────────┐
│                           MCP Client (Claude Code)                           │
└──────────────────────────────┬──────────────────────────────────────────────┘
                               │ MCP Protocol (stdio/SSE)
┌──────────────────────────────▼──────────────────────────────────────────────┐
│                         idempiere-cli MCP Server                             │
│  ┌─────────────────────────────────────────────────────────────────────────┐ │
│  │                      Security Guard Layer                                │ │
│  │  • Validate credentials on startup                                       │ │
│  │  • Establish tenant context from config OR token                         │ │
│  │  • Enforce data access rules                                             │ │
│  └──────────────────────────────────────┬──────────────────────────────────┘ │
│                                         │                                     │
│  ┌───────────────────┬──────────────────┴────────────────┬────────────────┐ │
│  │   Direct DB Path  │           REST API Path           │   Read-Only    │ │
│  │   (Development)   │           (Production)            │   AD Queries   │ │
│  └─────────┬─────────┴──────────────────┬────────────────┴───────┬────────┘ │
│            │                            │                        │          │
│  ┌─────────▼─────────┐      ┌───────────▼───────────┐  ┌─────────▼────────┐│
│  │ Static Context    │      │ Token-Based Context   │  │ No Tenant Filter ││
│  │ AD_Client_ID=env  │      │ AD_Client_ID=JWT      │  │ (AD_ tables only)││
│  │ AD_Org_ID=env     │      │ AD_Org_ID=JWT         │  │                  ││
│  │ AD_User_ID=env    │      │ AD_Role_ID=JWT        │  │                  ││
│  └─────────┬─────────┘      └───────────┬───────────┘  └─────────┬────────┘│
│            │                            │                        │          │
└────────────┼────────────────────────────┼────────────────────────┼──────────┘
             │                            │                        │
┌────────────▼────────────┐  ┌────────────▼────────────┐  ┌────────▼─────────┐
│   PostgreSQL Database   │  │  iDempiere REST API     │  │  PostgreSQL DB   │
│   (Development DB)      │  │  (Token validation,     │  │  (AD_Table only) │
│                         │  │   security enforcement) │  │                  │
└─────────────────────────┘  └─────────────────────────┘  └──────────────────┘

1. Direct Database Path Security

For direct DB access (QueryToolLogic, TableToolLogic):

# application.properties - Static tenant context
idempiere.defaults.ad-client-id=11        # GardenWorld
idempiere.defaults.ad-org-id=11           # HQ
idempiere.defaults.ad-user-id=100         # SuperUser

Security measures:

Measure Implementation
Read-only queries Only SELECT allowed, no DDL/DML
Row limiting Max 1000 rows per query
Query timeout 30 seconds max
Sensitive masking Password/token columns masked
SQL injection prevention Pattern-based validation
Tenant filter injection Auto-append AND AD_Client_ID = ?

New: Automatic Tenant Filtering

/**
 * Inject tenant filter into queries.
 * Ensures users can only see their tenant's data.
 */
public String injectTenantFilter(String sql, int clientId) {
    // For tables with AD_Client_ID column
    if (hasClientColumn(sql)) {
        return wrapWithTenantFilter(sql, clientId);
    }
    return sql;
}

// Example transformation:
// Input:  SELECT * FROM C_Order WHERE DocStatus = 'CO'
// Output: SELECT * FROM C_Order WHERE DocStatus = 'CO' AND AD_Client_ID = 11

2. MCP User Authorization Flow

Users need a simple way to authorize MCP access. We provide a device authorization flow similar to GitHub CLI:

┌──────────────────────────────────────────────────────────────────────────────┐
│                     MCP Authorization Flow (User Perspective)                 │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                               │
│  STEP 1: User runs authorization command in Claude Code                      │
│  ─────────────────────────────────────────────────────────────────────────── │
│                                                                               │
│  Claude Code prompt:                                                         │
│  > Connect me to iDempiere                                                   │
│                                                                               │
│  CLI Response:                                                               │
│  ┌─────────────────────────────────────────────────────────────────────────┐ │
│  │ To authorize iDempiere access, please:                                  │ │
│  │                                                                         │ │
│  │ 1. Open this URL in your browser:                                       │ │
│  │    http://localhost:8080/webui/mcp-auth                                 │ │
│  │                                                                         │ │
│  │ 2. Login with your iDempiere credentials                                │ │
│  │                                                                         │ │
│  │ 3. Select Role and Organization                                         │ │
│  │                                                                         │ │
│  │ 4. Copy the generated token and paste it below                          │ │
│  │                                                                         │ │
│  │ Waiting for token...                                                    │ │
│  └─────────────────────────────────────────────────────────────────────────┘ │
│                                                                               │
│  STEP 2: User opens URL and authenticates                                    │
│  ─────────────────────────────────────────────────────────────────────────── │
│                                                                               │
│  Browser: http://localhost:8080/webui/mcp-auth                               │
│  ┌─────────────────────────────────────────────────────────────────────────┐ │
│  │           iDempiere MCP Authorization                                   │ │
│  │  ┌───────────────────────────────────────────────────────────────────┐  │ │
│  │  │ User:     [admin@example.com        ]                             │  │ │
│  │  │ Password: [••••••••                 ]                             │  │ │
│  │  │                                                                   │  │ │
│  │  │ Role:     [GardenWorld Admin        ▼]                            │  │ │
│  │  │ Org:      [HQ                       ▼]                            │  │ │
│  │  │ Warehouse:[HQ Warehouse             ▼]                            │  │ │
│  │  │                                                                   │  │ │
│  │  │           [  Generate MCP Token  ]                                │  │ │
│  │  └───────────────────────────────────────────────────────────────────┘  │ │
│  └─────────────────────────────────────────────────────────────────────────┘ │
│                                                                               │
│  STEP 3: User copies token displayed                                         │
│  ─────────────────────────────────────────────────────────────────────────── │
│                                                                               │
│  Browser shows:                                                              │
│  ┌─────────────────────────────────────────────────────────────────────────┐ │
│  │  ✓ Token Generated Successfully                                        │ │
│  │                                                                         │ │
│  │  Your MCP Token (copy this):                                           │ │
│  │  ┌───────────────────────────────────────────────────────────────────┐ │ │
│  │  │ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQiOjExLC...       │ │ │
│  │  └───────────────────────────────────────────────────────────────────┘ │ │
│  │                                              [Copy to Clipboard]       │ │
│  │                                                                         │ │
│  │  Context: GardenWorld / HQ / GardenWorld Admin                         │ │
│  │  Expires: 2025-12-13 (7 days)                                          │ │
│  └─────────────────────────────────────────────────────────────────────────┘ │
│                                                                               │
│  STEP 4: User pastes token in Claude Code                                    │
│  ─────────────────────────────────────────────────────────────────────────── │
│                                                                               │
│  Claude Code prompt:                                                         │
│  > eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnQiOjExLC...             │
│                                                                               │
│  CLI Response:                                                               │
│  ┌─────────────────────────────────────────────────────────────────────────┐ │
│  │ ✓ Connected to iDempiere!                                              │ │
│  │                                                                         │ │
│  │   Server:   http://localhost:8080                                      │ │
│  │   Client:   GardenWorld (11)                                           │ │
│  │   Org:      HQ (11)                                                    │ │
│  │   Role:     GardenWorld Admin                                          │ │
│  │   User:     GardenAdmin                                                │ │
│  │                                                                         │ │
│  │ Token saved to: ~/.idempiere-cli/tokens/localhost-8080.token           │ │
│  └─────────────────────────────────────────────────────────────────────────┘ │
│                                                                               │
└──────────────────────────────────────────────────────────────────────────────┘

Alternative: Direct Token URL (for automation)

For users who prefer a direct URL:

GET http://localhost:8080/api/v1/auth/mcp-token?client=11&org=11&role=102

Response:
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "client_id": 11,
  "client_name": "GardenWorld",
  "org_id": 11,
  "org_name": "HQ",
  "role_id": 102,
  "role_name": "GardenWorld Admin",
  "user_id": 100,
  "user_name": "GardenAdmin",
  "expires_at": "2025-12-13T00:00:00Z"
}

3. REST API Path Security

For REST API access (RestDataToolLogic per ADR-015):

Token-based authentication flow:

┌──────────────────────────────────────────────────────────────────────────────┐
│                           Token Authentication Flow                           │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                               │
│  1. User obtains token via MCP Auth Flow (above) or creates manually         │
│     ┌─────────────────────────────────────────────┐                          │
│     │ AD_AuthToken (stored in iDempiere)         │                          │
│     │  • AD_User_ID = 100                        │                          │
│     │  • AD_Role_ID = 102                        │                          │
│     │  • AD_Client_ID = 11 (from role)           │                          │
│     │  • AD_Org_ID = 11 (from role)              │                          │
│     │  • Token = "eyJhbGciOiJIUzI1NiIs..."       │                          │
│     └─────────────────────────────────────────────┘                          │
│                                                                               │
│  2. Token stored locally by CLI                                              │
│     ┌─────────────────────────────────────────────┐                          │
│     │ ~/.idempiere-cli/tokens/localhost-8080.token│                          │
│     │  (encrypted, permissions 600)               │                          │
│     └─────────────────────────────────────────────┘                          │
│                                                                               │
│  3. CLI sends requests with Bearer token                                     │
│     ┌─────────────────────────────────────────────┐                          │
│     │ HTTP Request                                │                          │
│     │  Authorization: Bearer eyJhbGciOiJIUzI1NiIs│                          │
│     └─────────────────────────────────────────────┘                          │
│                                                                               │
│  4. iDempiere validates token & establishes context                          │
│     • Validates token exists and not expired                                 │
│     • Sets AD_Client_ID, AD_Org_ID, AD_Role_ID, AD_User_ID                  │
│     • Enforces role-based data access                                        │
│     • Returns only authorized data                                           │
│                                                                               │
└──────────────────────────────────────────────────────────────────────────────┘

Configuration for REST API:

# application.properties
idempiere.rest.url=http://localhost:8080/api/v1
idempiere.rest.token=${IDEMPIERE_REST_TOKEN:}

4. CLI Auth Commands

New commands for token management:

# Interactive login (opens browser for auth)
idempiere-cli auth login --server http://localhost:8080

# Login with token directly
idempiere-cli auth login --token eyJhbGciOiJIUzI1NiIs...

# Show current auth status
idempiere-cli auth status

# List saved tokens
idempiere-cli auth list

# Logout (remove token)
idempiere-cli auth logout --server localhost-8080

# Switch between saved tokens
idempiere-cli auth switch localhost-8080-gardenworld

Example session:

$ idempiere-cli auth login --server http://localhost:8080

Opening browser for authentication...
URL: http://localhost:8080/webui/mcp-auth

Paste your token here: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

✓ Authenticated successfully!
  Server:  http://localhost:8080
  Client:  GardenWorld (11)
  Role:    GardenWorld Admin
  User:    GardenAdmin

Token saved to: ~/.idempiere-cli/tokens/localhost-8080.token

$ idempiere-cli auth status

Current Authentication:
  Server:  http://localhost:8080
  Client:  GardenWorld (11)
  Org:     HQ (11)
  Role:    GardenWorld Admin
  User:    GardenAdmin
  Expires: 2025-12-13 (7 days remaining)

5. Application Dictionary Queries

For AD queries (RegistryToolLogic) that access system metadata:

No tenant filter needed for:

Tenant filter required for:

6. MCP Client Configuration

Multi-tenant via separate configurations:

// ~/.claude/mcp.json
{
  "mcpServers": {
    "idempiere-gardenworld": {
      "command": "java",
      "args": ["-jar", "/path/to/idempiere-cli.jar", "--mcp"],
      "env": {
        "IDEMPIERE_REST_URL": "http://localhost:8080/api/v1",
        "IDEMPIERE_REST_TOKEN": "${GARDENWORLD_TOKEN}",
        "IDEMPIERE_DB_CLIENT_ID": "11"
      }
    },
    "idempiere-system": {
      "command": "java",
      "args": ["-jar", "/path/to/idempiere-cli.jar", "--mcp"],
      "env": {
        "IDEMPIERE_REST_URL": "http://localhost:8080/api/v1",
        "IDEMPIERE_REST_TOKEN": "${SYSTEM_TOKEN}",
        "IDEMPIERE_DB_CLIENT_ID": "0"
      },
      "excluded": true
    }
  }
}

7. Security Guard Implementation

New SecurityGuard class to centralize security checks:

/**
 * Security guard for MCP/AI tool access.
 *
 * <p>Enforces tenant isolation and access control for all data operations.</p>
 *
 * @see <a href="https://wiki.idempiere.org/en/Role">iDempiere Role Security</a>
 */
@ApplicationScoped
public class SecurityGuard {

    @Inject
    IdempiereConfig config;

    @Inject
    RestClientConfig restConfig;

    /**
     * Get the current security context.
     *
     * @return SecurityContext with tenant and user information
     */
    public SecurityContext getContext() {
        // For REST API: context comes from token validation
        if (restConfig.isConfigured()) {
            return getContextFromToken();
        }
        // For direct DB: context from static configuration
        return getContextFromConfig();
    }

    /**
     * Validate that an operation is allowed for the current context.
     *
     * @param operation the operation type (READ, WRITE, EXECUTE)
     * @param resource the resource being accessed (table name, process)
     * @throws SecurityException if access is denied
     */
    public void validateAccess(Operation operation, String resource) {
        SecurityContext ctx = getContext();

        // Check if operation is allowed
        if (!isOperationAllowed(operation, ctx)) {
            throw new SecurityException("Operation not allowed: " + operation);
        }

        // Check resource access
        if (!isResourceAllowed(resource, ctx)) {
            throw new SecurityException("Access denied to: " + resource);
        }
    }

    /**
     * Inject tenant filter into SQL query.
     *
     * @param sql original query
     * @return query with tenant filter
     */
    public String injectTenantFilter(String sql) {
        SecurityContext ctx = getContext();
        return QuerySecurityFilter.apply(sql, ctx.getClientId(), ctx.getOrgId());
    }
}

8. Data Access Levels

iDempiere has 7 data access levels that must be respected:

Level Code Description Tenant Filter
System Only 4 Only AD_Client_ID=0 No
System+Client 7 System or any client Client filter
Client Only 6 Client data only Yes
Client+Organization 3 Client + Org filter Yes + Org
Organization 1 Organization only Yes + Org
All 0 No restrictions No
Parent Organization 2 Parent org hierarchy Yes + Hierarchy

Implementation:

public enum DataAccessLevel {
    SYSTEM_ONLY(4),
    SYSTEM_PLUS_CLIENT(7),
    CLIENT_ONLY(6),
    CLIENT_PLUS_ORG(3),
    ORG_ONLY(1),
    ALL(0),
    PARENT_ORG(2);

    public boolean requiresClientFilter() {
        return this != SYSTEM_ONLY && this != ALL;
    }

    public boolean requiresOrgFilter() {
        return this == CLIENT_PLUS_ORG || this == ORG_ONLY || this == PARENT_ORG;
    }
}

Confirmation

Implementation verification checklist:

Auth Commands

MCP Authorization Page (iDempiere side)

Security Guard

Testing

Documentation

Pros and Cons of the Options

Option 1: Static Configuration

Single tenant per CLI instance via environment variables.

Option 2: Token-Based Multi-Tenant

All access via REST API tokens with embedded context.

Option 3: Hybrid Approach (CHOSEN)

Different security for different paths.

Option 4: Reuse iDempiere Core Security Classes (Database Tools Only)

Adapt iDempiere's MRole, MUser, and security infrastructure to work in Quarkus.

> Note: This option applies only to direct database tools (QueryToolLogic, RegistryToolLogic). > REST API tools use iDempiere server's own security - no adaptation needed.

Tool Path Security Approach
Direct DB (QueryToolLogic) Option 4: Adapt MRole logic in Quarkus
REST API (RestDataToolLogic) iDempiere REST handles via token

iDempiere Core Classes Available:

// org.compiere.model.MRole - Role-based access control
MRole role = MRole.get(ctx, AD_Role_ID);

// Key security methods:
role.addAccessSQL(sql, tableAlias, fullyQualified, rw);  // Add WHERE clause
role.getWhereClause(tableName, rw);                       // Get security filter
role.isTableAccess(AD_Table_ID, ro);                      // Check table access
role.isColumnAccess(AD_Table_ID, AD_Column_ID, ro);       // Check column access
role.getAD_Client_ID();                                   // Tenant context
role.getAD_Org_ID();                                      // Org context

How It Would Work:

┌─────────────────────────────────────────────────────────────────────────────┐
│                    idempiere-cli (Quarkus)                                   │
│  ┌─────────────────────────────────────────────────────────────────────────┐ │
│  │                    SecurityContext (CDI Bean)                            │ │
│  │  ┌───────────────────────────────────────────────────────────────────┐  │ │
│  │  │  MRole role = MRole.get(ctx, roleId);                            │  │ │
│  │  │  String whereClause = role.addAccessSQL(sql, "t", true, false);  │  │ │
│  │  └───────────────────────────────────────────────────────────────────┘  │ │
│  └─────────────────────────────────────────────────────────────────────────┘ │
│                                    │                                          │
│                    Uses iDempiere core classes from Maven dependency          │
│                                    │                                          │
│  ┌─────────────────────────────────▼───────────────────────────────────────┐ │
│  │  <dependency>                                                            │ │
│  │    <groupId>org.idempiere</groupId>                                     │ │
│  │    <artifactId>org.adempiere.base</artifactId>                          │ │
│  │    <version>12.0.0-SNAPSHOT</version>                                   │ │
│  │  </dependency>                                                           │ │
│  └─────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘

Pros:

Cons:

Potential Approach: Lightweight Adapter

Instead of full MRole, create adapter that reads same AD tables:

@ApplicationScoped
public class RoleSecurityAdapter {

    @Inject
    AgroalDataSource dataSource;

    /**
     * Load role's access SQL from AD_Role, AD_Role_OrgAccess tables.
     * Replicates MRole.addAccessSQL() logic without OSGi dependency.
     */
    public String getAccessWhereClause(int roleId, String tableName) {
        // Query AD_Role for IsAccessAllOrgs, UserLevel
        // Query AD_Role_OrgAccess for org list
        // Build WHERE clause: AD_Client_ID = ? AND AD_Org_ID IN (...)
    }
}

More Information

iDempiere Security Architecture

iDempiere security is based on:

  1. Clients (Tenants): Complete data isolation between companies
  2. Organizations: Data sharing within a tenant
  3. Roles: Function and data access control
  4. Users: Individual accounts with role assignments
AD_Client (Tenant)
├── AD_Org (Organization)
│   ├── AD_User (User)
│   │   └── AD_User_Roles (Role assignments)
│   └── Data (filtered by org)
└── AD_Role (Role)
    ├── AD_Window_Access (Window permissions)
    ├── AD_Process_Access (Process permissions)
    ├── AD_Document_Action_Access (Doc actions)
    └── AD_Role_OrgAccess (Org visibility)

REST Auth Token Creation

To create a REST Auth Token in iDempiere:

  1. Login to iDempiere as the target user
  2. Navigate to: System Admin > General Rules > Security > Rest Auth Token
  3. Create new record (token auto-generated)
  4. Copy token for CLI configuration

Reference: REST Web Services Documentation

References

Path: /docs/developers/architecture/idempiere-hub/016-mcp-security-tenant-routing