forked from baron/baron-sso
RP 대시보드 기능 추가
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user