Saltar a contenido

Seguridad y multitenancy

Esta página explica cómo el backend autentica a sus dos tipos de usuario (administradores y clientes), cómo controla qué puede hacer cada admin (roles y permisos), cómo aísla los datos de cada restaurante (multitenancy) y qué mecanismos transversales protegen las operaciones críticas: idempotencia, bloqueo optimista, rate limiting y el handler global de validación.

Es una página de tipo explicación (Diátaxis): describe el porqué y el cómo del modelo de seguridad, no es un tutorial paso a paso.

Contexto arquitectónico

El proyecto sigue una arquitectura hexagonal modular (ver Arquitectura hexagonal). La seguridad respeta el contrato de independencia de módulos (ADR-004): las dependencias HTTP compartidas nunca importan un módulo concreto, sino que hablan con él a través de puertos registrados en el arranque.


Términos

  • Admin / administrador: usuario del panel de gestión (dueño o personal del restaurante). Se autentica vía OAuth y porta un JWT de admin.
  • Customer / cliente: comensal que escanea el QR de la mesa. Tiene su propio JWT de cliente.
  • SUPER_ADMIN: rol especial que ve y opera sobre todos los restaurantes (bypassa el guard multitenant).
  • Restaurant-scoped: endpoint admin cuyo recurso pertenece a un restaurante concreto; siempre se valida el acceso a ese restaurante.
  • Puerto (port): interfaz abstracta de la capa application (app/shared/application/ports.py) implementada por un adaptador de otro módulo. Las dependencias HTTP la consumen sin importar el módulo.

Autenticación de administradores: OAuth → JWT

Los administradores no tienen contraseña en el backend: se autentican con un proveedor OAuth externo (Google o Apple) y reciben a cambio un JWT firmado por el backend.

El mensaje de error del 401 admin lo deja explícito:

"Admin authentication required. Please log in via /admin/auth/google or /admin/auth/apple"

El flujo OAuth y sus protecciones

El flujo de autorización OAuth está endurecido contra dos ataques clásicos:

  • CSRF (anti-falsificación de petición): al iniciar el flujo se emite un parámetro state aleatorio de un solo uso, con TTL (STATE_TTL_SECONDS = 600, 10 minutos) y guardado server-side. En el callback se consume exactamente una vez y debe coincidir en audience y provider. Implementación: InMemoryOAuthStateStore / RedisOAuthStateStore (app/modules/auth/infrastructure/oauth_state_store.py), seleccionado por build_oauth_state_store(settings) (Redis en producción multi-pod, memoria en local/test).
  • PKCE (RFC 7636): el state transporta además el code_verifier, de modo que viaja por el redirect sin necesidad de un store separado (consume_with_verifier).

Verificación de firma del id_token de Apple

El id_token de Apple se verifica con firma: RS256 contra el JWKS publicado por Apple, más iss, aud y exp obligatorios (verify_apple_id_token en app/modules/auth/infrastructure/security/apple_id_token.py). Anteriormente se decodificaba con verify_signature=False, lo que permitía forjar un JWT y suplantar cualquier cuenta (corregido en SEC02 / SEC-OAUTH01). La descarga del JWKS está acotada por doble timeout y falla cerrada: ante un endpoint colgado, el token nunca se confía implícitamente.

Emisión y verificación del JWT

Tras un callback OAuth válido, el backend emite el JWT con JoseTokenService (app/modules/auth/infrastructure/adapters/jose_token_service.py):

  • Token de admin (mint_admin_token): claims sub (email), admin_id, role, provider y exp. Caducidad de 24 horas.
  • Token de cliente (mint_customer_token): claim sub (email) y exp. Caducidad regida por settings.access_token_expire_minutes.

La verificación (decode) está endurecida:

  • Algoritmo pineado: jwt.decode(..., algorithms=[settings.algorithm]). Solo se acepta el algoritmo configurado (algorithm = "HS256" por defecto en app/core/config.py); no se confía en el header alg del token (evita el ataque de algorithm confusion).
  • exp verificado: un token caducado se rechaza (la librería jose lo valida y decode devuelve None).
  • Secret largo: settings.secret_key se valida en el arranque y debe tener ≥ 32 caracteres en todos los entornos; un valor vacío o corto aborta el arranque con ValueError.

decode nunca propaga errores de la librería: ante cualquier JWTError devuelve None, y la dependencia HTTP traduce ese None a un 401.


Dependencias compartidas (app/core/deps.py)

app/core/deps.py es el único hogar canónico de las dependencias FastAPI que usan todos los routers. No importa ningún módulo de app.modules.*: cada colaborador concreto se obtiene a través de un puerto registrado en el arranque por app/main.py. Esto preserva ADR-004.

Dependencia Tipo Qué hace
DbSession Annotated[AsyncSession, Depends(get_db)] Sesión de BD por request.
CurrentCustomer Annotated[Any, Depends(get_current_customer)] Cliente autenticado vía JWT. 401 si el token es inválido.
CurrentAdmin Annotated[Any, Depends(get_current_admin_user)] Admin autenticado vía JWT. 401 si el token es inválido; 403 si el admin no está active.
require_restaurant_access función Verifica que el admin puede operar sobre un restaurante (ver multitenancy).
require_restaurant_or_super función Variante para endpoints con restaurant_id opcional.

Esquema de seguridad: Bearer

Ambas dependencias usan HTTPBearer (security para clientes, admin_security para admins): el token viaja en la cabecera Authorization: Bearer <jwt>. Un token ausente o mal formado produce el 401 de FastAPI; un token presente pero inválido produce el 401 de la dependencia.

Cómo se cablea sin romper ADR-004

get_current_admin_user y get_current_customer no decodifican el token ellos mismos: delegan en los puertos IAdminAuthenticator / ICustomerAuthenticator (definidos en app/shared/application/ports.py). El adaptador concreto (JoseAdminAuthenticator / JoseCustomerAuthenticator, módulo auth) se registra en el arranque (register_admin_authenticator(...) en app/main.py).

El authenticator de admin decodifica el token, exige el claim admin_id y carga el AdminUser; no comprueba el estado. La comprobación de status == "active" (y el 403 asociado) la hace la dependencia compartida, de forma que el authenticator se mantiene agnóstico.

# En cualquier router — nunca Depends() inline:
from app.core.deps import DbSession, CurrentAdmin

async def mi_endpoint(db: DbSession, admin: CurrentAdmin): ...

Estado del admin: pending → active

El AdminUser (app/modules/admin_user/infrastructure/models/admin_user_model.py) nace en estado pending (default AdminStatus.PENDING) y solo opera cuando pasa a active. La dependencia compartida get_current_admin_user devuelve 403 "Admin account is not active" mientras el admin no esté active, aunque su JWT sea válido. La columna histórica is_active es legacy; la fuente de verdad es status (propiedades is_active_status / is_pending).


Roles y permisos (RBAC)

Además del aislamiento por restaurante, cada admin tiene un rol que determina qué operaciones puede realizar. Los roles y el mapa de permisos viven en app/core/permissions.py.

Roles (AdminRole)

AdminRole es un StrEnum con cinco niveles:

Rol Valor Alcance
SUPER_ADMIN super_admin Acceso total a todo y a todos los restaurantes.
RESTAURANT_OWNER owner Gestión completa del restaurante.
MANAGER manager Gestión excluyendo ajustes críticos del sistema.
STAFF staff Operaciones básicas de pedidos y consulta.
VIEWER viewer Solo lectura (reporting).

El rol se almacena como string (role) en la columna del AdminUser; la propiedad admin_role lo resuelve al enum (con fallback a VIEWER ante un valor inválido).

Permisos (Permission)

Permission enumera permisos granulares por dominio (menú, pedidos, estadísticas, usuarios, ajustes, logs de errores, administración del sistema), p. ej. menu:edit, orders:delete, settings:edit, system:config. El dict ROLE_PERMISSIONS mapea cada rol a su conjunto de permisos; SUPER_ADMIN recibe el conjunto completo.

Helpers en permissions.py:

  • has_permission(role, permission) — comprobación base; expuesta también como método AdminUser.has_permission(...).
  • can_access_menu_management / can_access_user_management / can_access_system_settings / can_delete_errors — atajos por capacidad.
  • get_available_roles_for_assignment(role) y can_manage_user(actor, target) — gobiernan la jerarquía de gestión de usuarios: un admin solo puede asignar/gestionar roles por debajo del suyo y nunca a un SUPER_ADMIN (ver Usuarios y roles admin).

Multitenancy: aislamiento por restaurante

Cada restaurante es un tenant. El backend nunca debe permitir que un admin lea o modifique datos de un restaurante al que no pertenece.

require_restaurant_access en todo endpoint admin scoped

La regla central es: todo endpoint admin restaurant-scoped depende de require_restaurant_access(restaurant_id, db, admin). La función:

  1. Delega en el puerto IRestaurantAccessChecker.check_access(...) (implementado por el módulo restaurant; ver app/shared/application/ports.py).
  2. Devuelve un DTO RestaurantAccessInfo (id, name, status, owner_id) — no la entidad Restaurant completa. Quien necesite la entidad la carga por separado vía el repositorio del módulo.
  3. Traduce las excepciones de dominio a HTTP:
    • NotFoundError → 404 (el restaurante no existe).
    • UnauthorizedError → 403 (el admin no tiene acceso).

Nunca confíes en el restaurant_id del body

El restaurant_id se toma siempre del path param verificado por require_restaurant_access, nunca del cuerpo de la petición. Las queries de los servicios filtran siempre por restaurant_id.

El puerto expone también list_accessible_ids(admin, db), que devuelve los IDs de restaurante que el admin puede operar (usado para vistas agregadas y para resolver el alcance de un admin con varios restaurantes).

SUPER_ADMIN bypassa el guard

El rol SUPER_ADMIN tiene acceso a todos los restaurantes (la decisión vive dentro de check_access). Para endpoints donde el restaurant_id es opcional, require_restaurant_or_super formaliza el comportamiento:

  • Con restaurant_id: verifica acceso y devuelve la info.
  • Sin restaurant_id y SUPER_ADMIN (comprobado vía admin.admin_role): devuelve None (vista global de todos los restaurantes).
  • Sin restaurant_id y admin normal: 400 (restaurant_id es obligatorio).

Idempotencia: Idempotency-Key en POST críticos

Las operaciones POST que no deben duplicarse ante un reintento de red llevan una cabecera Idempotency-Key. Un segundo POST con la misma clave devuelve el resultado original en lugar de crear un duplicado.

Se aplica en POST críticos como la validación de sesión, el alta en el carrito y la llamada al manager, así como en el pago (pay-bill), que persiste sus claves en una tabla dedicada (payment_idempotency_keys, modelo PaymentIdempotencyKey).

Endpoints sin Idempotency-Key

Algunos POST se documentan explícitamente como no idempotentes porque su diseño ya impide el duplicado por otro mecanismo (ver comentarios en app/modules/session/interface/sessions_router.py). La idempotencia se aplica donde un reintento de red podría duplicar un efecto real, no indiscriminadamente.


Bloqueo optimista (version)

Las entidades que pueden mutarse concurrentemente (varios camareros, el cliente y la cocina a la vez) llevan una columna version para bloqueo optimista:

  • Comanda: version: Mapped[int] con default=0 / server_default="0", nullable=False (app/modules/comanda/infrastructure/orm.py). Se incrementa en cada UPDATE que pasa por el pipeline.
  • Sesión de mesa: misma columna version (app/modules/session/infrastructure/models/session_model.py, añadida por la migración 20260603_safety01).

Cuando dos escrituras concurrentes chocan, la perdedora detecta que la version esperada ya no coincide y se lanza un OptimisticLockError, que el handler global traduce a 409 Conflict (ver más abajo). El cliente debe recargar y reintentar.


Rate limiting

El backend tiene dos mecanismos de rate limiting distintos y complementarios: un middleware global por IP y un limitador persistente por sesión para el chat.

Middleware global por IP (RateLimitMiddleware)

RateLimitMiddleware (app/infrastructure/rate_limiting/middleware.py) se monta en app/main.py y aplica un límite in-memory, por IP de cliente y por ventana deslizante sobre los prefijos /admin y /api/v1/conversations (RATE_LIMITED_PREFIXES). Límites por prefijo:

Prefijo Límite Ventana
/admin/users 30 60 s
/admin/chat 50 60 s
/admin 60 60 s
/api/v1/conversations 20 60 s
(otros bajo prefijos limitados) 100 (default) 60 s

Al excederse devuelve 429 con Retry-After: 60 y las cabeceras X-RateLimit-Limit / X-RateLimit-Remaining.

El middleware es in-memory y por proceso

Este límite vive en memoria del proceso y no sobrevive a reinicios ni se comparte entre pods; es un cortafuegos básico anti-abuso. La protección persistente y multi-pod la aporta el limitador del chat (abajo).

Limitador persistente por sesión (IRateLimiter)

El chat de cliente usa un limitador persistente que sí sobrevive a reinicios y es seguro entre múltiples pods. Se modela con el puerto IRateLimiter (app/shared/application/ports.py), que expone check_and_increment(key, limit, window_seconds) y devuelve un RateLimitResult (allowed, remaining, retry_after_seconds) sobre una ventana fija.

  • Dónde se aplica: _check_chat_rate_limit en app/modules/chat/interface/dependencies.py, con clave por sesión (key=f"chat:{session_token}"), CHAT_RATE_LIMIT = 30 mensajes y CHAT_RATE_WINDOW = 60 segundos. Ante exceso lanza 429 con Retry-After igual a retry_after_seconds.
  • Fail-open: si el limitador no está disponible (error de Redis/BD), la petición se permite y se registra un warning; nunca tumba el chat.
  • Implementación seleccionada en el arranque (app/main.py, RATE01):
    • Redis/Valkey (RedisRateLimiter) cuando hay settings.rate_limiter_redis_url configurado — la vía multi-pod.
    • Base de datos (DbRateLimiter, tabla rate_limit_buckets) en su defecto.

El handler HTTP global propaga la cabecera Retry-After (Starlette no la reenvía por defecto en los 429).


Handler global de validación (422)

Las peticiones con cuerpo o parámetros inválidos producen un 422 Unprocessable Entity mediante un handler global registrado en app/main.py:

@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request, exc):
    return JSONResponse(
        status_code=422,
        content={"detail": jsonable_encoder(exc.errors())},
    )

Por qué jsonable_encoder

exc.errors() puede contener objetos no serializables a JSON dentro de ctx (p. ej. el ValueError original lanzado por un field_validator de Pydantic, o un error de EmailStr). Envolverlo en jsonable_encoder — igual que hace el handler por defecto de FastAPI — garantiza un 422 limpio en lugar de un 500.

Otros handlers globales de dominio

app/main.py mapea las excepciones de dominio compartidas a su código HTTP, de modo que la lógica de negocio nunca construye HTTPException (que vive solo en la capa interface/):

Excepción HTTP
NotFoundError 404
UnauthorizedError 403
ValidationError (dominio) / RequestValidationError 422
AlreadyExistsError 409
OptimisticLockError 409
DomainException (base) 400

Hay también handlers específicos del módulo comanda: ComandaConflict/InvalidComandaTransition → 409, ComandaNotFound/LineNotFound → 404, EmptyOrder/InvalidExtras/ProductUnavailable → 400, e InvalidManagerCallTransition → 409.

Además, el handler de StarletteHTTPException y el de Exception ocultan el detalle de los 5xx al cliente ({"detail": "Internal server error"}) y registran la traza en el log.


Resumen de buenas prácticas

  • JWT con algoritmo pineado en decode; exp siempre verificado; secret ≥ 32 caracteres.
  • OAuth: validar state (anti-CSRF) y verificar la firma del id_token (Apple RS256 contra claves públicas).
  • Admin solo opera en estado active; el rol (AdminRole) gobierna los permisos (Permission / ROLE_PERMISSIONS).
  • require_restaurant_access en todo endpoint admin scoped; SUPER_ADMIN bypassa.
  • Idempotency-Key en los POST críticos.
  • Bloqueo optimista (columna version) en comanda y sesión de mesa.
  • Rate limiting: middleware global por IP (in-memory) más limitador persistente por sesión en el chat (Redis/BD).

Páginas relacionadas