fix
This commit is contained in:
@@ -24,12 +24,10 @@ func isLocalDemoEmail(email string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveDevImpersonationActor returns the privileged actor allowed to drive non-prod
|
||||
// user switching: the current full admin/demo user, or the stored impersonator.
|
||||
// resolveImpersonationActor returns the privileged actor allowed to drive user
|
||||
// switching: platform staff_role=admin (any env), plus non-prod full admin / demo
|
||||
// users, or the stored impersonator when still privileged.
|
||||
func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid.UUID, ok bool, err error) {
|
||||
if s.Config.IsProduction() {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
uid, hasUID := UserIDFromContext(ctx)
|
||||
if !hasUID || uid == uuid.Nil {
|
||||
return uuid.Nil, false, nil
|
||||
@@ -38,15 +36,9 @@ func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid
|
||||
return uuid.Nil, false, errors.New("auth unavailable")
|
||||
}
|
||||
|
||||
access, err := s.checkStaffAccess(ctx, uid)
|
||||
if err != nil {
|
||||
if canImpersonate, err := s.userMayImpersonate(ctx, uid); err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
if access.FullAdmin {
|
||||
return uid, true, nil
|
||||
}
|
||||
user, err := s.Auth.GetUser(ctx, uid)
|
||||
if err == nil && isLocalDemoEmail(user.Email) {
|
||||
} else if canImpersonate {
|
||||
return uid, true, nil
|
||||
}
|
||||
|
||||
@@ -58,27 +50,44 @@ func (s *Server) resolveDevImpersonationActor(ctx context.Context) (actorID uuid
|
||||
if err != nil || impID == uuid.Nil {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
impAccess, err := s.checkStaffAccess(ctx, impID)
|
||||
if err != nil {
|
||||
if canImpersonate, err := s.userMayImpersonate(ctx, impID); err != nil {
|
||||
return uuid.Nil, false, err
|
||||
}
|
||||
if impAccess.FullAdmin {
|
||||
return impID, true, nil
|
||||
}
|
||||
impUser, err := s.Auth.GetUser(ctx, impID)
|
||||
if err == nil && isLocalDemoEmail(impUser.Email) {
|
||||
} else if canImpersonate {
|
||||
return impID, true, nil
|
||||
}
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
|
||||
// handleAdminDevSetPassword sets a known local password for any active user.
|
||||
// Blocked in production. Intended for @legacy.local migrated accounts (invite emails skip those).
|
||||
func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
// userMayImpersonate is true for platform staff_role=admin in any environment.
|
||||
// Non-production also allows other full admins and local demo accounts.
|
||||
func (s *Server) userMayImpersonate(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
access, err := s.checkStaffAccess(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if access.FullAdmin && access.Role == auth.StaffRoleAdmin {
|
||||
return true, nil
|
||||
}
|
||||
if s.Config.IsProduction() {
|
||||
return false, nil
|
||||
}
|
||||
if access.FullAdmin {
|
||||
return true, nil
|
||||
}
|
||||
if s.Auth == nil {
|
||||
return false, nil
|
||||
}
|
||||
user, err := s.Auth.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return isLocalDemoEmail(user.Email), nil
|
||||
}
|
||||
|
||||
// handleAdminDevSetPassword force-sets a password for any active user.
|
||||
// Platform admin only (route). Production requires an explicit password (no default)
|
||||
// so ops can unlock @legacy.local / fake-email accounts that cannot receive invites.
|
||||
func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
@@ -92,8 +101,12 @@ func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Reques
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = DecodeJSONOptional(r, &body)
|
||||
password := body.Password
|
||||
if strings.TrimSpace(password) == "" {
|
||||
password := strings.TrimSpace(body.Password)
|
||||
if password == "" {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusBadRequest, "password is required")
|
||||
return
|
||||
}
|
||||
password = defaultDevPassword
|
||||
}
|
||||
if len(password) < 8 {
|
||||
@@ -117,20 +130,21 @@ func (s *Server) handleAdminDevSetPassword(w http.ResponseWriter, r *http.Reques
|
||||
LogAndError(w, http.StatusInternalServerError, "could not set password", err)
|
||||
return
|
||||
}
|
||||
hint := "Password set. Sign in with this email and the password you provided."
|
||||
if !s.Config.IsProduction() {
|
||||
hint = "Password set for local login. Omit body.password to use the built-in local default."
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"user_id": id,
|
||||
"email": user.Email,
|
||||
"hint": "Password set for local login. Omit body.password to use the built-in local default.",
|
||||
"hint": hint,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminDevImpersonate swaps the current session to the target user (non-production only).
|
||||
// handleAdminDevImpersonate swaps the current session to the target user so the
|
||||
// operator sees that user's tenant membership and plan (not platform staff chrome).
|
||||
func (s *Server) handleAdminDevImpersonate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
@@ -191,10 +205,6 @@ func (s *Server) handleAdminDevImpersonate(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// handleAdminDevStopImpersonate restores the session to the original admin/demo actor.
|
||||
func (s *Server) handleAdminDevStopImpersonate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if s.Auth == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "auth unavailable")
|
||||
return
|
||||
@@ -219,14 +229,10 @@ func (s *Server) handleAdminDevStopImpersonate(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
if impID != actorID {
|
||||
// Prefer the stored impersonator when it is still the privileged actor.
|
||||
impAccess, aerr := s.checkStaffAccess(r.Context(), impID)
|
||||
if aerr != nil || !impAccess.FullAdmin {
|
||||
impUser, uerr := s.Auth.GetUser(r.Context(), impID)
|
||||
if uerr != nil || !isLocalDemoEmail(impUser.Email) {
|
||||
Error(w, http.StatusForbidden, "user switch not allowed")
|
||||
return
|
||||
}
|
||||
canImp, ierr := s.userMayImpersonate(r.Context(), impID)
|
||||
if ierr != nil || !canImp {
|
||||
Error(w, http.StatusForbidden, "user switch not allowed")
|
||||
return
|
||||
}
|
||||
}
|
||||
user, err := s.Auth.GetUser(r.Context(), impID)
|
||||
@@ -393,12 +399,8 @@ func enrichSwitchableUser(u *switchableUserRow, legacyCompanyID string) {
|
||||
}
|
||||
|
||||
// handleAdminDevListSwitchableUsers lists active users with a preferred company label
|
||||
// for the header user-switch dropdown (non-production only).
|
||||
// for the header user-switch dropdown (platform staff_role=admin, or non-prod demo).
|
||||
func (s *Server) handleAdminDevListSwitchableUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Config.IsProduction() {
|
||||
Error(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if s.Pool == nil {
|
||||
Error(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) {
|
||||
func TestRouterProductionMountsImpersonationBehindAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := testAPIServer()
|
||||
s.Config.AppEnv = "production"
|
||||
@@ -16,6 +16,7 @@ func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) {
|
||||
"/api/admin/users/00000000-0000-0000-0000-000000000001/impersonate",
|
||||
"/api/admin/dev/stop-impersonate",
|
||||
"/api/admin/dev/switchable-users",
|
||||
"/api/admin/users/00000000-0000-0000-0000-000000000001/dev-password",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
method := http.MethodPost
|
||||
@@ -23,10 +24,9 @@ func TestRouterProductionOmitsImpersonationRoutes(t *testing.T) {
|
||||
method = http.MethodGet
|
||||
}
|
||||
h.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
||||
// Unauthenticated session yields 401; production must not expose the route as 200/403 from the handler.
|
||||
// Mounted routes behind RequireSession return 401; unmounted chi paths under /api/admin still hit RequireSession then 404 for unknown — either way not a successful switch.
|
||||
// Unauthenticated: RequireSession → 401. Must not succeed without a session.
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("%s returned 200 in production", path)
|
||||
t.Fatalf("%s returned 200 without auth in production", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,16 +379,20 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if !s.Config.IsProduction() {
|
||||
canSwitch := impersonating
|
||||
if !canSwitch {
|
||||
canUserSwitch := impersonating
|
||||
if !canUserSwitch {
|
||||
if accessErr == nil && access.FullAdmin && access.Role == auth.StaffRoleAdmin {
|
||||
canUserSwitch = true
|
||||
} else if !s.Config.IsProduction() {
|
||||
if accessErr == nil && access.FullAdmin {
|
||||
canSwitch = true
|
||||
canUserSwitch = true
|
||||
} else if isLocalDemoEmail(user.Email) {
|
||||
canSwitch = true
|
||||
canUserSwitch = true
|
||||
}
|
||||
}
|
||||
out["dev_user_switch"] = canSwitch
|
||||
}
|
||||
if canUserSwitch {
|
||||
out["dev_user_switch"] = true
|
||||
}
|
||||
JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestAllowCompanyAdminOrPlatform(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dev_impersonator_retains_admin", func(t *testing.T) {
|
||||
t.Run("impersonated_member_denied", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
actor := uuid.MustParse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")
|
||||
sm := scs.New()
|
||||
@@ -185,28 +185,16 @@ func TestAllowCompanyAdminOrPlatform(t *testing.T) {
|
||||
LoadSession(sm)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), ctxUserID, uid)
|
||||
ctx = context.WithValue(ctx, ctxRole, "member")
|
||||
req := r.WithContext(ctx)
|
||||
if !s.allowCompanyAdminOrPlatform(w, req) {
|
||||
t.Fatal("impersonating privileged actor must retain company-admin powers")
|
||||
if s.allowCompanyAdminOrPlatform(w, r.WithContext(ctx)) {
|
||||
t.Fatal("impersonated member must not retain company-admin powers")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})).ServeHTTP(rec, func() *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: sm.Cookie.Name, Value: token})
|
||||
return req
|
||||
}())
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dev_impersonator_helper_empty_session", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{Config: config.Config{AppEnv: "development"}}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxUserID, uid))
|
||||
if s.devImpersonatorRetainsCompanyAdmin(req) {
|
||||
t.Fatal("nil Sessions must not retain admin")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,8 +68,6 @@ func requireCompanyAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
|
||||
// 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
|
||||
@@ -87,38 +85,10 @@ func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Requ
|
||||
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)
|
||||
|
||||
@@ -343,12 +343,10 @@ func (s *Server) Router() http.Handler {
|
||||
r.Route("/api/admin", func(r chi.Router) {
|
||||
r.Use(s.RequireSession)
|
||||
|
||||
// Non-prod only: user switch / impersonation (handlers also fail closed).
|
||||
if !s.Config.IsProduction() {
|
||||
r.Get("/dev/switchable-users", s.handleAdminDevListSwitchableUsers)
|
||||
r.Post("/dev/stop-impersonate", s.handleAdminDevStopImpersonate)
|
||||
r.Post("/users/{id}/impersonate", s.handleAdminDevImpersonate)
|
||||
}
|
||||
// User switch / impersonation: platform staff_role=admin (handlers enforce).
|
||||
r.Get("/dev/switchable-users", s.handleAdminDevListSwitchableUsers)
|
||||
r.Post("/dev/stop-impersonate", s.handleAdminDevStopImpersonate)
|
||||
r.Post("/users/{id}/impersonate", s.handleAdminDevImpersonate)
|
||||
|
||||
// Support desk: full admin OR support_staff (least privilege).
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -389,11 +387,9 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/users", s.handleAdminListUsers)
|
||||
r.Patch("/users/{id}/staff-role", s.handleAdminSetStaffRole)
|
||||
r.Put("/support/agents/{id}", s.handleAdminSetSupportAgent)
|
||||
r.Get("/staff", s.handleAdminListStaff)
|
||||
if !s.Config.IsProduction() {
|
||||
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
||||
}
|
||||
r.Get("/companies", s.handleAdminListCompanies)
|
||||
r.Get("/staff", s.handleAdminListStaff)
|
||||
r.Post("/users/{id}/dev-password", s.handleAdminDevSetPassword)
|
||||
r.Get("/companies", s.handleAdminListCompanies)
|
||||
r.Get("/readiness", s.handleAdminReadiness)
|
||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||
r.Get("/analytics", s.handleAdminAnalytics)
|
||||
|
||||
Reference in New Issue
Block a user