forked from baron/baron-sso
클라이언트 대시보드 통계 실지표 연동 및 백엔드 API 구현
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user