feat: Funcion de sistema tenants

This commit is contained in:
2026-02-23 13:01:24 -07:00
parent ceea67eb2b
commit 1ccc39732b
58 changed files with 1889 additions and 315 deletions

View File

@@ -208,7 +208,7 @@ async def get_security_analysis(all_tenants: bool = Query(False), current_user:
recommended_actions.append("Continuar monitoreando actividad del sistema")
# Calcular IPs sospechosas (más de 5 intentos fallidos)
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'auth.login.failed']))
suspicious_ips = len(set([log.ip_address for log in logs if log.ip_address and log.action == 'user.login_failed']))
# Contar acciones críticas (delete, privilege changes, etc)
critical_actions = mass_deletions + privilege_changes

View File

@@ -4,7 +4,7 @@ Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -18,7 +18,9 @@ from app.core.config import get_settings
from app.models.user import User
from app.models.tenant import Tenant
from app.services.audit_service import AuditService
from app.services.token_service import TokenService
from app.api.deps import oauth2_scheme, get_current_user
from app.core.cache import cache, cache_key
from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
TwoFactorStatusResponse, TwoFactorSetupResponse,
@@ -38,6 +40,7 @@ settings = get_settings()
@router.post("/login", response_model=LoginResponse)
async def login(
login_data: LoginRequest,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
@@ -58,13 +61,85 @@ async def login(
email=login_data.email,
tenant_slug=login_data.tenant_slug
)
# Rate limiting (best-effort): by IP before any tenant/user lookup.
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
client_ip = request.client.host if request.client else "unknown"
ip_key = cache_key("rl", "login", "ip", client_ip)
ip_count = await cache.incr(ip_key, 1)
if ip_count == 1:
await cache.expire(ip_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
if ip_count is not None and ip_count > settings.LOGIN_RATE_LIMIT_IP_MAX_ATTEMPTS:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again later.",
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 1. Buscar usuario en base de datos
query = select(User).where(User.email == login_data.email)
# 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
ident_count = await cache.incr(ident_key, 1)
if ident_count == 1:
await cache.expire(ident_key, settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)
if ident_count is not None and ident_count > settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS:
try:
await AuditService.log(
db=db,
tenant_id=tenant.id,
user_id=None,
action="user.login_rate_limited",
resource_type="user",
resource_id=None,
metadata={
"email": email_norm,
"tenant_slug": login_data.tenant_slug,
"ip": request.client.host if request.client else None,
"scope": "tenant_email",
"window_seconds": settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
"max_attempts": settings.LOGIN_RATE_LIMIT_ID_MAX_ATTEMPTS,
},
request=request,
)
await db.commit()
except Exception as e:
logger.warning("Failed to log rate limit audit entry", error=str(e))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again later.",
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
result = await db.execute(query)
user = result.scalar_one_or_none()
# 2. Verificar usuario y contraseña
# 3. Verificar usuario y contraseña
if not user or not security.verify_password(login_data.password, user.password_hash):
logger.warning(
"Login failed - invalid credentials",
@@ -89,21 +164,21 @@ async def login(
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Credenciales inválidas"
detail="Invalid credentials",
)
# 3. Verificar si está activo
# 4. Verificar si está activo
if not user.is_active:
logger.warning(
"Login failed - user inactive",
email=login_data.email
)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Usuario inactivo"
status_code=status.HTTP_403_FORBIDDEN,
detail="User inactive",
)
# 4. Verificar 2FA si está habilitado
# 5. Verificar 2FA si está habilitado
if user.totp_enabled:
if not login_data.totp_code:
# Indicar al frontend que debe pedir el código TOTP
@@ -128,6 +203,24 @@ async def login(
access_token = security.create_access_token(token_data)
refresh_token = security.create_refresh_token(token_data)
# Persist refresh token so it can be revoked/validated later
try:
await TokenService.create_refresh_token(
db=db,
user=user,
refresh_token=refresh_token,
user_agent=request.headers.get("user-agent"),
ip_address=request.client.host if request.client else None,
)
await db.commit()
except Exception as e:
# If persistence fails, do not leak tokens
logger.error("Failed to persist refresh token", error=str(e), user_id=str(user.id))
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Service temporarily unavailable",
)
# Registrar login exitoso en auditoría
try:
@@ -150,6 +243,10 @@ async def login(
tenant_slug=login_data.tenant_slug,
user_id=str(user.id)
)
# Best-effort: clear per-identity limiter on success.
if ident_key:
await cache.delete(ident_key)
return LoginResponse(
access_token=access_token,
@@ -198,7 +295,20 @@ async def refresh_token(
detail="Invalid refresh token"
)
# TODO: Check if refresh token exists in database and is not revoked
# Check token exists in database and is not revoked/expired
db_token = await TokenService.verify_refresh_token(db=db, refresh_token=refresh_data.refresh_token)
if db_token is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Defensive: ensure DB token belongs to same subject
if str(db_token.user_id) != str(payload.get("sub")):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Create new access token
token_data = {
@@ -243,7 +353,19 @@ async def logout(
detail="Invalid token"
)
# TODO: Revoke refresh token in database
# Revoke all active refresh tokens for this user (logout invalidates refresh)
try:
import uuid
user_id = uuid.UUID(payload["sub"])
await TokenService.revoke_all_user_tokens(
db=db,
user_id=user_id,
revoked_by_user_id=user_id,
)
await db.commit()
except Exception as e:
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
# Registrar logout en auditoría
try:

View File

@@ -80,8 +80,15 @@ async def create_ticket(ticket: TicketCreate, db: AsyncSession = Depends(get_db)
"title": db_ticket.subject, "description": db_ticket.description, "status": db_ticket.status.value,
"priority": db_ticket.priority.value, "category_id": str(db_ticket.category_id) if db_ticket.category_id else None,
"affected_system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"system_id": str(db_ticket.affected_system_id) if db_ticket.affected_system_id else None,
"contact_email": ticket.contact_email,
"contact_phone": ticket.contact_phone,
"created_by": str(db_ticket.created_by), "assigned_to": str(db_ticket.assigned_to) if db_ticket.assigned_to else None,
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at
"created_at": db_ticket.created_at, "updated_at": db_ticket.updated_at,
"sla_response_due": db_ticket.sla_response_due,
"sla_resolution_due": db_ticket.sla_resolution_due,
"first_response_at": db_ticket.first_response_at,
"resolved_at": db_ticket.resolved_at,
}
except ValueError as e:
@@ -111,35 +118,36 @@ async def get_tickets(skip: int = 0, limit: int = 100, status: Optional[str] = N
query = apply_enum_filter(query, Ticket.status, status, TicketStatus, "status")
query = apply_enum_filter(query, Ticket.priority, priority, TicketPriority, "priority")
query = query.options(
selectinload(Ticket.category),
selectinload(Ticket.affected_system),
selectinload(Ticket.assigned_to_user)
)
query = query.order_by(Ticket.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
tickets = result.scalars().all()
return [
{"id": str(t.id), "ticket_number": t.ticket_number, "subject": t.subject, "title": t.subject,
"description": t.description, "status": t.status.value, "priority": t.priority.value,
"category_id": str(t.category_id) if t.category_id else None,
"affected_system_id": str(t.affected_system_id) if t.affected_system_id else None,
"created_by": str(t.created_by), "assigned_to": str(t.assigned_to) if t.assigned_to else None,
"created_at": t.created_at, "updated_at": t.updated_at, "sla_response_due": t.sla_response_due,
"sla_resolution_due": t.sla_resolution_due, "first_response_at": t.first_response_at, "resolved_at": t.resolved_at}
for t in tickets
]
return [ticket_to_dict(t) for t in tickets]
@router.get("/admin/all", response_model=List[dict])
async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter: Optional[str] = None,
priority_filter: Optional[str] = None, tenant_id_filter: Optional[str] = None, category_filter: Optional[str] = None,
assigned_to_filter: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None,
date_to: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Obtener todos los tickets de todos los tenants (solo para administradores)"""
"""Obtener todos los tickets del tenant del administrador (ADMIN/SUPPORT_MANAGER)."""
if current_user.role not in ["ADMIN", "SUPPORT_MANAGER"]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No tienes permisos para acceder a esta función")
query = select(Ticket, Tenant, User).join(Tenant, Ticket.tenant_id == Tenant.id).join(User, Ticket.created_by == User.id)
# SUPPORT_MANAGER solo ve su propio tenant.
# ADMIN ve todos los tenants (es el administrador de la plataforma).
if current_user.role == "SUPPORT_MANAGER":
query = query.where(Ticket.tenant_id == current_user.tenant_id)
query = apply_enum_filter(query, Ticket.status, status_filter, TicketStatus, "status")
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
query = apply_enum_filter(query, Ticket.priority, priority_filter, TicketPriority, "priority")
if tenant_id_filter:
query = query.where(Ticket.tenant_id == validate_uuid_param(tenant_id_filter, "tenant ID"))
if category_filter:
@@ -166,10 +174,20 @@ async def get_all_tickets_admin(skip: int = 0, limit: int = 100, status_filter:
result = await db.execute(query)
rows = result.all()
# Cargar categorías en un solo query para evitar N+1
category_ids = list({ticket.category_id for ticket, _, _ in rows if ticket.category_id})
categories_map = {}
if category_ids:
from app.models.category import Category as CategoryModel
cat_result = await db.execute(select(CategoryModel).where(CategoryModel.id.in_(category_ids)))
categories_map = {c.id: c.name for c in cat_result.scalars().all()}
return [
{"id": str(ticket.id), "ticket_number": ticket.ticket_number, "subject": ticket.subject,
"description": ticket.description, "status": ticket.status.value, "priority": ticket.priority.value,
"tenant_id": str(ticket.tenant_id), "tenant_name": tenant.name, "tenant_slug": tenant.slug,
"category_id": str(ticket.category_id) if ticket.category_id else None,
"category_name": categories_map.get(ticket.category_id) if ticket.category_id else None,
"created_by": str(ticket.created_by), "creator_name": f"{creator.first_name} {creator.last_name}",
"creator_email": creator.email, "assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"created_at": ticket.created_at, "updated_at": ticket.updated_at, "sla_response_due": ticket.sla_response_due,
@@ -358,6 +376,9 @@ async def get_ticket_attachments(ticket_id: str, db: AsyncSession = Depends(get_
if not ticket:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
result = await db.execute(select(TicketAttachment).where(TicketAttachment.ticket_id == ticket_uuid).options(selectinload(TicketAttachment.uploaded_by_user)).order_by(TicketAttachment.created_at.desc()))
attachments = result.scalars().all()
@@ -382,6 +403,9 @@ async def upload_attachment(ticket_id: str, file: UploadFile = File(...), db: As
if not ticket:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
file_metadata = await file_handler.save_upload(file, current_tenant.id, ticket_uuid)
@@ -425,6 +449,9 @@ async def download_attachment(ticket_id: str, attachment_id: str, db: AsyncSessi
if not ticket:
logger.error(f"Ticket not found - ticket_id: {ticket_id}")
raise HTTPException(status_code=404, detail="Ticket no encontrado")
if current_user.role in ["CLIENT_USER", "CLIENT_ADMIN"] and ticket.created_by != current_user.id:
raise HTTPException(status_code=404, detail="Ticket no encontrado")
result = await db.execute(select(TicketAttachment).where(TicketAttachment.id == attachment_uuid, TicketAttachment.ticket_id == ticket_uuid))
attachment = result.scalar_one_or_none()

View File

@@ -19,6 +19,14 @@ router = APIRouter()
# ENDPOINTS
# ===================================
@router.get("/me", response_model=UserResponse)
async def read_current_user(
current_user: User = Depends(deps.get_current_user),
):
"""Obtener el perfil del usuario actual."""
return current_user
@router.get("/", response_model=List[UserResponse])
async def read_users(
skip: int = 0,

View File

@@ -100,7 +100,10 @@ def ticket_to_dict(ticket: Ticket) -> dict:
"category_id": str(ticket.category_id) if ticket.category_id else None,
"category_name": ticket.category.name if ticket.category else None,
"affected_system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
"system_id": str(ticket.affected_system_id) if ticket.affected_system_id else None,
"affected_system_name": ticket.affected_system.name if ticket.affected_system else None,
"contact_email": None,
"contact_phone": None,
"created_by": str(ticket.created_by),
"assigned_to": str(ticket.assigned_to) if ticket.assigned_to else None,
"assigned_to_name": f"{ticket.assigned_to_user.first_name} {ticket.assigned_to_user.last_name}" if ticket.assigned_to_user else None,