Saltar a contenido

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 AdminUser en estado PENDING (con is_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 = ACTIVE e is_active = True,
  • enlaza las credenciales OAuth (oauth_provider, oauth_id) y el name del perfil,
  • limpia invitation_token e invitation_expires,
  • registra last_login_at e inicializa login_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 los Permission.
  • RESTAURANT_OWNER: gestión completa del restaurante (CRUD de menú, comandas, stats, usuarios, ajustes y logs de error), salvo los SYSTEM_*.
  • MANAGER: como owner pero sin borrar menú, sin USERS_MANAGE, sin SETTINGS_EDIT ni ERRORS_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) y has_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_ADMIN todos; 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_ADMIN gestiona a cualquiera salvo a otro SUPER_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 (devuelve None si 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