(auth): Añade CurrentUser y sistema de permisos

- Nuevo módulo `auth` que exporta `CurrentUser`, `CheckPermission` y
  `has_permission()`.
- `Context` y `Page` exponen `current_user()` vía `Contextual`.
- `HttpRequest` accede a extensiones de middleware con `extension<T>()`.
- `Extension` incluye nuevo método `configure_middleware`.
- `try_dispatch_actions` para despachar acciones con control del flujo.
This commit is contained in:
Manuel Cillero 2026-07-02 20:52:13 +02:00
parent 3120fd9a6f
commit 0e96bcce64
22 changed files with 544 additions and 84 deletions

View file

@ -12,7 +12,7 @@ use list::ActionsList;
mod all;
pub(crate) use all::add_action;
pub use all::dispatch_actions;
pub use all::{dispatch_actions, try_dispatch_actions};
// **< actions! >***********************************************************************************

View file

@ -45,9 +45,9 @@ pub(crate) fn add_action(action: ActionBox) {
/// # Parámetros genéricos
///
/// - `A`: Tipo de acción que esperamos procesar. Debe implementar [`ActionDispatcher`].
/// - `F`: Función asociada a cada acción, devuelve un valor de tipo `B`.
/// - `F`: Función que se aplica para una acción dada.
///
/// # Ejemplo de uso
/// # Ejemplo
///
/// ```rust,ignore
/// pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
@ -61,12 +61,47 @@ pub(crate) fn add_action(action: ActionBox) {
/// );
/// }
/// ```
pub fn dispatch_actions<A, B, F>(key: &ActionKey, f: F)
pub fn dispatch_actions<A, F>(key: &ActionKey, f: F)
where
A: ActionDispatcher,
F: FnMut(&A) -> B,
F: FnMut(&A),
{
if let Some(list) = ACTIONS.read().get(key) {
list.iter_map(f);
list.for_each(f);
}
}
/// Despacha las funciones asociadas a una [`ActionKey`] con posible salida anticipada.
///
/// Funciona igual que [`dispatch_actions`], pero el *closure* puede devolver
/// [`std::ops::ControlFlow::Continue`] para continuar ejecutando la siguiente acción; o
/// [`std::ops::ControlFlow::Break`] para detener la iteración inmediatamente.
///
/// # Ejemplo
///
/// ```rust,ignore
/// pub(crate) fn check(cx: &Context, key: &str) -> bool {
/// let mut granted = false;
/// try_dispatch_actions(
/// &ActionKey::new(UniqueId::of::<Self>(), None, None),
/// |action: &Self| {
/// (action.f)(cx, key, &mut granted);
/// if granted {
/// std::ops::ControlFlow::Break(())
/// } else {
/// std::ops::ControlFlow::Continue(())
/// }
/// },
/// );
/// granted
/// }
/// ```
pub fn try_dispatch_actions<A, F>(key: &ActionKey, f: F)
where
A: ActionDispatcher,
F: FnMut(&A) -> std::ops::ControlFlow<()>,
{
if let Some(list) = ACTIONS.read().get(key) {
list.try_for_each(f);
}
}

View file

@ -45,17 +45,17 @@ impl ActionKey {
/// Las acciones tienen que sobrescribir los métodos para el filtro que apliquen. Por defecto
/// implementa un filtro nulo.
pub trait ActionDispatcher: AnyInfo + Send + Sync {
/// Identificador de tipo ([`UniqueId`]) del objeto referido. En este caso devuelve `None`.
/// Devuelve el identificador de tipo ([`UniqueId`]) del objeto referido.
fn referer_type_id(&self) -> Option<UniqueId> {
None
}
/// Identificador del objeto referido. En este caso devuelve `None`.
/// Devuelve el identificador del objeto referido.
fn referer_id(&self) -> Option<String> {
None
}
/// Funciones con pesos más bajos se aplican antes. En este caso siempre devuelve `0`.
/// Devuelve el peso para definir el orden de ejecución.
fn weight(&self) -> Weight {
0
}

View file

@ -19,24 +19,35 @@ impl ActionsList {
list.sort_by_key(|a| a.weight());
}
pub fn iter_map<A, B, F>(&self, mut f: F)
pub fn for_each<A, F>(&self, mut f: F)
where
Self: Sized,
A: ActionDispatcher,
F: FnMut(&A) -> B,
F: FnMut(&A),
{
let _: Vec<_> = self
.0
.read()
.iter()
.rev()
.map(|a| {
if let Some(action) = (**a).downcast_ref::<A>() {
f(action);
} else {
trace::error!("Failed to downcast action of type {}", (**a).type_name());
let list = self.0.read();
for a in list.iter().rev() {
if let Some(action) = (**a).downcast_ref::<A>() {
f(action);
} else {
trace::error!("Failed to downcast action of type {}", (**a).type_name());
}
}
}
pub fn try_for_each<A, F>(&self, mut f: F)
where
A: ActionDispatcher,
F: FnMut(&A) -> std::ops::ControlFlow<()>,
{
let list = self.0.read();
for a in list.iter().rev() {
if let Some(action) = (**a).downcast_ref::<A>() {
if f(action).is_break() {
break;
}
})
.collect();
} else {
trace::error!("Failed to downcast action of type {}", (**a).type_name());
}
}
}
}

View file

@ -1,3 +1,4 @@
use crate::auth::CurrentUser;
use crate::core::TypeInfo;
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
use crate::core::theme::all::DEFAULT_THEME;
@ -156,6 +157,26 @@ pub trait Contextual: LangId {
/// Devuelve una referencia a la petición HTTP asociada, si existe.
fn request(&self) -> Option<&HttpRequest>;
/// Devuelve la identidad del usuario actual.
///
/// Si ninguna extensión de autenticación ha inyectado un
/// [`CurrentUser`](crate::auth::CurrentUser) en las extensiones de la petición HTTP, devuelve
/// `&CurrentUser::Anonymous`.
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// async fn greet(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// let mut page = Page::new(request);
/// if page.current_user().is_authenticated() {
/// // Personalizar la página para el usuario autenticado.
/// }
/// page.render()
/// }
/// ```
fn current_user(&self) -> &CurrentUser;
/// Devuelve el tema que se usará para renderizar el documento.
fn theme(&self) -> ThemeRef;
@ -284,18 +305,19 @@ pub trait Contextual: LangId {
/// ```
#[rustfmt::skip]
pub struct Context {
request : Option<HttpRequest>, // Petición HTTP de origen.
locale : RequestLocale, // Idioma asociado a la petición.
theme : ThemeRef, // Referencia al tema usado para renderizar.
template : TemplateRef, // Plantilla usada para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets: Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts: Assets<JavaScript>, // Scripts JavaScript.
body_props : Props, // Identificador, clases CSS y atributos del <body>.
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
params : HashMap<&'static str, (Box<dyn Any>, &'static str)>, // Parámetros en ejecución.
id_counters: RefCell<HashMap<TypeId, usize>>, // RefCell permite mutar desde build_id(&self).
messages : Vec<StatusMessage>, // Mensajes de usuario acumulados.
request : Option<HttpRequest>, // Petición HTTP de origen.
locale : RequestLocale, // Idioma asociado a la petición.
current_user: CurrentUser, // Identidad del usuario actual.
theme : ThemeRef, // Referencia al tema usado para renderizar.
template : TemplateRef, // Plantilla usada para renderizar.
favicon : Option<Favicon>, // Favicon, si se ha definido.
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
javascripts : Assets<JavaScript>, // Scripts JavaScript.
body_props : Props, // Identificador, clases CSS y atributos del <body>.
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
params : HashMap<&'static str, (Box<dyn Any>, &'static str)>, // Parámetros en ejecución.
id_counters : RefCell<HashMap<TypeId, usize>>, // RefCell permite mutar desde build_id(&self).
messages : Vec<StatusMessage>, // Mensajes de usuario acumulados.
}
impl Default for Context {
@ -312,9 +334,11 @@ impl Context {
#[rustfmt::skip]
pub fn new(request: Option<HttpRequest>) -> Self {
let locale = RequestLocale::from_request(request.as_ref());
let current_user = Self::resolve_current_user(request.as_ref());
Context {
request,
locale,
current_user,
theme : *DEFAULT_THEME,
template : DEFAULT_THEME.default_template(),
favicon : None,
@ -328,6 +352,15 @@ impl Context {
}
}
// Extrae el `CurrentUser` inyectado por *middleware* en las extensiones de la petición, o
// `CurrentUser::Anonymous` si no hay petición o ninguna extensión de autenticación está activa.
fn resolve_current_user(request: Option<&HttpRequest>) -> CurrentUser {
request
.and_then(|r| r.extension::<CurrentUser>())
.cloned()
.unwrap_or(CurrentUser::Anonymous)
}
// **< Context RENDER >*************************************************************************
/// Renderiza los recursos del contexto.
@ -421,9 +454,9 @@ impl Context {
}
}
/// Acumula un [`StatusMessage`] en el contexto para notificar al visitante.
/// Acumula un [`StatusMessage`] en el contexto para notificar al usuario.
///
/// Pueden generarse en cualquier punto del ciclo de una petición web (manejadores, renderizado,
/// Pueden generarse en cualquier punto del ciclo de una petición web (*handlers*, renderizado,
/// lógica de negocio, etc.) que tengan acceso al contexto, y mostrarlos luego, por ejemplo, en
/// la página final devuelta al usuario.
///
@ -470,8 +503,10 @@ impl Contextual for Context {
#[builder_fn]
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
self.request = request;
// Recalcula el locale según la nueva petición y la política de negociación configurada.
// Recalcula el *locale* y el usuario actual según la nueva petición y la política de
// negociación configurada.
self.locale = RequestLocale::from_request(self.request.as_ref());
self.current_user = Self::resolve_current_user(self.request.as_ref());
self
}
@ -555,6 +590,10 @@ impl Contextual for Context {
self.request.as_ref()
}
fn current_user(&self) -> &CurrentUser {
&self.current_user
}
fn theme(&self) -> ThemeRef {
self.theme
}

View file

@ -4,7 +4,7 @@ use crate::core::theme::ThemeRef;
use crate::core::{AnyInfo, TypeInfo};
use crate::html::{Markup, html};
/// Permite clonar un componente.
/// Habilita el clonado de componentes.
///
/// Se implementa automáticamente para todo tipo que implemente [`Component`] y [`Clone`]. El método
/// [`clone_box`](Self::clone_box) devuelve una copia en la *pila* del componente original, lo que

View file

@ -17,7 +17,7 @@ pub enum MessageLevel {
///
/// Representa un mensaje con carácter informativo, una advertencia o un error. A diferencia de
/// [`ComponentError`](super::ComponentError), no está ligado a un fallo interno de renderizado,
/// puede generarse en cualquier punto del procesamiento de una petición web (manejadores,
/// puede generarse en cualquier punto del procesamiento de una petición web (*handlers*,
/// renderizado, lógica de negocio, etc.).
///
/// El texto se almacena como [`L10n`] para resolverse con el idioma del contexto en el momento de

View file

@ -99,3 +99,17 @@ pub fn configure_routes(router: Router) -> Router {
router
}
// **< CONFIGURA EL MIDDLEWARE GLOBAL >*************************************************************
/// Aplica las capas de *middleware* globales de todas las extensiones sobre el router ya enrutado.
///
/// Se llama después de [`configure_routes`] para garantizar que las capas envuelven todas las
/// rutas de la aplicación, independientemente del orden de las extensiones.
pub fn configure_middleware(router: Router) -> Router {
EXTENSIONS
.get()
.into_iter()
.flatten()
.fold(router, |r, e| e.configure_middleware(r))
}

View file

@ -100,7 +100,6 @@ pub trait Extension: AnyInfo + Send + Sync {
/// | Ruta HTTP | `.route("/path", web::get(handler))` |
/// | Rutas bajo prefijo común | `.nest("/prefix", sub_router)` |
/// | Archivos estáticos | `serve_static_files!(router, [...] => "/path")` |
/// | Capa de *middleware* | `.layer(some_layer)` |
/// | Estado compartido entre *handlers* | `.with_state(my_state)` |
///
/// # Ejemplos
@ -143,7 +142,16 @@ pub trait Extension: AnyInfo + Send + Sync {
/// }
/// ```
///
/// ## Rutas con capa de *middleware*
/// ## Rutas con *middleware* acotado a esta extensión
///
/// Cada `Router` mantiene su propia pila de *middleware* independiente. Cuando se crea un
/// `Router::new()` separado y se llama a `.layer()` sobre él, esa capa sólo se aplica a las
/// rutas de ese *router* concreto. Al fusionarlo con `.merge()` en el `router` principal, cada
/// ruta se añade con su *middleware* asociado, sin tocar las demás.
///
/// Otra cosa es llamar a `.layer()` directamente sobre el `router` principal que se recibe como
/// parámetro, porque ese objeto ya contiene todas las rutas acumuladas por extensiones
/// anteriores, y la capa se aplica a todas las rutas, no sólo a las propias.
///
/// ```rust,ignore
/// # use pagetop::prelude::*;
@ -151,13 +159,17 @@ pub trait Extension: AnyInfo + Send + Sync {
///
/// impl Extension for Api {
/// fn configure_router(&self, router: Router) -> Router {
/// router
/// let api = Router::new()
/// .route("/api/data", web::get(get_data))
/// .layer(auth_layer())
/// .layer(auth_layer());
/// router.merge(api)
/// }
/// }
/// ```
///
/// Para *middleware* que deba cubrir **todas** las rutas, usar
/// [`configure_middleware`](Self::configure_middleware).
///
/// ## Archivos estáticos
///
/// La macro [`serve_static_files!`](crate::serve_static_files) sombrea `router` internamente,
@ -177,6 +189,36 @@ pub trait Extension: AnyInfo + Send + Sync {
fn configure_router(&self, router: Router) -> Router {
router
}
/// Añade capas de *middleware* globales al *router* ya completamente preparado.
///
/// Se invoca **después** de que todas las extensiones hayan registrado sus rutas con
/// [`configure_router`](Self::configure_router), de modo que las capas añadidas aquí se aplican
/// a **todas** las rutas de la aplicación, independientemente del orden de las extensiones.
///
/// Usar este método cuando el *middleware* deba interceptar cualquier petición entrante (p. ej.
/// resolución de sesión, autenticación, cabeceras de seguridad, ...).
///
/// # Ejemplo
///
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use axum::middleware;
/// # async fn session_middleware(
/// # req: axum::extract::Request,
/// # next: middleware::Next,
/// # ) -> axum::response::Response { next.run(req).await }
/// pub struct MyAuth;
///
/// impl Extension for MyAuth {
/// fn configure_middleware(&self, router: Router) -> Router {
/// router.layer(middleware::from_fn(session_middleware))
/// }
/// }
/// ```
fn configure_middleware(&self, router: Router) -> Router {
router
}
}
/// Representa una referencia a una extensión.