2026-08-09 22:47:43 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:39:25 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
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 {
|
2026-08-14 00:06:43 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-09 22:47:43 +02:00
|
|
|
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))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 00:06:43 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 22:47:43 +02:00
|
|
|
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)
|
2026-08-09 22:47:43 +02:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|