클라이언트 대시보드 통계 실지표 연동 및 백엔드 API 구현
This commit is contained in:
1 parent
8db37c377a
commit
20c97843c3
6 files changed
+530
-101
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="p-8 text-center">
|
||||
{t("msg.dev.clients.loading", "Loading clients...")}
|
||||
@@ -113,10 +129,10 @@ function ClientsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (clientError) {
|
||||
const errMsg =
|
||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(error as Error).message;
|
||||
(clientError as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(clientError as Error).message;
|
||||
return (
|
||||
<div className="p-8 text-center text-red-500">
|
||||
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
|
||||
@@ -268,7 +284,13 @@ function ClientsPage() {
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{item.value}</span>
|
||||
<Badge
|
||||
variant={item.tone === "up" ? "success" : "muted"}
|
||||
variant={
|
||||
item.tone === "up"
|
||||
? "success"
|
||||
: item.tone === "down"
|
||||
? "destructive"
|
||||
: "muted"
|
||||
}
|
||||
className={cn(
|
||||
"px-2",
|
||||
item.tone === "stable" && "bg-muted/40 text-foreground",
|
||||
|
||||
@@ -20,6 +20,12 @@ export type ClientListResponse = {
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export type DevStats = {
|
||||
total_clients: number;
|
||||
active_sessions: number;
|
||||
auth_failures_24h: number;
|
||||
};
|
||||
|
||||
export type DevAuditLog = {
|
||||
event_id: string;
|
||||
timestamp: string;
|
||||
@@ -121,6 +127,11 @@ export async function fetchClients() {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchDevStats() {
|
||||
const { data } = await apiClient.get<DevStats>("/dev/stats");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchClient(clientId: string) {
|
||||
const { data } = await apiClient.get<ClientDetailResponse>(
|
||||
`/dev/clients/${clientId}`,
|
||||
|
||||
Reference in new issue
Block a user