492 lines
14 KiB
Go
492 lines
14 KiB
Go
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
|
|
}
|
|
if h == nil || h.UserProjectionRepo == nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "user projection service unavailable"})
|
|
}
|
|
projectionStatus, err := h.UserProjectionRepo.GetStatus(c.Context())
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
cacheStatus := domain.IdentityCacheStatus{
|
|
Status: "unavailable",
|
|
RedisReady: false,
|
|
LastError: "identity cache service unavailable",
|
|
}
|
|
if h.IdentityCache != nil {
|
|
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{
|
|
"userProjection": projectionStatus,
|
|
"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
|
|
}
|