un editor solo para mí: construyendo el panel de admin

26 de junio de 2026 meta devlogsveltekit

un panel que probablemente nadie más vaya a ver jamás

Siendo realistas, soy la única persona que alguna vez va a iniciar sesión en /admin en este blog. Aun así lo construí como si más de un rol fuera a existir eventualmente, porque el modelo de permisos terminó siendo más interesante de construir que las pantallas CRUD reales que se sientan encima de él.

el editor: codemirror a un lado, una vista previa real al otro

Escribir un post pasa en un panel dividido, CodeMirror configurado para markdown a la izquierda, una vista previa renderizada a la derecha que se actualiza medio segundo después de que dejas de teclear:

<script lang="ts">
	import { EditorView, basicSetup } from 'codemirror';
	import { EditorState } from '@codemirror/state';
	import { markdown } from '@codemirror/lang-markdown';
	...
	const PREVIEW_DEBOUNCE_MS = 500;

	function schedulePreview(text: string) {
		if (debounceTimer) clearTimeout(debounceTimer);
		debounceTimer = setTimeout(() => renderPreview(text), PREVIEW_DEBOUNCE_MS);
	}
</script>

El detalle que casi me salto: la vista previa se renderiza a través del mismo pipeline de markdown exacto que usa la página pública del post, golpeando un pequeño endpoint del servidor en vez de algún renderizador aparte del lado del cliente que habría tenido que mantener sincronizado a mano para siempre. Lo que sea que aparece en el panel de vista previa es lo que realmente sale cuando publicas. Sin sorpresas después del hecho, algo que solía preocuparme antes de construirlo de esta forma, ya que una vista previa que te miente es peor que no tener ninguna vista previa.

Hay una barra de herramientas sobre el editor para envolver el texto seleccionado, insertar encabezados, meter un bloque de código con el lenguaje elegido de un desplegable, y un selector de medios que abre un diálogo con todo lo ya subido en vez de hacerme recordar URLs a mano.

moderación, y una comprobación de rango que no esperaba necesitar

Las colas de comentarios y reportes son la otra mitad del panel, y ahí es donde realmente tuve que pensar en qué pasa cuando hay más de un moderador. Un moderador básico puede banear a un usuario normal o quitar un timeout, pero nada impide que un moderador apunte a otro moderador, o a un admin, a menos que el servidor compruebe el rango en cada mutación:

// Escalation guard: an actor may only modify a user whose current role
// ranks strictly below their own (rbac spec's "cannot act on an equal
// or higher role"). This also blocks a moderator from banning an admin
// or another moderator, regardless of which fields the request changes.
if capability.RoleRank(current.Role) >= capability.RoleRank(actor.Role) {
	writeError(w, r, http.StatusForbidden, "forbidden", "cannot modify a user with an equal or higher role")
	return
}

No diseñé esto de antemano, lo añadí después de imaginarme de verdad a un moderador que había añadido metiéndose en una discusión con otro moderador y baneándolo por rencor. El rango viene de la base de datos, no de los claims propios del JWT, a propósito: el access token de un admin degradado sigue siendo técnicamente válido hasta que expira por sí solo, hasta 15 minutos después, así que la comprobación de rango tiene que preguntar "qué puede hacer esta cuenta ahora mismo" en vez de confiar en lo que fuera que dijera el token cuando se emitió.

roles, y esconder un botón no es seguridad

El shell de admin muestra secciones distintas según tu rol: un moderador ve comentarios y reportes, un admin lo ve todo incluyendo la lista de usuarios y la propia matriz de permisos. Mi primera versión de esto comprobaba el rol una vez, del lado del cliente, y renderizaba la navegación según eso:

// first version, no warning, no second thought
export function checkAdminAccess(user: LocalsUser | null): AdminGuardResult {
	if (!user) return 'login';
	if (user.role === 'user') return 'not_found';
	return 'ok';
}

Funcionaba bien, se veía correcto, y era completamente insignificante como frontera de seguridad real, cosa que me tomó un tiempo incómodamente largo aceptar del todo, ya que user aquí viene directo de una cookie que el frontend nunca verifica. La función terminó siendo la misma, solo dejé de confiar en ella para nada más allá de decidir qué se renderiza, y me escribí una etiqueta de advertencia encima para no volver a olvidarlo:

/**
 * Cosmetic UX guard only: `user` comes from `locals.user`, which is decoded
 * from the access token cookie WITHOUT signature verification (see
 * hooks.server.ts / session.ts). A crafted cookie can therefore claim any
 * role. This function only decides whether the *admin shell* renders at
 * all -- any role except `user` may see it; which sections and controls
 * inside it render is decided per capability, not here. This must never be
 * treated as authorization. Every admin data read or mutation goes through
 * the /api or /auth proxy to the Go services, which verify the JWT via
 * JWKS and enforce the required capability server-side. If this check and
 * the server enforcement ever disagree, the server wins: a spoofed "ok"
 * here just gets 403s back from every fetch.
 */
export function checkAdminAccess(user: LocalsUser | null): AdminGuardResult {
	if (!user) return 'login';
	if (user.role === 'user') return 'not_found';
	return 'ok';
}

Ese comentario es básicamente yo escribiéndome a mí mismo una etiqueta de advertencia después de aprender la lección por las malas. Toda la noción del frontend de "rol" viene de una cookie que nunca verifica, exactamente igual que el estado de login del sitio público, así que es trivialmente falsificable y tengo que asumir que siempre lo es.

el servidor comprueba de todas formas, y ese es todo el punto

Cada ruta de admin en el servicio de blog está envuelta en una comprobación de capability, ejecutada contra el JWT realmente verificado, antes de que el handler siquiera empiece a correr:

func RegisterAdminPostRoutes(r chi.Router, s *Server, verifier *authmw.Verifier, matrix *authmw.MatrixClient) {
	r.Route("/admin/posts", func(r chi.Router) {
		r.Use(authmw.RequireCapability(verifier, matrix, capability.PostsManage))
		r.Get("/", s.AdminListPosts)
		r.Post("/", s.AdminCreatePost)
		r.Patch("/{id}", s.AdminUpdatePost)
		r.Delete("/{id}", s.AdminDeletePost)
	})
}
func RequireCapability(v *Verifier, m *MatrixClient, cap string) func(http.Handler) http.Handler {
	auth := RequireAuth(v)
	return func(next http.Handler) http.Handler {
		return auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			claims := ClaimsFromContext(r.Context())
			if !m.HasCapability(r.Context(), claims.Role, cap) {
				writeError(w, r, http.StatusForbidden, "forbidden", fmt.Sprintf("requires the %s capability", cap))
				return
			}
			next.ServeHTTP(w, r)
		}))
	}
}

RequireAuth verifica la firma del token contra las claves publicadas en el endpoint JWKS de auth, prueba criptográfica real de quién está preguntando, y solo después de eso HasCapability comprueba si ese rol realmente tiene permitido gestionar posts, moderar comentarios, o lo que sea que necesite la ruta. Los roles se mapean a capabilities a través de una pequeña matriz (users.view, posts.manage, comments.moderate, y demás) que un admin puede editar desde su propia página de configuración, así que "qué puede hacer un moderador en realidad" es una fila de config en una tabla, no algo hardcodeado por nombre de rol repartido por una docena de handlers.

El frontend mantiene su propia copia de esa misma lista de capabilities, puramente para decidir qué botones dibujar. Si las dos copias alguna vez no coinciden, digamos que añado una capability nueva en el lado de Go y me olvido de enseñársela al frontend, el frontend simplemente esconde un botón que el servidor de hecho habría permitido. Molesto, pero seguro en la dirección que importa. Al revés, un botón que se renderiza y que el servidor luego permite en silencio porque me olvidé de la llamada a RequireCapability en alguna ruta, ese sí es el desastre real, y todavía no ha pasado, sobre todo porque olvidar ese middleware significa olvidar una línea entera, no ajustar una que ya está ahí.

Escribir esto hizo que algo hiciera click que había escuchado como consejo una docena de veces sin absorberlo realmente: la interfaz es una sugerencia, el API es la regla real. Esconde todos los botones que quieras, no cambia nada sobre lo que una petición puede hacer una vez que llega.

0 comentarios

Inicia sesión para comentar.

Iniciar sesión

¿No tienes cuenta?