Skip to content

Gateway: routing binds a chat to an agent but not to an isolated tenant profile — memory, credentials and model are shared across every route, so one gateway cannot safely multiplex tenants #3189

Description

@MervinPraison

Summary

The gateway can route inbound messages to different agents behind one process (RouteBinding / ChannelRouteConfig), and it can scope the toolset per route (trust tiers, allow_tools/deny_tools). What it cannot do is give each route its own isolated tenant profile — a separated memory store, credential/secret scope, model and config home.

Concretely, every route on a single gateway shares:

  • one memory store (agent memory is a bare on/off flag, not a namespace);
  • one credential space (tokens resolve from the shared process environment / per-channel secret refs, not a per-tenant secret scope);
  • one implicit config home (no per-profile working directory / knowledge / policy).

This blocks the single most valuable "world-class gateway" deployment shape: one gateway serving many communities/customers/workspaces at once, each fully isolated. Today an operator who wants tenant A's Discord server and tenant B's Slack workspace to have separate memory and separate secrets must run two gateway processes. That is the opposite of "easy to use, robust, world-class". Memory bleed across tenants is also a correctness/privacy hazard: a group in one route can surface context (or a resolved secret in logs) belonging to another.

Current behaviour

Routing selects an agent, and nothing else. RouteBinding carries an agent id plus tool-scoping fields, but no memory / credential / home dimension:

src/praisonai-agents/praisonaiagents/gateway/protocols.py:1662

@dataclass
class RouteBinding:
    agent: str
    chat_type: Optional[str] = None
    peer: Optional[str] = None
    role: Optional[str] = None
    channel_id: Optional[str] = None
    account: Optional[str] = None
    priority: int = 0
    trust: Optional[str] = None          # tool trust tier (Issue #2298)
    allow_tools: Optional[List[str]] = None
    deny_tools: Optional[List[str]] = None
    # ← no `profile` / `tenant` / memory-scope / secret-scope / home

Session isolation stops at per_user / per_chat. There is no per_tenant / per_profile scope:

src/praisonai-bot/praisonai_bot/bots/_config_schema.py:140

session_scope: str = "per_user"
...
@field_validator("session_scope")
def validate_session_scope(cls, v: str) -> str:
    allowed = {"per_user", "per_chat"}   # ← no per_tenant / per_profile

Memory is a shared boolean, not a namespace. AgentConfigSchema.memory: bool = False (_config_schema.py:39) toggles memory on/off; two agents behind one gateway with memory: true write into the same store — there is no per-route memory namespace.

Credentials are one shared pool. Channel tokens resolve from the shared process environment (src/praisonai-bot/praisonai_bot/bots/bot.py:43 _TOKEN_ENV_MAP) or per-channel secret refs (_config_schema.py:291); there is no per-tenant secret scope so an authz check or a tool call on route A can read route B's credentials.

Absence confirmed: grep -rniE "secret_scope|per.route.*memory|memory.*namespace|credential.*scope|home.*scope|tenant_id" across src/praisonai-bot/praisonai_bot/ returns nothing; tenant appears in core only as a rate-limit scope token (gateway/protocols.py rate-limit comments), never as an isolation boundary.

Desired behaviour

A route (or a channel) can name an isolated profile, and the gateway enters that profile's scope for the whole turn:

  • memory reads/writes are namespaced to the profile (no cross-tenant bleed);
  • secrets/credentials resolve from the profile's scope first (a route can only see its own tokens);
  • model / instructions / knowledge / policy may be overridden per profile;
  • resolution is most-specific-wins and fails closed (a route with no profile binding never silently falls back to another tenant's memory or secrets).

One gateway.yaml should be able to run N isolated tenants from one process, via CLI, YAML and Python alike.

Layer placement

  • Primary layer: wrapper (src/praisonai-bot/praisonai_bot/ — routing, session, secret resolution, memory wiring). The value is runtime isolation wiring: namespacing the memory store, scoping secret resolution, and entering a per-profile home for the duration of a turn.
  • Why not core: core must stay protocol-only with no heavy imports; the memory/secret/home side-effects are impl, not contract. Core's role is limited to the contract (see secondary touch).
  • Why not tools: this is framework lifecycle wiring the gateway performs around a turn, not an agent-callable integration.
  • Why not plugins: isolation is a first-class gateway guarantee (safety by default), not an optional cross-cutting policy a user opts into — a plugin cannot retrofit a namespace boundary onto the session/secret/memory paths.
  • Secondary touch (core protocol): extend RouteBinding with a profile field and add a small ProfileScopeProtocol (resolve a profile → memory namespace + secret scope + home) alongside the existing resolve_route (gateway/protocols.py:1857), mirroring how ToolPolicy/SendPolicy are pure-decision protocols in core with side-effects owned by the wrapper.
  • 3-way surface (CLI + YAML + Python): yesgateway.yaml profiles: + per-route profile:; Python RouteBinding(profile=...) / BotOS(profiles=...); CLI praisonai gateway start honours the same config (plus gateway profiles list).

Proposed approach

  • Extension point: core protocol (RouteBinding.profile + ProfileScopeProtocol) + wrapper resolver that enters the scope; config surface in _config_schema.py.
  • Minimal API sketch:
# core: gateway/protocols.py
@dataclass
class ProfileScope:
    name: str
    memory_namespace: str            # isolates the memory store
    secret_scope: Optional[str] = None   # isolates credential resolution
    home: Optional[str] = None       # per-profile working dir / knowledge / policy
    model: Optional[str] = None
    instructions: Optional[str] = None

@runtime_checkable
class ProfileResolverProtocol(Protocol):
    def resolve(self, facts: "RouteFacts") -> Optional[ProfileScope]: ...

# RouteBinding gains one optional field
@dataclass
class RouteBinding:
    agent: str
    profile: Optional[str] = None     # ← new: isolated tenant profile
    ...

Resolution sketch

# Before (today): one memory store + one secret pool for the whole gateway
gateway: { host: 0.0.0.0, port: 8080 }
agents:
  support: { instructions: "Be helpful", memory: true }
channels:
  discord-acme:  { platform: discord, token: ${ACME_DISCORD},  routes: { default: support } }
  slack-globex:  { platform: slack,   token: ${GLOBEX_SLACK},  routes: { default: support } }
# support's memory mixes ACME and Globex conversations; a tool on either
# route can read both tokens from the shared environment.
# After (proposed): isolated profiles, one process
gateway: { host: 0.0.0.0, port: 8080 }
profiles:
  acme:   { memory_namespace: acme,   secret_scope: acme,   model: gpt-4o }
  globex: { memory_namespace: globex, secret_scope: globex, model: gpt-4o-mini }
agents:
  support: { instructions: "Be helpful", memory: true }
channels:
  discord-acme: { platform: discord, token: {source: env, id: ACME_DISCORD},  routes: { default: { agent: support, profile: acme } } }
  slack-globex: { platform: slack,   token: {source: env, id: GLOBEX_SLACK},  routes: { default: { agent: support, profile: globex } } }
# Each tenant gets its own memory namespace + secret scope; resolution is
# most-specific-wins and fails closed (no profile → no cross-tenant fallback).
# Python parity
from praisonaiagents.gateway.protocols import RouteBinding
bindings = [
    RouteBinding(agent="support", channel_id="discord-acme", profile="acme"),
    RouteBinding(agent="support", channel_id="slack-globex", profile="globex"),
]

Severity

Critical — multi-tenant isolation is the defining capability of a production, world-class gateway and it is currently absent; the only workaround (one process per tenant) defeats the point of a multiplexing gateway, and the shared memory/secret space is an active cross-tenant privacy/correctness hazard.

Validation

  • RouteBinding fields enumerated — no memory/credential/home/profile dimension: src/praisonai-agents/praisonaiagents/gateway/protocols.py:1662-1699.
  • session_scope restricted to per_user/per_chat: src/praisonai-bot/praisonai_bot/bots/_config_schema.py:140,157-163.
  • Memory is a shared boolean, not a namespace: src/praisonai-bot/praisonai_bot/bots/_config_schema.py:39.
  • Credentials come from the shared process env / per-channel secret refs, no per-tenant scope: src/praisonai-bot/praisonai_bot/bots/bot.py:43-67, _config_schema.py:288-372.
  • Absence of any per-route/per-tenant memory or secret scoping confirmed by grep -rniE "secret_scope|memory.*namespace|credential.*scope|home.*scope|tenant_id" over src/praisonai-bot/praisonai_bot/ → no matches.
  • No existing issue covers this: searched mervinpraison/praisonai issues for gateway + tenant/profile/multiplex/isolation — nothing overlaps (Three high-impact gaps in src/praisonai/praisonai: broken ollama publish, multi-tenant-unsafe tool resolver, sync-only auto-generator with unreliable cleanup #1735 concerns the tool resolver, not gateway routing/session isolation).

Metadata

Metadata

Assignees

No one assigned

    Labels

    claudeAuto-trigger Claude analysis

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions