mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-24 20:06:21 +02:00
feat: Superadmin impersonation and env password override
This commit is contained in:
parent
f833602145
commit
d0a02b1506
3 changed files with 155 additions and 10 deletions
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue