package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "os" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/google/uuid" ) type DevHandler struct { Hydra *service.HydraAdminService Redis domain.RedisRepository SecretRepo domain.ClientSecretRepository AuditRepo domain.AuditRepository KratosAdmin service.KratosAdminService ConsentRepo repository.ClientConsentRepository Keto service.KetoService RPSvc service.RelyingPartyService TenantSvc service.TenantService Auth interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } } func NewDevHandler( redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService, keto service.KetoService, tenantSvc service.TenantService, auth ...interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) }, ) *DevHandler { var authProvider interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } if len(auth) > 0 { authProvider = auth[0] } return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, SecretRepo: secretRepo, AuditRepo: nil, KratosAdmin: service.NewKratosAdminService(), ConsentRepo: consentRepo, Keto: keto, RPSvc: rpSvc, TenantSvc: tenantSvc, Auth: authProvider, } } type devAuditListResponse struct { Items []domain.AuditLog `json:"items"` Limit int `json:"limit"` Cursor string `json:"cursor,omitempty"` 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"` Type string `json:"type"` Status string `json:"status"` CreatedAt *time.Time `json:"createdAt,omitempty"` RedirectURIs []string `json:"redirectUris"` Scopes []string `json:"scopes"` ClientSecret string `json:"clientSecret,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` } type clientListResponse struct { Items []clientSummary `json:"items"` Limit int `json:"limit"` Offset int `json:"offset"` } type clientDetailResponse struct { Client clientSummary `json:"client"` Endpoints clientEndpoints `json:"endpoints"` } type clientEndpoints struct { Discovery string `json:"discovery"` Issuer string `json:"issuer"` Authorization string `json:"authorization"` Token string `json:"token"` UserInfo string `json:"userinfo"` } type consentSummary struct { Subject string `json:"subject"` UserName string `json:"userName,omitempty"` ClientID string `json:"clientId"` ClientName string `json:"clientName,omitempty"` GrantedScopes []string `json:"grantedScopes"` AuthenticatedAt string `json:"authenticatedAt,omitempty"` CreatedAt time.Time `json:"createdAt"` DeletedAt *time.Time `json:"deletedAt,omitempty"` Status string `json:"status"` TenantID string `json:"tenantId,omitempty"` TenantName string `json:"tenantName,omitempty"` } type consentListResponse struct { Items []consentSummary `json:"items"` } type clientUpsertRequest struct { ID *string `json:"id"` Name *string `json:"name"` Type *string `json:"type"` Status *string `json:"status"` RedirectURIs *[]string `json:"redirectUris"` Scopes *[]string `json:"scopes"` GrantTypes *[]string `json:"grantTypes"` ResponseTypes *[]string `json:"responseTypes"` TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"` Metadata *map[string]interface{} `json:"metadata"` } var protectedSystemClientIDs = map[string]struct{}{ "oathkeeper-introspect": {}, } var reservedSystemClientNames = map[string]string{ "adminfront": "adminfront", "devfront": "devfront", } func normalizeUserRole(role string) string { return domain.NormalizeRole(role) } func isDevConsoleRoleAllowed(role string) bool { switch normalizeUserRole(role) { case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin: return true default: return false } } func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse { if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { return profile } if h.Auth != nil { enriched, err := h.Auth.GetEnrichedProfile(c) if err == nil && enriched != nil { c.Locals("user_profile", enriched) return enriched } } return nil } func tenantIDFromProfile(profile *domain.UserProfileResponse) string { if profile == nil || profile.TenantID == nil { return "" } return strings.TrimSpace(*profile.TenantID) } func addClientIDToSet(set map[string]struct{}, raw any) { switch value := raw.(type) { case string: for _, chunk := range strings.Split(value, ",") { id := strings.TrimSpace(chunk) if id != "" { set[id] = struct{}{} } } case []string: for _, item := range value { id := strings.TrimSpace(item) if id != "" { set[id] = struct{}{} } } case []any: for _, item := range value { if str, ok := item.(string); ok { id := strings.TrimSpace(str) if id != "" { set[id] = struct{}{} } } } } } func managedClientIDsFromProfile(profile *domain.UserProfileResponse) map[string]struct{} { ids := make(map[string]struct{}) if profile == nil { return ids } if profile.RelyingPartyID != nil { if id := strings.TrimSpace(*profile.RelyingPartyID); id != "" { ids[id] = struct{}{} } } if profile.Metadata == nil { return ids } for _, key := range []string{ "managed_client_ids", "managedClientIds", "relying_party_id", "relyingPartyId", "client_id", "clientId", } { if raw, ok := profile.Metadata[key]; ok { addClientIDToSet(ids, raw) } } return ids } func resolveClientTenantID(summary clientSummary) string { if summary.Metadata == nil { return "" } clientTenantID, _ := summary.Metadata["tenant_id"].(string) return strings.TrimSpace(clientTenantID) } func isRPAdminClientAllowed(profile *domain.UserProfileResponse, clientID string) bool { if normalizeUserRole(profileRole(profile)) != domain.RoleRPAdmin { return true } allowed := managedClientIDsFromProfile(profile) if len(allowed) == 0 { return false } _, ok := allowed[strings.TrimSpace(clientID)] return ok } func profileRole(profile *domain.UserProfileResponse) string { if profile == nil { return "" } return strings.TrimSpace(profile.Role) } func isProtectedSystemClientID(clientID string) bool { _, ok := protectedSystemClientIDs[strings.TrimSpace(clientID)] return ok } func isProtectedSystemClient(client domain.HydraClient) bool { return isProtectedSystemClientID(client.ClientID) } func isReservedSystemClientAlias(client domain.HydraClient) bool { ownerID, reserved := reservedSystemClientOwnerID(client.ClientName) if !reserved { return false } return !strings.EqualFold(strings.TrimSpace(client.ClientID), ownerID) } func isHiddenSystemClient(client domain.HydraClient) bool { return isProtectedSystemClient(client) || isReservedSystemClientAlias(client) } func reservedSystemClientOwnerID(name string) (string, bool) { ownerID, ok := reservedSystemClientNames[strings.ToLower(strings.TrimSpace(name))] return ownerID, ok } func validateReservedSystemClientName(clientID, name string) error { ownerID, reserved := reservedSystemClientOwnerID(name) if !reserved { return nil } if strings.EqualFold(strings.TrimSpace(clientID), ownerID) { return nil } return fmt.Errorf("forbidden: reserved system client name") } func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if (!ok || profile == nil) && h.Auth != nil { enriched, err := h.Auth.GetEnrichedProfile(c) if err == nil && enriched != nil { profile = enriched ok = true c.Locals("user_profile", enriched) } } if ok && profile != nil { role := normalizeUserRole(profile.Role) switch role { case domain.RoleSuperAdmin: slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID) return true, nil case domain.RoleTenantAdmin, domain.RoleRPAdmin: slog.Info("Dev private permission granted by role", "user_id", profile.ID, "role", role) return true, nil case domain.RoleUser: return false, nil } // Super Admin bypass if isAdminEmail(profile.Email) { slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email) return true, nil } 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) } } 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 } 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 = normalizeUserRole(info.Role) } else if err != nil { slog.Warn("Dev private permission userinfo fallback failed", "error", err) } } tokenRole = normalizeUserRole(tokenRole) if tokenRole == domain.RoleSuperAdmin { slog.Info("Dev private permission granted by token role", "role", tokenRole) return true, nil } if tokenRole == domain.RoleTenantAdmin || tokenRole == domain.RoleRPAdmin { slog.Info("Dev private permission granted by token role", "role", tokenRole) return true, nil } if tokenRole == domain.RoleUser { return false, nil } if isAdminEmail(tokenEmail) { slog.Info("Dev private permission granted by token email", "email", tokenEmail) 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 } // Fallback: resolve role from Kratos identity traits when user_profile is not injected. if h.KratosAdmin != nil { identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject) if err == nil && identity != nil { if rawRole, ok := identity.Traits["role"].(string); ok && normalizeUserRole(rawRole) == domain.RoleSuperAdmin { slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject) return true, nil } if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) { slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email) return true, nil } } } // Check with Keto: System:AppManager#member allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "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", tokenSubject, "error", err) return false, nil } slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed) return allowed, nil } func extractAuthClaimsFromBearer(authHeader string) (string, string) { authHeader = strings.TrimSpace(authHeader) if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { return "", "" } token := strings.TrimSpace(authHeader[len("Bearer "):]) if token == "" || strings.Count(token, ".") != 2 { return "", "" } parts := strings.Split(token, ".") if len(parts) != 3 { return "", "" } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { payload, err = base64.URLEncoding.DecodeString(parts[1]) if err != nil { return "", "" } } var claims map[string]interface{} if err := json.Unmarshal(payload, &claims); err != nil { return "", "" } sub := "" if sub, ok := claims["sub"].(string); ok { sub = strings.TrimSpace(sub) } email := "" if claimEmail, ok := claims["email"].(string); ok { email = strings.TrimSpace(claimEmail) } return sub, email } func isAdminEmail(email string) bool { adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail) } 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") } 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)) } 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 &oidcUserInfo{ Sub: pick("sub"), Email: pick("email"), TenantID: pick("tenant_id", "tenantId"), Role: pick("role"), }, nil } func (h *DevHandler) ListClients(c *fiber.Ctx) error { h.injectTenantContextFromHeader(c) limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } 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) if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients") } 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 { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "clients not found") } 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.") } 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 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 } } // 3. [Role Scope] RP Admin can only access managed RP IDs if role == domain.RoleRPAdmin { if _, ok := allowedClientIDs[summary.ID]; !ok { continue } } items = append(items, summary) } return c.JSON(clientListResponse{ Items: items, Limit: limit, Offset: offset, }) } func (h *DevHandler) GetClient(c *fiber.Ctx) error { h.injectTenantContextFromHeader(c) clientID := c.Params("id") if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } client, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if isHiddenSystemClient(*client) { return errorJSON(c, fiber.StatusNotFound, "client not found") } summary := h.mapClientSummary(*client) 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") } // [Tenant Isolation] Check if user has access to this client isSuperAdmin := role == domain.RoleSuperAdmin userTenantID := tenantIDFromProfile(profile) if !isSuperAdmin { clientTenantID := resolveClientTenantID(summary) if clientTenantID != userTenantID { return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") } } if !isRPAdminClientAllowed(profile, summary.ID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } // Check permission for private clients if summary.Type == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } return c.JSON(clientDetailResponse{ Client: summary, Endpoints: clientEndpoints{ Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration", Issuer: h.Hydra.PublicURL, Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth", Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token", UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo", }, }) } func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { tenantID := h.injectTenantContextFromHeader(c) clientID := c.Params("id") if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } var req struct { Status string `json:"status"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } status := strings.ToLower(strings.TrimSpace(req.Status)) if status != "active" && status != "inactive" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } // [Security] Check permission before patching current, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if isHiddenSystemClient(*current) { return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") } summary := h.mapClientSummary(*current) 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") } // [Tenant Isolation] isSuperAdmin := role == domain.RoleSuperAdmin userTenantID := tenantIDFromProfile(profile) if !isSuperAdmin { clientTenantID := resolveClientTenantID(summary) if clientTenantID != userTenantID { return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") } } if !isRPAdminClientAllowed(profile, summary.ID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } 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, "tenant_id": tenantID, "before": map[string]any{ "status": beforeStatus, }, "after": map[string]any{ "status": status, }, }) updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } updatedSummary := h.mapClientSummary(*updated) return c.JSON(clientDetailResponse{ Client: updatedSummary, Endpoints: clientEndpoints{ Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration", Issuer: h.Hydra.PublicURL, Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth", Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token", UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo", }, }) } func (h *DevHandler) CreateClient(c *fiber.Ctx) error { tenantID := h.injectTenantContextFromHeader(c) 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") } var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } clientID := strings.TrimSpace(valueOr(req.ID, "")) if clientID == "" { clientID = uuid.NewString() } if isProtectedSystemClientID(clientID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: reserved system client id") } name := strings.TrimSpace(valueOr(req.Name, "")) if name == "" { name = clientID } if err := validateReservedSystemClientName(clientID, name); err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) } redirectURIs := derefSlice(req.RedirectURIs, nil) if len(redirectURIs) == 0 { return errorJSON(c, fiber.StatusBadRequest, "redirectUris is required") } scopes := derefSlice(req.Scopes, defaultClientScopes()) grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes()) responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes()) clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private"))) if clientType != "pkce" && clientType != "private" { return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private") } // [Security] Check permission for private clients if clientType == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client") } } status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active"))) if status != "active" && status != "inactive" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } metadata := mergeMetadata(nil, req.Metadata) if metadata == nil { metadata = map[string]interface{}{} } // [Tenant Isolation] Record owner information if profile != nil { metadata["user_id"] = profile.ID if tenantID == "" && profile.TenantID != nil { tenantID = *profile.TenantID } } if tenantID != "" { metadata["tenant_id"] = tenantID } metadata["status"] = status metadata["created_at"] = time.Now().Format(time.RFC3339) tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" { if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" } } clientReq := domain.HydraClient{ ClientID: clientID, ClientName: name, RedirectURIs: redirectURIs, GrantTypes: grantTypes, ResponseTypes: responseTypes, Scope: strings.Join(scopes, " "), TokenEndpointAuthMethod: tokenAuthMethod, Metadata: metadata, } h.setAuditDetailsExtra(c, map[string]any{ "action": "CREATE_CLIENT", "target_id": clientID, "tenant_id": tenantID, "after": map[string]any{ "type": clientType, "status": status, "redirect_uri_count": len(redirectURIs), "scope_count": len(scopes), }, }) created, err := h.Hydra.CreateClient(c.Context(), clientReq) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Store secret in metadata for later retrieval if created.ClientSecret != "" { // 1. Store in PostgreSQL (Source of Truth) if h.SecretRepo != nil { _ = h.SecretRepo.Upsert(c.Context(), created.ClientID, created.ClientSecret) } // 2. Also store in Redis (Cache) if h.Redis != nil { _ = h.Redis.Set("client_secret:"+created.ClientID, created.ClientSecret, 0) } } h.setAuditDetailsExtra(c, map[string]any{"target_id": created.ClientID}) summary := h.mapClientSummary(*created) return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{ Client: summary, Endpoints: clientEndpoints{ Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration", Issuer: h.Hydra.PublicURL, Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth", Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token", UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo", }, }) } func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { tenantID := h.injectTenantContextFromHeader(c) clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } var req clientUpsertRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } current, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if isHiddenSystemClient(*current) { return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") } currentSummary := h.mapClientSummary(*current) 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") } // [Tenant Isolation] isSuperAdmin := role == domain.RoleSuperAdmin userTenantID := tenantIDFromProfile(profile) if !isSuperAdmin { clientTenantID := resolveClientTenantID(currentSummary) if clientTenantID != userTenantID { return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") } } if !isRPAdminClientAllowed(profile, currentSummary.ID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } clientType := "" if req.Type != nil { clientType = strings.ToLower(strings.TrimSpace(*req.Type)) if clientType != "pkce" && clientType != "private" { return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private") } } // [Security] Check permission for private clients (both current and new type) if currentSummary.Type == "private" || clientType == "private" { isAppManager, err := h.checkAppManagerPermission(c) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "permission check error") } if !isAppManager { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for private client") } } status := "" if req.Status != nil { status = strings.ToLower(strings.TrimSpace(*req.Status)) if status != "active" && status != "inactive" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } } tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" && clientType != "" { if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" } } if req.RedirectURIs != nil && len(*req.RedirectURIs) == 0 { return errorJSON(c, fiber.StatusBadRequest, "redirectUris cannot be empty") } metadata := mergeMetadata(current.Metadata, req.Metadata) if status != "" { if metadata == nil { metadata = map[string]interface{}{} } metadata["status"] = status } updated := domain.HydraClient{ ClientID: current.ClientID, ClientName: valueOr(req.Name, current.ClientName), RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs), GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes), ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod), Metadata: metadata, } if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) } h.setAuditDetailsExtra(c, map[string]any{ "action": "UPDATE_CLIENT", "target_id": clientID, "tenant_id": tenantID, "before": map[string]any{ "name": currentSummary.Name, "type": currentSummary.Type, "status": currentSummary.Status, }, "after": map[string]any{ "name": strings.TrimSpace(updated.ClientName), "type": clientTypeOrDefault(updated.TokenEndpointAuthMethod), "status": resolveStatusFromMetadata(updated.Metadata), }, }) updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := h.mapClientSummary(*updatedClient) return c.JSON(clientDetailResponse{ Client: summary, Endpoints: clientEndpoints{ Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration", Issuer: h.Hydra.PublicURL, Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth", Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token", UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo", }, }) } func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { tenantID := h.injectTenantContextFromHeader(c) clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } current, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if isHiddenSystemClient(*current) { return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") } summary := h.mapClientSummary(*current) 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") } // [Tenant Isolation] isSuperAdmin := role == domain.RoleSuperAdmin userTenantID := tenantIDFromProfile(profile) if !isSuperAdmin { clientTenantID := resolveClientTenantID(summary) if clientTenantID != userTenantID { return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") } } if !isRPAdminClientAllowed(profile, summary.ID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } // [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") } } h.setAuditDetailsExtra(c, map[string]any{ "action": "DELETE_CLIENT", "target_id": clientID, "tenant_id": tenantID, }) if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 1. Clean up PostgreSQL if h.SecretRepo != nil { _ = h.SecretRepo.Delete(c.Context(), clientID) } // 2. Clean up Redis if h.Redis != nil { _ = h.Redis.Delete("client_secret:" + clientID) } return c.SendStatus(fiber.StatusNoContent) } func (h *DevHandler) ListConsents(c *fiber.Ctx) error { h.injectTenantContextFromHeader(c) clientID := strings.TrimSpace(c.Query("client_id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client_id is required") } 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") } if !isRPAdminClientAllowed(profile, clientID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } subject := strings.TrimSpace(c.Query("subject")) limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) if limit <= 0 { limit = 50 } // [Isolation] Get admin tenant ID from locals or header adminTenantID := "" if profile != nil { if 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 var total int64 var err error if subject != "" { // Resolve subject if it's email/name (Legacy support) if _, err := uuid.Parse(subject); err != nil { resolved, _ := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject) if resolved != "" { subject = resolved } } } if adminTenantID != "" { consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, limit, offset) } else { consents, total, err = h.ConsentRepo.List(c.Context(), clientID, limit, offset) } if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } items := make([]consentSummary, 0, len(consents)) for _, consent := range consents { // Filter by subject if search is active if subject != "" && consent.Subject != subject { continue } var deletedAt *time.Time status := "active" if consent.DeletedAt.Valid { deletedAt = &consent.DeletedAt.Time status = "revoked" } // Filter by status if requested if statusFilter != "" && statusFilter != "all" { if statusFilter == "active" && status != "active" { continue } if statusFilter == "revoked" && status != "revoked" { continue } } userName := "" identity, err := h.KratosAdmin.GetIdentity(c.Context(), consent.Subject) if err == nil && identity != nil { if name, ok := identity.Traits["name"].(string); ok { userName = name } else if email, ok := identity.Traits["email"].(string); ok { userName = email } } items = append(items, consentSummary{ Subject: consent.Subject, UserName: userName, ClientID: consent.ClientID, GrantedScopes: consent.GrantedScopes, AuthenticatedAt: consent.UpdatedAt.Format(time.RFC3339), CreatedAt: consent.CreatedAt, DeletedAt: deletedAt, Status: status, TenantID: consent.TenantID, TenantName: consent.TenantName, }) } return c.JSON(fiber.Map{ "items": items, "total": total, }) } func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error { tenantID := h.injectTenantContextFromHeader(c) subject := strings.TrimSpace(c.Query("subject")) if subject == "" { return errorJSON(c, fiber.StatusBadRequest, "subject is required") } clientID := strings.TrimSpace(c.Query("client_id")) 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") } if clientID != "" && !isRPAdminClientAllowed(profile, clientID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } // If subject is not a UUID, try to resolve it as an identifier (email/username) if _, err := uuid.Parse(subject); err != nil { resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), subject) if err == nil && resolved != "" { subject = resolved } } h.setAuditDetailsExtra(c, map[string]any{ "action": "REVOKE_CONSENT", "target_id": clientID, "tenant_id": tenantID, "after": map[string]any{ "subject": subject, }, }) // 1. Revoke in Hydra if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // 2. Sync to Local DB (Delete) if h.ConsentRepo != nil { _ = h.ConsentRepo.Delete(c.Context(), subject, clientID) } return c.SendStatus(fiber.StatusNoContent) } func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { tenantID := h.injectTenantContextFromHeader(c) clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } current, err := h.Hydra.GetClient(c.Context(), clientID) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return errorJSON(c, fiber.StatusNotFound, "client not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if isHiddenSystemClient(*current) { return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client") } summary := h.mapClientSummary(*current) 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") } // [Tenant Isolation] isSuperAdmin := role == domain.RoleSuperAdmin userTenantID := tenantIDFromProfile(profile) if !isSuperAdmin { clientTenantID := resolveClientTenantID(summary) if clientTenantID != userTenantID { return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant") } } if !isRPAdminClientAllowed(profile, summary.ID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } // [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") } } h.setAuditDetailsExtra(c, map[string]any{ "action": "ROTATE_SECRET", "target_id": clientID, "tenant_id": tenantID, }) // 1. Generate new secret newSecret, err := generateRandomSecret(20) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to generate secret") } // 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()) } // 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 fmt.Printf("failed to update secret in repo: %v\n", err) } } if h.Redis != nil { _ = h.Redis.Set("client_secret:"+clientID, newSecret, 0) } // Return the new secret updatedSummary := h.mapClientSummary(*updated) updatedSummary.ClientSecret = newSecret return c.JSON(clientDetailResponse{ Client: updatedSummary, Endpoints: clientEndpoints{ Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration", Issuer: h.Hydra.PublicURL, Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth", Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token", UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo", }, }) } func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error { if h.AuditRepo == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable") } h.injectTenantContextFromHeader(c) 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") } allowedClientIDs := managedClientIDsFromProfile(profile) if role == domain.RoleRPAdmin && len(allowedClientIDs) == 0 { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin has no managed clients") } limit := c.QueryInt("limit", 50) if limit <= 0 { limit = 50 } if limit > 200 { limit = 200 } actionFilter := strings.ToUpper(strings.TrimSpace(c.Query("action"))) clientFilter := strings.TrimSpace(c.Query("client_id")) statusFilter := strings.ToLower(strings.TrimSpace(c.Query("status"))) tenantFilter := strings.TrimSpace(c.Query("tenant_id")) if tenantFilter == "" { tenantFilter = h.resolveDevTenantScope(c) } if role != domain.RoleSuperAdmin && tenantFilter == "" { tenantFilter = tenantIDFromProfile(profile) } cursorRaw := c.Query("cursor") cursor, err := parseAuditCursor(cursorRaw) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid cursor") } collected := make([]domain.AuditLog, 0, limit+1) nextCursor := cursor scanned := 0 const pageSize = 100 const maxScan = 3000 for len(collected) < limit+1 && scanned < maxScan { page, findErr := h.AuditRepo.FindPage(c.Context(), pageSize, nextCursor, tenantFilter) if findErr != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to retrieve audit logs") } if len(page) == 0 { break } for _, logItem := range page { scanned++ if h.matchesDevAuditFilter(logItem, tenantFilter, clientFilter, actionFilter, statusFilter, allowedClientIDs) { collected = append(collected, logItem) if len(collected) == limit+1 { break } } } last := page[len(page)-1] nextCursor = &domain.AuditCursor{Timestamp: last.Timestamp, EventID: last.EventID} if len(page) < pageSize { break } } nextCursorRaw := "" if len(collected) > limit { last := collected[limit-1] nextCursorRaw = encodeAuditCursor(last) collected = collected[:limit] } return c.JSON(devAuditListResponse{ Items: collected, Limit: limit, Cursor: cursorRaw, NextCursor: nextCursorRaw, }) } 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 = normalizeUserRole(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 isHiddenSystemClient(client) { continue } 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 { return "", err } // Use Base64 URL encoding (no padding) to look like Hydra's native secrets return base64.RawURLEncoding.EncodeToString(bytes), nil } func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { status := "active" var createdAt *time.Time if client.Metadata != nil { if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" { status = "inactive" } if value, ok := client.Metadata["created_at"].(string); ok { if t, err := time.Parse(time.RFC3339, value); err == nil { createdAt = &t } } } clientType := "private" if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { clientType = "pkce" } name := strings.TrimSpace(client.ClientName) if name == "" { name = client.ClientID } scopes := strings.Fields(client.Scope) clientSecret := client.ClientSecret // 1. Check Metadata (Legacy/Fallback) if clientSecret == "" && client.Metadata != nil { if val, ok := client.Metadata["client_secret"].(string); ok { clientSecret = val } } // 2. Check Redis (Cache) if clientSecret == "" && h.Redis != nil { if val, err := h.Redis.Get("client_secret:" + client.ClientID); err == nil && val != "" { clientSecret = val } } // 3. Check PostgreSQL (Source of Truth) & Cache Warming if clientSecret == "" && h.SecretRepo != nil { if val, err := h.SecretRepo.GetByID(context.Background(), client.ClientID); err == nil && val != "" { clientSecret = val // Warm up cache if h.Redis != nil { _ = h.Redis.Set("client_secret:"+client.ClientID, clientSecret, 0) } } } return clientSummary{ ID: client.ClientID, Name: name, Type: clientType, Status: status, CreatedAt: createdAt, RedirectURIs: client.RedirectURIs, Scopes: scopes, ClientSecret: clientSecret, Metadata: client.Metadata, } } func defaultClientScopes() []string { return []string{"openid", "profile", "email"} } func defaultGrantTypes() []string { return []string{"authorization_code", "refresh_token"} } func defaultResponseTypes() []string { return []string{"code"} } func buildScope(scopes []string) string { return strings.Join(scopes, " ") } func valueOr(ptr *string, fallback string) string { if ptr == nil { return fallback } return *ptr } func valueOrSlice(ptr *[]string, fallback []string) []string { if ptr == nil { return fallback } return *ptr } func derefSlice(ptr *[]string, fallback []string) []string { if ptr == nil { return fallback } return *ptr } func mergeMetadata(current map[string]interface{}, incoming *map[string]interface{}) map[string]interface{} { if incoming == nil { return current } merged := map[string]interface{}{} for k, v := range current { merged[k] = v } for k, v := range *incoming { merged[k] = v } return merged } func resolveTokenAuthMethod(requested, fallback string) string { if strings.TrimSpace(requested) == "" { return fallback } return requested } func (h *DevHandler) injectTenantContextFromHeader(c *fiber.Ctx) string { tenantID := strings.TrimSpace(c.Get("X-Tenant-ID")) if tenantID != "" { c.Locals("tenant_id", tenantID) } return tenantID } func (h *DevHandler) setAuditDetailsExtra(c *fiber.Ctx, extra map[string]any) { if c == nil || len(extra) == 0 { return } if existing := c.Locals("audit_details_extra"); existing != nil { if m, ok := existing.(map[string]any); ok { for k, v := range extra { m[k] = v } c.Locals("audit_details_extra", m) return } } c.Locals("audit_details_extra", extra) } func normalizeAuditAction(eventType string, details map[string]any) string { if raw, ok := details["action"].(string); ok && strings.TrimSpace(raw) != "" { return strings.ToUpper(strings.TrimSpace(raw)) } normalized := strings.TrimSpace(eventType) switch { case normalized == "POST /api/v1/dev/clients": return "CREATE_CLIENT" case strings.HasPrefix(normalized, "PUT /api/v1/dev/clients/"): return "UPDATE_CLIENT" case strings.HasPrefix(normalized, "PATCH /api/v1/dev/clients/") && strings.HasSuffix(normalized, "/status"): return "UPDATE_CLIENT_STATUS" case strings.HasPrefix(normalized, "POST /api/v1/dev/clients/") && strings.HasSuffix(normalized, "/secret/rotate"): return "ROTATE_SECRET" case strings.HasPrefix(normalized, "DELETE /api/v1/dev/clients/"): return "DELETE_CLIENT" case normalized == "DELETE /api/v1/dev/consents": return "REVOKE_CONSENT" default: return "" } } func resolveStatusFromMetadata(metadata map[string]interface{}) string { if metadata != nil { if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" { return "inactive" } } return "active" } func clientTypeOrDefault(tokenEndpointAuthMethod string) string { if strings.EqualFold(tokenEndpointAuthMethod, "none") { return "pkce" } return "private" } func (h *DevHandler) matchesDevAuditFilter( logItem domain.AuditLog, tenantFilter, clientFilter, actionFilter, statusFilter string, allowedClientIDs map[string]struct{}, ) bool { if !strings.Contains(logItem.EventType, "/api/v1/dev/") { return false } details, _ := parseAuditDetails(logItem.Details) if statusFilter != "" && statusFilter != "all" && strings.ToLower(logItem.Status) != statusFilter { return false } if tenantFilter != "" { detailTenant, _ := details["tenant_id"].(string) if strings.TrimSpace(detailTenant) != tenantFilter { return false } } if clientFilter != "" { targetID, _ := details["target_id"].(string) clientID, _ := details["client_id"].(string) if strings.TrimSpace(targetID) != clientFilter && strings.TrimSpace(clientID) != clientFilter { return false } } if len(allowedClientIDs) > 0 { targetID, _ := details["target_id"].(string) clientID, _ := details["client_id"].(string) resolvedID := strings.TrimSpace(targetID) if resolvedID == "" { resolvedID = strings.TrimSpace(clientID) } if resolvedID == "" { return false } if _, ok := allowedClientIDs[resolvedID]; !ok { return false } } if actionFilter != "" { if normalizeAuditAction(logItem.EventType, details) != actionFilter { return false } } return true } func (h *DevHandler) resolveDevTenantScope(c *fiber.Ctx) string { fromHeader := strings.TrimSpace(c.Get("X-Tenant-ID")) if fromHeader != "" { return fromHeader } if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil && profile.TenantID != nil { return strings.TrimSpace(*profile.TenantID) } return "" } // 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) if err != nil || profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } role := normalizeUserRole(profile.Role) if role == domain.RoleUser { return errorJSON(c, fiber.StatusForbidden, "access denied") } if role == domain.RoleSuperAdmin { tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants") } return c.JSON(tenants) } tenants, err := h.TenantSvc.ListManageableTenants(c.Context(), profile.ID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error()) } if profile.TenantID != nil && *profile.TenantID != "" { found := false for _, t := range tenants { if t.ID == *profile.TenantID { found = true break } } if !found { if primary, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID); err == nil && primary != nil { tenants = append(tenants, *primary) } } } return c.JSON(tenants) }