Añade pagetop-user, con autenticación y RBAC

Identidad de usuario, sesiones en base de datos, roles y permisos (RBAC)
con "authenticated" implícito, contraseñas con Argon2id, tokens de
un solo uso para verificación de email y restablecimiento de contraseña,
y la UI de administración completa (usuarios, roles, permisos) sobre
pagetop-admin.

Incluye datos de demostración opcionales tras la feature `demo-data`.
This commit is contained in:
Manuel Cillero 2026-08-28 20:59:17 +02:00
parent 043873a954
commit def0513246
57 changed files with 7409 additions and 0 deletions

View file

@ -0,0 +1,56 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
table_auto(Users::Table)
.col(pk_auto(Users::Id))
.col(string_len_uniq(Users::Username, 64))
.col(string_len_uniq(Users::Email, 254))
.col(timestamp_null(Users::EmailVerifiedAt))
.col(string(Users::PasswordHash))
// 0=Blocked, 1=Active, 2=Pending
.col(small_integer(Users::Status).default(1))
.col(string_len_null(Users::Language, 16))
.col(string_len_null(Users::Timezone, 64))
.col(string_len_null(Users::DisplayName, 128))
.col(timestamp_null(Users::LastLoginAt))
.col(timestamp_null(Users::LastAccessAt))
.col(integer(Users::FailedLoginCount).default(0))
.col(timestamp_null(Users::LockedUntil))
// Acceso irrestricto al sistema, sin pasar por roles ni permisos.
.col(boolean(Users::IsAdmin).default(false))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Users::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum Users {
Table,
Id,
Username,
Email,
EmailVerifiedAt,
PasswordHash,
Status,
Language,
Timezone,
DisplayName,
LastLoginAt,
LastAccessAt,
FailedLoginCount,
LockedUntil,
IsAdmin,
}

View file

@ -0,0 +1,83 @@
use pagetop_seaorm::migration::*;
use sea_orm::{ConnectionTrait, DbBackend};
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
table_auto(Roles::Table)
.col(pk_auto(Roles::Id))
.col(string_len_uniq(Roles::MachineName, 64))
.col(string_len(Roles::Label, 128))
.col(text_null(Roles::Description))
.col(integer(Roles::Weight).default(0))
// Los roles del sistema (anonymous, authenticated) no se borran.
.col(boolean(Roles::Locked).default(false))
.to_owned(),
)
.await?;
// Filas de sistema: anonymous (1), authenticated (2).
let insert = Query::insert()
.into_table(Roles::Table)
.columns([
Roles::Id,
Roles::MachineName,
Roles::Label,
Roles::Weight,
Roles::Locked,
])
.values_panic([
1.into(),
"anonymous".into(),
"Anonymous".into(),
0.into(),
true.into(),
])
.values_panic([
2.into(),
"authenticated".into(),
"Authenticated".into(),
1.into(),
true.into(),
])
.to_owned();
manager.exec_stmt(insert).await?;
// Los IDs anteriores se insertan explícitamente; en PostgreSQL la secuencia del `serial`
// no avanza con inserciones explícitas, así que el próximo alta chocaría con estas filas.
if manager.get_database_backend() == DbBackend::Postgres {
manager
.get_connection()
.execute_unprepared(
"SELECT setval(pg_get_serial_sequence('roles', 'id'), \
(SELECT MAX(id) FROM roles))",
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Roles::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum Roles {
Table,
Id,
MachineName,
Label,
Description,
Weight,
Locked,
}

View file

@ -0,0 +1,55 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(UserRoles::Table)
.if_not_exists()
.col(integer(UserRoles::UserId))
.col(integer(UserRoles::RoleId))
.primary_key(
Index::create()
.col(UserRoles::UserId)
.col(UserRoles::RoleId),
)
.foreign_key(
ForeignKey::create()
.from(UserRoles::Table, UserRoles::UserId)
.to(
super::m20260629_000001_create_users::Users::Table,
super::m20260629_000001_create_users::Users::Id,
)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.from(UserRoles::Table, UserRoles::RoleId)
.to(
super::m20260629_000002_create_roles::Roles::Table,
super::m20260629_000002_create_roles::Roles::Id,
)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(UserRoles::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum UserRoles {
Table,
UserId,
RoleId,
}

View file

@ -0,0 +1,49 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(RolePermissions::Table)
.if_not_exists()
.col(integer(RolePermissions::RoleId))
// Clave del permiso como string con namespace: "provider.action"
.col(string_len(RolePermissions::PermissionKey, 190))
.col(timestamp(RolePermissions::GrantedAt).default(Expr::current_timestamp()))
.primary_key(
Index::create()
.col(RolePermissions::RoleId)
.col(RolePermissions::PermissionKey),
)
.foreign_key(
ForeignKey::create()
.from(RolePermissions::Table, RolePermissions::RoleId)
.to(
super::m20260629_000002_create_roles::Roles::Table,
super::m20260629_000002_create_roles::Roles::Id,
)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(RolePermissions::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum RolePermissions {
Table,
RoleId,
PermissionKey,
GrantedAt,
}

View file

@ -0,0 +1,71 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(Sessions::Table)
.if_not_exists()
// sid: 64 chars hex (32 bytes aleatorios)
.col(char_len(Sessions::Sid, 64).primary_key())
.col(integer(Sessions::UserId))
// Datos de sesión en JSON (flash messages, etc.)
.col(text(Sessions::Data).default("{}"))
.col(timestamp_null(Sessions::LastActivityAt))
.col(timestamp(Sessions::ExpiresAt).default(Expr::current_timestamp()))
.col(timestamp(Sessions::CreatedAt).default(Expr::current_timestamp()))
.foreign_key(
ForeignKey::create()
.from(Sessions::Table, Sessions::UserId)
.to(
super::m20260629_000001_create_users::Users::Table,
super::m20260629_000001_create_users::Users::Id,
)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_sessions_user_id")
.table(Sessions::Table)
.col(Sessions::UserId)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_sessions_expires_at")
.table(Sessions::Table)
.col(Sessions::ExpiresAt)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Sessions::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum Sessions {
Table,
Sid,
UserId,
Data,
LastActivityAt,
ExpiresAt,
CreatedAt,
}

View file

@ -0,0 +1,49 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
table_auto(UserTokens::Table)
.col(pk_auto(UserTokens::Id))
.col(integer(UserTokens::UserId))
// "email_verification" | "password_reset"
.col(string_len(UserTokens::Kind, 32))
// SHA-256 del token plano (nunca se almacena el token en claro)
.col(char_len_uniq(UserTokens::TokenHash, 64))
.col(timestamp(UserTokens::ExpiresAt).default(Expr::current_timestamp()))
.col(timestamp_null(UserTokens::ConsumedAt))
.foreign_key(
ForeignKey::create()
.from(UserTokens::Table, UserTokens::UserId)
.to(
super::m20260629_000001_create_users::Users::Table,
super::m20260629_000001_create_users::Users::Id,
)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(UserTokens::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum UserTokens {
Table,
Id,
UserId,
Kind,
TokenHash,
ExpiresAt,
ConsumedAt,
}