Files
silo-server/internal/api/middleware/auth.go
T
a0f7810481 fix(web): show admin chrome only on the admin account's primary profile (#131)
* fix(web): show admin chrome only on the admin account's primary profile

The top-right ServerActivity indicator and the sidebar Admin section were
gated on the account-level role alone, so every profile on an admin
account — including child profiles — saw admin system notifications and
the indicator polled four admin endpoints on their behalf. Gate both on
the active profile being the household primary, matching the existing
is_primary idiom in SettingsLayout and the server-side quota exemption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): resolve active profile via useCurrentProfile in admin route gates

RequireAdmin/RequirePrimaryOrAdmin read the profile from useAuth(), but the
admin chrome (AppSidebar, Layout) gates on useCurrentProfile(), which resolves
the selected profile. Use the same source in the route gates so the redirect
and the visible admin UI can never disagree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web,api): centralize acting-admin policy and enforce it server-side

Address code-review findings on the primary-profile admin gate:

- Add isActingAdmin to web/src/lib/permissions.ts as the single
  client-side definition of the policy (admin role + primary or no
  profile), with a useIsActingAdmin hook on top. Route gates, sidebar,
  Layout, and realtime channel gating all use it now, so the gate and
  the chrome can no longer disagree on null-profile handling.
- Convert the admin-gated surfaces the original change missed
  (MediaItemMenu, EditMetadataDialog images tab, AddToCollectionDialog,
  MarkerEditor, theme CatalogBrowser, PersonDetail, SettingsLayout,
  ItemDetail content pages) so an admin on a non-primary profile is a
  regular viewer everywhere, not just in the sidebar.
- Make the role-derived permission bypass (metadata curation, marker
  edit) follow the same policy on both client and server.
- Enforce the policy server-side: RequireActingAdmin middleware refuses
  admin routes when the request declares a non-primary profile via
  X-Profile-Id, and the metadata-curation middleware holds admins on
  non-primary profiles to explicitly assigned permissions.
- Stop spreading the profiles query result from useCurrentProfile so
  route gates only re-render when the resolved profile changes, and
  make it safe outside AuthProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web,api): fail closed on unresolved profiles in acting-admin policy

Address review feedback on the acting-admin gate:

- Server: actingAdminAllowed now denies when the declared profile cannot
  be resolved to one of the caller's profiles, so a bogus X-Profile-Id
  can no longer restore admin powers to a non-primary session.
- Client: useIsActingAdmin returns false while a selected profile id has
  not yet resolved (e.g. hard refresh before the profiles query
  returns), instead of briefly treating it as "no profile selected".
  useCurrentProfile exposes hasSelectedProfile to make that state
  distinguishable.
- hasPermission/canCurateMetadata/canEditMarkers now require the profile
  argument (resolved profile or explicit null), so a missed call site
  fails the typecheck instead of silently restoring the admin bypass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-11 10:41:52 -04:00

342 lines
11 KiB
Go

// Package middleware provides HTTP middleware for the Silo API,
// including authentication and authorization.
package middleware
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/Silo-Server/silo-server/internal/activitylog"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// contextKey is an unexported type for context keys in this package.
type contextKey string
// claimsKey is the context key for storing JWT claims.
const claimsKey contextKey = "claims"
// SessionValidator checks whether a session is still valid (not revoked/expired).
type SessionValidator interface {
IsValid(ctx context.Context, sessionID string) (bool, error)
}
// TokenValidator validates a JWT token string and returns the parsed claims.
type TokenValidator interface {
ValidateToken(tokenStr string) (*auth.Claims, error)
}
// APIKeyValidator looks up an API key by its full key string.
type APIKeyValidator interface {
GetByKey(ctx context.Context, key string) (*models.APIKey, error)
UpdateLastUsed(ctx context.Context, id int64) error
}
// APIKeyUserLoader loads a user by ID for API key authentication.
type APIKeyUserLoader interface {
GetByID(ctx context.Context, id int) (*models.User, error)
}
// AuthMiddleware provides HTTP middleware for JWT-based authentication with
// session validity caching.
type AuthMiddleware struct {
tokenValidator TokenValidator
sessionValidator SessionValidator
apiKeyValidator APIKeyValidator // nil if API keys not configured
apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured
}
// NewAuthMiddleware creates a new AuthMiddleware with the given token validator
// and session validator.
func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidator, akul APIKeyUserLoader) *AuthMiddleware {
return &AuthMiddleware{
tokenValidator: tv,
sessionValidator: sv,
apiKeyValidator: akv,
apiKeyUserLoader: akul,
}
}
// RequireAuth is an HTTP middleware that enforces JWT authentication.
// It extracts the Bearer token from the Authorization header, validates the
// JWT, checks session validity (with an in-memory cache), and sets the
// parsed claims in the request context for downstream handlers.
func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token, ok := extractBearerToken(r)
if !ok {
writeUnauthorized(w, "Missing or malformed authorization header")
return
}
var claims *auth.Claims
if strings.HasPrefix(token, "sa_") {
// API key authentication.
if am.apiKeyValidator == nil {
writeUnauthorized(w, "API key authentication not available")
return
}
apiKey, err := am.apiKeyValidator.GetByKey(r.Context(), token)
if err != nil {
writeUnauthorized(w, "Invalid API key")
return
}
user, err := am.apiKeyUserLoader.GetByID(r.Context(), apiKey.UserID)
if err != nil {
writeUnauthorized(w, "Invalid API key")
return
}
if !user.Enabled {
writeUnauthorized(w, "User account is disabled")
return
}
// Update last_used_at asynchronously.
go func(id int64) {
_ = am.apiKeyValidator.UpdateLastUsed(context.Background(), id)
}(apiKey.ID)
claims = &auth.Claims{
UserID: user.ID,
Role: user.Role,
SessionID: "",
TokenType: auth.TokenTypeAPIKey,
APIKeyID: apiKey.ID,
RateTier: apiKey.RateTier,
}
} else {
// JWT authentication (existing flow).
var err error
claims, err = am.tokenValidator.ValidateToken(token)
if err != nil {
writeUnauthorized(w, "Invalid or expired token")
return
}
if claims.TokenType != auth.TokenTypeAccess {
writeUnauthorized(w, "Invalid or expired token")
return
}
valid, err := am.checkSession(r.Context(), claims.SessionID)
if err != nil || !valid {
writeUnauthorized(w, "Session is no longer valid")
return
}
}
// Populate activity log context if present (set by activitylog middleware upstream)
if lc := activitylog.GetLogContext(r.Context()); lc != nil {
uid := claims.UserID
lc.UserID = &uid
lc.ImpersonatorUserID = claims.ImpersonatorUserID
lc.SessionID = claims.SessionID
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// RequireAdmin is a standalone HTTP middleware that checks if the authenticated
// user has the "admin" role. It expects RequireAuth to have already placed
// claims in the request context.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role != "admin" {
writeForbidden(w, "Admin access required")
return
}
next.ServeHTTP(w, r)
})
}
// PrimaryProfileChecker reports whether profileID belongs to userID and, if
// so, whether it is the household primary profile. found must be false when
// the profile does not exist or belongs to a different account.
type PrimaryProfileChecker func(ctx context.Context, userID int, profileID string) (isPrimary bool, found bool, err error)
// RequireActingAdmin enforces the admin role plus the household policy that
// admin powers are only exercised through the account's primary profile.
// When the request declares an active profile (X-Profile-Id) that belongs to
// the admin account but is not the primary profile, the request is refused;
// requests with no declared profile keep working (clients that haven't
// selected a profile yet). With a nil checker it behaves exactly like
// RequireAdmin.
//
// Note this enforces the declared profile, not an authenticated one: all
// profiles on an account share the login session, so this is a policy
// boundary for well-behaved clients, not a defense against the account
// holder themselves.
func RequireActingAdmin(checkPrimary PrimaryProfileChecker) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims := GetClaims(r.Context())
if claims == nil {
writeUnauthorized(w, "Authentication required")
return
}
if claims.Role != "admin" {
writeForbidden(w, "Admin access required")
return
}
allowed, err := actingAdminAllowed(r, claims.UserID, checkPrimary)
if err != nil {
writeInternalError(w, "Failed to verify active profile")
return
}
if !allowed {
writeForbidden(w, "Admin access requires the account's primary profile")
return
}
next.ServeHTTP(w, r)
})
}
}
// actingAdminAllowed reports whether an admin request may exercise admin
// powers given the profile it declares. Allowed when no checker is
// configured, no profile is declared, or the declared profile is the
// account's primary profile. A declared profile that cannot be resolved to
// one of the caller's profiles fails closed: otherwise a non-primary session
// could regain admin powers by sending a bogus X-Profile-Id.
func actingAdminAllowed(r *http.Request, userID int, checkPrimary PrimaryProfileChecker) (bool, error) {
if checkPrimary == nil {
return true, nil
}
profileID := declaredProfileID(r)
if profileID == "" {
return true, nil
}
isPrimary, found, err := checkPrimary(r.Context(), userID, profileID)
if err != nil {
return false, err
}
return found && isPrimary, nil
}
// declaredProfileID returns the active profile the request declares: the
// profile context when RequireProfile ran earlier in the chain, otherwise
// the raw X-Profile-Id header.
func declaredProfileID(r *http.Request) string {
if id := GetProfileID(r.Context()); id != "" {
return id
}
return r.Header.Get("X-Profile-Id")
}
// SetClaims stores JWT claims in the context. This is useful for testing
// handlers that depend on authentication without going through the full
// middleware chain.
func SetClaims(ctx context.Context, claims *auth.Claims) context.Context {
return context.WithValue(ctx, claimsKey, claims)
}
// GetClaims retrieves the JWT claims from the context. Returns nil if no
// claims are present (caller should handle this case).
func GetClaims(ctx context.Context) *auth.Claims {
claims, ok := ctx.Value(claimsKey).(*auth.Claims)
if !ok {
return nil
}
return claims
}
// IsAdmin reports whether the context's authenticated user account has the
// admin role. Returns false when no claims are present. Note this is the
// account-level role; it says nothing about which household profile is active.
func IsAdmin(ctx context.Context) bool {
claims := GetClaims(ctx)
return claims != nil && claims.Role == "admin"
}
// GetUserID retrieves the user ID from the JWT claims in the context.
// Returns 0 if no claims are present.
func GetUserID(ctx context.Context) int {
claims := GetClaims(ctx)
if claims == nil {
return 0
}
return claims.UserID
}
// checkSession checks whether the session is valid, using the in-memory cache
// first and falling back to the session validator on cache miss.
func (am *AuthMiddleware) checkSession(ctx context.Context, sessionID string) (bool, error) {
return am.sessionValidator.IsValid(ctx, sessionID)
}
// extractBearerToken extracts a JWT from the request. It checks (in order):
// 1. Authorization: Bearer <token> header
// 2. ?token=<token> query parameter (for native media elements that can't set headers)
func extractBearerToken(r *http.Request) (string, bool) {
// Try Authorization header first.
if header := r.Header.Get("Authorization"); header != "" {
parts := strings.SplitN(header, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
if token := strings.TrimSpace(parts[1]); token != "" {
return token, true
}
}
}
// Fall back to query parameter (used by <video> / <audio> src URLs).
if token := r.URL.Query().Get("token"); token != "" {
return token, true
}
return "", false
}
// errorResponse is the JSON structure for error responses.
type errorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
}
// writeUnauthorized writes a 401 JSON error response.
func writeUnauthorized(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(errorResponse{
Error: "unauthorized",
Message: message,
})
}
// writeInternalError writes a 500 JSON error response.
func writeInternalError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(errorResponse{
Error: "internal_error",
Message: message,
})
}
// writeForbidden writes a 403 JSON error response.
func writeForbidden(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(errorResponse{
Error: "forbidden",
Message: message,
})
}