feat: Superadmin impersonation and env password override

This commit is contained in:
DmitrL-dev 2026-03-31 07:41:07 +10:00
parent f833602145
commit d0a02b1506
3 changed files with 155 additions and 10 deletions

View file

@ -406,3 +406,123 @@ func usagePercent(used, limit int) float64 {
} }
return pct 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,
})
}
}

View file

@ -64,23 +64,45 @@ func NewUserStore(db ...*sql.DB) *UserStore {
} }
} }
// Ensure default admin exists // Ensure default admin exists or is updated
if _, err := s.GetByEmail("admin@syntrex.pro"); err != nil { adminPass := os.Getenv("SYNTREX_ADMIN_PASSWORD")
adminPass := os.Getenv("SYNTREX_ADMIN_PASSWORD") if adminPass == "" {
if adminPass == "" { // If no env var, use a secure random password to prevent accidental exposure
b := make([]byte, 16) // if the database is clean, but do not override an existing admin's password.
rand.Read(b) b := make([]byte, 16)
adminPass = hex.EncodeToString(b) 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) slog.Warn("SYNTREX_ADMIN_PASSWORD not set. Generated random admin password", "password", adminPass)
} }
hash, _ := bcrypt.GenerateFromPassword([]byte(adminPass), bcrypt.DefaultCost)
admin := &User{ admin := &User{
ID: generateID("usr"), ID: generateID("usr"),
Email: "admin@syntrex.pro", Email: "admin@syntrex.pro",
DisplayName: "Administrator", DisplayName: "Administrator",
Role: "admin", Role: "superadmin",
Active: true, Active: true,
EmailVerified: true, // default admin is pre-verified EmailVerified: true,
PasswordHash: string(hash), PasswordHash: string(hash),
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }

View file

@ -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("POST /api/auth/tenant/plan", auth.HandleUpdateTenantPlan(s.tenantStore))
mux.HandleFunc("GET /api/auth/billing", auth.HandleBillingStatus(s.tenantStore)) mux.HandleFunc("GET /api/auth/billing", auth.HandleBillingStatus(s.tenantStore))
mux.HandleFunc("POST /api/billing/webhook", auth.HandleStripeWebhook(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))
} }
} }