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". 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 } Error(w, http.StatusForbidden, "admin required") return false } // allowCompanyOwnerOrPlatform allows the company billing owner or a platform admin. // When owner_user_id is unset (pre-backfill), falls back to company admin so billing is not locked out. func (s *Server) allowCompanyOwnerOrPlatform(w http.ResponseWriter, r *http.Request) bool { 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 } cid, ok := CompanyIDFromContext(r.Context()) if !ok { Error(w, http.StatusForbidden, "company required") return false } if s.Auth != nil { ownerID, hasOwner, err := s.Auth.CompanyOwnerID(r.Context(), cid) if err != nil { Error(w, http.StatusInternalServerError, "authorization check failed") return false } if hasOwner { if ownerID == uid { return true } Error(w, http.StatusForbidden, "company owner required") return false } } // Legacy fallback before owner backfill, or unit tests without Auth wired. if CompanyAdminAllowed(r.Context()) { return true } Error(w, http.StatusForbidden, "company owner required") return false } 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 { if errors.Is(err, auth.ErrNotCompanyMember) && s.staffMayActAsCompany(r.Context(), uid) { ctx := context.WithValue(r.Context(), ctxCompanyID, cid) ctx = context.WithValue(ctx, ctxRole, "admin") next.ServeHTTP(w, r.WithContext(ctx)) return } 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)) }) } // staffMayActAsCompany is true only for platform staff_role=admin (or legacy // is_platform_admin → admin). Developers and support_staff cannot tenant-switch. func (s *Server) staffMayActAsCompany(ctx context.Context, userID uuid.UUID) bool { access, err := s.checkStaffAccess(ctx, userID) if err != nil || !access.FullAdmin { return false } return access.Role == auth.StaffRoleAdmin } 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, }) } // 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) }