Files
OwnCord/Server/admin/middleware.go
T
Claude bcdc0ef385 fix(admin): owner gate consumes the context role — no second lookup (OC-0379)
OC-0345's fix kept the redundant GetRoleByID its own title named (its
suggestedFix says why: reading adminRoleKey broke two tests that injected
only the user). Finish the job: ownerOnlyMiddleware now consumes the
*db.Role adminAuthMiddleware stored, exactly like requirePerm — missing
role fails closed as 401, position below Owner stays 403, and the query
plus its 503 branch are gone (a role read fault surfaces once, at the
perimeter). The signature drops *db.DB at all nine call sites, so a
reintroduced lookup is a compile-visible change.

Test-first: TestOwnerOnlyMiddleware_NoSecondRoleLookup renames the roles
table away with the role in context and demands 200 — red 503 against the
old code, green now. The old RoleLookupFailureIs503 test guarded a branch
that no longer exists in any form; a tombstone comment records where its
contract lives on (the perimeter's default branch). Added below-owner 403
and user-without-role 401 pins; the blackbox owner-route tests pass
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 04:55:47 +00:00

143 lines
6.1 KiB
Go

package admin
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
)
// ─── Middleware ───────────────────────────────────────────────────────────────
// RequireAdminAuth is the exported admin gate for surfaces outside this
// package (api/router.go's plugin admin handler). Those routes stay
// ADMINISTRATOR-only, so it chains the perimeter check with an explicit
// ADMINISTRATOR requirement rather than exposing the widened perimeter.
func RequireAdminAuth(database *db.DB) func(http.Handler) http.Handler {
perimeter := adminAuthMiddleware(database)
administrator := requirePerm(permissions.Administrator)
return func(next http.Handler) http.Handler {
return perimeter(administrator(next))
}
}
// adminAuthMiddleware validates the Bearer token and requires at least one
// moderation-capable bit (permissions.AdminPerimeter) — not ADMINISTRATOR, so
// a Moderator role can reach the routes its bits allow. Individual route
// groups re-check the specific bit they need via requirePerm.
// On success it stores the *db.User, *db.Role and *db.Session in the request
// context so downstream handlers can retrieve them without re-querying.
func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := auth.ExtractBearerToken(r)
if !ok {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "missing or invalid authorization header")
return
}
hash := auth.HashToken(token)
// Resolve the bearer token: login session first, then API token. An
// API token inherits its owning user's role, so a token whose user
// clears the perimeter authenticates here too and /admin/api/*
// works for headless clients.
user, role, sess, err := auth.ResolveTokenHash(r.Context(), database, hash)
if err != nil {
switch {
case errors.Is(err, auth.ErrTokenExpired):
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "session has expired")
case errors.Is(err, auth.ErrUserNotFound):
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found")
case errors.Is(err, auth.ErrRoleNotFound):
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found")
case errors.Is(err, auth.ErrTokenNotFound):
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
default:
// A wrapped DB error, not one of the sentinels above (mirrors
// api.AuthMiddleware). A DB outage is not a bad token:
// answering 401 here would make the client treat a live,
// valid session as expired — the desktop client's doFetch
// 401 sink clears auth and deletes the stored credential for
// a session that was never revoked. Log it and report the
// failure as a server-side fault instead.
slog.ErrorContext(r.Context(), "admin: token resolution failed", "error", err)
writeErr(w, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "authentication service temporarily unavailable")
}
return
}
// F1: reject effectively-banned users before any further processing,
// as api.AuthMiddleware does — a ban must revoke admin-panel access
// immediately, not only once the session expires. Deliberately placed
// AFTER ResolveTokenHash so it also covers the API-token path this
// commit introduces; gating only the session branch would let a
// banned administrator keep working through a bot token.
if auth.IsEffectivelyBanned(user) {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "your account has been suspended")
return
}
if !permissions.HasAnyPerm(role.Permissions, permissions.AdminPerimeter) {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "moderation permission required")
return
}
ctx := context.WithValue(r.Context(), adminUserKey, user)
ctx = context.WithValue(ctx, adminRoleKey, role)
ctx = context.WithValue(ctx, adminSessionKey, sess) // nil for API-token principals; consumers guard nil
ctx = context.WithValue(ctx, adminTokenHashKey, hash)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// requirePerm gates a route group on a single server-wide permission bit.
// ADMINISTRATOR bypasses via permissions.HasServerPerm. The role comes from
// the request context (set by adminAuthMiddleware), so no extra query runs.
func requirePerm(perm int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role, ok := r.Context().Value(adminRoleKey).(*db.Role)
if !ok || role == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
return
}
if !permissions.HasServerPerm(role.Permissions, perm) {
writeErr(w, http.StatusForbidden, "FORBIDDEN",
permissions.Name(perm)+" permission required")
return
}
next.ServeHTTP(w, r)
})
}
}
// ownerOnlyMiddleware wraps a handler to require the Owner role
// (position == permissions.OwnerRolePosition). It consumes the *db.Role that
// adminAuthMiddleware resolved and stored in the request context — the same
// contract as requirePerm, so no second role read runs and no read-fault
// error mapping exists here at all: OC-0345's 503 branch died with the query
// it served, and OC-0379 pins the absence (a role read fault now surfaces
// once, at the perimeter, as its 503). A request that somehow arrives without
// the context role fails closed as unauthenticated.
func ownerOnlyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
role, ok := r.Context().Value(adminRoleKey).(*db.Role)
if !ok || role == nil {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
return
}
if role.Position < permissions.OwnerRolePosition {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "owner role required")
return
}
next.ServeHTTP(w, r)
})
}