Usuarios admin y roles¶
Esta página explica el modelo de usuarios de backoffice (admin_user) y el sistema de roles y permisos que controla qué puede hacer cada administrador. Cubre el ciclo de vida de la cuenta (invitación, activación, login), cómo se asignan restaurantes a un admin y cómo encaja con la autenticación y el control multitenant.
Para el endurecimiento de seguridad transversal (JWT, require_restaurant_access, OAuth) ver Seguridad y multitenancy. Para la arquitectura por capas, Arquitectura hexagonal.
El módulo admin_user¶
El módulo vive en app/modules/admin_user/ y sigue la arquitectura hexagonal: domain, application, infrastructure e interface. La entidad raíz es AdminUser (app/modules/admin_user/domain/entities.py), un administrador de backoffice que gestiona restaurantes, menús, comandas y otros recursos.
La entidad AdminUser¶
AdminUser es un agregado que lleva su estado de autenticación, el ciclo de vida de invitación, el rol y las asignaciones de restaurante como comportamiento de dominio, no como simples setters. Sus campos principales:
| Campo | Tipo | Notas |
|---|---|---|
email |
Email (VO) |
El constructor también acepta str y lo convierte vía Email.from_string. |
name |
str |
Nombre de visualización (del perfil OAuth tras activar). |
role |
AdminRole |
Por defecto AdminRole.VIEWER. |
status |
AdminStatus |
Por defecto AdminStatus.PENDING. |
oauth_provider |
OAuthProvider |
Por defecto OAuthProvider.PENDING. |
oauth_id |
str |
Identificador del proveedor OAuth. |
is_active |
bool |
Se mantiene sincronizado con status == ACTIVE. |
last_login_at / login_count |
datetime \| None / int |
Telemetría de login. |
invited_by |
int \| None |
Id del admin que envió la invitación. |
invitation_token |
str \| None |
Token opaco de invitación. |
invitation_expires |
datetime \| None |
Caducidad timezone-aware (ver más abajo). |
assigned_restaurant_ids / assigned_restaurant_names |
list[int] / list[str] |
Restaurantes asignados al admin. |
Las propiedades calculadas restaurant_id y restaurant_name devuelven el primer restaurante asignado (o None si la lista está vacía), e is_pending indica si el estado es PENDING.
Comportamiento de dominio, no setters
El estado se cambia llamando a métodos del agregado: activate(...), change_role(...), change_status(...), refresh_invitation(...), record_login(). Estos métodos mantienen invariantes (por ejemplo, change_status resincroniza is_active).
Estados de la cuenta¶
Los estados válidos los define AdminStatus (app/modules/admin_user/domain/value_objects.py), un StrEnum:
| Estado | Valor | Significado |
|---|---|---|
PENDING |
pending |
Cuenta invitada, a la espera de activación tras el primer login OAuth. |
ACTIVE |
active |
Operativa: puede iniciar sesión y gestionar recursos. |
SUSPENDED |
suspended |
Deshabilitada temporalmente (p. ej. revisión de seguridad). |
change_status sincroniza el flag is_active para que sea True solo cuando el estado es ACTIVE.
Invitaciones¶
No hay autorregistro: un administrador existente invita a otro. El flujo lo orquesta InviteAdminUseCase (app/modules/admin_user/application/use_cases/invite_admin.py), que tiene tres ramas según el email invitado:
- Email nuevo → se crea un
AdminUseren estadoPENDING(conis_active=False,oauth_provider=PENDING) más la asignación de restaurante. - Email ya pendiente → se refresca la invitación (
refresh_invitation) y se asegura la asignación. - Email ya activo → solo se añade la asignación del restaurante extra; no se reemite token.
El token de invitación se genera con secrets.token_urlsafe(32) y la caducidad es datetime.now(timezone.utc) + timedelta(hours=48) (48 horas, timezone-aware).
Caducidad timezone-aware¶
La validación al aceptar la invitación está en AcceptInvitationUseCase (app/modules/admin_user/application/use_cases/accept_invitation.py). Busca el usuario por invitation_token y compara la caducidad contra el reloj UTC:
if user.invitation_expires and user.invitation_expires.replace(
tzinfo=timezone.utc
) < datetime.now(timezone.utc):
raise InvalidInvitationError("Invalid or expired invitation")
Si el token no existe o ha caducado, lanza InvalidInvitationError. Si es válido, llama a user.activate(...) y devuelve un AdminUserDTO.
Datetime siempre timezone-aware
Tanto la generación como la validación de la caducidad usan datetime.now(timezone.utc). El helper _utc_now() de la entidad y los default_factory de created_at / updated_at también son UTC. Nunca se trabaja con datetime naive (convención de dominio del backend).
Login y activación¶
La activación ocurre en el primer login OAuth de un usuario invitado. El método de dominio AdminUser.activate(oauth_provider, oauth_id, name):
- pone
status = ACTIVEeis_active = True, - enlaza las credenciales OAuth (
oauth_provider,oauth_id) y elnamedel perfil, - limpia
invitation_tokeneinvitation_expires, - registra
last_login_ate inicializalogin_count = 1.
En logins posteriores, record_login() actualiza last_login_at e incrementa login_count.
Los proveedores OAuth soportados los define OAuthProvider (StrEnum): GOOGLE (google), APPLE (apple) y PENDING (pending, placeholder antes del primer login).
Roles y permisos¶
El sistema de roles vive en app/core/permissions.py (código compartido, no en un módulo concreto).
AdminRole¶
StrEnum con cinco niveles de acceso:
| Rol | Valor | Alcance |
|---|---|---|
SUPER_ADMIN |
super_admin |
Acceso total a todo. |
RESTAURANT_OWNER |
owner |
Gestión completa de su restaurante. |
MANAGER |
manager |
Gestión salvo ajustes críticos. |
STAFF |
staff |
Operaciones básicas (comandas, vistas). |
VIEWER |
viewer |
Solo lectura para reporting. |
Permission y ROLE_PERMISSIONS¶
Permission es un StrEnum de permisos granulares con formato recurso:accion (p. ej. menu:view, orders:edit, users:manage, settings:edit, errors:delete, system:config). Las familias son: menú (MENU_*), comandas (ORDERS_*), estadísticas (STATS_*), usuarios (USERS_*), ajustes (SETTINGS_*), logs de error (ERRORS_*) y administración del sistema (SYSTEM_*).
El mapa ROLE_PERMISSIONS: dict[AdminRole, Set[Permission]] asocia cada rol con su conjunto de permisos:
SUPER_ADMIN: todos losPermission.RESTAURANT_OWNER: gestión completa del restaurante (CRUD de menú, comandas, stats, usuarios, ajustes y logs de error), salvo losSYSTEM_*.MANAGER: como owner pero sin borrar menú, sinUSERS_MANAGE, sinSETTINGS_EDITniERRORS_DELETE.STAFF:MENU_VIEW,ORDERS_VIEW,ORDERS_EDIT,STATS_VIEW,SETTINGS_VIEW.VIEWER: solo lectura (MENU_VIEW,ORDERS_VIEW,STATS_VIEW,SETTINGS_VIEW,ERRORS_VIEW).
Helpers de comprobación¶
app/core/permissions.py expone helpers para consultar el modelo sin manipular los sets a mano:
get_role_permissions(role)yhas_permission(role, permission)— base de todo lo demás.- Atajos semánticos:
can_access_menu_management,can_access_user_management,can_access_system_settings,can_delete_errors. get_admin_role_display_name(role)— nombre legible en español ("Super Administrador", "Propietario", "Gerente", "Personal", "Observador").get_available_roles_for_assignment(current_user_role)— qué roles puede asignar un admin:SUPER_ADMINtodos;RESTAURANT_OWNER→ manager/staff/viewer;MANAGER→ staff/viewer; el resto, ninguno.can_manage_user(current_user_role, target_user_role)— si un admin puede editar/borrar a otro:SUPER_ADMINgestiona a cualquiera salvo a otroSUPER_ADMIN; owner gestiona manager/staff/viewer; manager gestiona staff/viewer.
Jerarquía, no solo permisos
Los permisos (ROLE_PERMISSIONS) dicen qué recursos puede tocar un rol. La jerarquía (get_available_roles_for_assignment, can_manage_user) impide que un admin escale privilegios o gestione a alguien de rango igual o superior.
Asignación de restaurantes y bypass de SUPER_ADMIN¶
Un admin se vincula a uno o varios restaurantes mediante assigned_restaurant_ids. La asignación se materializa en InviteAdminUseCase con repository.upsert_restaurant_assignment(restaurant_id, admin_id), idempotente entre las tres ramas de invitación.
En tiempo de petición, el control de acceso por restaurante lo aplica require_restaurant_access (definido en app/core/deps.py): verifica que el admin tiene acceso al restaurant_id del path y devuelve el Restaurant ya validado. El SUPER_ADMIN bypassa esta comprobación: tiene acceso a todos los restaurantes con independencia de sus asignaciones. El detalle de esta dependencia y la regla multitenant están en Seguridad y multitenancy.
Autenticación admin¶
La autenticación se modela con el puerto compartido IAdminAuthenticator (app/shared/application/ports.py), implementado por JoseAdminAuthenticator (app/modules/auth/infrastructure/adapters/authenticators.py). Su método authenticate(token, db):
- decodifica el bearer token con
JoseTokenService, - extrae el claim
admin_id(devuelveNonesi falta o el token es inválido), - carga la fila del admin vía
SqlAlchemyAdminAccountRepository.get_active_by_id(...).
El adaptador no comprueba el status; quien levanta el 403 si el admin no está activo es la dependencia compartida get_current_admin_user.
El token se emite con create_admin_token(admin) (app/modules/auth/infrastructure/security/oauth_service.py), un JWT con expiración de 24 horas y los claims:
data={
"sub": admin.email,
"admin_id": admin.id,
"role": admin.role,
"provider": admin.oauth_provider,
}
El alta vía OAuth la maneja get_or_create_admin_from_oauth(...) en el mismo fichero, con tres pasos: (1) admin existente por oauth_id → actualiza y devuelve; (2) usuario pending con ese email → lo activa y enlaza las credenciales OAuth; (3) email no registrado → rechaza el acceso (sin autorregistro).
Referencias¶
- Seguridad y multitenancy — JWT, OAuth,
require_restaurant_access. - Arquitectura hexagonal — capas y puertos.
- Referencia de módulos y Referencia API.