Files
Bookra/apps/backend/internal/admin/service.go
T
Tomas DvorakandDevin f2f7439050 refactor(db): replace sqlc with handwritten pgx repos
- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs
- Renumber migrations; add 00012 memberships+manage_token,
  00013 location_type_capacity
- Add tests: admin, auth, bookings customer service
- Remove graphify-out artifacts and stale env files

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-17 20:21:34 +02:00

260 lines
6.8 KiB
Go

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
}