Files
leocrm/app/ai/agent_permissions.py
T

231 lines
7.8 KiB
Python
Raw Normal View History

"""Agent permission context resolution for AI agents.
Resolves the effective permissions available to an agent run as the
intersection of the user's (or run-as user's) RBAC permissions, the agent's
configured tools/skills, and the tools each skill is allowed to use.
Effective = User/Run-as ∩ Agent ∩ Skill ∩ Tool
Key principles:
- Skills orchestrate tools but NEVER grant additional permissions.
- Every tool/service call re-checks permissions — rights are NOT frozen
for a run.
- System admins get all tools.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass, field
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.error_codes import ApiError
from app.core.permissions import check_permission, resolve_permissions
logger = logging.getLogger(__name__)
@dataclass
class AgentPermissionContext:
"""Effective permission context for a single agent run."""
user_id: uuid.UUID
tenant_id: uuid.UUID
run_as_user_id: uuid.UUID | None
user_permissions: dict[str, Any] # RBAC permissions from Role
agent_tool_ids: list[str]
agent_skill_ids: list[str]
effective_tool_ids: list[str] # After intersection
is_system_admin: bool = False
def has_permission(self, permission: str) -> bool:
"""Check whether the run-as user has the given RBAC permission."""
return check_permission(self.user_permissions, permission)
def can_use_tool(self, tool_id: str) -> bool:
"""Check whether the agent may call the given tool."""
return tool_id in self.effective_tool_ids
def _resolve_effective_tool_ids(
agent_definition: Any,
user_permissions: dict[str, Any],
) -> list[str]:
"""Compute the effective tool IDs after User ∩ Agent ∩ Skill ∩ Tool.
Mirrors the semantics of ``app.ai.agent_tools.get_agent_tools``: skills
orchestrate tools but never grant additional permissions.
"""
from app.ai.skill_registry import get_skill_registry
from app.ai.tool_registry import get_tool_registry
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
skill_registry = get_skill_registry()
skills = skill_registry.get_by_names(agent_skill_ids)
direct_tool_ids = set(agent_tool_ids)
skill_tool_ids: set[str] = set()
for skill in skills:
skill_tool_ids.update(skill.allowed_tool_ids or [])
# Tools directly on the agent, plus tools reachable via skills that are
# also directly on the agent (skills never widen the agent's tool set).
available_tool_ids = direct_tool_ids | (direct_tool_ids & skill_tool_ids)
tool_registry = get_tool_registry()
tools = tool_registry.get_by_names(sorted(available_tool_ids))
permitted = [
tool
for tool in tools
if not getattr(tool, "required_permission", None)
or check_permission(user_permissions, tool.required_permission)
]
return [tool.name for tool in permitted]
async def resolve_agent_permissions(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
agent_definition: Any,
run_as_user_id: uuid.UUID | None = None,
) -> AgentPermissionContext:
"""Resolve effective permissions for an agent run.
Effective = User/Run-as ∩ Agent ∩ Skill ∩ Tool.
Args:
db: Async DB session.
tenant_id: Tenant ID for multi-tenancy.
user_id: The user requesting the run (permission source).
agent_definition: AgentDefinition with tool_ids and skill_ids.
run_as_user_id: Optional user the agent runs as. When provided, the
run-as user's permissions are used instead of the requester's.
Returns:
AgentPermissionContext with the resolved effective tool IDs.
"""
effective_user_id = run_as_user_id or user_id
user_permissions = await resolve_permissions(db, effective_user_id, tenant_id)
is_system_admin = bool(user_permissions.get("is_system_admin", False))
agent_tool_ids: list[str] = list(getattr(agent_definition, "tool_ids", None) or [])
agent_skill_ids: list[str] = list(getattr(agent_definition, "skill_ids", None) or [])
if is_system_admin:
effective_tool_ids = list(agent_tool_ids)
else:
effective_tool_ids = _resolve_effective_tool_ids(agent_definition, user_permissions)
return AgentPermissionContext(
user_id=user_id,
tenant_id=tenant_id,
run_as_user_id=run_as_user_id,
user_permissions=user_permissions,
agent_tool_ids=agent_tool_ids,
agent_skill_ids=agent_skill_ids,
effective_tool_ids=effective_tool_ids,
is_system_admin=is_system_admin,
)
async def check_entity_lock(
db: AsyncSession,
entity_type: str,
entity_id: uuid.UUID,
expected_version: int,
) -> bool:
"""Optimistic-lock check: raise ApiError('conflict') on version mismatch.
Loads the entity's ``version`` column. If the current version differs from
``expected_version``, raises ``ApiError`` with code ``conflict``. Models
without a ``version`` column are treated as unlocked (no-op).
Returns True when the lock check passes.
"""
from app.services.entity_permission_service import ENTITY_MODELS
model = ENTITY_MODELS.get(entity_type)
if model is None or not hasattr(model, "version"):
return True
result = await db.execute(select(model.version).where(model.id == entity_id))
current_version = result.scalar_one_or_none()
if current_version is None:
raise ApiError(code="not_found", detail=f"{entity_type} not found")
if int(current_version) != int(expected_version):
raise ApiError(
code="conflict",
detail=(
f"{entity_type} {entity_id} was modified concurrently "
f"(expected version {expected_version}, current {current_version})"
),
)
return True
async def filter_visible_agents(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
agents: list,
) -> list:
"""Filter agents visible to a user based on agents:read + EntityPermission.
A user sees an agent when they have the ``agents:read`` permission AND the
agent is visible via ownership, tenant-ownership, or entity_permissions.
System admins see all agents.
"""
user_permissions = await resolve_permissions(db, user_id, tenant_id)
if user_permissions.get("is_system_admin"):
return list(agents)
if not check_permission(user_permissions, "agents:read"):
return []
from app.services.permission_resolver import get_visible_ids
visible_ids, _ = await get_visible_ids(db, tenant_id, user_id, "agent_definition")
return [a for a in agents if a.id in visible_ids]
async def check_agent_execute_permission(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
agent_id: uuid.UUID,
) -> bool:
"""Check if a user has agents:execute permission for a specific agent.
Requires the ``agents:execute`` RBAC permission AND entity-level access
(owner, tenant-owned, or shared via entity_permissions). System admins
always pass.
"""
user_permissions = await resolve_permissions(db, user_id, tenant_id)
if user_permissions.get("is_system_admin"):
return True
if not check_permission(user_permissions, "agents:execute"):
return False
from app.services.permission_resolver import check_entity_access
return await check_entity_access(
db, tenant_id, user_id, "agent_definition", agent_id, required_level="read"
)
__all__ = [
"AgentPermissionContext",
"resolve_agent_permissions",
"check_entity_lock",
"filter_visible_agents",
"check_agent_execute_permission",
]