package admin import ( "context" "crypto/subtle" "errors" "net/http" "strings" "time" "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 } // RequireAdmin is middleware that checks for admin authentication // It supports two modes: // 1. Admin credentials via X-Admin-Email and X-Admin-Key headers (for API access) // 2. Session-based auth where the user has role "admin" or "superadmin" func RequireAdmin(adminSvc *Service, authSvc interface{ IsAdmin(ctx context.Context, userID string) (bool, error) }) gin.HandlerFunc { return func(c *gin.Context) { // 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("isAdmin", true) c.Set("adminMode", "credentials") c.Next() return } c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid admin credentials"}) return } // Check for Bearer token with admin role auth := c.GetHeader("Authorization") if auth != "" && strings.HasPrefix(auth, "Bearer ") { // The auth middleware should have already validated the token // and set the user info in context userID, exists := c.Get("userID") if !exists { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } isAdmin, err := authSvc.IsAdmin(c.Request.Context(), userID.(string)) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to check admin status"}) return } if isAdmin { c.Set("isAdmin", true) c.Set("adminMode", "session") c.Next() return } } c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"}) } } // 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, }) } // SyncTenantSubscription manually syncs a tenant's subscription from Stripe func (s *Service) SyncTenantSubscription(ctx context.Context, tenantID string) error { // This will be called from the billing service return nil } func stringPtrToStr(s *string) string { if s == nil { return "" } return *s } func init() { // Ensure time package is imported _ = time.Now() }