* 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>
342 lines
11 KiB
Go
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,
|
|
})
|
|
}
|