e2cd435861
- Create call_crm_api tool: single tool that can call ANY CRM API endpoint - Inject OpenAPI spec into system prompt so AI knows all available endpoints - Always include call_crm_api in agent tools (not just via tool_ids) - Extend get_current_user to support internal header-based auth (X-Internal-Call, X-Tenant-Id, X-User-Id) for AI tool API access - No more manual tool-per-endpoint registration needed
203 lines
7.2 KiB
Python
203 lines
7.2 KiB
Python
"""FastAPI dependencies: auth, db, tenant context, RBAC."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any
|
|
|
|
import redis.asyncio as aioredis
|
|
from fastapi import Depends, HTTPException, Request, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import get_settings
|
|
from app.core.auth import get_redis, get_session_data, refresh_session_ttl
|
|
from app.core.db import get_db, set_tenant_context
|
|
|
|
|
|
async def get_redis_dep() -> aioredis.Redis:
|
|
"""FastAPI dependency for Redis client."""
|
|
return get_redis()
|
|
|
|
|
|
async def get_current_user(
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db),
|
|
redis: aioredis.Redis = Depends(get_redis_dep),
|
|
) -> dict[str, Any]:
|
|
"""Get the current authenticated user from session cookie or internal headers.
|
|
|
|
Returns session data dict with user_id, tenant_id, email, name, role,
|
|
and resolved permissions from Redis cache.
|
|
|
|
Supports internal calls via X-Internal-Call: true header with
|
|
X-Tenant-Id and X-User-Id headers (for AI tool API access).
|
|
"""
|
|
settings = get_settings()
|
|
|
|
# Check for internal call (AI tool access)
|
|
if request.headers.get("X-Internal-Call") == "true":
|
|
tenant_id_str = request.headers.get("X-Tenant-Id", "")
|
|
user_id_str = request.headers.get("X-User-Id", "")
|
|
if tenant_id_str and user_id_str:
|
|
try:
|
|
tenant_id = uuid.UUID(tenant_id_str)
|
|
user_id = uuid.UUID(user_id_str)
|
|
await set_tenant_context(db, tenant_id)
|
|
|
|
from app.core.permissions import get_cached_permissions
|
|
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
|
|
return {
|
|
"user_id": user_id_str,
|
|
"tenant_id": tenant_id_str,
|
|
"permissions": resolved.get("permissions", []),
|
|
"denied_permissions": resolved.get("denied", []),
|
|
"field_permissions": resolved.get("field_permissions", {}),
|
|
"is_system_admin": resolved.get("is_system_admin", False),
|
|
"is_active": True,
|
|
}
|
|
except (ValueError, Exception):
|
|
pass # Fall through to session cookie auth
|
|
|
|
session_id = request.cookies.get(settings.session_cookie_name)
|
|
if not session_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
|
)
|
|
|
|
session_data = await get_session_data(redis, session_id)
|
|
if session_data is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
|
|
)
|
|
|
|
# Sliding session: extend TTL on each authenticated request
|
|
await refresh_session_ttl(redis, session_id)
|
|
|
|
if not session_data.get("is_active", True):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail={"detail": "User account deactivated", "code": "user_inactive"},
|
|
)
|
|
|
|
# Set RLS tenant context
|
|
tenant_id = uuid.UUID(session_data["tenant_id"])
|
|
await set_tenant_context(db, tenant_id)
|
|
|
|
# Load resolved permissions from cache (or DB on miss)
|
|
from app.core.permissions import get_cached_permissions
|
|
|
|
user_id = uuid.UUID(session_data["user_id"])
|
|
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
|
|
session_data["permissions"] = resolved.get("permissions", [])
|
|
session_data["denied_permissions"] = resolved.get("denied", [])
|
|
session_data["field_permissions"] = resolved.get("field_permissions", {})
|
|
session_data["is_system_admin"] = resolved.get("is_system_admin", False)
|
|
|
|
return session_data
|
|
|
|
|
|
async def require_admin(
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
"""Require admin role (legacy + new permission system)."""
|
|
if current_user.get("is_system_admin") or current_user.get("role") == "admin":
|
|
return current_user
|
|
# Also check via permission system
|
|
from app.core.permissions import check_permission
|
|
|
|
if check_permission(current_user, "*:*"):
|
|
return current_user
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={"detail": "Admin access required", "code": "forbidden"},
|
|
)
|
|
|
|
|
|
async def require_write(
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
"""Require write permission (admin, editor, or custom role with write perms)."""
|
|
if current_user.get("is_system_admin"):
|
|
return current_user
|
|
role = current_user.get("role", "viewer")
|
|
if role in ("admin", "editor"):
|
|
return current_user
|
|
# Check via permission system for custom roles
|
|
from app.core.permissions import check_permission
|
|
|
|
if check_permission(current_user, "*:write") or check_permission(current_user, "*:create"):
|
|
return current_user
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={"detail": "Write access required", "code": "forbidden"},
|
|
)
|
|
|
|
|
|
def require_permission(permission: str):
|
|
"""FastAPI dependency factory: require a specific permission.
|
|
|
|
Usage:
|
|
@router.get("/contacts", dependencies=[Depends(require_permission("contacts:read"))])
|
|
"""
|
|
async def _check(
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
if current_user.get("is_system_admin"):
|
|
return current_user
|
|
from app.core.permissions import check_permission
|
|
|
|
if check_permission(current_user, permission):
|
|
return current_user
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={
|
|
"detail": f"Permission '{permission}' required",
|
|
"code": "forbidden",
|
|
},
|
|
)
|
|
|
|
return _check
|
|
|
|
|
|
def require_field_access(module: str, field: str, default: str = "read"):
|
|
"""FastAPI dependency factory: require field-level access.
|
|
|
|
Usage:
|
|
@router.get("/contacts/{id}", dependencies=[Depends(require_field_access("contacts", "annual_revenue"))])
|
|
"""
|
|
async def _check(
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
if current_user.get("is_system_admin"):
|
|
return current_user
|
|
from app.core.permissions import check_field_access
|
|
|
|
access = check_field_access(current_user, module, field, default)
|
|
if access == "hidden":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail={
|
|
"detail": f"Field '{field}' is hidden",
|
|
"code": "field_hidden",
|
|
},
|
|
)
|
|
return current_user
|
|
|
|
return _check
|
|
|
|
|
|
async def get_tenant_id(
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
) -> uuid.UUID:
|
|
"""Extract tenant_id from current user session."""
|
|
return uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
|
async def get_current_user_id(
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
) -> uuid.UUID:
|
|
"""Extract user_id from current user session."""
|
|
return uuid.UUID(current_user["user_id"])
|