Skip to content

[Feature]: Per-sender RBAC for multi-tenant agent deployments #5982

@metalmon

Description

@metalmon

Summary

Add optional per-sender role-based access control so a single ZeroClaw instance can serve multiple user classes (customers, operators, developers) with isolated workspaces, tool sets, rate limits, and system prompts.

Problem statement

ZeroClaw currently runs with a single security policy for all users. When deploying an agent accessible via channels (Telegram, Discord, etc.), every sender gets the same tools, model, rate limits, and workspace. This makes multi-tenant deployments impractical:

  • A customer-facing sales agent should have read-only product tools and a friendly prompt, but should not access shell, file writes, or internal docs.
  • An operator/owner should see all conversations, manage the system, and use a more capable model.
  • A developer should have full access with debug tools.

Today, achieving this requires running separate ZeroClaw instances per role, which multiplies infrastructure cost and prevents cross-role features like deferred task delivery.

The existing security scaffolding (IamPolicy, NevisIdentity, WorkspaceManager) provides types and tests but is not yet wired into the agent loop, channel processing, or tool execution. This proposal fills that gap with a lightweight, config-driven approach that can bridge to the existing security types when they are connected.

Proposed solution

A new optional crate zeroclaw-rbac (~120 lines) plus minimal integration points:

Core concepts:

  • RoleResolver — maps channel sender ID to a named role via config assignments
  • RoleDefinition — per-role: autonomy level, allowed_tools, workspace, model_hint, rag_access, rate_limit_per_minute, cost_daily_limit_usd, memory_isolation
  • WorkspaceSelector — maps role to workspace directory + per-sender memory DB path (SHA256-based isolation when memory_isolation = true)
  • Each workspace has its own AGENTS.md serving as the system prompt for that role

Enforcement via decorator pattern (no upstream signature changes):

  • RateLimitedProvider wraps Box<dyn Provider> — intercepts chat() for per-role LLM rate limiting
  • RateLimitedTool wraps Box<dyn Tool> — intercepts execute() for per-role action rate limiting
  • Tool filtering applied before run_tool_call_loop — only allowed tools passed in
  • Multimodal path canonicalization applied before run_tool_call_loop
  • HookRunner::with_role_context() embeds role info without changing hook signatures

Integration pattern:

// In RBAC branch (channel orchestrator, CLI loop, dispatch executor):
if let Some(ref rbac) = config.rbac {
    let resolver = RoleResolver::from_config(rbac);
    let selector = WorkspaceSelector::new(&base_dir, rbac);
    let role_ctx = resolver.resolve(&sender_id);
    let workspace_dir = selector.workspace_dir(&role_ctx);
    let memory_db = selector.memory_db_path(&role_ctx, &sender_id);
    let provider = RateLimitedProvider::wrap(provider, &role_ctx);
    let tools = filter_and_wrap_tools(tools, &role_ctx);
    let hooks = hooks.with_role_context(role_ctx.clone());
    // ... call run_tool_call_loop with upstream-identical signature
}

Config example (inside existing config.toml):

[rbac]
default_role = "buyer"

[rbac.roles.buyer]
autonomy = "readonly"
allowed_tools = ["product_search", "product_info", "web_search"]
workspace = "shared"
memory_isolation = true
rag_access = ["public"]
rate_limit_per_minute = 10

[rbac.roles.owner]
autonomy = "supervised"
allowed_tools = ["*"]
workspace = "owner"
memory_isolation = true
rag_access = ["public", "internal"]

[rbac.roles.developer]
autonomy = "full"
allowed_tools = ["*"]
workspace = "dev"
memory_isolation = false
rag_access = ["*"]

[rbac.workspaces.shared]
path = "shared"

[rbac.workspaces.owner]
path = "owner"

[rbac.workspaces.dev]
path = "dev"

[[rbac.assignments]]
sender = "${OWNER_TELEGRAM_ID}"
role = "owner"

[[rbac.assignments]]
sender = "${DEV_TELEGRAM_ID}"
role = "developer"

Unmatched senders fall back to default_role.

Key design decision: All RBAC code lives in if let Some(rbac) branches. The non-RBAC path is identical to upstream. The decorator pattern means run_tool_call_loop, execute_one_tool, and other upstream function signatures remain unchanged.

Non-goals / out of scope

  • Replacing or modifying existing IamPolicy/NevisIdentity/WorkspaceManager types
  • OAuth2/JWT authentication (Nevis SSO) — orthogonal, can coexist
  • UI/dashboard for role management
  • Cross-instance federation
  • Agent output verification/quality checks (future work)

Alternatives considered

  1. Extend upstream WorkspaceProfile with RBAC fields — rejected because WorkspaceProfile is filesystem-driven (profile.toml per workspace) while RBAC needs a centralized config mapping senders to roles. Adding sender-mapping to WorkspaceProfile would change its semantics.

  2. Use IamPolicy with synthetic NevisIdentity — rejected because IamPolicy expects OAuth2 identity objects. Constructing fake NevisIdentity from channel sender IDs is a hack that misrepresents the auth model.

  3. Multiple ZeroClaw instances — rejected because it prevents cross-role features (dispatch), multiplies infra cost, and can't share config.

Acceptance criteria

  • Single config file defines roles, tool sets, rate limits, workspaces
  • Channel messages are resolved to roles by sender ID
  • Each role gets isolated workspace directory and memory DB
  • Per-workspace AGENTS.md serves as system prompt for the role
  • Tool filtering enforced before agent loop
  • LLM and tool rate limits enforced per role via decorators
  • Non-RBAC deployments are unaffected (feature is opt-in via [rbac] section)
  • run_tool_call_loop signature unchanged from upstream
  • All enforcement testable in isolation (decorator unit tests)

Architecture impact

  • New crate: zeroclaw-rbac (leaf dependency, ~120 lines)
  • New types in zeroclaw-api: RbacConfig, RoleDefinition, RoleContext, SenderRoleAssignment, WorkspaceDefinition
  • Integration: zeroclaw-runtime/agent/loop_.rs, zeroclaw-channels/orchestrator/mod.rs (additive if let branches)
  • Config: one Option<RbacConfig> field in Config struct

Risk and rollback

Risk: RBAC branches in agent loop and channel orchestrator could affect non-RBAC paths if not properly isolated.
Rollback: Remove [rbac] section from config — all RBAC branches become no-ops. Or revert the crate addition (single workspace member line).

Breaking change?

No

Relationship to existing security modules

This proposal is designed to be forward-compatible with the existing security scaffolding:

  • When IamPolicy is wired into the runtime, RoleResolver can accept NevisIdentity as an additional identity source alongside channel sender IDs
  • When WorkspaceManager is connected to the agent loop, WorkspaceSelector can delegate to it instead of doing its own directory resolution
  • The decorator pattern (RateLimitedProvider, RateLimitedTool) is independent of identity source — it only needs a role name and limits

Implementation plan

This RFC covers the overall design. Implementation will be split into separate PRs:

  1. RBAC types in zeroclaw-api + zeroclaw-rbac crate
  2. Decorator-based enforcement: RateLimitedProvider, RateLimitedTool
  3. Integration points in agent loop and channel orchestrator
  4. Dispatch system for deferred agent-to-agent tasks (complements existing cron scheduler)

Metadata

Metadata

Assignees

Labels

agentAuto scope: src/agent/** changed.channelAuto scope: src/channels/** changed.enhancementNew feature or requestpriority:p2Medium priorityrisk: highAuto risk: security/runtime/gateway/tools/workflows.securityAuto scope: src/security/** changed.status:blockedBlocked on an external dependency, decision, or prerequisite.

Type

No type

Projects

Status

Backlog

Relationships

None yet

Development

No branches or pull requests

Issue actions