mirror of
https://github.com/syntrex-lab/gomcp.git
synced 2026-04-25 04:16:22 +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
|
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
|
// 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(),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue