Панель которую, вероятно, никто кроме меня не увидит
Реально я вероятно единственный кто будет логиниться на /admin этого блога. Но строил я это так как если бы ролей в будущем было несколько, потому что модель прав получилась интереснее для разработки чем сами CRUD-экраны на которых она лежит.
Редактор: CodeMirror с одной стороны, настоящий предпросмотр с другой
Писать пост это работать в разделённом окне. Слева CodeMirror настроен для markdown, справа живой предпросмотр который обновляется полсекунды после того как ты перестал печатать:
<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>Деталь которую я чуть не пропустил: предпросмотр рендерится через ровно тот же markdown-пайплайн что использует публичная страница поста, через маленький серверный эндпоинт вместо какого-нибудь отдельного клиентского рендерера что мне пришлось бы вручную синхронизировать навеки. Что угодно что ты видишь в окне предпросмотра это то что реально выйдет когда опубликуешь. Никаких сюрпризов потом, что раньше мне было бы страшновато, потому что предпросмотр который врёт это хуже чем вообще без предпросмотра.
Над редактором есть тулбар для обёртывания выделенного текста, вставки заголовков, добавления кодовых блоков с выбором языка из дропдауна, и пикер медиа что открывает диалог ко всему уже загруженному вместо того чтобы я помнил URLs от руки.
Модерация и проверка ранга которую я не ожидал потребностью
Очередь комментариев и очередь жалоб это вторая половина панели, и вот где я реально должен был подумать что случится когда модераторов больше одного. Базовый модератор может заблокировать обычного пользователя или снять таймаут, но ничего не мешает модератору затронуть другого модератора или администратора, пока сервер не проверяет ранг на каждой отдельной мутации:
// 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
}Я не спланировал это с самого начала, добавил это потом когда реально представил себе модератора кого я добавил в систему ввязывающегося в ссору с другим модератором и баннящего его от злости. Ранг берётся из БД, не из самих утверждений JWT, специально: демотированного администратора токен доступа остаётся технически рабочим пока не истечёт сам по себе, до 15 минут спустя, значит проверка ранга должна спросить «что этот аккаунт может делать прямо сейчас» вместо того чтобы доверять какое-то утверждение был в токене когда его выдали.
Роли и скрытие кнопки это не безопасность
Админ-шелл показывает разные секции в зависимости от твоей роли: модератор видит комментарии и жалобы, администратор видит всё включая список пользователей и саму матрицу разрешений. Моя первая версия это проверила роль один раз, клиентски, и렌дерила навигацию сообразно:
// 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';
}Работало, выглядело правильно, и было абсолютно бесполезно как настоящая граница безопасности, что мне заняло неудобно долго полностью принять, потому что user здесь берётся прямо из куки что фронтенд никогда не верифицирует. Функция в итоге осталась той же, я просто прекратил доверять ей для чего угодно кроме решения что рендерить, и написал себе предупреждение над ней чтобы я не забыл снова:
/**
* 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';
}Этот комментарий по сути я написал себе как этикетку-предупреждение после того как узнал урок трудным путём. Вся идея фронтенда о «роли» берётся из куки что он никогда не верифицирует, совсем как статус логина публичного сайта, значит легко подделать и я должен предполагать что оно всегда.
Сервер проверяет в любом случае и вот весь смысл
Каждый админ-маршрут на блог-сервисе обёрнут в проверку способности, запущенную против реально верифицированного JWT, до того как хендлер даже начал запускаться:
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 верифицирует подпись токена против ключей опубликованных на JWKS-эндпоинте auth-сервиса, реальное криптографическое доказательство кто спрашивает, и только после этого HasCapability проверяет может ли эта роль на самом деле управлять постами, модерировать комментарии или что там этому маршруту нужно. Роли маппятся на способности через маленькую матрицу (users.view, posts.manage, comments.moderate и т.д.) которую администратор может редактировать с собственной страницы настроек, значит «что может делать модератор на самом деле» это конфиг-роу в таблице, не что-то захардкожено по имени роли разбросано по дюжине хендлеров.
Фронтенд держит свою собственную копию той же самой матрицы способностей, чисто чтобы решить какие кнопки рисовать. Если две копии когда-нибудь разойдутся, скажем я добавлю новую способность на Go-стороне и забуду рассказать об этом фронтенду, фронтенд просто спрячет кнопку что сервер на самом деле позволил бы. Раздражающе, но безопасно в том смысле который важен. Другой путь, кнопка что рендерит и сервер потом молча позволяет потому что я забыл вызов RequireCapability на каком-то маршруте, это реальная катастрофа, и не случалось пока, в основном потому что забыть тот middleware значит забыть целую строку, не подредактировать что-то что уже там.
Писание этого заставило щёлкнуть совет что я слышал дюжину раз не особенно его впитывая: UI это предложение, API это реальное правило. Спрячь каждую кнопку что хочешь, это ничего не меняет в том что запрос может сделать как только он прилетит.