package admin import ( "context" "crypto/subtle" "errors" "net/http" "strings" "bookra/apps/backend/internal/auth" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" "github.com/gin-gonic/gin" ) var ( ErrUnauthorized = errors.New("unauthorized") ErrForbidden = errors.New("forbidden: admin access required") ErrInvalidAdminCreds = errors.New("invalid admin credentials") ) type Service struct { repo db.Repository adminEmail string adminKey string } func NewService(repo db.Repository, adminEmail, adminKey string) *Service { return &Service{ repo: repo, adminEmail: adminEmail, adminKey: adminKey, } } // IsConfigured returns true if admin credentials are set func (s *Service) IsConfigured() bool { return s.adminEmail != "" && s.adminKey != "" } // ValidateAdminLogin checks if the provided credentials match the admin credentials // Uses constant-time comparison to prevent timing attacks func (s *Service) ValidateAdminLogin(email, key string) bool { if !s.IsConfigured() { return false } emailMatch := subtle.ConstantTimeCompare([]byte(email), []byte(s.adminEmail)) == 1 keyMatch := subtle.ConstantTimeCompare([]byte(key), []byte(s.adminKey)) == 1 return emailMatch && keyMatch } const adminActorKey = "adminActor" // ActorFromContext returns the admin identity recorded by RequireAdmin. func ActorFromContext(c *gin.Context) string { value, ok := c.Get(adminActorKey) if !ok { return "" } actor, _ := value.(string) return actor } // RequireAdmin is middleware that checks for admin authentication. // It supports three modes: // 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access) // 2. Bearer tokens verified against the auth verifier, where the token subject // maps to a user with role "admin" or "superadmin" // 3. Demo mode, where the demo owner is treated as admin func RequireAdmin(adminSvc *Service, verifier *auth.Verifier, repo db.Repository, demoMode bool) gin.HandlerFunc { return func(c *gin.Context) { if demoMode { c.Set(adminActorKey, auth.DemoPrincipal.Subject) c.Next() return } // Check for admin header credentials (direct admin login) adminEmail := c.GetHeader("X-Admin-Email") adminKey := c.GetHeader("X-Admin-Key") if adminEmail != "" || adminKey != "" { if adminSvc.ValidateAdminLogin(adminEmail, adminKey) { c.Set(adminActorKey, adminEmail) c.Next() return } c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"}) return } // Check for Bearer token with admin role tokenString, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ") if !ok || strings.TrimSpace(tokenString) == "" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"}) return } if verifier == nil || !verifier.Enabled() { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "auth_not_configured"}) return } claims, err := verifier.Verify(strings.TrimSpace(tokenString)) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"}) return } subject, _ := claims["sub"].(string) if strings.TrimSpace(subject) == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid_token_subject"}) return } isAdmin, err := repo.IsAdminSubject(c.Request.Context(), subject) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"}) return } if !isAdmin { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"}) return } c.Set(adminActorKey, subject) c.Next() } } // GetDashboardStats returns platform-wide statistics for admin dashboard func (s *Service) GetDashboardStats(ctx context.Context) (domain.AdminDashboardStats, error) { stats, err := s.repo.GetPlatformStats(ctx) if err != nil { return domain.AdminDashboardStats{}, err } return domain.AdminDashboardStats{ TotalTenants: stats.TotalTenants, TotalUsers: stats.TotalUsers, ActiveSubscriptions: stats.ActiveSubscriptions, TrialSubscriptions: stats.TrialSubscriptions, BookingsThisMonth: stats.BookingsThisMonth, RevenueThisMonthCents: stats.RevenueThisMonth, }, nil } // ListTenants returns paginated list of all tenants func (s *Service) ListTenants(ctx context.Context, page, pageSize int) (domain.AdminTenantList, error) { if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } offset := (page - 1) * pageSize tenants, total, err := s.repo.ListAllTenants(ctx, pageSize, offset) if err != nil { return domain.AdminTenantList{}, err } result := domain.AdminTenantList{ Total: total, Page: page, PageSize: pageSize, Tenants: make([]domain.AdminTenant, len(tenants)), } for i, t := range tenants { result.Tenants[i] = domain.AdminTenant{ ID: t.ID, Slug: t.Slug, Name: t.Name, PlanCode: t.PlanCode, SubscriptionStatus: t.SubscriptionStatus, BillingProvider: t.BillingProvider, } } return result, nil } // ListUsers returns paginated list of all users func (s *Service) ListUsers(ctx context.Context, page, pageSize int) (domain.AdminUserList, error) { if page < 1 { page = 1 } if pageSize < 1 || pageSize > 100 { pageSize = 20 } offset := (page - 1) * pageSize users, total, err := s.repo.ListAllUsers(ctx, pageSize, offset) if err != nil { return domain.AdminUserList{}, err } result := domain.AdminUserList{ Total: total, Page: page, PageSize: pageSize, Users: make([]domain.AdminUser, len(users)), } for i, u := range users { result.Users[i] = domain.AdminUser{ ID: u.ID.String(), Email: u.Email, Name: stringPtrToStr(u.Name), EmailVerified: u.EmailVerified, Provider: u.Provider, Role: u.Role, CreatedAt: u.CreatedAt, } } return result, nil } // UpdateUserRole changes a user's role func (s *Service) UpdateUserRole(ctx context.Context, adminUserID, targetUserID, newRole string, ip, userAgent string) error { // Validate role validRoles := map[string]bool{ "user": true, "admin": true, "superadmin": true, } if !validRoles[newRole] { return errors.New("invalid role") } if err := s.repo.UpdateUserRole(ctx, targetUserID, newRole); err != nil { return err } // Log the action return s.repo.CreateAdminAuditLog(ctx, db.AdminAuditLogParams{ AdminUserID: adminUserID, Action: "update_user_role", ResourceType: "user", ResourceID: targetUserID, Details: map[string]any{ "newRole": newRole, }, IPAddress: ip, UserAgent: userAgent, }) } func stringPtrToStr(s *string) string { if s == nil { return "" } return *s }