diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 325aa46a..86b11862 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -625,6 +625,7 @@ func main() { // 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정) dev := api.Group("/dev") + dev.Get("/stats", devHandler.GetStats) dev.Get("/clients", devHandler.ListClients) dev.Post("/clients", devHandler.CreateClient) dev.Get("/clients/:id", devHandler.GetClient) diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index fe911ac2..ad86ca7a 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -25,6 +25,8 @@ type AuditRepository interface { Create(log *AuditLog) error FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error) + CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) + CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) Ping(ctx context.Context) error } diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 282c3d45..91f80b10 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -10,7 +10,9 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" + "net/http" "os" "strings" "time" @@ -70,6 +72,12 @@ type devAuditListResponse struct { NextCursor string `json:"next_cursor,omitempty"` } +type devStatsResponse struct { + TotalClients int64 `json:"total_clients"` + ActiveSessions int64 `json:"active_sessions"` + AuthFailures int64 `json:"auth_failures_24h"` +} + type clientSummary struct { ID string `json:"id"` Name string `json:"name"` @@ -153,27 +161,75 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { return true, nil } - // Check with Keto: System:AppManager#member - allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member") - if err != nil { - return false, err + subject := strings.TrimSpace(profile.ID) + if subject == "" && strings.TrimSpace(profile.Email) != "" && h.KratosAdmin != nil { + resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), strings.TrimSpace(profile.Email)) + if err == nil && strings.TrimSpace(resolved) != "" { + subject = strings.TrimSpace(resolved) + } } - slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed) + if subject == "" { + slog.Warn("Dev private permission denied: missing subject in profile", "email", profile.Email) + return false, nil + } + if h.Keto == nil { + slog.Warn("Dev private permission denied: keto service unavailable") + return false, nil + } + + // Check with Keto: System:AppManager#member + allowed, err := h.Keto.CheckPermission(c.Context(), subject, "System", "AppManager", "member") + if err != nil { + // Fail closed for dev private endpoints: deny on permission backend error. + slog.Warn("Dev private permission check failed; denying access", "subject", subject, "error", err) + return false, nil + } + slog.Info("Dev private permission evaluated by Keto", "subject", subject, "allowed", allowed) return allowed, nil } - tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization")) + authHeader := c.Get("Authorization") + bearerToken := extractBearerToken(authHeader) + tokenSubject, tokenEmail := extractAuthClaimsFromBearer(authHeader) + tokenRole := "" + + // Fallback for OIDC access tokens that do not include full claims locally. + if bearerToken != "" && (tokenSubject == "" || tokenEmail == "") { + if info, err := h.fetchOIDCUserInfo(c.Context(), bearerToken); err == nil && info != nil { + if tokenSubject == "" { + tokenSubject = strings.TrimSpace(info.Sub) + } + if tokenEmail == "" { + tokenEmail = strings.TrimSpace(info.Email) + } + tokenRole = strings.TrimSpace(info.Role) + } else if err != nil { + slog.Warn("Dev private permission userinfo fallback failed", "error", err) + } + } + + if tokenRole == domain.RoleSuperAdmin { + slog.Info("Dev private permission granted by token role", "role", tokenRole) + return true, nil + } if isAdminEmail(tokenEmail) { slog.Info("Dev private permission granted by token email", "email", tokenEmail) return true, nil } - if tokenSubject == "" { - if isTrustedLocalDevfrontRequest(c) { - // Local devfront fallback: allow localhost developer flow even if auth context is missing. - slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin")) - return true, nil + + // If subject is missing, resolve it from Kratos by identifier(email) so Keto checks can still run. + if tokenSubject == "" && tokenEmail != "" && h.KratosAdmin != nil { + resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), tokenEmail) + if err == nil && strings.TrimSpace(resolved) != "" { + tokenSubject = strings.TrimSpace(resolved) } + } + if tokenSubject == "" { + return false, nil + } + if h.Keto == nil { + slog.Warn("Dev private permission denied: keto service unavailable") return false, nil } @@ -195,7 +251,9 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { // Check with Keto: System:AppManager#member allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member") if err != nil { - return false, err + // Fail closed for dev private endpoints: deny on permission backend error. + slog.Warn("Dev private permission check failed; denying access", "subject", tokenSubject, "error", err) + return false, nil } slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed) @@ -247,25 +305,71 @@ func isAdminEmail(email string) bool { return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail) } -func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool { - if c == nil { - return false +func extractBearerToken(authHeader string) string { + authHeader = strings.TrimSpace(authHeader) + if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return "" + } + return strings.TrimSpace(authHeader[len("Bearer "):]) +} + +type oidcUserInfo struct { + Sub string `json:"sub"` + Email string `json:"email"` + TenantID string `json:"tenant_id"` + Role string `json:"role"` +} + +func (h *DevHandler) fetchOIDCUserInfo(ctx context.Context, accessToken string) (*oidcUserInfo, error) { + if strings.TrimSpace(accessToken) == "" { + return nil, fmt.Errorf("missing access token") + } + if h.Hydra == nil || strings.TrimSpace(h.Hydra.PublicURL) == "" { + return nil, fmt.Errorf("hydra public url is not configured") } - origin := strings.ToLower(strings.TrimSpace(c.Get("Origin"))) - referer := strings.ToLower(strings.TrimSpace(c.Get("Referer"))) - allowedPrefixes := []string{ - "http://localhost:5174", - "https://localhost:5174", + endpoint := strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("userinfo failed status=%d body=%s", resp.StatusCode, string(body)) } - for _, prefix := range allowedPrefixes { - if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) { - return true + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + + pick := func(keys ...string) string { + for _, key := range keys { + if raw, ok := payload[key]; ok { + if value, ok := raw.(string); ok { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + } } + return "" } - return false + return &oidcUserInfo{ + Sub: pick("sub"), + Email: pick("email"), + TenantID: pick("tenant_id", "tenantId"), + Role: pick("role"), + }, nil } func (h *DevHandler) ListClients(c *fiber.Ctx) error { @@ -279,6 +383,92 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { offset = 0 } + // [Tenant Isolation] Get current user's tenant ID + userTenantID := "" + isSuperAdmin := false + profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) + if (!ok || profile == nil) && h.Auth != nil { + enriched, _ := h.Auth.GetEnrichedProfile(c) + if enriched != nil { + profile = enriched + ok = true + c.Locals("user_profile", enriched) + } + } + + if ok && profile != nil { + if profile.TenantID != nil { + userTenantID = *profile.TenantID + } + isSuperAdmin = profile.Role == domain.RoleSuperAdmin + } else { + // If profile resolution failed, verify bearer token via OIDC userinfo fallback. + authHeader := c.Get("Authorization") + bearerToken := extractBearerToken(authHeader) + if bearerToken == "" { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") + } + + sub, email := extractAuthClaimsFromBearer(authHeader) + if sub == "" { + info, infoErr := h.fetchOIDCUserInfo(c.Context(), bearerToken) + if infoErr != nil { + slog.Warn("ListClients userinfo fallback failed", "error", infoErr) + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") + } + sub = strings.TrimSpace(info.Sub) + if email == "" { + email = strings.TrimSpace(info.Email) + } + if userTenantID == "" { + userTenantID = strings.TrimSpace(info.TenantID) + } + if strings.EqualFold(strings.TrimSpace(info.Role), domain.RoleSuperAdmin) { + isSuperAdmin = true + } + } + + if sub == "" && email == "" { + return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") + } + + if h.KratosAdmin != nil && (userTenantID == "" || !isSuperAdmin) { + identityID := strings.TrimSpace(sub) + if identityID == "" && email != "" { + if resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email); err == nil { + identityID = strings.TrimSpace(resolved) + } + } + if identityID != "" { + if identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID); err == nil && identity != nil { + if userTenantID == "" { + if tid, ok := identity.Traits["tenant_id"].(string); ok { + userTenantID = strings.TrimSpace(tid) + } + } + role := "" + if rawRole, ok := identity.Traits["role"].(string); ok { + role = strings.TrimSpace(rawRole) + } + if role == domain.RoleSuperAdmin { + isSuperAdmin = true + } + profile = &domain.UserProfileResponse{ + ID: identityID, + Email: email, + Role: role, + TenantID: nil, + } + if userTenantID != "" { + tid := userTenantID + profile.TenantID = &tid + } + c.Locals("user_profile", profile) + } + } + } + } + isAppManager, err := h.checkAppManagerPermission(c) if err != nil { slog.Error("Failed to check app manager permission", "error", err) @@ -299,10 +489,20 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { items := make([]clientSummary, 0, len(clients)) for _, client := range clients { summary := h.mapClientSummary(client) - // Filter out 'private' clients if user is not an AppManager + + // 1. [Security] Filter out 'private' clients if user is not an AppManager if summary.Type == "private" && !isAppManager { continue } + + // 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant + if !isSuperAdmin { + clientTenantID, _ := summary.Metadata["tenant_id"].(string) + if clientTenantID != userTenantID { + continue + } + } + items = append(items, summary) } @@ -330,6 +530,22 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { summary := h.mapClientSummary(*client) + // [Tenant Isolation] Check if user has access to this client + isSuperAdmin := false + userTenantID := "" + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { + isSuperAdmin = profile.Role == domain.RoleSuperAdmin + if profile.TenantID != nil { + userTenantID = *profile.TenantID + } + } + if !isSuperAdmin { + clientTenantID, _ := summary.Metadata["tenant_id"].(string) + if clientTenantID != userTenantID { + return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") + } + } + // Check permission for private clients if summary.Type == "private" { isAppManager, err := h.checkAppManagerPermission(c) @@ -374,20 +590,39 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { // [Security] Check permission before patching current, err := h.Hydra.GetClient(c.Context(), clientID) - if err == nil { - summary := h.mapClientSummary(*current) - if summary.Type == "private" { - isAppManager, _ := h.checkAppManagerPermission(c) - if !isAppManager { - return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") - } + if err != nil { + if errors.Is(err, service.ErrHydraNotFound) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + summary := h.mapClientSummary(*current) + + // [Tenant Isolation] + isSuperAdmin := false + userTenantID := "" + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { + isSuperAdmin = profile.Role == domain.RoleSuperAdmin + if profile.TenantID != nil { + userTenantID = *profile.TenantID + } + } + if !isSuperAdmin { + clientTenantID, _ := summary.Metadata["tenant_id"].(string) + if clientTenantID != userTenantID { + return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") } } - beforeStatus := "" - if current != nil { - beforeStatus = h.mapClientSummary(*current).Status + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") + } } + + beforeStatus := summary.Status h.setAuditDetailsExtra(c, map[string]any{ "action": "UPDATE_CLIENT_STATUS", "target_id": clientID, @@ -402,15 +637,12 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status) if err != nil { - if errors.Is(err, service.ErrHydraNotFound) { - return errorJSON(c, fiber.StatusNotFound, "client not found") - } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - summary := h.mapClientSummary(*updated) + updatedSummary := h.mapClientSummary(*updated) return c.JSON(clientDetailResponse{ - Client: summary, + Client: updatedSummary, Endpoints: clientEndpoints{ Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration", Issuer: h.Hydra.PublicURL, @@ -472,6 +704,15 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if metadata == nil { metadata = map[string]interface{}{} } + + // [Tenant Isolation] Record owner information + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { + metadata["user_id"] = profile.ID + if tenantID == "" && profile.TenantID != nil { + tenantID = *profile.TenantID + } + } + if tenantID != "" { metadata["tenant_id"] = tenantID } @@ -563,6 +804,24 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + currentSummary := h.mapClientSummary(*current) + + // [Tenant Isolation] + isSuperAdmin := false + userTenantID := "" + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { + isSuperAdmin = profile.Role == domain.RoleSuperAdmin + if profile.TenantID != nil { + userTenantID = *profile.TenantID + } + } + if !isSuperAdmin { + clientTenantID, _ := currentSummary.Metadata["tenant_id"].(string) + if clientTenantID != userTenantID { + return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") + } + } + clientType := "" if req.Type != nil { clientType = strings.ToLower(strings.TrimSpace(*req.Type)) @@ -572,7 +831,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { } // [Security] Check permission for private clients (both current and new type) - currentSummary := h.mapClientSummary(*current) if currentSummary.Type == "private" || clientType == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { @@ -641,9 +899,6 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated) if err != nil { - if errors.Is(err, service.ErrHydraNotFound) { - return errorJSON(c, fiber.StatusNotFound, "client not found") - } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -667,15 +922,37 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } - // [Security] Check permission for private clients current, err := h.Hydra.GetClient(c.Context(), clientID) - if err == nil { - summary := h.mapClientSummary(*current) - if summary.Type == "private" { - isAppManager, _ := h.checkAppManagerPermission(c) - if !isAppManager { - return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") - } + if err != nil { + if errors.Is(err, service.ErrHydraNotFound) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + summary := h.mapClientSummary(*current) + + // [Tenant Isolation] + isSuperAdmin := false + userTenantID := "" + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { + isSuperAdmin = profile.Role == domain.RoleSuperAdmin + if profile.TenantID != nil { + userTenantID = *profile.TenantID + } + } + if !isSuperAdmin { + clientTenantID, _ := summary.Metadata["tenant_id"].(string) + if clientTenantID != userTenantID { + return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") + } + } + + // [Security] Check permission for private clients + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -686,9 +963,6 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { }) if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { - if errors.Is(err, service.ErrHydraNotFound) { - return errorJSON(c, fiber.StatusNotFound, "client not found") - } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } @@ -719,8 +993,17 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { limit = 50 } - // [Isolation] Get admin tenant ID from header or locals - adminTenantID := c.Get("X-Tenant-ID") // Assume middleware sets this or trusted in dev + // [Isolation] Get admin tenant ID from locals or header + adminTenantID := "" + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { + if profile.Role != domain.RoleSuperAdmin && profile.TenantID != nil { + adminTenantID = *profile.TenantID + } + } + if adminTenantID == "" { + adminTenantID = c.Get("X-Tenant-ID") + } + statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status"))) var consents []domain.ClientConsentWithTenantInfo @@ -735,12 +1018,6 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { subject = resolved } } - - // Single user fetch from Hydra (to get latest status) or Local DB - // Issue says: "List All", so we prefer Local DB for consistency in listing - // But for a single user, we could still use Hydra. - // Let's use Local DB to support tenant filtering even for search. - // For simplicity, we just filter the list later if search is used. } if adminTenantID != "" { @@ -852,15 +1129,37 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } - // [Security] Check permission for private clients current, err := h.Hydra.GetClient(c.Context(), clientID) - if err == nil { - summary := h.mapClientSummary(*current) - if summary.Type == "private" { - isAppManager, _ := h.checkAppManagerPermission(c) - if !isAppManager { - return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") - } + if err != nil { + if errors.Is(err, service.ErrHydraNotFound) { + return errorJSON(c, fiber.StatusNotFound, "client not found") + } + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + summary := h.mapClientSummary(*current) + + // [Tenant Isolation] + isSuperAdmin := false + userTenantID := "" + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { + isSuperAdmin = profile.Role == domain.RoleSuperAdmin + if profile.TenantID != nil { + userTenantID = *profile.TenantID + } + } + if !isSuperAdmin { + clientTenantID, _ := summary.Metadata["tenant_id"].(string) + if clientTenantID != userTenantID { + return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") + } + } + + // [Security] Check permission for private clients + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } @@ -876,22 +1175,14 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret") } - // 2. Get current client to preserve other fields (already fetched above) - if err != nil { - if errors.Is(err, service.ErrHydraNotFound) { - return errorJSON(c, fiber.StatusNotFound, "client not found") - } - return errorJSON(c, fiber.StatusInternalServerError, err.Error()) - } - - // 3. Update Hydra + // 2. Update Hydra current.ClientSecret = newSecret updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } - // 4. Update Persistence (DB & Redis) + // 3. Update Persistence (DB & Redis) if h.SecretRepo != nil { if err := h.SecretRepo.Upsert(c.Context(), clientID, newSecret); err != nil { // Log error but don't fail the request as Hydra is already updated @@ -904,11 +1195,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { } // Return the new secret - summary := h.mapClientSummary(*updated) - summary.ClientSecret = newSecret + updatedSummary := h.mapClientSummary(*updated) + updatedSummary.ClientSecret = newSecret return c.JSON(clientDetailResponse{ - Client: summary, + Client: updatedSummary, Endpoints: clientEndpoints{ Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration", Issuer: h.Hydra.PublicURL, @@ -1002,6 +1293,67 @@ func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error { }) } +func (h *DevHandler) GetStats(c *fiber.Ctx) error { + h.injectTenantContextFromHeader(c) + + // [Security] Check permission + allowed, err := h.checkAppManagerPermission(c) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "permission check error") + } + if !allowed { + return errorJSON(c, fiber.StatusForbidden, "forbidden") + } + + userTenantID := "" + isSuperAdmin := false + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { + isSuperAdmin = profile.Role == domain.RoleSuperAdmin + if profile.TenantID != nil { + userTenantID = *profile.TenantID + } + } + + // 1. Total Clients (Tenant Scoped) + // Hydra doesn't support tenant filtering natively, so we list and filter. + // For stats, we might want to fetch a larger batch or use a cached count. + clients, err := h.Hydra.ListClients(c.Context(), 500, 0) + var totalClients int64 + if err == nil { + for _, client := range clients { + if isSuperAdmin { + totalClients++ + continue + } + if client.Metadata != nil { + if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID { + totalClients++ + } + } + } + } + + // 2. Auth Failures (24h) + var authFailures int64 + if h.AuditRepo != nil { + since := time.Now().Add(-24 * time.Hour) + authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), since, userTenantID) + } + + // 3. Active Sessions (1h) + var activeSessions int64 + if h.AuditRepo != nil { + since := time.Now().Add(-1 * time.Hour) + activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID) + } + + return c.JSON(devStatsResponse{ + TotalClients: totalClients, + ActiveSessions: activeSessions, + AuthFailures: authFailures, + }) +} + func generateRandomSecret(length int) (string, error) { bytes := make([]byte, length) if _, err := rand.Read(bytes); err != nil { diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index ee1c029e..532cfa55 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -195,3 +195,44 @@ func (r *ClickHouseRepository) Ping(ctx context.Context) error { } return r.conn.Ping(ctx) } + +func (r *ClickHouseRepository) CountFailuresSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { + query := ` + SELECT count() + FROM audit_logs + WHERE status = 'failure' AND timestamp >= ? + ` + args := []any{since} + if tenantID != "" { + query += " AND JSONExtractString(details, 'tenant_id') = ?" + args = append(args, tenantID) + } + + var count int64 + err := r.conn.QueryRow(ctx, query, args...).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count failures: %w", err) + } + return count, nil +} + +func (r *ClickHouseRepository) CountActiveSessionsSince(ctx context.Context, since time.Time, tenantID string) (int64, error) { + // We use uniqExact(session_id) to count unique sessions that had success events recently. + query := ` + SELECT uniqExact(session_id) + FROM audit_logs + WHERE status = 'success' AND timestamp >= ? AND session_id != '' + ` + args := []any{since} + if tenantID != "" { + query += " AND JSONExtractString(details, 'tenant_id') = ?" + args = append(args, tenantID) + } + + var count int64 + err := r.conn.QueryRow(ctx, query, args...).Scan(&count) + if err != nil { + return 0, fmt.Errorf("failed to count active sessions: %w", err) + } + return count, nil +} diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index f719db50..bdc9b784 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -9,6 +9,7 @@ import { ShieldHalf, } from "lucide-react"; import { useState } from "react"; +import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; import { Avatar, @@ -34,15 +35,29 @@ import { TableHeader, TableRow, } from "../../components/ui/table"; -import { fetchClients } from "../../lib/devApi"; +import { fetchClients, fetchDevStats } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; function ClientsPage() { const navigate = useNavigate(); - const { data, isLoading, error } = useQuery({ + const auth = useAuth(); + const hasAccessToken = Boolean(auth.user?.access_token); + + const { + data, + isLoading: isLoadingClients, + error: clientError, + } = useQuery({ queryKey: ["clients"], queryFn: fetchClients, + enabled: hasAccessToken, + }); + + const { data: statsData, isLoading: isLoadingStats } = useQuery({ + queryKey: ["dev-stats"], + queryFn: fetchDevStats, + enabled: hasAccessToken, }); const [searchQuery, setSearchQuery] = useState(""); @@ -63,11 +78,10 @@ function ClientsPage() { return matchesSearch && matchesType && matchesStatus; }); - const totalClients = clients.length; - const activeClients = clients.filter( - (client) => client.status === "active", - ).length; - // TODO: Replace with real session/auth-failure metrics when backend endpoints are available. + const totalClients = statsData?.total_clients ?? clients.length; + const activeSessions = statsData?.active_sessions ?? 0; + const authFailures = statsData?.auth_failures_24h ?? 0; + type StatTone = "up" | "down" | "stable"; type StatItem = { labelKey: string; @@ -90,7 +104,7 @@ function ClientsPage() { { labelKey: "ui.dev.clients.stats.active_sessions", labelFallback: "Active Sessions", - value: activeClients.toString(), + value: activeSessions.toString(), deltaKey: "ui.dev.clients.stats.realtime", deltaFallback: "Realtime", tone: "up" as const, @@ -98,14 +112,16 @@ function ClientsPage() { { labelKey: "ui.dev.clients.stats.auth_failures", labelFallback: "Auth Failures (24h)", - value: "0", - deltaKey: "ui.dev.clients.stats.stable", - deltaFallback: "Stable", - tone: "stable" as const, + value: authFailures.toString(), + deltaKey: authFailures > 0 ? "ui.dev.clients.stats.alert" : "ui.dev.clients.stats.stable", + deltaFallback: authFailures > 0 ? "Check Logs" : "Stable", + tone: authFailures > 0 ? ("down" as const) : ("stable" as const), }, ]; - if (isLoading) { + const isLoading = isLoadingClients || isLoadingStats; + + if (auth.isLoading || !hasAccessToken || isLoading) { return (