package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" "runtime" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) type adminHydraClientLister interface { ListClients(ctx context.Context, limit, offset int) ([]domain.HydraClient, error) } type identityCacheAdmin interface { GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) FlushIdentityCache(ctx context.Context) (domain.IdentityCacheFlushResult, error) } type AdminHandler struct { DB *gorm.DB Keto service.KetoService KetoOutbox repository.KetoOutboxRepository RPUsageQueries domain.RPUsageQueryRepository TenantRepo repository.TenantRepository Hydra adminHydraClientLister AuditRepo domain.AuditRepository UserProjectionRepo repository.UserProjectionRepository IdentityCache identityCacheAdmin IntegrityChecker repository.DataIntegrityChecker } const globalCustomClaimsSettingKey = "global_custom_claim_definitions" type globalCustomClaimDefinition struct { Key string `json:"key"` Label string `json:"label"` ValueType string `json:"valueType"` ReadPermission string `json:"readPermission"` WritePermission string `json:"writePermission"` Description string `json:"description,omitempty"` } type globalCustomClaimDefinitionsResponse struct { Items []globalCustomClaimDefinition `json:"items"` } func NewAdminHandler(keto service.KetoService, ketoOutbox repository.KetoOutboxRepository) *AdminHandler { return &AdminHandler{ Keto: keto, KetoOutbox: ketoOutbox, } } func (h *AdminHandler) GetRPUsageDaily(c *fiber.Ctx) error { if h == nil || h.RPUsageQueries == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "rp usage query service unavailable", }) } days := 14 if raw := c.Query("days"); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil { days = parsed } } period := normalizeRPUsagePeriod(c.Query("period")) tenantID, allowed := h.authorizedRPUsageTenantID(c, strings.TrimSpace(c.Query("tenantId"))) if !allowed { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "error": "forbidden: tenant rp usage stats permission denied", }) } items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{ Days: days, Period: period, TenantID: tenantID, }) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": err.Error(), }) } return c.JSON(fiber.Map{ "items": items, "days": days, "period": period, "tenantId": tenantID, }) } func normalizeRPUsagePeriod(period string) string { switch strings.ToLower(strings.TrimSpace(period)) { case "week": return "week" case "month": return "month" default: return "day" } } func (h *AdminHandler) authorizedRPUsageTenantID(c *fiber.Ctx, requestedTenantID string) (string, bool) { profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin { return requestedTenantID, true } tenantID := requestedTenantID if tenantID == "" && profile != nil && profile.TenantID != nil { tenantID = strings.TrimSpace(*profile.TenantID) } if tenantID == "" { return "", false } if h == nil || h.Keto == nil || profile == nil || strings.TrimSpace(profile.ID) == "" { return "", false } allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+profile.ID, "Tenant", tenantID, "view_rp_usage_stats") if err != nil || !allowed { return "", false } return tenantID, true } func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) } func (h *AdminHandler) GetGlobalCustomClaimDefinitions(c *fiber.Ctx) error { if h == nil || h.DB == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "settings store unavailable", }) } var setting domain.SystemSetting if err := h.DB.WithContext(c.Context()).First(&setting, "key = ?", globalCustomClaimsSettingKey).Error; err != nil { if err == gorm.ErrRecordNotFound { return c.JSON(globalCustomClaimDefinitionsResponse{Items: []globalCustomClaimDefinition{}}) } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(globalCustomClaimDefinitionsResponse{ Items: normalizeGlobalCustomClaimDefinitions(setting.Value["items"]), }) } func (h *AdminHandler) UpdateGlobalCustomClaimDefinitions(c *fiber.Ctx) error { if h == nil || h.DB == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "error": "settings store unavailable", }) } var req globalCustomClaimDefinitionsResponse if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } items, err := validateGlobalCustomClaimDefinitions(req.Items) if err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } setting := domain.SystemSetting{ Key: globalCustomClaimsSettingKey, Value: domain.JSONMap{"items": globalCustomClaimDefinitionsToJSON(items)}, } if err := h.DB.WithContext(c.Context()).Save(&setting).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(globalCustomClaimDefinitionsResponse{Items: items}) } func normalizeGlobalCustomClaimDefinitions(value any) []globalCustomClaimDefinition { rawItems, ok := value.([]any) if !ok { return []globalCustomClaimDefinition{} } items := make([]globalCustomClaimDefinition, 0, len(rawItems)) for _, item := range rawItems { raw, ok := item.(map[string]any) if !ok { continue } def := globalCustomClaimDefinition{ Key: strings.TrimSpace(stringValue(raw["key"])), Label: strings.TrimSpace(stringValue(raw["label"])), ValueType: normalizeGlobalCustomClaimType(stringValue(raw["valueType"])), ReadPermission: adminNormalizeCustomClaimPermission(stringValue(raw["readPermission"])), WritePermission: adminNormalizeCustomClaimPermission(stringValue(raw["writePermission"])), Description: strings.TrimSpace(stringValue(raw["description"])), } if def.Key != "" { items = append(items, def) } } return items } func validateGlobalCustomClaimDefinitions(items []globalCustomClaimDefinition) ([]globalCustomClaimDefinition, error) { seen := map[string]struct{}{} normalized := make([]globalCustomClaimDefinition, 0, len(items)) for _, item := range items { key := strings.TrimSpace(item.Key) if key == "" { continue } if !isValidCustomClaimKey(key) { return nil, fiber.NewError(fiber.StatusBadRequest, "claim key must use letters, numbers, underscore, dot, or hyphen") } if _, exists := seen[key]; exists { return nil, fiber.NewError(fiber.StatusBadRequest, "duplicate claim key: "+key) } seen[key] = struct{}{} normalized = append(normalized, globalCustomClaimDefinition{ Key: key, Label: strings.TrimSpace(item.Label), ValueType: normalizeGlobalCustomClaimType(item.ValueType), ReadPermission: adminNormalizeCustomClaimPermission(item.ReadPermission), WritePermission: adminNormalizeCustomClaimPermission(item.WritePermission), Description: strings.TrimSpace(item.Description), }) } return normalized, nil } func globalCustomClaimDefinitionsToJSON(items []globalCustomClaimDefinition) []any { values := make([]any, 0, len(items)) for _, item := range items { values = append(values, map[string]any{ "key": item.Key, "label": item.Label, "valueType": item.ValueType, "readPermission": item.ReadPermission, "writePermission": item.WritePermission, "description": item.Description, }) } return values } func normalizeGlobalCustomClaimType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "number", "boolean", "array", "object", "date", "datetime": return strings.ToLower(strings.TrimSpace(value)) default: return "text" } } func adminNormalizeCustomClaimPermission(value string) string { if strings.TrimSpace(value) == "user_and_admin" { return "user_and_admin" } return "admin_only" } func isValidCustomClaimKey(value string) bool { for _, r := range value { if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' || r == '.' { continue } return false } return true } func stringValue(value any) string { if text, ok := value.(string); ok { return text } return "" } func requireSuperAdminProfile(c *fiber.Ctx) bool { profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin { _ = c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: super_admin required"}) return false } return true } func (h *AdminHandler) GetUserProjectionStatus(c *fiber.Ctx) error { if !requireSuperAdminProfile(c) { return nil } if h == nil || h.UserProjectionRepo == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"}) } status, err := h.UserProjectionRepo.GetStatus(c.Context()) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(status) } func (h *AdminHandler) GetOrySSOTSystemStatus(c *fiber.Ctx) error { if !requireSuperAdminProfile(c) { return nil } cacheStatus := domain.IdentityCacheStatus{ Status: "unavailable", RedisReady: false, LastError: "identity cache service unavailable", } if h.IdentityCache != nil { var err error cacheStatus, err = h.IdentityCache.GetIdentityCacheStatus(c.Context()) if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()}) } } return c.JSON(fiber.Map{ "identityCache": cacheStatus, }) } func (h *AdminHandler) FlushIdentityCache(c *fiber.Ctx) error { if !requireSuperAdminProfile(c) { return nil } if h == nil || h.IdentityCache == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity cache service unavailable"}) } result, err := h.IdentityCache.FlushIdentityCache(c.Context()) if err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(result) } func (h *AdminHandler) GetDataIntegrity(c *fiber.Ctx) error { if !requireSuperAdminProfile(c) { return nil } if h == nil || h.IntegrityChecker == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"}) } report, err := h.IntegrityChecker.CheckDataIntegrity(c.Context()) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(report) } func (h *AdminHandler) ListOrphanUserLoginIDs(c *fiber.Ctx) error { if !requireSuperAdminProfile(c) { return nil } if h == nil || h.IntegrityChecker == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"}) } items, err := h.IntegrityChecker.ListOrphanUserLoginIDs(c.Context()) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{ "items": items, "total": len(items), }) } func (h *AdminHandler) DeleteOrphanUserLoginIDs(c *fiber.Ctx) error { if !requireSuperAdminProfile(c) { return nil } if h == nil || h.IntegrityChecker == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "data integrity checker unavailable"}) } var req struct { IDs []string `json:"ids"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } result, err := h.IntegrityChecker.DeleteOrphanUserLoginIDs(c.Context(), req.IDs) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(result) } // GetSystemStats returns runtime statistics for monitoring func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { var m runtime.MemStats runtime.ReadMemStats(&m) ctx := c.Context() stats := fiber.Map{ "totalTenants": h.countTenants(ctx), "totalUsers": h.countUsers(ctx), "oidcClients": h.countOIDCClients(ctx), "auditEvents24h": h.countAuditEventsSince(ctx, time.Now().UTC().Add(-24*time.Hour)), "goroutines": runtime.NumGoroutine(), "cpus": runtime.NumCPU(), "memory": fiber.Map{ "alloc": m.Alloc, "totalAlign": m.TotalAlloc, "sys": m.Sys, "numGC": m.NumGC, }, "timestamp": time.Now(), } return c.Status(fiber.StatusOK).JSON(stats) } func (h *AdminHandler) countTenants(ctx context.Context) int64 { if h == nil || h.TenantRepo == nil { return 0 } _, total, err := h.TenantRepo.List(ctx, 1, 0, "", "") if err != nil { return 0 } return total } func (h *AdminHandler) countUsers(ctx context.Context) int64 { if h == nil || h.UserProjectionRepo == nil { return 0 } status, err := h.UserProjectionRepo.GetStatus(ctx) if err != nil { return 0 } return status.ProjectedUsers } func (h *AdminHandler) countOIDCClients(ctx context.Context) int64 { if h == nil || h.Hydra == nil { return 0 } const pageSize = 500 var total int64 for offset := 0; ; offset += pageSize { clients, err := h.Hydra.ListClients(ctx, pageSize, offset) if err != nil { return total } for _, client := range clients { if isHiddenSystemClient(client) { continue } total++ } if len(clients) < pageSize { break } } return total } func (h *AdminHandler) countAuditEventsSince(ctx context.Context, since time.Time) int64 { if h == nil || h.AuditRepo == nil { return 0 } count, err := h.AuditRepo.CountEventsSince(ctx, since) if err == nil && count > 0 { return count } logs, pageErr := h.AuditRepo.FindPage(ctx, 10000, nil, "") if pageErr != nil { return count } var fallbackCount int64 for _, log := range logs { if !log.Timestamp.Before(since) { fallbackCount++ } } return fallbackCount }