a panel nobody else will probably ever see
Realistically I'm the only person who will ever log into /admin on this blog. Built it like more than one role would eventually exist anyway, because the permission model turned out more interesting to build than the actual CRUD screens sitting on top of it.
the editor: codemirror on one side, a real preview on the other
Writing a post happens in a split pane, CodeMirror configured for markdown on the left, a rendered preview on the right that updates half a second after you stop typing:
<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>The detail I almost skipped: the preview renders through the exact same markdown pipeline the public post page uses, hitting a small server endpoint instead of some separate client-side renderer I'd have had to keep in sync by hand forever. Whatever shows up in the preview pane is what actually goes out when you publish. No surprises after the fact, which used to worry me before I built it this way, since a preview that lies to you is worse than having no preview at all.
There's a toolbar above the editor for wrapping selected text, inserting headings, dropping in a code block with the language picked from a dropdown, and a media picker that opens a dialog onto everything already uploaded instead of making me remember URLs by hand.
moderation, and a rank check I didn't expect to need
The comments and reports queues are the other half of the panel, and they're where I actually had to think about what happens when there's more than one moderator. A basic moderator can ban a regular user or clear a timeout, but nothing stops a moderator from targeting another moderator, or an admin, unless the server checks rank on every single mutation:
// 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
}I didn't design this up front, I added it after actually picturing a moderator I'd added getting into an argument with another moderator and banning them out of spite. Rank comes from the database, not from the JWT's own claims, on purpose: a demoted admin's access token stays technically valid until it expires on its own, up to 15 minutes later, so the rank check has to ask "what is this account allowed to do right now" rather than trust whatever the token happened to say when it was issued.
roles, and hiding a button is not security
The admin shell shows different sections depending on your role: a moderator sees comments and reports, an admin sees everything including the user list and the permission matrix itself. My first version of this checked the role once, client-side, and rendered the nav accordingly:
// 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';
}Worked fine, looked correct, and was completely meaningless as an actual security boundary, which took me an uncomfortably long time to fully accept, since user here comes straight off a cookie the frontend never verifies. The function ended up the same, I just stopped trusting it for anything beyond deciding what to render, and wrote myself a warning label above it so I wouldn't forget again:
/**
* 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';
}That comment is basically me writing myself a warning label after learning the lesson the slow way. The frontend's whole notion of "role" comes from a cookie it never verifies, exactly like the login state from the public site, so it's trivially spoofable and I have to assume it always is.
the server checks anyway, and that's the whole point
Every admin route on the blog service is wrapped in a capability check, run against the actual verified JWT, before the handler even starts running:
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 verifies the token's signature against the keys published at auth's JWKS endpoint, actual cryptographic proof of who's asking, and only after that does HasCapability check whether that role is actually allowed to manage posts, moderate comments, or whatever the route needs. Roles map to capabilities through a small matrix (users.view, posts.manage, comments.moderate, and so on) an admin can edit from its own settings page, so "what can a moderator actually do" is a config row in a table, not something hardcoded per role name scattered across a dozen handlers.
The frontend keeps its own copy of that same capability list, purely to decide which buttons to draw. If the two copies ever disagree, say I add a new capability on the Go side and forget to teach the frontend about it, the frontend just hides a button the server would have actually allowed. Annoying, but safe in the direction that matters. The other way around, a button rendering that the server then silently allows because I forgot the RequireCapability call on some route, is the actual disaster, and hasn't happened yet, mostly because forgetting that middleware means forgetting an entire line, not adjusting one that's already there.
Writing this made something click that I'd heard as advice a dozen times without really absorbing it: the UI is a suggestion, the API is the actual rule. Hide every button you want, it changes nothing about what a request can do once it lands.