1
0
forked from baron/baron-sso

RP 대시보드 기능 추가

This commit is contained in:
2026-05-12 11:31:18 +09:00
parent a2a6938246
commit 3626584046
10 changed files with 1483 additions and 378 deletions

View File

@@ -39,6 +39,7 @@ type DevHandler struct {
TenantSvc service.TenantService
DeveloperSvc *service.DeveloperService
RPUserMetadataRepo repository.RPUserMetadataRepository
RPUsageQueries domain.RPUsageQueryRepository
Auth interface {
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
}
@@ -94,6 +95,13 @@ type devStatsResponse struct {
AuthFailures int64 `json:"auth_failures_24h"`
}
type devRPUsageDailyResponse struct {
Items []domain.RPUsageDailyMetric `json:"items"`
Days int `json:"days"`
Period string `json:"period"`
TenantID string `json:"tenantId,omitempty"`
}
type clientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -369,16 +377,12 @@ func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserPro
if role == domain.RoleSuperAdmin {
return true
}
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
if canAccessClientByLegacyScope(profile, summary) {
return true
}
clientTenantID := resolveClientTenantID(summary)
if role != domain.RoleUser && clientTenantID != "" {
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
return true
}
if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
return true
}
allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view")
@@ -512,6 +516,26 @@ func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[strin
return dst
}
func shouldScopeDashboardToExplicitClients(role string) bool {
switch normalizeUserRole(role) {
case domain.RoleRPAdmin, domain.RoleUser:
return true
default:
return false
}
}
func clientIDSetFromSummaries(items []clientSummary) map[string]struct{} {
ids := make(map[string]struct{}, len(items))
for _, item := range items {
id := strings.TrimSpace(item.ID)
if id != "" {
ids[id] = struct{}{}
}
}
return ids
}
func canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
if profile == nil {
return false
@@ -1009,6 +1033,70 @@ func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) {
return allowed, nil
}
func (h *DevHandler) listVisibleClientSummaries(
c *fiber.Ctx,
profile *domain.UserProfileResponse,
limit int,
offset int,
) ([]clientSummary, error) {
if profile == nil {
return nil, fiber.NewError(fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return nil, fiber.NewError(fiber.StatusForbidden, "forbidden")
}
userTenantID := tenantIDFromProfile(profile)
isSuperAdmin := role == domain.RoleSuperAdmin
allowedClientIDs := managedClientIDsFromProfile(profile)
isAppManager, err := h.checkAppManagerPermission(c)
if err != nil {
slog.Error("Failed to check app manager permission", "error", err)
}
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil {
return nil, err
}
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
summary := h.mapClientSummary(client)
canViewByPermit := h.canViewClientByPermit(c, profile, summary)
if summary.Type == "private" && !isAppManager && !canViewByPermit {
continue
}
if !isSuperAdmin {
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
if clientTenantID != userTenantID && !canViewByPermit {
continue
}
}
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
if _, ok := allowedClientIDs[summary.ID]; !ok && !canViewByPermit {
continue
}
}
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
continue
}
items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary))
}
return items, nil
}
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
authHeader = strings.TrimSpace(authHeader)
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
@@ -1133,72 +1221,22 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
}
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleRoleAllowed(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
userTenantID := tenantIDFromProfile(profile)
isSuperAdmin := role == domain.RoleSuperAdmin
allowedClientIDs := managedClientIDsFromProfile(profile)
isAppManager, err := h.checkAppManagerPermission(c)
items, err := h.listVisibleClientSummaries(c, profile, limit, offset)
if err != nil {
slog.Error("Failed to check app manager permission", "error", err)
}
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
if err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return errorJSON(c, fiber.StatusNotFound, "clients not found")
}
status := fiber.StatusInternalServerError
errMsg := err.Error()
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
status = fiberErr.Code
errMsg = fiberErr.Message
} else if errors.Is(err, service.ErrHydraNotFound) {
status = fiber.StatusNotFound
errMsg = "clients not found"
} else if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
status = fiber.StatusServiceUnavailable
errMsg = "Hydra service is unavailable. Please check if Ory Hydra is running."
}
return errorJSON(c, fiber.StatusInternalServerError, errMsg)
}
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
if isHiddenSystemClient(client) {
continue
}
summary := h.mapClientSummary(client)
// 1. [Security] Filter out 'private' clients if user is not an AppManager
canViewByPermit := h.canViewClientByPermit(c, profile, summary)
if summary.Type == "private" && !isAppManager && !canViewByPermit {
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 && !canViewByPermit {
continue
}
}
// 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
if _, ok := allowedClientIDs[summary.ID]; !ok {
if !canViewByPermit {
continue
}
}
}
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
continue
}
items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary))
return errorJSON(c, status, errMsg)
}
return c.JSON(clientListResponse{
@@ -2547,59 +2585,42 @@ 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")
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
if !allowed {
role := normalizeUserRole(profile.Role)
if !isDevConsoleViewerRole(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
userTenantID := ""
isSuperAdmin := false
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin
if profile.TenantID != nil {
userTenantID = *profile.TenantID
}
visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients")
}
// 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 isHiddenSystemClient(client) {
continue
}
if isSuperAdmin {
totalClients++
continue
}
if client.Metadata != nil {
if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID {
totalClients++
}
}
}
}
userTenantID := tenantIDFromProfile(profile)
totalClients := int64(len(visibleClients))
visibleClientIDs := clientIDSetFromSummaries(visibleClients)
// 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)
failureSince := time.Now().Add(-24 * time.Hour)
sessionSince := time.Now().Add(-1 * time.Hour)
if shouldScopeDashboardToExplicitClients(role) {
authFailures, activeSessions, _ = h.countScopedDashboardAuditMetrics(
c,
userTenantID,
visibleClientIDs,
failureSince,
sessionSince,
)
} else {
authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), failureSince, userTenantID)
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), sessionSince, userTenantID)
}
}
return c.JSON(devStatsResponse{
@@ -2609,6 +2630,75 @@ func (h *DevHandler) GetStats(c *fiber.Ctx) error {
})
}
func (h *DevHandler) GetRPUsageDaily(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
profile := h.getCurrentProfile(c)
if profile == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
}
role := normalizeUserRole(profile.Role)
if !isDevConsoleViewerRole(role) {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
if h == nil || h.RPUsageQueries == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "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"))
visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients")
}
allowedClientIDs := clientIDSetFromSummaries(visibleClients)
if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 {
return c.JSON(devRPUsageDailyResponse{
Items: []domain.RPUsageDailyMetric{},
Days: days,
Period: period,
})
}
tenantID := ""
if role != domain.RoleSuperAdmin {
tenantID = tenantIDFromProfile(profile)
}
items, err := h.RPUsageQueries.FindRPUsage(c.Context(), domain.RPUsageQuery{
Days: days,
Period: period,
TenantID: tenantID,
})
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
filtered := make([]domain.RPUsageDailyMetric, 0, len(items))
for _, item := range items {
if role != domain.RoleSuperAdmin {
if _, ok := allowedClientIDs[strings.TrimSpace(item.ClientID)]; !ok {
continue
}
}
filtered = append(filtered, item)
}
return c.JSON(devRPUsageDailyResponse{
Items: filtered,
Days: days,
Period: period,
TenantID: tenantID,
})
}
func generateRandomSecret(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
@@ -3262,6 +3352,74 @@ func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string {
return ""
}
func (h *DevHandler) countScopedDashboardAuditMetrics(
c *fiber.Ctx,
tenantID string,
allowedClientIDs map[string]struct{},
failureSince, sessionSince time.Time,
) (int64, int64, error) {
if h.AuditRepo == nil || len(allowedClientIDs) == 0 {
return 0, 0, nil
}
oldestSince := failureSince
if sessionSince.Before(oldestSince) {
oldestSince = sessionSince
}
var failureCount int64
activeSessions := make(map[string]struct{})
var cursor *domain.AuditCursor
const pageSize = 200
const maxScan = 5000
scanned := 0
for scanned < maxScan {
page, err := h.AuditRepo.FindPage(c.Context(), pageSize, cursor, tenantID)
if err != nil {
return 0, 0, err
}
if len(page) == 0 {
break
}
stop := false
for _, logItem := range page {
scanned++
if logItem.Timestamp.Before(oldestSince) {
stop = true
break
}
details, _ := utils.ParseAuditDetails(logItem.Details)
clientID := strings.TrimSpace(resolveDevAuditClientID(logItem, details))
if _, ok := allowedClientIDs[clientID]; !ok {
continue
}
if strings.EqualFold(logItem.Status, "failure") && !logItem.Timestamp.Before(failureSince) {
failureCount++
}
if strings.EqualFold(logItem.Status, "success") && !logItem.Timestamp.Before(sessionSince) {
sessionID := strings.TrimSpace(logItem.SessionID)
if sessionID != "" {
activeSessions[sessionID] = struct{}{}
}
}
}
if stop || len(page) < pageSize {
break
}
last := page[len(page)-1]
cursor = &domain.AuditCursor{Timestamp: last.Timestamp, EventID: last.EventID}
}
return failureCount, int64(len(activeSessions)), nil
}
// ListMyTenants returns the list of tenants the current user manages or belongs to.
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
profile, err := h.Auth.GetEnrichedProfile(c)