Files
descrybe/apps/api/internal/httpapi/middleware.go
T

415 lines
13 KiB
Go
Raw Normal View History

package httpapi
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"errors"
"net/http"
"strings"
"github.com/alexedwards/scs/v2"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/google/uuid"
)
type ctxKey string
const (
ctxUserID ctxKey = "user_id"
ctxCompanyID ctxKey = "company_id"
ctxRole ctxKey = "role"
ctxStaffAccess ctxKey = "staff_access"
)
func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
v, ok := ctx.Value(ctxUserID).(uuid.UUID)
return v, ok
}
func CompanyIDFromContext(ctx context.Context) (uuid.UUID, bool) {
v, ok := ctx.Value(ctxCompanyID).(uuid.UUID)
return v, ok
}
func RoleFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxRole).(string)
return v, ok
}
// CompanyAdminAllowed reports whether the caller may perform company-admin
// mutations. Session role "admin" and API-key auth role "api" (admin-owned keys
// only — see apiKeyContextRole) are allowed; members are not.
func CompanyAdminAllowed(ctx context.Context) bool {
role, _ := RoleFromContext(ctx)
return role == "admin" || role == "api"
}
// apiKeyContextRole maps the key owner's membership role onto the request role.
// Admin-owned keys keep legacy "api" privileges (CompanyAdminAllowed). Non-admin
// owners keep membership role so product reset / admin-gated deletes stay closed.
// Full scopes + expiry are deferred: api_keys has no scopes/expires_at columns yet;
// dashboard creation remains admin-only (allowCompanyAdminOrPlatform).
func apiKeyContextRole(membershipRole string) string {
if auth.NormalizeMembershipRole(membershipRole) == "admin" {
return "api"
}
return auth.NormalizeMembershipRole(membershipRole)
}
func requireCompanyAdmin(w http.ResponseWriter, r *http.Request) bool {
if CompanyAdminAllowed(r.Context()) {
return true
}
Error(w, http.StatusForbidden, "admin required")
return false
}
// allowCompanyAdminOrPlatform allows company admins, API keys, or platform admins.
// Platform admins can manage team after migration when all memberships are still "member".
// Non-prod: while a privileged demo/platform actor is impersonating, retain company-admin powers
// so local user-switch can still create API keys and manage the tenant.
func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Request) bool {
if CompanyAdminAllowed(r.Context()) {
return true
}
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return false
}
isAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
if err != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return false
}
if isAdmin {
return true
}
if s.devImpersonatorRetainsCompanyAdmin(r) {
return true
}
Error(w, http.StatusForbidden, "admin required")
return false
}
// devImpersonatorRetainsCompanyAdmin is true in non-production when the session is
// impersonating and the stored actor is still a privileged demo/platform admin.
func (s *Server) devImpersonatorRetainsCompanyAdmin(r *http.Request) bool {
if s.Config.IsProduction() || s.Sessions == nil {
return false
}
impStr := strings.TrimSpace(s.Sessions.GetString(r.Context(), auth.SessionImpersonatorIDKey))
if impStr == "" {
return false
}
impID, err := uuid.Parse(impStr)
if err != nil || impID == uuid.Nil {
return false
}
access, err := s.checkStaffAccess(r.Context(), impID)
if err == nil && access.FullAdmin {
return true
}
if s.Auth == nil {
return false
}
impUser, err := s.Auth.GetUser(r.Context(), impID)
return err == nil && isLocalDemoEmail(impUser.Email)
}
func (s *Server) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey)
if uidStr == "" {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
uid, err := uuid.Parse(uidStr)
if err != nil {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
sessionVersion := s.Sessions.GetInt(r.Context(), auth.SessionVersionKey)
if active, checked, err := s.sessionUserIsActive(r.Context(), uid, sessionVersion); err != nil || (checked && !active) {
_ = s.Sessions.Destroy(r.Context())
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
ctx := context.WithValue(r.Context(), ctxUserID, uid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// sessionUserIsActive reports whether the session user may continue.
// checked=false means the active flag could not be verified (unit tests without a DB pool).
// sessionVersion must match users.session_version (bumped on password reset).
func (s *Server) sessionUserIsActive(ctx context.Context, userID uuid.UUID, sessionVersion int) (active bool, checked bool, err error) {
if s != nil && s.testUserSessionState != nil {
st, err := s.testUserSessionState(ctx, userID)
if err != nil {
return false, true, err
}
if !st.Active || st.Version != sessionVersion {
return false, true, nil
}
return true, true, nil
}
if s != nil && s.testUserActive != nil {
ok, err := s.testUserActive(ctx, userID)
return ok, true, err
}
if s == nil || s.Auth == nil || s.Auth.Pool == nil {
return true, false, nil
}
st, err := s.Auth.UserSessionState(ctx, userID)
if err != nil {
return false, true, err
}
if !st.Active || st.Version != sessionVersion {
return false, true, nil
}
return true, true, nil
}
func (s *Server) RequireCompany(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
cidStr := s.Sessions.GetString(r.Context(), auth.SessionCompanyIDKey)
if cidStr == "" {
Error(w, http.StatusBadRequest, "company not selected")
return
}
cid, err := uuid.Parse(cidStr)
if err != nil {
Error(w, http.StatusBadRequest, "invalid company")
return
}
m, err := s.Auth.EnsureMembership(r.Context(), uid, cid)
if err != nil {
Error(w, http.StatusForbidden, "forbidden")
return
}
ctx := context.WithValue(r.Context(), ctxCompanyID, cid)
ctx = context.WithValue(ctx, ctxRole, m.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) CSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Public API-key and token export routes do not use cookie CSRF.
// Match path segments only (/api/v1, /api/v1/...) — not prefixes like /api/v10.
if csrfExemptPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
cookie, err := r.Cookie(s.Config.CSRFCookieName)
token := ""
if err == nil {
token = cookie.Value
}
if token == "" {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
Error(w, http.StatusInternalServerError, "csrf token unavailable")
return
}
token = hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{
Name: s.Config.CSRFCookieName,
Value: token,
Path: "/",
HttpOnly: false, // readable by SPA for X-CSRF-Token double-submit
Secure: s.Config.CookieSecure(),
SameSite: http.SameSiteLaxMode,
MaxAge: 7 * 24 * 60 * 60,
})
}
2026-08-13 21:01:49 +02:00
// Expose for cross-origin SPAs (api.* vs app host): document.cookie cannot
// read host-only API cookies; the client seeds via GET and mirrors this header.
w.Header().Set("X-CSRF-Token", token)
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
next.ServeHTTP(w, r)
return
}
header := r.Header.Get("X-CSRF-Token")
if header == "" || subtle.ConstantTimeCompare([]byte(header), []byte(token)) != 1 {
Error(w, http.StatusForbidden, "csrf token mismatch")
return
}
next.ServeHTTP(w, r)
})
}
// csrfExemptPath is true for public API-key / token / webhook surfaces that
// authenticate without cookie CSRF (Bearer/HMAC/signature).
func csrfExemptPath(path string) bool {
switch {
case path == "/api/v1", strings.HasPrefix(path, "/api/v1/"):
return true
case path == "/api/public", strings.HasPrefix(path, "/api/public/"):
return true
case path == "/api/webhooks", strings.HasPrefix(path, "/api/webhooks/"):
return true
default:
return false
}
}
// extractAPIKey reads the raw key from Authorization Bearer or X-API-Key.
// Preference matches legacy Descrybe: Bearer first, then X-API-Key / X-Api-Key
// (Go canonicalizes header names; both spellings resolve).
func extractAPIKey(r *http.Request) string {
authz := strings.TrimSpace(r.Header.Get("Authorization"))
if authz != "" {
const bearer = "Bearer "
if len(authz) > len(bearer) && strings.EqualFold(authz[:len(bearer)], bearer) {
if key := strings.TrimSpace(authz[len(bearer):]); key != "" {
return key
}
}
}
if k := strings.TrimSpace(r.Header.Get("X-API-Key")); k != "" {
return k
}
return ""
}
// RequireAPIKey authenticates via Bearer or X-API-Key and binds company/user context.
func (s *Server) RequireAPIKey(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := extractAPIKey(r)
if raw == "" {
CodedError(w, http.StatusUnauthorized, "unauthorized", "Unauthorized")
return
}
id, err := s.Auth.AuthenticateAPIKey(r.Context(), raw)
if err != nil {
if errors.Is(err, auth.ErrInvalidAPIKey) {
CodedError(w, http.StatusUnauthorized, "unauthorized", "Unauthorized")
return
}
CodedError(w, http.StatusInternalServerError, "auth_failed", "Authentication failed")
return
}
ctx := context.WithValue(r.Context(), ctxUserID, id.UserID)
ctx = context.WithValue(ctx, ctxCompanyID, id.CompanyID)
ctx = context.WithValue(ctx, ctxRole, apiKeyContextRole(id.MembershipRole))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func LoadSession(sm *scs.SessionManager) func(http.Handler) http.Handler {
return sm.LoadAndSave
}
// MaintenanceGate enforces MAINTENANCE_MODE / READ_ONLY_MODE.
// /healthz and /readyz always pass so cutover rehearsal probes keep working.
func (s *Server) MaintenanceGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {
next.ServeHTTP(w, r)
return
}
if s.Config.MaintenanceMode {
JSON(w, http.StatusServiceUnavailable, map[string]any{
"error": "maintenance", "maintenance": true, "read_only": s.Config.ReadOnlyMode,
})
return
}
if s.Config.ReadOnlyMode {
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
JSON(w, http.StatusServiceUnavailable, map[string]any{
"error": "read_only", "maintenance": false, "read_only": true,
})
return
}
}
next.ServeHTTP(w, r)
})
}
// RequirePlatformAdmin allows full platform staff (admin/developer or legacy
// is_platform_admin with empty staff_role). support_staff is excluded.
func (s *Server) RequirePlatformAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
access, err := s.checkStaffAccess(r.Context(), uid)
if err != nil || !access.FullAdmin {
Error(w, http.StatusForbidden, "platform admin required")
return
}
next.ServeHTTP(w, r.WithContext(withStaffAccess(r.Context(), access)))
})
}
// RequireSupportDesk allows full platform admin OR support_staff.
// Plan/billing/settings mutations must stay on RequirePlatformAdmin.
func (s *Server) RequireSupportDesk(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
access, err := s.checkStaffAccess(r.Context(), uid)
if err != nil || !access.SupportDesk {
Error(w, http.StatusForbidden, "support desk access required")
return
}
next.ServeHTTP(w, r.WithContext(withStaffAccess(r.Context(), access)))
})
}
func withStaffAccess(ctx context.Context, access auth.StaffAccess) context.Context {
return context.WithValue(ctx, ctxStaffAccess, access)
}
// StaffAccessFromContext returns capability flags set by RequirePlatformAdmin / RequireSupportDesk.
func StaffAccessFromContext(ctx context.Context) (auth.StaffAccess, bool) {
v, ok := ctx.Value(ctxStaffAccess).(auth.StaffAccess)
return v, ok
}
// checkPlatformAdmin prefers an optional test hook, otherwise Auth.IsPlatformAdmin.
func (s *Server) checkPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool, error) {
if s != nil && s.testPlatformAdmin != nil {
return s.testPlatformAdmin(ctx, userID)
}
if s == nil || s.Auth == nil {
return false, nil
}
return s.Auth.IsPlatformAdmin(ctx, userID)
}
// checkStaffAccess prefers test hooks, otherwise Auth.GetStaffAccess.
func (s *Server) checkStaffAccess(ctx context.Context, userID uuid.UUID) (auth.StaffAccess, error) {
if s != nil && s.testStaffAccess != nil {
return s.testStaffAccess(ctx, userID)
}
if s != nil && s.testPlatformAdmin != nil {
ok, err := s.testPlatformAdmin(ctx, userID)
if err != nil {
return auth.StaffAccess{}, err
}
return auth.ResolveStaffAccess(ok, ""), nil
}
if s == nil || s.Auth == nil {
return auth.StaffAccess{}, nil
}
return s.Auth.GetStaffAccess(ctx, userID)
}