en editor bare for meg: å bygge admin-panelet

26. juni 2026 meta devlogsveltekit

et panel nesten ingen andre noensinne kommer til å se

Realistisk sett er jeg den eneste personen som noensinne kommer til å logge inn på /admin på denne bloggen. Bygde den likevel som om mer enn én rolle til slutt ville eksistere, fordi tillatelsesmodellen viste seg å være mer interessant å bygge enn de faktiske CRUD-skjermene som sitter oppå den.

editoren: codemirror på den ene siden, en ekte forhåndsvisning på den andre

Å skrive et innlegg skjer i et delt panel, CodeMirror konfigurert for markdown til venstre, en rendret forhåndsvisning til høyre som oppdaterer et halvt sekund etter at du slutter å skrive:

<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>

Detaljen jeg nesten hoppet over: forhåndsvisningen rendrer gjennom nøyaktig samme markdown-pipeline den offentlige innleggssiden bruker, og treffer et lite server-endepunkt i stedet for en separat klientside-renderer jeg måtte holdt synkronisert for hånd for alltid. Det som vises i forhåndsvisningspanelet er det som faktisk går ut når du publiserer. Ingen overraskelser i ettertid, noe som pleide å bekymre meg før jeg bygde det på denne måten, siden en forhåndsvisning som lyver til deg er verre enn ingen forhåndsvisning i det hele tatt.

Det er en verktøylinje over editoren for å pakke inn markert tekst, sette inn overskrifter, slippe inn en kodeblokk med språket valgt fra en nedtrekksmeny, og en mediepicker som åpner en dialog med alt som allerede er lastet opp i stedet for å få meg til å huske URL-er for hånd.

moderering, og en rangsjekk jeg ikke forventet å trenge

Kommentar- og rapportkøene er den andre halvparten av panelet, og det er der jeg faktisk måtte tenke på hva som skjer når det er mer enn én moderator. En vanlig moderator kan utestenge en vanlig bruker eller fjerne en timeout, men ingenting stopper en moderator fra å målrette en annen moderator, eller en admin, med mindre serveren sjekker rang på hver eneste endring:

// 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
}

Jeg designet ikke dette på forhånd, jeg la det til etter faktisk å ha sett for meg en moderator jeg hadde lagt til komme i en krangel med en annen moderator og utestenge dem av spite. Rangen kommer fra databasen, ikke fra JWT-ens egne claims, med vilje: en degradert admins access-token forblir teknisk gyldig helt til det utløper av seg selv, opptil 15 minutter senere, så rangsjekken må spørre "hva har denne kontoen lov til å gjøre akkurat nå" i stedet for å stole på hva enn tokenet tilfeldigvis sa da det ble utstedt.

roller, og å gjemme en knapp er ikke sikkerhet

Admin-skallet viser forskjellige seksjoner avhengig av rollen din: en moderator ser kommentarer og rapporter, en admin ser alt inkludert brukerlisten og selve tillatelsesmatrisen. Min første versjon av dette sjekket rollen én gang, klientside, og rendret navigasjonen deretter:

// 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';
}

Fungerte fint, så riktig ut, og var fullstendig meningsløst som en faktisk sikkerhetsgrense, noe det tok meg ubehagelig lang tid å akseptere fullt ut, siden user her kommer rett fra en cookie frontend aldri verifiserer. Funksjonen endte opp den samme, jeg sluttet bare å stole på den for noe utover å bestemme hva som skal rendres, og skrev meg selv et advarselsskilt over den slik at jeg ikke skulle glemme igjen:

/**
 * 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';
}

Den kommentaren er i bunn og grunn meg som skriver meg selv et advarselsskilt etter å ha lært lekse på den trege måten. Frontendens hele forestilling om "rolle" kommer fra en cookie den aldri verifiserer, akkurat som innloggingstilstanden fra det offentlige nettstedet, så den er trivielt forfalskbar og jeg må anta at den alltid er det.

serveren sjekker uansett, og det er hele poenget

Hver admin-rute på blog-tjenesten er pakket inn i en capability-sjekk, kjørt mot det faktiske verifiserte JWT-et, før selve handleren i det hele tatt begynner å kjøre:

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 verifiserer tokenets signatur mot nøklene publisert på auths JWKS-endepunkt, faktisk kryptografisk bevis på hvem som spør, og først etter det sjekker HasCapability om den rollen faktisk har lov til å administrere innlegg, moderere kommentarer, eller hva enn ruten trenger. Roller kobles til capabilities gjennom en liten matrise (users.view, posts.manage, comments.moderate, og så videre) en admin kan redigere fra sin egen innstillingsside, så "hva kan en moderator faktisk gjøre" er en konfig-rad i en tabell, ikke noe hardkodet per rollenavn spredt over et dusin handlere.

Frontend holder sin egen kopi av den samme capability-listen, rent for å bestemme hvilke knapper som skal tegnes. Hvis de to kopiene noensinne er uenige, si jeg legger til en ny capability på Go-siden og glemmer å lære frontend om den, gjemmer frontend bare en knapp serveren faktisk ville tillatt. Irriterende, men trygt i den retningen som betyr noe. Motsatt vei, en knapp som rendres som serveren så stille tillater fordi jeg glemte RequireCapability-kallet på en eller annen rute, er den faktiske katastrofen, og har ikke skjedd ennå, mest fordi å glemme den middlewaren betyr å glemme en hel linje, ikke å justere en som allerede er der.

Å skrive dette fikk noe til å klikke som jeg hadde hørt som råd et dusin ganger uten egentlig å absorbere det: UI-en er et forslag, API-et er den faktiske regelen. Gjem hver eneste knapp du vil, det endrer ingenting om hva en forespørsel faktisk kan gjøre når den lander.

0 kommentarer

Logg inn for å kommentere.

Logg inn

Ingen konto?