mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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
143 lines
6.1 KiB
Go
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)
|
|
})
|
|
}
|