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/googleor/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
statealeatorio 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 enaudienceyprovider. Implementación:InMemoryOAuthStateStore/RedisOAuthStateStore(app/modules/auth/infrastructure/oauth_state_store.py), seleccionado porbuild_oauth_state_store(settings)(Redis en producción multi-pod, memoria en local/test). - PKCE (RFC 7636): el
statetransporta además elcode_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): claimssub(email),admin_id,role,provideryexp. Caducidad de 24 horas. - Token de cliente (
mint_customer_token): claimsub(email) yexp. Caducidad regida porsettings.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 enapp/core/config.py); no se confía en el headeralgdel token (evita el ataque de algorithm confusion). expverificado: un token caducado se rechaza (la libreríajoselo valida ydecodedevuelveNone).- Secret largo:
settings.secret_keyse valida en el arranque y debe tener ≥ 32 caracteres en todos los entornos; un valor vacío o corto aborta el arranque conValueError.
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étodoAdminUser.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)ycan_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 unSUPER_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:
- Delega en el puerto
IRestaurantAccessChecker.check_access(...)(implementado por el módulorestaurant; verapp/shared/application/ports.py). - Devuelve un DTO
RestaurantAccessInfo(id,name,status,owner_id) — no la entidadRestaurantcompleta. Quien necesite la entidad la carga por separado vía el repositorio del módulo. - 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_idySUPER_ADMIN(comprobado víaadmin.admin_role): devuelveNone(vista global de todos los restaurantes). - Sin
restaurant_idy admin normal: 400 (restaurant_ides 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]condefault=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ón20260603_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_limitenapp/modules/chat/interface/dependencies.py, con clave por sesión (key=f"chat:{session_token}"),CHAT_RATE_LIMIT = 30mensajes yCHAT_RATE_WINDOW = 60segundos. Ante exceso lanza 429 conRetry-Afterigual aretry_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 haysettings.rate_limiter_redis_urlconfigurado — la vía multi-pod. - Base de datos (
DbRateLimiter, tablarate_limit_buckets) en su defecto.
- Redis/Valkey (
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;expsiempre verificado; secret ≥ 32 caracteres. - OAuth: validar
state(anti-CSRF) y verificar la firma delid_token(Apple RS256 contra claves públicas). - Admin solo opera en estado
active; el rol (AdminRole) gobierna los permisos (Permission/ROLE_PERMISSIONS). require_restaurant_accessen todo endpoint admin scoped;SUPER_ADMINbypassa.Idempotency-Keyen 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¶
- Arquitectura hexagonal — capas, puertos y ADR-004.
- Usuarios y roles admin — gestión de admins y jerarquía de roles.
- Sesiones y carrito — el JWT de cliente y la sesión de mesa.
- Pago — idempotencia persistente en
pay-bill.