C3 — Auth con RBAC custom: 3 roles apilables¶
Pre-requisitos: C2 — Schema + workflow fitz db
cerrado. Tenés las 4 tablas creadas + endpoint smoke GET /api/users
respondiendo []. El campo role: Str = "member" está en el
@table type User esperándonos.
Objetivo: implementar registro + login con JWT + Argon2id,
declarar el @auth_provider que valida el bearer token contra la
DB, y aplicar @requires("admin") / @requires("owner") /
@requires("member") sobre los handlers para enforzar RBAC.
Demostrar @requires apilable (semántica OR) en un endpoint
real. Probar end-to-end con curl que un member no puede
promote, que el admin sí, y que un owner heredan permisos según
el rol.
Por qué importa: el RBAC custom apilable es un diferencial
fuerte del lenguaje. Stack típico Python+FastAPI / Node+Express
resuelven esto con middleware ad-hoc (@require_role("admin")
decorator definido a mano) o dependency injection runtime
(Depends(get_admin_user)). El checker no valida nada — si te
equivocás de string de rol o referenciás un usuario sin el field
role, te enterás en runtime. Fitz lo valida en compile-time:
el checker exige role: Str no nullable en el User, rechaza
@requires con string duplicado en apilados, y conoce qué
endpoints están detrás de cada rol — eso vuelve al spec
verificable estáticamente.
Cross-link: Cap 28 de la guía — Auth nativa para la referencia exhaustiva del subsistema.
Mapa del cap¶
flowchart LR
A[POST /api/auth/register] --> B[hash.password Argon2id]
B --> C[User.insert role member]
D[POST /api/auth/login] --> E[hash.verify]
E --> F[jwt.encode HS256]
F --> G[Token JWT]
G --> H[GET /api/me Bearer]
H --> I[@auth_provider valida]
I --> J[User devuelto]
K[GET /api/users] --> L[@requires admin]
L --> M[Lista todos los users]
N[POST /api/users/promote] --> L
O[GET /api/stats] --> P[@requires admin + owner apilable]
Por qué Fitz es distinto¶
| Feature | Spring Security | FastAPI + custom decorator | Express + middleware | Fitz |
|---|---|---|---|---|
| Setup auth provider | clase UserDetailsService + bean wiring |
Depends(get_current_user) definido a mano |
passport.use(JwtStrategy()) + serialize |
@auth_provider async fn check_token(...) built-in, singleton del programa |
| JWT signing | jjwt o spring-jwt | python-jose + manual sign |
jsonwebtoken package |
jwt.encode(claims, secret) built-in del lenguaje |
| Password hashing | BCryptPasswordEncoder |
passlib[bcrypt] o argon2-cffi |
bcrypt package |
hash.password(pw) / hash.verify(pw, h) Argon2id built-in |
| Role check estático | @PreAuthorize("hasRole('ADMIN')") ⚠ string, no checker validation |
custom decorator con runtime check | middleware con if (req.user.role !== ...) |
@requires("admin") validado por el checker estático — exige role: Str no nullable en User |
| Hierarchy de roles | RoleHierarchy bean | manual en code | manual en code | @requires apilable = semántica OR (@requires("admin") @requires("owner") = admin O owner) |
| Hide password en JSON | @JsonIgnore annotation |
Field(exclude=True) en Pydantic |
manual delete user.password |
@hidden decorator del lenguaje sobre el field |
| 401 / 403 responses | manual ExceptionHandlers | manual exception_handler | manual middleware | automático — @auth_provider falla → 401, @requires rol no matchea → 403 |
| Tipos del provider | manual | manual | manual | @auth_provider exige fn(Map<Str, Str>) -> Result<User>, validado en compile-time |
| Validación apilada | manual | manual | manual | @requires("admin") @requires("owner") apilable, parsea como OR |
Diferencial estructural: el RBAC en Fitz es parte del
sistema de tipos. El checker conoce qué endpoints requieren
role == "admin", exige que User.role no sea nullable, rechaza
@requires con role duplicado, y valida el shape del provider
en compile-time (signature exacta fn(Map<Str, Str>) -> Result<User>).
En FastAPI/Express/Spring esto es runtime — un typo en el string
del rol o un campo faltante explota cuando llega la primera
request al endpoint.
Paso 1 — Tipos auxiliares + @hidden en password_hash¶
Editás src/main.fitz. Primero agregás @hidden al
password_hash del User (el field sigue existiendo en la DB,
pero no se serializa en JSON responses):
@table("users") type User {
@primary id: Int = 0
email: Str
@hidden password_hash: Str = "" // ← NUEVO @hidden
role: Str = "member"
created_at: DateTime
}
@hidden es solo cambio de código, no requiere migration —
el column sigue siendo text NOT NULL DEFAULT '' en Postgres. Lo
que cambia es que el codegen del __ToFitzJson lo omite cuando
serializa el User a la response.
Sumás los tipos de input/output del auth flow:
type RegisterInput {
email: Str
password: Str
}
type LoginInput {
email: Str
password: Str
}
type LoginResponse {
token: Str
}
type PromoteInput {
new_role: Str // "admin" / "owner" / "member"
}
Por qué tipos separados de User: el User es el shape DB.
Los inputs HTTP tienen shapes distintos (la password viene en
claro del request, el password_hash sale al INSERT). Separar
los types previene mezclas accidentales (ej. devolver el
password_hash en la response porque alguien olvidó proyectar).
Paso 2 — @auth_provider que valida el bearer¶
let JWT_SECRET = env_or("JWT_SECRET", "dev-secret-cambiame")
// Helper para lookup por email (el UNIQUE index hace que sea barato).
async fn find_user_by_email(email: Str) -> Result<User> {
let conn: DbConn = match db_result {
Ok(c) => c,
Err(_) => return Err("db no disponible"),
}
return User.where(fn(u) => u.email == email).first(conn).await
}
@auth_provider
async fn check_token(headers: Map<Str, Str>) -> Result<User> {
let auth: Str = match headers.get("authorization") {
Ok(v) => v,
Err(_) => return Err("falta header Authorization"),
}
let parts = auth.split(" ")
if (parts.len() != 2) {
return Err("Authorization debe ser 'Bearer <token>'")
}
if (parts[0] != "Bearer") {
return Err("scheme debe ser Bearer")
}
let token = parts[1]
let claims = jwt.decode(token, JWT_SECRET)?
let email: Str = match claims.get("email") {
Ok(v) => v,
Err(_) => return Err("token sin claim email"),
}
// Lookup contra DB (no contra el claim del token) — si demoteas
// un admin a member, la próxima request ya ve el nuevo role.
return find_user_by_email(email).await
}
Detalles importantes:
@auth_provideres un singleton del programa — solo podés tener uno. Si declarás dos, el checker aborta.- Async
fnporque tiene.awaitadentro (consulta a la DB). - Lookup por DB, no por claim del token: esto es importante
por seguridad. Si guardás
roleen el JWT y un admin se demote a sí mismo, el viejo token sigue valiendo como admin hasta que expire. Validando contra DB en cada request, la demotion es inmediata. Trade-off: más queries (cubierto por el UNIQUE index en email — query es O(log n)). jwt.decode(token, JWT_SECRET)?propagaErrsi el token está malformado, signature inválida, o expirado.- Mensajes de error específicos (
falta header Authorization/scheme debe ser Bearer/token sin claim email) — son lo que el cliente recibe en el 401 response. Útiles para debug.
Paso 3 — POST /api/auth/register¶
@post("/auth/register")
async fn register(input: RegisterInput) -> Result<User> {
if (input.email == "" or input.password == "") {
return Err("email y password son obligatorios")
}
if (input.password.len() < 8) {
return Err("password debe tener al menos 8 caracteres")
}
let conn: DbConn = match db_result {
Ok(c) => c,
Err(_) => return Err("db no disponible"),
}
// Check email único antes de insertar — mensaje user-friendly
// en lugar del UNIQUE violation crudo del driver.
let existing = User.where(fn(u) => u.email == input.email).first(conn).await
match existing {
Ok(_) => return Err("email ya registrado"),
Err(_) => 0,
}
let pw_hash = hash.password(input.password)
let new_user = User.insert(conn, User {
id: 0,
email: input.email,
password_hash: pw_hash,
role: "member", // default — solo admin promueve
created_at: DateTime.now(),
}).await?
return Ok(new_user)
}
Detalles:
- Validación temprana (email vacío / password corto) — antes de tocar la DB. Mensaje de error claro al cliente.
hash.password(input.password)corre Argon2id (built-in del lenguaje, sin deps externas). Devuelve un hash con salt embebido — guardás en DB tal cual, no hace falta columna separada para salt.role: "member"hardcodeado — un endpoint de registro público NO permite elegir role. El primer admin se elevará manualmente en el Paso 9.DateTime.now()explícito — el@tabledeclaracreated_at: DateTimesin default. En C4 podríamos sumar@db_default("NOW()")si queremos hacerlo automático.- Response es
Userpero NO incluyepassword_hashporque el field tiene@hidden. El cliente ve{id, email, role, created_at}solamente.
Paso 4 — POST /api/auth/login¶
@post("/auth/login")
async fn login(creds: LoginInput) -> Result<LoginResponse> {
let user: User = match find_user_by_email(creds.email).await {
Ok(u) => u,
Err(_) => return Err("credenciales inválidas"),
}
if (not hash.verify(creds.password, user.password_hash)) {
return Err("credenciales inválidas")
}
let claims = {
"email": user.email,
"role": user.role,
}
let token = jwt.encode(claims, JWT_SECRET)
return Ok(LoginResponse { token: token })
}
Detalles:
- Mismo mensaje "credenciales inválidas" tanto si el email no existe como si el password no matchea. Esto es mitigation contra timing attacks + enumeración de usuarios (un atacante no puede saber si un email está registrado probando passwords random).
hash.verify(plain, hashed)devuelveBool(noResult). Hash malformado →falsepor seguridad (no panic).claims = {"email": ..., "role": ...}esMap<Str, Str>. El JWT MVP solo acepta este shape (heterogéneos requieren__FitzValueen codegen, deuda menor). Suficiente para auth.jwt.encode(claims, secret)firma con HS256 por default. HS384/HS512 también disponibles via kwarg.
Paso 5 — GET /api/me¶
Una línea. El @authenticated invoca el @auth_provider,
inyecta el user: User resuelto, el handler simplemente lo
devuelve. El @hidden del password_hash aplica — la response
omite el hash automáticamente.
Paso 6 — GET /api/users con @requires("admin")¶
@requires("admin")
@get("/users")
async fn list_users_admin(user: User) -> Result<List<User>> {
let conn: DbConn = match db_result {
Ok(c) => c,
Err(_) => return Err("db no disponible"),
}
return User.all(conn).await
}
Diferencias con el endpoint smoke del C2 (GET /api/users sin
auth, que vamos a remover):
@requires("admin")delante hace dos cosas:- Implica
@authenticated— corre el provider antes del handler. - Valida que
user.role == "admin". Si no, 403 automático con mensajeforbidden: requires role 'admin', user has 'member'(o el role actual). user: Userse inyecta (mismo patrón que@authenticated).User.all(conn)devuelve todos los users, sinpassword_hashen la response (gracias a@hidden).
Paso 7 — POST /api/users/{id}/promote¶
@requires("admin")
@post("/users/{id}/promote")
async fn promote_user(id: Int, input: PromoteInput, user: User) -> Result<User> {
// Validá el role pedido.
if (input.new_role != "admin" and input.new_role != "owner" and input.new_role != "member") {
return Err("new_role debe ser 'admin', 'owner' o 'member'")
}
let conn: DbConn = match db_result {
Ok(c) => c,
Err(_) => return Err("db no disponible"),
}
// Update con guard obligatorio (where).
let updated_count = User.where(fn(u) => u.id == id)
.update(conn, { "role": input.new_role })
.await?
if (updated_count == 0) {
return Err("user con id={id} no existe")
}
// Devolver el user actualizado.
return User.where(fn(u) => u.id == id).first(conn).await
}
Detalles:
@requires("admin")— solo admin promueve.- Validación del role string antes de tocar DB. Mensaje específico al cliente.
.update(conn, { "role": ... })conwhere(...)guard obligatorio. El ORM rechaza.update(...)sin where (cubierto en M6.C3 del curso).- Devolvemos el user actualizado (no solo
Ok) para que el cliente pueda verificar el nuevo role sin re-fetch.
Paso 8 — @requires apilable: demo GET /api/stats¶
Acá demostramos la semántica OR del decorator apilable: admin O owner pueden ver stats agregadas (member no).
type StatsResponse {
total_users: Int
total_projects: Int
total_tasks: Int
}
@requires("admin")
@requires("owner")
@get("/stats")
async fn stats(user: User) -> Result<StatsResponse> {
let conn: DbConn = match db_result {
Ok(c) => c,
Err(_) => return Err("db no disponible"),
}
let users = User.count(conn).await?
let projects = Project.count(conn).await?
let tasks = Task.count(conn).await?
return Ok(StatsResponse {
total_users: users,
total_projects: projects,
total_tasks: tasks,
})
}
Apilable = OR:
@requires("admin") @requires("owner")= admin O owner.- Member llega → 403.
- Admin llega → 200.
- Owner llega → 200.
Por qué OR y no AND: un user tiene un solo role Str (no
lista). Pedir AND (admin Y owner) sería incoherente — nadie es
admin y owner simultáneamente. Para hierarchies más complejas
(membership en N grupos), el modelo es User has many Roles y se
maneja con tabla relacional + join — fuera del scope del RBAC
declarativo.
Por qué NO @authenticated acá: con @requires("admin") @requires("owner"),
member queda fuera (no matchea ninguno de los dos). Si quisieras
"todo logueado", usás @authenticated solo. Si querés "admin Y
member Y owner explícitos" (equivalente a @authenticated pero
declarativo), apilás los tres @requires.
Paso 9 — Remover el endpoint smoke + Rebuild¶
Borrás el endpoint GET /users del C2 (sin auth) — está
reemplazado por GET /users con @requires("admin"). Tu
src/main.fitz final tiene:
Endpoints C3:
- GET /healthz (no auth)
- POST /auth/register (no auth)
- POST /auth/login (no auth)
- GET /me (@authenticated)
- GET /users (@requires("admin"))
- POST /users/{id}/promote (@requires("admin"))
- GET /stats (@requires("admin") @requires("owner"))
Rebuild del binario para incorporar la lógica nueva:
Verificación rápida:
curl http://localhost:8000/healthz
# → {"status":"ok","version":"0.1.0-c3"}
curl http://localhost:8000/api/me
# → 401 Unauthorized — sin token (esperado)
curl http://localhost:8000/api/auth/register \
-X POST -H 'Content-Type: application/json' \
-d '{"email":"x","password":"short"}'
# → 500 con {"error":"password debe tener al menos 8 caracteres"}
Paso 10 — Bootstrap del primer admin¶
Acá viene el chicken-and-egg: el endpoint /users/{id}/promote
está protegido con @requires("admin"), pero no hay admin
todavía en la DB. Necesitás un admin para crear un admin.
Solución pragmática: el primer admin se eleva manualmente con psql después del primer register. Una sola vez.
# 1. Hacés `source dev-env.sh` si todavía no.
source dev-env.sh
# 2. Registrás el user que va a ser admin.
curl -X POST http://localhost:8000/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"admin@taskhub.local","password":"adminpass123"}'
# Response:
# → {"id":1,"email":"admin@taskhub.local","role":"member","created_at":"..."}
# (password_hash NO aparece — @hidden funciona)
# 3. Elevás a admin desde psql.
psql "$DATABASE_URL" -c "UPDATE users SET role='admin' WHERE id=1;"
# → UPDATE 1
Otra opción (refinamiento futuro): una .fitz migration nativa
que pregunte por una env var INITIAL_ADMIN_EMAIL y eleve ese user
al boot. Lo dejamos como deuda explícita.
Paso 11 — Probar end-to-end con curl¶
Login como admin:
ADMIN_TOKEN=$(curl -sX POST http://localhost:8000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@taskhub.local","password":"adminpass123"}' \
| jq -r .token)
echo $ADMIN_TOKEN
# → eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6...
GET /me como admin:
curl http://localhost:8000/api/me -H "Authorization: Bearer $ADMIN_TOKEN"
# → {"id":1,"email":"admin@taskhub.local","role":"admin","created_at":"..."}
GET /users como admin (solo lista todos):
curl http://localhost:8000/api/users -H "Authorization: Bearer $ADMIN_TOKEN"
# → [{"id":1,"email":"admin@taskhub.local","role":"admin","created_at":"..."}]
Registrar un member normal:
curl -X POST http://localhost:8000/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"bob@taskhub.local","password":"bobpass123"}'
# → {"id":2,"email":"bob@taskhub.local","role":"member","created_at":"..."}
MEMBER_TOKEN=$(curl -sX POST http://localhost:8000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"bob@taskhub.local","password":"bobpass123"}' \
| jq -r .token)
Intentar GET /users como member → 403:
curl -i http://localhost:8000/api/users -H "Authorization: Bearer $MEMBER_TOKEN"
# → HTTP/1.1 403 Forbidden
# {"error":"forbidden: requires role 'admin', user has 'member'"}
Intentar GET /stats como member → 403:
curl -i http://localhost:8000/api/stats -H "Authorization: Bearer $MEMBER_TOKEN"
# → HTTP/1.1 403 Forbidden
# {"error":"forbidden: requires role 'admin' or 'owner', user has 'member'"}
Admin promueve a Bob a owner:
curl -X POST http://localhost:8000/api/users/2/promote \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"new_role":"owner"}'
# → {"id":2,"email":"bob@taskhub.local","role":"owner","created_at":"..."}
Re-login de Bob (el viejo token tiene role: member en los
claims, pero el provider revalida contra DB en cada request — ya
es owner desde la próxima llamada):
# El viejo token sigue funcionando, el provider hace lookup en DB y
# ve el role actualizado.
curl http://localhost:8000/api/stats -H "Authorization: Bearer $MEMBER_TOKEN"
# → {"total_users":2,"total_projects":0,"total_tasks":0}
Esto es la magia del lookup-contra-DB del provider: los tokens viejos siguen funcionando con el role actualizado. Cuando expiren, el cliente re-loguea y obtiene un token nuevo con el claim actualizado.
Validación del cap¶
-
POST /api/auth/registercon email/password OK devuelveUsersinpassword_hash. -
POST /api/auth/logincon creds OK devuelve{token: "..."}. -
GET /api/mesin token → 401. -
GET /api/mecon token → 200 con el user. -
GET /api/userscomo member → 403. -
GET /api/userscomo admin → 200 con lista. -
POST /api/users/{id}/promotecomo member → 403. -
POST /api/users/{id}/promotecomo admin → 200, user cambia de role. -
GET /api/statscomo member → 403. -
GET /api/statscomo admin → 200. -
GET /api/statscomo owner → 200 (apilable funciona). - El token viejo sigue funcionando después del promote (lookup contra DB).
Troubleshooting¶
401 Unauthorized con mensaje token sin claim email¶
El JWT no tiene el claim email. Causas típicas:
- Estás usando un token de otro proyecto / otro
JWT_SECRET. - El
jwt.encode(claims, ...)no incluyóemailen los claims.
Decodificá el token en jwt.io para inspeccionar los claims.
500 Internal Server Error en POST /auth/register¶
Lo más probable:
- El
db_resultestá en estadoErr(DB no responde). Mirádocker compose logs app. - Estás intentando registrar un email que ya existe — el código
debería devolver
Err("email ya registrado")antes del INSERT, pero si el check falla por alguna razón, el UNIQUE constraint del schema tira 500.
403 Forbidden cuando creés que sos admin¶
El JWT tiene role: "admin" en los claims pero la DB tiene otro
role. El provider hace lookup contra DB, así que el role
efectivo es el de DB. Verificá:
Si dice role: member, hacé el UPDATE del Paso 10 de nuevo.
fitz check aborta con @requires requiere role: Str no nullable en User¶
El User tiene role: Str? (nullable) o le falta el field. El
checker exige role: Str no-null. Editá el @table type User.
Tests con wscat o frontend dicen CORS error¶
@requires no afecta CORS — eso vive en @middleware(cors(...))
que vamos a sumar en C4 cuando el frontend pegue desde
http://localhost:8000 con orígenes diferentes.
Lo que cubriste¶
@hiddendecorator enpassword_hash— el field está en DB pero no aparece en JSON responses.- Tipos auxiliares separados del shape DB (
RegisterInput,LoginInput,LoginResponse,PromoteInput). @auth_provider async fn check_token(...)singleton que valida el Bearer token, decodifica el JWT, y hace lookup contra DB por email.POST /api/auth/registercon validación +hash.passwordArgon2id + INSERT.POST /api/auth/loginconhash.verify+jwt.encodeHS256 + mismo mensaje "credenciales inválidas" para evitar enumeración.GET /api/mecon@authenticated(handler de una línea).@requires("admin")en handlers admin-only (lista de users- promote).
@requiresapilable (semántica OR) en endpoint stats: admin O owner.- Bootstrap manual del primer admin via psql.
- Tests end-to-end con curl validando cada rol contra cada endpoint.
- Patrón canónico del lookup contra DB en el provider — un promote surte efecto inmediato sin esperar a que el token expire.
El sistema de auth está vivo. TaskHub ahora tiene register + login + 3 roles + RBAC apilable. Los caps siguientes construyen sobre esto.
Próximo cap¶
C4 — CRUD + relations + WebSocket en vivo por project.
Vamos a sumar los CRUD de projects + tasks + comments con
relations (@belongs_to / @has_many para navigation methods),
eager loading con .preload(...), y @ws("/ws/projects/{id}")
para broadcastear cambios en vivo a todos los conectados al mismo
board. El scoping por rol entra en cada handler (un owner solo ve
sus projects, un member solo ve projects donde tiene tasks
asignadas).
Mientras tanto, commiteá este cap. Tu repo tiene auth real con RBAC apilable, los tests por rol pasan end-to-end, y cualquier cap futuro que sume endpoints solo decoró con el role apropiado.