Tool Permission Enforcement
Every assistant tool is gated by ABAC permissions at two layers: the registry filters which tools the LLM sees, and each handler checks permissions again before accessing data. This defense-in-depth pattern ensures unauthorized access is impossible even if the model hallucinates a tool call.
Two-Layer Enforcement
Section titled “Two-Layer Enforcement”User connects to assistant WebSocket → collect_assistant_tools(&session) → Registry layer: only include tools the user's permissions allow → build_assistant_system_prompt() receives filtered tool_names → System prompt only describes available tools → LLM receives tool definitions + prompt matching permissions → Tool called by LLM → Handler layer: checks permission again before executing → Returns Forbidden if check fails (defense-in-depth)Layer 1: Registry Pre-Filtering
Section titled “Layer 1: Registry Pre-Filtering”collect_assistant_tools() in tool_registry_service.rs builds the tool list based on Session permissions. Each tool is only added when the user holds a qualifying permission.
fn has_any_of(session: &Session, permissions: &[Permission]) -> bool { session.is_super_admin || session.is_owner || permissions.iter().any(|p| session.permissions.contains(p))}
pub fn collect_assistant_tools(session: &Session) -> Vec<Tool> { let mut tools = Vec::new();
let can_list_contacts = has_any_of(session, &[ Permission::Contact(ContactPermission::Collection(ContactCollectionPermission::List)), Permission::Contact(ContactPermission::Collection(ContactCollectionPermission::ListAssigned)), ]);
if can_list_contacts { tools.push(build_search_contacts_tool(session.clone())); tools.push(build_get_contact_tags_tool(session.clone())); }
// ... more gates per category tools}Super admins and org owners bypass all gates — they see every tool.
Layer 2: Handler Runtime Checks
Section titled “Layer 2: Handler Runtime Checks”Each tool handler independently verifies permissions using the Resource trait before accessing data. This catches any edge case where the registry might be out of sync.
// Example: get_recent_calls handlerlet has_list = CallResource::has_collection_permission( &session, CallCollectionPermission::List);if !has_list { return Err(ToolError::forbidden("Call:Collection:List"));}Permission Gate Reference
Section titled “Permission Gate Reference”| Category | Tools | Required Permission |
|---|---|---|
| Contact — read | search_contacts, get_contact_tags | Contact:Collection:List or ListAssigned |
| Contact — view | get_contact_details, find_contact_by_phone | Contact:Instance:View or ViewAssigned |
| Contact — write | update_contact, manage_contact_emails/phones/tags | Contact:Instance:Update or UpdateAssigned |
| Contact — create | create_contact, create_contact_tag | Contact:Collection:Create |
| Notes | add_contact_note | ContactNote:Collection:Create + contact view |
| Calls | get_recent_calls | Call:Collection:List |
| Calls | get_call_details | Call:Instance:View |
| Messages | get_contact_messages, get_unanswered_contacts | Message:Collection:List |
| Messages | send_sms | Message:Collection:Create |
| Analytics | get_dashboard_stats, get_engagement_stats, get_activity_counts, get_plan_stats | Dashboard:Instance:ViewOrg or ViewOwn |
| Workflow | get_plans_for_contact | Plan:Collection:List + contact view |
| Workflow | create_plan | Plan:Collection:Create + contact update |
| Workflow | get_plan_templates | PlanTemplate:Collection:List |
| Tasks — read | get_tasks | Task:Collection:List |
| Tasks — view | get_task_details | Task:Instance:View |
| Tasks — create | create_task | Task:Collection:Create |
| Tasks — write | update_task, complete_task, dismiss_task, reopen_task | Task:Instance:Update |
| Analyzers — read | get_analyzers | Analyzer:Collection:List |
| Analyzers — view | get_analyzer_details | Analyzer:Instance:View |
| Analyzers — create | create_analyzer | Analyzer:Collection:Create |
| Analyzers — write | update_analyzer | Analyzer:Instance:Update |
| Voice Agents — read | get_agents | Agent:Collection:List |
| Voice Agents — view | get_agent_details | Agent:Instance:View |
| Voice Agents — create | create_agent | Agent:Collection:Create |
| Voice Agents — write | update_agent | Agent:Instance:Update |
| Voice Agents — guide | get_prompt_writing_guide | Agent:Collection:List |
| Text Agents — read | get_text_agents | TextAgent:Collection:List |
| Text Agents — view | get_text_agent_details | TextAgent:Instance:View |
| Text Agents — create | create_text_agent | TextAgent:Collection:Create |
| Text Agents — write | update_text_agent | TextAgent:Instance:Update |
| Infrastructure | get_phone_numbers | Phone:Collection:List |
| Infrastructure | get_phone_details | Phone:Instance:View |
| Infrastructure | update_phone, assign_phone_agent, assign_phone_text_agent, assign_phone_analyzers, set_phone_agent | Phone:Instance:Update |
| Infrastructure | buy_phone_number | Phone:Collection:Create |
| Infrastructure | search_available_numbers | Twilio:Collection:Manage |
| Infrastructure | get_knowledge_bases | Knowledge:Collection:List |
Dashboard Scope
Section titled “Dashboard Scope”Analytics tools use resolve_dashboard_scope(&session) to restrict data visibility:
Dashboard:Instance:ViewOrg— sees all organization dataDashboard:Instance:ViewOwn— sees only data for assigned phones and contacts
The scope resolution passes phone_ids and member_id filters down to the analytics services, so scoped users never see data outside their assignments.
Dynamic System Prompt
Section titled “Dynamic System Prompt”build_assistant_system_prompt() receives the filtered tool_names list and builds prompt sections dynamically. Only capabilities the user can actually use appear in the prompt:
let has: HashSet<&str> = tool_names.iter().map(|s| s.as_str()).collect();prompt.push_str(&build_capabilities_section(&has));prompt.push_str(&build_workflows_section(&has));This reduces token usage (~30% fewer prompt tokens for restricted users) and eliminates hallucinated tool calls for tools the user can’t access.
Adding a New Tool
Section titled “Adding a New Tool”When you add a new assistant tool, enforce permissions at both layers:
- Handler — add a
Resourcepermission check at the top of your tool function - Registry — add a
has_any_of()gate incollect_assistant_tools()matching your handler check - System prompt — if your tool needs prompt guidance, add it to the relevant section builder in
build_system_prompt_service.rsgated byhas.contains("your_tool_name")
// 1. Handler checklet has_perm = MyResource::has_collection_permission(&session, MyCollectionPermission::List);if !has_perm { return Err(ToolError::forbidden("MyResource:Collection:List"));}
// 2. Registry gate (in collect_assistant_tools)if has_any_of(session, &[Permission::MyResource( MyResourcePermission::Collection(MyResourceCollectionPermission::List),)]) { tools.push(build_my_tool(session.clone()));}Key Files
Section titled “Key Files”| Path | Purpose |
|---|---|
src/mods/assistant/services/tool_registry_service.rs | Permission-gated tool collection with has_any_of() |
src/mods/assistant/services/build_system_prompt_service.rs | Dynamic prompt builder filtered by available tools |
src/mods/assistant/services/assistant_service.rs | Orchestrator that wires registry → prompt → LLM |
src/mods/assistant/tools/ | Individual tool handlers with runtime permission checks |