diff --git a/internal/infrastructure/auth/tenant_handlers.go b/internal/infrastructure/auth/tenant_handlers.go index 35da0ef..9e32513 100644 --- a/internal/infrastructure/auth/tenant_handlers.go +++ b/internal/infrastructure/auth/tenant_handlers.go @@ -406,3 +406,123 @@ func usagePercent(used, limit int) float64 { } return pct } + +// HandleListTenants returns a list of all tenants for superadmin dashboard. +// GET /api/auth/tenants +func HandleListTenants(tenantStore *TenantStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r.Context()) + if claims == nil || claims.Role != "superadmin" { + http.Error(w, `{"error":"forbidden: superadmin only"}`, http.StatusForbidden) + return + } + + tenants := tenantStore.ListTenants() + + type tenantResp struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + PlanID string `json:"plan_id"` + Active bool `json:"active"` + } + + res := make([]tenantResp, len(tenants)) + for i, t := range tenants { + res[i] = tenantResp{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + PlanID: t.PlanID, + Active: t.Active, + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) + } +} + +// HandleImpersonateTenant allows a superadmin to switch their working tenant context. +// POST /api/auth/impersonate +func HandleImpersonateTenant(tenantStore *TenantStore, jwtSecret []byte) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + claims := GetClaims(r.Context()) + if claims == nil || claims.Role != "superadmin" { + http.Error(w, `{"error":"forbidden: superadmin only"}`, http.StatusForbidden) + return + } + + var req struct { + TenantID string `json:"tenant_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) + return + } + + // Verify target tenant is valid + if _, err := tenantStore.GetTenant(req.TenantID); err != nil { + http.Error(w, `{"error":"tenant not found"}`, http.StatusNotFound) + return + } + + // Issue new token with updated TenantID + accessClaims := Claims{ + Sub: claims.Sub, + Role: claims.Role, // Preserves superadmin explicitly + TenantID: req.TenantID, // The impersonated tenant ID + TokenType: "access", + Exp: time.Now().Add(15 * time.Minute).Unix(), + } + + refreshClaims := Claims{ + Sub: claims.Sub, + Role: claims.Role, + TenantID: req.TenantID, + TokenType: "refresh", + Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), + } + + accessToken, err := Sign(accessClaims, jwtSecret) + if err != nil { + http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError) + return + } + refreshToken, err := Sign(refreshClaims, jwtSecret) + if err != nil { + http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "syntrex_token", + Value: accessToken, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 900, + }) + http.SetCookie(w, &http.Cookie{ + Name: "syntrex_refresh", + Value: refreshToken, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 7 * 24 * 3600, + }) + + // Also provide CSRF token for strict SPA requirements + csrfToken := hmacSign([]byte(accessToken), jwtSecret)[:32] + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "tenant_id": req.TenantID, + "csrf_token": csrfToken, + }) + } +} diff --git a/internal/infrastructure/auth/users.go b/internal/infrastructure/auth/users.go index 0ca23ef..f8d9c99 100644 --- a/internal/infrastructure/auth/users.go +++ b/internal/infrastructure/auth/users.go @@ -64,23 +64,45 @@ func NewUserStore(db ...*sql.DB) *UserStore { } } - // Ensure default admin exists - if _, err := s.GetByEmail("admin@syntrex.pro"); err != nil { - adminPass := os.Getenv("SYNTREX_ADMIN_PASSWORD") - if adminPass == "" { - b := make([]byte, 16) - rand.Read(b) - adminPass = hex.EncodeToString(b) + // Ensure default admin exists or is updated + adminPass := os.Getenv("SYNTREX_ADMIN_PASSWORD") + if adminPass == "" { + // If no env var, use a secure random password to prevent accidental exposure + // if the database is clean, but do not override an existing admin's password. + b := make([]byte, 16) + rand.Read(b) + adminPass = hex.EncodeToString(b) + } + + hash, _ := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) + + if existingAdmin, err := s.GetByEmail("admin@syntrex.pro"); err == nil { + // User exists. Let's update their password and role to superadmin + // only if SYNTREX_ADMIN_PASSWORD was explicitly provided. + if os.Getenv("SYNTREX_ADMIN_PASSWORD") != "" { + existingAdmin.PasswordHash = string(hash) + existingAdmin.Role = "superadmin" // Guarantee superadmin role + s.mu.Lock() + s.users[existingAdmin.Email] = existingAdmin + s.mu.Unlock() + if s.db != nil { + // Use PostgreSQL placeholder $1, $2, $3 instead of ? + s.db.Exec(`UPDATE users SET password_hash=$1, role=$2 WHERE email=$3`, string(hash), "superadmin", existingAdmin.Email) + } + slog.Info("default admin updated with password from SYNTREX_ADMIN_PASSWORD") + } + } else { + // User does not exist, create it. + if os.Getenv("SYNTREX_ADMIN_PASSWORD") == "" { slog.Warn("SYNTREX_ADMIN_PASSWORD not set. Generated random admin password", "password", adminPass) } - hash, _ := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost) admin := &User{ ID: generateID("usr"), Email: "admin@syntrex.pro", DisplayName: "Administrator", - Role: "admin", + Role: "superadmin", Active: true, - EmailVerified: true, // default admin is pre-verified + EmailVerified: true, PasswordHash: string(hash), CreatedAt: time.Now(), } diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go index b8b557c..29f6afe 100644 --- a/internal/transport/http/server.go +++ b/internal/transport/http/server.go @@ -379,6 +379,9 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("POST /api/auth/tenant/plan", auth.HandleUpdateTenantPlan(s.tenantStore)) mux.HandleFunc("GET /api/auth/billing", auth.HandleBillingStatus(s.tenantStore)) mux.HandleFunc("POST /api/billing/webhook", auth.HandleStripeWebhook(s.tenantStore)) + // Superadmin endpoints + mux.HandleFunc("GET /api/auth/tenants", auth.HandleListTenants(s.tenantStore)) + mux.HandleFunc("POST /api/auth/impersonate", auth.HandleImpersonateTenant(s.tenantStore, s.jwtSecret)) } }