package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/pagination" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" "maps" "net" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/google/uuid" ) type DevHandler struct { Hydra *service.HydraAdminService Redis domain.RedisRepository HeadlessJWKS *service.HeadlessJWKSCacheService SecretRepo domain.ClientSecretRepository AuditRepo domain.AuditRepository KratosAdmin service.KratosAdminService IdentityWriter service.IdentityWriteService ConsentRepo repository.ClientConsentRepository Keto service.KetoService KetoOutbox repository.KetoOutboxRepository RPSvc service.RelyingPartyService TenantSvc service.TenantService DeveloperSvc developerRequestService RPUserMetadataRepo repository.RPUserMetadataRepository RPUsageQueries domain.RPUsageQueryRepository Auth interface { GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) } } type developerRequestService interface { RequestAccess(ctx context.Context, req domain.DeveloperRequest) error GetRequestStatus(ctx context.Context, userID, tenantID string) (*domain.DeveloperAccessStatus, error) GetRequestByID(ctx context.Context, id uint) (*domain.DeveloperRequest, error) ListRequests(ctx context.Context, userID, status, tenantID string) ([]domain.DeveloperRequest, error) CreateGrant(ctx context.Context, req domain.DeveloperRequest) error ApproveRequest(ctx context.Context, id uint, adminNotes string) error RejectRequest(ctx context.Context, id uint, adminNotes string) error CancelApprovedRequest(ctx context.Context, id uint, adminNotes string) error } func NewDevHandler( redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService, keto service.KetoService, ketoOutbox repository.KetoOutboxRepository, tenantSvc service.TenantService, developerSvc *service.DeveloperService, 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] } kratosAdmin := service.NewKratosAdminService() return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, HeadlessJWKS: service.NewHeadlessJWKSCacheService(redis, nil), SecretRepo: secretRepo, AuditRepo: nil, KratosAdmin: kratosAdmin, IdentityWriter: service.NewIdentityWriteService( kratosAdmin, redis, ), ConsentRepo: consentRepo, Keto: keto, KetoOutbox: ketoOutbox, RPSvc: rpSvc, TenantSvc: tenantSvc, DeveloperSvc: developerSvc, 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 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"` 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"` TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` SkipConsent bool `json:"skipConsent"` JwksUri string `json:"jwksUri,omitempty"` Jwks any `json:"jwks,omitempty"` BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"` BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"` Metadata map[string]any `json:"metadata,omitempty"` } type clientListResponse struct { Items []clientSummary `json:"items"` Limit int `json:"limit"` Offset int `json:"offset"` Cursor string `json:"cursor,omitempty"` NextCursor string `json:"nextCursor,omitempty"` } type clientDetailResponse struct { Client clientSummary `json:"client"` Endpoints clientEndpoints `json:"endpoints"` HeadlessJWKSCache *domain.HeadlessJWKSCacheState `json:"headlessJwksCache,omitempty"` } type clientEndpoints struct { Discovery string `json:"discovery"` Issuer string `json:"issuer"` Authorization string `json:"authorization"` Token string `json:"token"` UserInfo string `json:"userinfo"` } type clientRelationSummary struct { Relation string `json:"relation"` Subject string `json:"subject"` SubjectType string `json:"subjectType"` SubjectID string `json:"subjectId"` UserName string `json:"userName,omitempty"` UserEmail string `json:"userEmail,omitempty"` UserLoginID string `json:"userLoginId,omitempty"` } type clientRelationListResponse struct { Items []clientRelationSummary `json:"items"` } type devUserSummary struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` LoginID string `json:"loginId,omitempty"` } type devUserListResponse struct { Items []devUserSummary `json:"items"` } type clientRelationUpsertRequest struct { Relation string `json:"relation"` Subject string `json:"subject"` UserID string `json:"userId"` } 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"` RPMetadata domain.JSONMap `json:"rpMetadata,omitempty"` } type consentListResponse struct { Items []consentSummary `json:"items"` Total int64 `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` Cursor string `json:"cursor,omitempty"` NextCursor string `json:"nextCursor,omitempty"` } 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"` SkipConsent *bool `json:"skipConsent"` JwksUri *string `json:"jwksUri"` Jwks any `json:"jwks"` BackchannelLogoutURI *string `json:"backchannelLogoutUri"` BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"` Metadata *map[string]any `json:"metadata"` } type normalizedIDTokenClaim struct { Namespace string `json:"namespace"` Key string `json:"key"` Value string `json:"value"` ValueType string `json:"valueType"` Nullable bool `json:"nullable"` ReadPermission string `json:"readPermission"` WritePermission string `json:"writePermission"` } var protectedSystemClientIDs = map[string]struct{}{ "oathkeeper-introspect": {}, } var reservedSystemClientNames = map[string]string{ "adminfront": "adminfront", "devfront": "devfront", } var allowedRelyingPartyOperatorRelations = map[string]struct{}{ "admins": {}, "creator": {}, "config_editor": {}, "secret_viewer": {}, "secret_rotator": {}, "jwks_viewer": {}, "jwks_operator": {}, "consent_viewer": {}, "consent_revoker": {}, "relationship_viewer": {}, "audit_viewer": {}, "status_operator": {}, } func normalizeUserRole(role string) string { return domain.NormalizeRole(role) } func isDevConsoleRoleAllowed(role string) bool { r := normalizeUserRole(role) return r == domain.RoleSuperAdmin || r == domain.RoleUser } func isDevConsoleViewerRole(role string) bool { r := normalizeUserRole(role) return r == domain.RoleSuperAdmin || r == domain.RoleUser } func normalizeDeveloperAccessPagesForHandler(pages []string) []string { seen := make(map[string]struct{}) normalized := make([]string, 0, len(pages)) add := func(page string) { page = strings.ToLower(strings.TrimSpace(page)) if page == "" { return } if page == domain.DeveloperAccessPageAll { normalized = []string{domain.DeveloperAccessPageAll} seen = map[string]struct{}{domain.DeveloperAccessPageAll: struct{}{}} return } for _, allowed := range domain.DeveloperAccessPageOrder { if page == allowed { if _, exists := seen[page]; exists { return } seen[page] = struct{}{} normalized = append(normalized, page) return } } } for _, page := range pages { add(page) if len(normalized) == 1 && normalized[0] == domain.DeveloperAccessPageAll { return normalized } } if len(normalized) == 0 { return []string{domain.DeveloperAccessPageAll} } return normalized } func developerAccessPagesEqual(left, right []string) bool { leftNormalized := normalizeDeveloperAccessPagesForHandler(left) rightNormalized := normalizeDeveloperAccessPagesForHandler(right) if len(leftNormalized) != len(rightNormalized) { return false } for i := range leftNormalized { if leftNormalized[i] != rightNormalized[i] { return false } } return true } func setCurrentProfileContext(c *fiber.Ctx, profile *domain.UserProfileResponse) { if profile == nil { return } c.Locals("user_profile", profile) if existingUserID, _ := c.Locals("user_id").(string); existingUserID == "" && profile.ID != "" { c.Locals("user_id", profile.ID) } } func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse { if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil { setCurrentProfileContext(c, profile) return profile } if h.Auth != nil { enriched, err := h.Auth.GetEnrichedProfile(c) if err == nil && enriched != nil { setCurrentProfileContext(c, 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.SplitSeq(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 ketoSubjectFromProfile(profile *domain.UserProfileResponse) string { if profile == nil { return "" } id := strings.TrimSpace(profile.ID) if id == "" { return "" } return "User:" + id } func (h *DevHandler) checkProfileKetoPermission(c *fiber.Ctx, profile *domain.UserProfileResponse, namespace, object, relation string) (bool, error) { if profile == nil { return false, nil } if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { return true, nil } if h.Keto == nil { return false, nil } subject := ketoSubjectFromProfile(profile) if subject == "" { return false, nil } return h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation) } func (h *DevHandler) canViewClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { if profile == nil { return false } role := normalizeUserRole(profile.Role) if role == domain.RoleSuperAdmin { return true } if canAccessClientByLegacyScope(profile, summary) { return true } if h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) { return true } allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "view") return err == nil && allowed } func (h *DevHandler) hasDirectRelyingPartyOperatorRelation(c *fiber.Ctx, profile *domain.UserProfileResponse, clientID string) bool { if h.Keto == nil || profile == nil { return false } subject := ketoSubjectFromProfile(profile) if subject == "" || strings.TrimSpace(clientID) == "" { return false } for relation := range allowedRelyingPartyOperatorRelations { tuples, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, relation, subject) if err == nil && len(tuples) > 0 { return true } } return false } func (h *DevHandler) canManageTenantClientsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool { if strings.TrimSpace(tenantID) == "" { return false } allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", tenantID, "grant_dev_permissions") if err == nil && allowed { return true } return h.hasApprovedDeveloperRequest(c, profile, tenantID) } func (h *DevHandler) hasApprovedDeveloperRequest(c *fiber.Ctx, profile *domain.UserProfileResponse, tenantID string) bool { if h.DeveloperSvc == nil || profile == nil { return false } userID := strings.TrimSpace(profile.ID) tenantID = strings.TrimSpace(tenantID) if userID == "" || tenantID == "" { return false } status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), userID, tenantID) if err != nil || status == nil { return false } return status.Status == domain.DeveloperRequestStatusApproved } func (h *DevHandler) canOperateClientByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool { allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, relation) return err == nil && allowed } func (h *DevHandler) canViewClientSecret(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { if canAccessClientByLegacyScope(profile, summary) { return true } return h.canOperateClientByPermit(c, profile, summary, "view_secret") } func (h *DevHandler) canBypassPrivateClientRestriction(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary, relation string) bool { if h.canOperateClientByPermit(c, profile, summary, relation) { return true } allowed, err := h.checkAppManagerPermission(c) return err == nil && allowed } func (h *DevHandler) redactClientSecretUnlessAllowed(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) clientSummary { if summary.ClientSecret == "" { return summary } if h.canViewClientSecret(c, profile, summary) { return summary } summary.ClientSecret = "" return summary } func (h *DevHandler) canViewClientRelations(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { if h.canOperateClientByPermit(c, profile, summary, "view_relationships") { return true } return canAccessClientByLegacyScope(profile, summary) } func (h *DevHandler) canManageClientRelations(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { if profile == nil { return false } if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { return true } if h.canOperateClientByPermit(c, profile, summary, "manage") { return true } clientTenantID := resolveClientTenantID(summary) if clientTenantID != "" && h.canManageTenantClientsByPermit(c, profile, clientTenantID) { return true } return canAccessClientByLegacyScope(profile, summary) } func (h *DevHandler) canManageRPUserMetadata(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { if profile == nil { return false } if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { return true } return h.canOperateClientByPermit(c, profile, summary, "manage") } func (h *DevHandler) canSelfUpdateRPUserMetadata(c *fiber.Ctx, profile *domain.UserProfileResponse, summary clientSummary) bool { if profile == nil { return false } if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { return true } if h.Keto == nil { return true } allowed, err := h.checkProfileKetoPermission(c, profile, "RelyingParty", summary.ID, "access") return err == nil && allowed } func (h *DevHandler) auditClientIDsByPermit(c *fiber.Ctx, profile *domain.UserProfileResponse, clientFilter string) map[string]struct{} { ids := make(map[string]struct{}) if profile == nil || h.Hydra == nil { return ids } if normalizeUserRole(profile.Role) == domain.RoleSuperAdmin { return ids } clientFilter = strings.TrimSpace(clientFilter) if clientFilter != "" { summary, err := h.loadClientSummary(c.Context(), clientFilter) if err == nil && h.canOperateClientByPermit(c, profile, summary, "audit_viewer") { ids[summary.ID] = struct{}{} } return ids } clients, err := h.Hydra.ListClients(c.Context(), 500, 0) if err != nil { slog.Warn("Failed to list clients for audit permission filtering", "error", err) return ids } for _, client := range clients { if isHiddenSystemClient(client) { continue } summary := h.mapClientSummary(client) if h.canOperateClientByPermit(c, profile, summary, "audit_viewer") { ids[summary.ID] = struct{}{} } } return ids } func mergeStringSets(dst map[string]struct{}, src map[string]struct{}) map[string]struct{} { if dst == nil { dst = make(map[string]struct{}, len(src)) } for key := range src { dst[key] = struct{}{} } return dst } func shouldScopeDashboardToExplicitClients(role string) bool { switch normalizeUserRole(role) { case "rp_admin", 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 } role := normalizeUserRole(profile.Role) return role == domain.RoleSuperAdmin } 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 { // [Deprecated] isRPAdminClientAllowed is now simplified. // Non-superadmins are already checked by tenant in canAccessClientByLegacyScope. role := normalizeUserRole(profileRole(profile)) return role == domain.RoleSuperAdmin || role == domain.RoleUser } func manageableTenantKeysFromProfile(profile *domain.UserProfileResponse) map[string]struct{} { keys := make(map[string]struct{}) if profile == nil { return keys } addKey := func(value string) { trimmed := strings.ToLower(strings.TrimSpace(value)) if trimmed != "" { keys[trimmed] = struct{}{} } } if profile.TenantID != nil { addKey(*profile.TenantID) } for _, tenant := range profile.ManageableTenants { addKey(tenant.ID) addKey(tenant.Slug) } for _, tenant := range profile.JoinedTenants { addKey(tenant.ID) addKey(tenant.Slug) } return keys } func canAccessIdentityByTenant(profile *domain.UserProfileResponse, identity service.KratosIdentity) bool { if normalizeUserRole(profileRole(profile)) == domain.RoleSuperAdmin { return true } keys := manageableTenantKeysFromProfile(profile) if len(keys) == 0 { return false } for _, raw := range []string{ extractTraitString(identity.Traits, "tenant_id"), } { if _, ok := keys[strings.ToLower(strings.TrimSpace(raw))]; ok { return true } } return false } func mapDevUserSummary(identity service.KratosIdentity) devUserSummary { traits := identity.Traits return devUserSummary{ ID: identity.ID, Name: extractTraitString(traits, "name"), Email: extractTraitString(traits, "email"), LoginID: resolvePasswordLoginID(traits), } } 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 tenantAccessPolicyChanged(before, after map[string]any) bool { if clientTenantAccessRestricted(before) != clientTenantAccessRestricted(after) { return true } beforeAllowed := clientAllowedTenants(before) afterAllowed := clientAllowedTenants(after) if len(beforeAllowed) != len(afterAllowed) { return true } for i := range beforeAllowed { if beforeAllowed[i] != afterAllowed[i] { return true } } return false } func (h *DevHandler) revokeClientConsentsForPolicyChange(ctx context.Context, clientID string) error { return revokeClientConsentsForPolicyChange(ctx, h.Hydra, h.ConsentRepo, clientID) } 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 normalizeRelyingPartyRelation(relation string) string { return strings.TrimSpace(relation) } func isAllowedRelyingPartyOperatorRelation(relation string) bool { _, ok := allowedRelyingPartyOperatorRelations[normalizeRelyingPartyRelation(relation)] return ok } func normalizeClientRelationSubject(subject, userID string) string { subject = strings.TrimSpace(subject) if subject != "" { return subject } userID = strings.TrimSpace(userID) if userID == "" { return "" } return "User:" + userID } func parseClientRelationSubject(subject string) (string, string) { subject = strings.TrimSpace(subject) if subject == "" { return "", "" } parts := strings.SplitN(subject, ":", 2) if len(parts) != 2 { return "", "" } return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) } func validateClientRelationWriteInput(relation, subject string) error { relation = normalizeRelyingPartyRelation(relation) if !isAllowedRelyingPartyOperatorRelation(relation) { return fmt.Errorf("unsupported relation") } subjectType, subjectID := parseClientRelationSubject(subject) if subjectType != "User" || subjectID == "" || strings.Contains(subjectID, "#") { return fmt.Errorf("subject must be in User: format") } return nil } func mapRelationTupleSummary(tuple service.RelationTuple, identity *service.KratosIdentity) clientRelationSummary { subjectType, subjectID := parseClientRelationSubject(tuple.SubjectID) summary := clientRelationSummary{ Relation: tuple.Relation, Subject: tuple.SubjectID, SubjectType: subjectType, SubjectID: subjectID, } if identity != nil { summary.UserName = extractTraitString(identity.Traits, "name") summary.UserEmail = extractTraitString(identity.Traits, "email") summary.UserLoginID = resolvePasswordLoginID(identity.Traits) } return summary } func dedupeRelationTuples(tuples []service.RelationTuple) []service.RelationTuple { if len(tuples) <= 1 { return tuples } seen := make(map[string]struct{}, len(tuples)) deduped := make([]service.RelationTuple, 0, len(tuples)) for _, tuple := range tuples { key := strings.TrimSpace(tuple.Relation) + "\x00" + strings.TrimSpace(tuple.SubjectID) if _, exists := seen[key]; exists { continue } seen[key] = struct{}{} deduped = append(deduped, tuple) } return deduped } func (h *DevHandler) loadClientSummary(ctx context.Context, clientID string) (clientSummary, error) { clientID = strings.TrimSpace(clientID) if clientID == "" { return clientSummary{}, fmt.Errorf("client id is required") } client, err := h.Hydra.GetClient(ctx, clientID) if err != nil { return clientSummary{}, err } return h.mapClientSummary(*client), nil } func (h *DevHandler) getRelationRequestProfile(c *fiber.Ctx) *domain.UserProfileResponse { return h.getCurrentProfile(c) } func (h *DevHandler) SearchUsers(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } // Tightened Security: Only SuperAdmin bypasses the client-specific manage check. // Regular users (RoleUser) or RPAdmins must have the 'manage' permit for the requested clientId. if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { clientID := strings.TrimSpace(c.Query("clientId")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "clientId is required for user search") } summary, err := h.loadClientSummary(c.Context(), clientID) if err != nil || !h.canManageClientRelations(c, profile, summary) { // canManageClientRelations checks for 'manage' permit in Keto. return errorJSON(c, fiber.StatusForbidden, "forbidden: manage permission required for user search") } } if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") } search := strings.ToLower(strings.TrimSpace(c.Query("search"))) limit := c.QueryInt("limit", 10) if limit <= 0 { limit = 10 } if limit > 20 { limit = 20 } identities, err := h.KratosAdmin.ListIdentities(c.Context()) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } items := make([]devUserSummary, 0, limit) for _, identity := range identities { if !canAccessIdentityByTenant(profile, identity) { continue } summary := mapDevUserSummary(identity) if search != "" { matched := false for _, candidate := range []string{ strings.ToLower(summary.Name), strings.ToLower(summary.Email), strings.ToLower(summary.LoginID), } { if candidate != "" && strings.Contains(candidate, search) { matched = true break } } if !matched { continue } } items = append(items, summary) if len(items) >= limit { break } } return c.JSON(devUserListResponse{Items: items}) } 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 setCurrentProfileContext(c, enriched) } } if ok && profile != nil { setCurrentProfileContext(c, profile) role := normalizeUserRole(profile.Role) if role == domain.RoleSuperAdmin { slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID) return true, nil } // Super Admin bypass by email 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:global#manage_all allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+subject, "System", "global", "manage_all") 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 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:global#manage_all allowed, err := h.Keto.CheckPermission(c.Context(), "User:"+tokenSubject, "System", "global", "manage_all") 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 (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 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 !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit { continue } items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary)) } return items, nil } func (h *DevHandler) listAllVisibleClientSummaries(c *fiber.Ctx, profile *domain.UserProfileResponse) ([]clientSummary, error) { const pageSize = 500 items := make([]clientSummary, 0) for offset := 0; ; offset += pageSize { page, err := h.listVisibleClientSummaries(c, profile, pageSize, offset) if err != nil { return nil, err } items = append(items, page...) if len(page) < pageSize { break } } return items, nil } func clientSummaryCursorKey(client clientSummary) (time.Time, string) { timestamp := time.Unix(0, 0).UTC() if client.CreatedAt != nil && !client.CreatedAt.IsZero() { timestamp = client.CreatedAt.UTC() } return timestamp, client.ID } 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]any 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) cursorRaw := strings.TrimSpace(c.Query("cursor")) if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } profile := h.getCurrentProfile(c) allItems, err := h.listAllVisibleClientSummaries(c, profile) if err != nil { status := fiber.StatusInternalServerError errMsg := err.Error() 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, status, errMsg) } var items []clientSummary nextCursor := "" if cursorRaw != "" { ordered := append([]clientSummary(nil), allItems...) pagination.SortByKeyDesc(ordered, clientSummaryCursorKey) items, nextCursor, err = pagination.PageByCursor(ordered, limit, cursorRaw, clientSummaryCursorKey) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") } offset = 0 } else { if offset > len(allItems) { offset = len(allItems) } end := min(offset+limit, len(allItems)) items = allItems[offset:end] if len(allItems) > end && len(items) > 0 { lastTimestamp, lastID := clientSummaryCursorKey(items[len(items)-1]) nextCursor = pagination.Encode(lastTimestamp, lastID) } } return c.JSON(clientListResponse{ Items: items, Limit: limit, Offset: offset, Cursor: cursorRaw, NextCursor: nextCursor, }) } func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } profile := h.getRelationRequestProfile(c) summary, err := h.loadClientSummary(c.Context(), clientID) if err != nil { return errorJSON(c, fiber.StatusNotFound, "client not found") } if !h.canViewClientRelations(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if h.Keto == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "keto service unavailable") } items := make([]clientRelationSummary, 0) for relation := range allowedRelyingPartyOperatorRelations { tuples, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, relation, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } tuples = dedupeRelationTuples(tuples) for _, tuple := range tuples { var identity *service.KratosIdentity if tuple.SubjectID != "" && h.KratosAdmin != nil { _, subjectID := parseClientRelationSubject(tuple.SubjectID) if subjectID != "" { identity, _ = h.KratosAdmin.GetIdentity(c.Context(), subjectID) } } items = append(items, mapRelationTupleSummary(tuple, identity)) } } return c.JSON(clientRelationListResponse{Items: items}) } func (h *DevHandler) AddClientRelation(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 clientRelationUpsertRequest if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } req.Relation = normalizeRelyingPartyRelation(req.Relation) req.Subject = normalizeClientRelationSubject(req.Subject, req.UserID) if err := validateClientRelationWriteInput(req.Relation, req.Subject); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } profile := h.getRelationRequestProfile(c) summary, err := h.loadClientSummary(c.Context(), clientID) if err != nil { return errorJSON(c, fiber.StatusNotFound, "client not found") } if !h.canManageClientRelations(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if h.Keto == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "keto service unavailable") } if h.KetoOutbox == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "keto outbox unavailable") } existing, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, req.Relation, req.Subject) if err == nil && len(existing) > 0 { return errorJSON(c, fiber.StatusConflict, "relation already exists") } if err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "RelyingParty", Object: clientID, Relation: req.Relation, Subject: req.Subject, Action: domain.KetoOutboxActionCreate, }); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } h.setAuditDetailsExtra(c, map[string]any{ "action": "ADD_RELATION", "target_id": clientID, "tenant_id": tenantID, "after": map[string]any{ "relation": req.Relation, "subject": req.Subject, }, }) return c.Status(fiber.StatusCreated).JSON(mapRelationTupleSummary(service.RelationTuple{ Object: clientID, Relation: req.Relation, SubjectID: req.Subject, }, nil)) } func (h *DevHandler) RemoveClientRelation(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") } relation := normalizeRelyingPartyRelation(c.Query("relation")) subject := normalizeClientRelationSubject(c.Query("subject"), c.Query("userId")) if err := validateClientRelationWriteInput(relation, subject); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } profile := h.getRelationRequestProfile(c) summary, err := h.loadClientSummary(c.Context(), clientID) if err != nil { return errorJSON(c, fiber.StatusNotFound, "client not found") } if !h.canManageClientRelations(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if h.KetoOutbox == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "keto outbox unavailable") } if err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "RelyingParty", Object: clientID, Relation: relation, Subject: subject, Action: domain.KetoOutboxActionDelete, }); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } h.setAuditDetailsExtra(c, map[string]any{ "action": "REMOVE_RELATION", "target_id": clientID, "tenant_id": tenantID, "before": map[string]any{ "relation": relation, "subject": subject, }, }) return c.SendStatus(fiber.StatusNoContent) } 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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if !canAccessClientByLegacyScope(profile, summary) && !h.canViewClientByPermit(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } // Check permission for private clients if summary.Type == "private" && !h.canViewClientByPermit(c, profile, summary) { 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") } } cacheState, _ := h.publicHeadlessJWKSCacheState(summary.ID) summary = h.redactClientSecretUnlessAllowed(c, profile, summary) return c.JSON(clientDetailResponse{ Client: summary, HeadlessJWKSCache: cacheState, 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) publicHeadlessJWKSCacheState(clientID string) (*domain.HeadlessJWKSCacheState, error) { if h.HeadlessJWKS == nil { h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil) } if h.HeadlessJWKS == nil { return nil, nil } return h.HeadlessJWKS.PublicState(clientID) } func (h *DevHandler) GetRPUserMetadata(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) userID := strings.TrimSpace(c.Params("userId")) if clientID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id and user id are required") } if h.RPUserMetadataRepo == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "rp user metadata repository unavailable") } profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } summary, err := h.loadClientSummary(c.Context(), clientID) if err != nil { return errorJSON(c, fiber.StatusNotFound, "client not found") } if !h.canViewClientByPermit(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to view client metadata") } metadata, err := h.RPUserMetadataRepo.Get(c.Context(), clientID, userID) if err != nil { return c.JSON(fiber.Map{ "clientId": clientID, "userId": userID, "metadata": domain.JSONMap{}, }) } return c.JSON(metadata) } func (h *DevHandler) UpsertRPUserMetadata(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) userID := strings.TrimSpace(c.Params("userId")) if clientID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id and user id are required") } if h.RPUserMetadataRepo == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "rp user metadata repository unavailable") } profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } summary, err := h.loadClientSummary(c.Context(), clientID) if err != nil { return errorJSON(c, fiber.StatusNotFound, "client not found") } if !h.canManageRPUserMetadata(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update client metadata") } var req struct { Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.Metadata == nil { req.Metadata = map[string]any{} } normalizedMetadata, err := normalizeRPUserMetadataForClient(req.Metadata, summary.Metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } row := &domain.RPUserMetadata{ ClientID: clientID, UserID: userID, Metadata: normalizedMetadata, } if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if err := h.syncRPUserMetadataToKratos(c.Context(), userID, clientID, normalizedMetadata); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(row) } func (h *DevHandler) SelfUpdateRPUserMetadata(c *fiber.Ctx) error { clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") } if h.RPUserMetadataRepo == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "rp user metadata repository unavailable") } profile := h.getCurrentProfile(c) if profile == nil || strings.TrimSpace(profile.ID) == "" { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } summary, err := h.loadClientSummary(c.Context(), clientID) if err != nil { return errorJSON(c, fiber.StatusNotFound, "client not found") } if !h.canSelfUpdateRPUserMetadata(c, profile, summary) { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permission to update own client metadata") } var req struct { Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.Metadata == nil { req.Metadata = map[string]any{} } filteredMetadata, err := filterSelfWritableRPUserMetadata(req.Metadata, summary.Metadata) if err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) } normalizedMetadata, err := normalizeRPUserMetadataForClient(filteredMetadata, summary.Metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } userID := strings.TrimSpace(profile.ID) mergedMetadata := domain.JSONMap{} if existing, err := h.RPUserMetadataRepo.Get(c.Context(), clientID, userID); err == nil && existing != nil { for key, value := range existing.Metadata { mergedMetadata[key] = value } } for key, value := range normalizedMetadata { mergedMetadata[key] = value } row := &domain.RPUserMetadata{ ClientID: clientID, UserID: userID, Metadata: mergedMetadata, } if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if err := h.syncRPUserMetadataToKratos(c.Context(), userID, clientID, mergedMetadata); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(row) } func (h *DevHandler) syncRPUserMetadataToKratos(ctx context.Context, userID string, clientID string, metadata domain.JSONMap) error { if h == nil || h.KratosAdmin == nil { return nil } identity, err := h.KratosAdmin.GetIdentity(ctx, userID) if err != nil { return fmt.Errorf("failed to load kratos identity for rp user metadata: %w", err) } if identity == nil { return errors.New("kratos identity not found for rp user metadata") } traits := identity.Traits if traits == nil { traits = map[string]any{} } rawRPClaims, _ := traits["rp_custom_claims"].(map[string]any) if rawRPClaims == nil { rawRPClaims = map[string]any{} } rawRPClaims[clientID] = metadata traits["rp_custom_claims"] = rawRPClaims identityWriter := h.IdentityWriter if identityWriter == nil { identityWriter = service.NewIdentityWriteService(h.KratosAdmin, h.Redis) } _, err = identityWriter.UpdateIdentity(ctx, service.IdentityUpdateRequest{ IdentityID: identity.ID, Traits: traits, State: identity.State, Reason: "rp_custom_claims_sync", Source: "dev_handler", }) if err != nil { return fmt.Errorf("failed to update kratos rp user metadata: %w", err) } return nil } type rpUserMetadataClaimSchema struct { Key string ValueType string ReadPermission string WritePermission string } func normalizeCustomClaimPermission(value any) string { permission := strings.TrimSpace(readInterfaceString(value, "")) switch permission { case "user_and_admin": return "user_and_admin" default: return "admin_only" } } func normalizeCustomClaimPermissions(value any, fallbackRead string, fallbackWrite string) map[string]any { var record map[string]any switch typed := value.(type) { case map[string]any: record = typed case domain.JSONMap: record = map[string]any(typed) } return map[string]any{ "readPermission": normalizeCustomClaimPermission(readMapValueOrFallback(record, "readPermission", fallbackRead)), "writePermission": normalizeCustomClaimPermission(readMapValueOrFallback(record, "writePermission", fallbackWrite)), } } func readMapValueOrFallback(values map[string]any, key string, fallback string) any { if values == nil { return fallback } if value, ok := values[key]; ok { return value } return fallback } func normalizeRPUserMetadataForClient(metadata map[string]any, clientMetadata map[string]any) (domain.JSONMap, error) { schemas, err := rpUserMetadataClaimSchemas(clientMetadata) if err != nil { return nil, err } normalized := domain.JSONMap{} for rawKey, rawValue := range metadata { key := strings.TrimSpace(rawKey) if key == "" || isEmptyRPUserMetadataValue(rawValue) { continue } if strings.HasSuffix(key, "_permissions") { claimKey := strings.TrimSuffix(key, "_permissions") schema, ok := schemas[claimKey] if !ok { return nil, fmt.Errorf("rp user metadata claim is not configured: %s", claimKey) } normalized[key] = normalizeCustomClaimPermissions(rawValue, schema.ReadPermission, schema.WritePermission) continue } schema, ok := schemas[key] if !ok { return nil, fmt.Errorf("rp user metadata claim is not configured: %s", key) } textValue, err := stringifyRPUserMetadataValue(rawValue) if err != nil { return nil, fmt.Errorf("rp user metadata %s is invalid: %w", key, err) } parsed, err := parseConfiguredClaimValue(textValue, schema.ValueType) if err != nil { return nil, fmt.Errorf("rp user metadata %s is invalid: %w", key, err) } normalized[key] = parsed permissionKey := key + "_permissions" if _, exists := normalized[permissionKey]; !exists { normalized[permissionKey] = map[string]any{ "readPermission": schema.ReadPermission, "writePermission": schema.WritePermission, } } } return normalized, nil } func filterSelfWritableRPUserMetadata(metadata map[string]any, clientMetadata map[string]any) (map[string]any, error) { schemas, err := rpUserMetadataClaimSchemas(clientMetadata) if err != nil { return nil, err } filtered := map[string]any{} for rawKey, rawValue := range metadata { key := strings.TrimSpace(rawKey) if key == "" || isEmptyRPUserMetadataValue(rawValue) { continue } if strings.HasSuffix(key, "_permissions") { return nil, fmt.Errorf("rp user metadata permission cannot be updated by user: %s", key) } schema, ok := schemas[key] if !ok { return nil, fmt.Errorf("rp user metadata claim is not configured: %s", key) } if normalizeCustomClaimPermission(schema.WritePermission) != "user_and_admin" { return nil, fmt.Errorf("rp user metadata claim is admin only: %s", key) } filtered[key] = rawValue } return filtered, nil } func rpUserMetadataClaimSchemas(clientMetadata map[string]any) (map[string]rpUserMetadataClaimSchema, error) { rawClaims, ok := clientMetadata[domain.MetadataIDTokenClaims] if !ok || rawClaims == nil { return map[string]rpUserMetadataClaimSchema{}, nil } claims, err := normalizeIDTokenClaimsForDevConsole(rawClaims) if err != nil { return nil, err } schemas := make(map[string]rpUserMetadataClaimSchema, len(claims)) for _, claim := range claims { if claim.Namespace != "rp_claims" { continue } schemas[claim.Key] = rpUserMetadataClaimSchema{ Key: claim.Key, ValueType: claim.ValueType, ReadPermission: claim.ReadPermission, WritePermission: claim.WritePermission, } } return schemas, nil } func isEmptyRPUserMetadataValue(value any) bool { if value == nil { return true } if text, ok := value.(string); ok { return strings.TrimSpace(text) == "" } return false } func stringifyRPUserMetadataValue(value any) (string, error) { switch typed := value.(type) { case string: return strings.TrimSpace(typed), nil case bool: return strconv.FormatBool(typed), nil case float64: return strconv.FormatFloat(typed, 'f', -1, 64), nil case float32: return strconv.FormatFloat(float64(typed), 'f', -1, 32), nil case int: return strconv.Itoa(typed), nil case int64: return strconv.FormatInt(typed, 10), nil case int32: return strconv.FormatInt(int64(typed), 10), nil case json.Number: return typed.String(), nil default: data, err := json.Marshal(value) if err != nil { return "", err } return string(data), nil } } func (h *DevHandler) syncHeadlessJWKSCache(ctx context.Context, client domain.HydraClient, reason string) { if h.HeadlessJWKS == nil { h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil) } if h.HeadlessJWKS == nil { return } if !client.IsHeadlessLoginEnabled() { _ = h.HeadlessJWKS.DeleteState(client.ClientID) return } if _, err := h.HeadlessJWKS.ForceRefresh(ctx, client, reason); err != nil { slog.Warn("failed to refresh headless jwks cache after client save", "clientID", client.ClientID, "reason", reason, "error", err) } } 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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } canChangeStatusByPermit := h.canOperateClientByPermit(c, profile, summary, "change_status") canEditConfigByPermit := h.canOperateClientByPermit(c, profile, summary, "edit_config") canChangeStatus := canChangeStatusByPermit || canEditConfigByPermit if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatus { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } if summary.Type == "private" && !h.canBypassPrivateClientRestriction(c, profile, summary, "change_status") && !h.canBypassPrivateClientRestriction(c, profile, summary, "edit_config") { if !canChangeStatus { 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) updatedSummary = h.redactClientSecretUnlessAllowed(c, profile, updatedSummary) cacheState, _ := h.publicHeadlessJWKSCacheState(updatedSummary.ID) return c.JSON(clientDetailResponse{ Client: updatedSummary, HeadlessJWKSCache: cacheState, 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") } if tenantID == "" && profile.TenantID != nil { tenantID = *profile.TenantID } if (role == "rp_admin" || role == domain.RoleUser) && !h.canManageTenantClientsByPermit(c, profile, tenantID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant grant permission is required") } 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") } 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") } if requestIncludesInlineHeadlessJWKS(req) { return errorJSON(c, fiber.StatusBadRequest, "headless login supports jwksUri only; inline jwks is not supported") } metadata := mergeMetadata(nil, req.Metadata) if metadata == nil { metadata = map[string]any{} } // [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 } var err error metadata["status"] = status metadata["created_at"] = time.Now().Format(time.RFC3339) backchannelLogoutURI := strings.TrimSpace(valueOr(req.BackchannelLogoutURI, "")) backchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, false) metadata, err = normalizeBackchannelLogoutMetadata(metadata, backchannelLogoutURI, backchannelLogoutSessionRequired) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } metadata, err = normalizeClientTenantAccessMetadata(metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } metadata, err = normalizeIDTokenClaimsMetadata(metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } clientType = normalizeClientTypeForHeadless(clientType, metadata) // [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 && !h.canManageTenantClientsByPermit(c, profile, tenantID) { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions to create private client") } } tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" { if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" } } if err := validateHeadlessClientInput(valueOr(req.JwksUri, ""), req.Jwks, metadata); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig( tokenAuthMethod, valueOr(req.JwksUri, ""), req.Jwks, metadata, ) clientReq := domain.HydraClient{ ClientID: clientID, ClientName: name, RedirectURIs: redirectURIs, GrantTypes: grantTypes, ResponseTypes: responseTypes, Scope: strings.Join(scopes, " "), TokenEndpointAuthMethod: tokenAuthMethod, SkipConsent: new(valueOrBool(req.SkipConsent, true)), JWKSUri: jwksURI, JWKS: jwks, BackChannelLogoutURI: backchannelLogoutURI, BackChannelLogoutSessionRequired: new(backchannelLogoutSessionRequired), 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()) } h.syncHeadlessJWKSCache(c.Context(), *created, "client_create") // [New] Automatically grant admin permission to the creator in Keto if h.KetoOutbox != nil && profile != nil { subject := "User:" + profile.ID if err := h.grantCreatorAdminRelation(c, created.ClientID, subject); err != nil { slog.Warn("failed to grant automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err) } else { slog.Info("granted automatic admin permission to creator", "clientID", created.ClientID, "userID", profile.ID) } } // 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) cacheState, _ := h.publicHeadlessJWKSCacheState(summary.ID) return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{ Client: summary, HeadlessJWKSCache: cacheState, 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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } isSuperAdmin := role == domain.RoleSuperAdmin if !isSuperAdmin && !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") { return errorJSON(c, fiber.StatusForbidden, "forbidden: edit_config permission is required") } 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 !isSuperAdmin && (currentSummary.Type == "private" || clientType == "private") { if !h.canBypassPrivateClientRestriction(c, profile, currentSummary, "edit_config") { 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") } if requestIncludesInlineHeadlessJWKS(req) { return errorJSON(c, fiber.StatusBadRequest, "headless login supports jwksUri only; inline jwks is not supported") } metadata := mergeMetadata(current.Metadata, req.Metadata) if status != "" { if metadata == nil { metadata = map[string]any{} } metadata["status"] = status } resolvedBackchannelLogoutURI := valueOr(req.BackchannelLogoutURI, current.BackchannelLogoutURI()) resolvedBackchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, current.BackchannelLogoutSessionRequiredValue()) metadata, err = normalizeBackchannelLogoutMetadata( metadata, resolvedBackchannelLogoutURI, resolvedBackchannelLogoutSessionRequired, ) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } metadata, err = normalizeClientTenantAccessMetadata(metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } metadata, err = normalizeIDTokenClaimsMetadata(metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } resolvedClientType := currentSummary.Type if clientType != "" { resolvedClientType = clientType } resolvedClientType = normalizeClientTypeForHeadless(resolvedClientType, metadata) resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod) resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri) resolvedJWKS := req.Jwks if req.Jwks == nil { if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { resolvedJWKS = nil } else { resolvedJWKS = current.JWKS } } if err := validateHeadlessClientInput(resolvedJWKSURI, resolvedJWKS, metadata); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig( resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata, ) resolvedSkipConsent := valueOrBool(req.SkipConsent, valueOrBool(current.SkipConsent, true)) 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: resolvedTokenAuthMethod, SkipConsent: new(resolvedSkipConsent), JWKSUri: resolvedJWKSURI, JWKS: resolvedJWKS, BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI), BackChannelLogoutSessionRequired: new(resolvedBackchannelLogoutSessionRequired), Metadata: metadata, } if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) } tenantPolicyChanged := tenantAccessPolicyChanged(current.Metadata, updated.Metadata) beforeScopes := strings.Fields(current.Scope) afterScopes := strings.Fields(updated.Scope) beforeAllowedTenants := readStringSliceMetadata(current.Metadata, "allowed_tenants") afterAllowedTenants := readStringSliceMetadata(metadata, "allowed_tenants") beforeIDTokenClaims := readMetadataValueOrNil(current.Metadata, "id_token_claims") afterIDTokenClaims := readMetadataValueOrNil(metadata, "id_token_claims") 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, "scopes": beforeScopes, "tenant_access_restricted": readMetadataBoolValue(current.Metadata, "tenant_access_restricted"), "allowed_tenants": beforeAllowedTenants, "id_token_claims": beforeIDTokenClaims, "token_endpoint_auth_method": current.TokenEndpointAuthMethod, "jwks_uri": current.JWKSUri, "backchannel_logout_uri": strings.TrimSpace(current.BackchannelLogoutURI()), "backchannel_logout_session_required": current.BackchannelLogoutSessionRequiredValue(), "headless_login_enabled": readMetadataBoolValue(current.Metadata, domain.MetadataHeadlessLoginEnabled), "headless_token_endpoint_auth_method": readMetadataStringValue(current.Metadata, domain.MetadataHeadlessTokenEndpointAuthMethod), "headless_jwks_uri": readMetadataStringValue(current.Metadata, domain.MetadataHeadlessJWKSURI), }, "after": map[string]any{ "name": strings.TrimSpace(updated.ClientName), "type": clientTypeOrDefault(updated.TokenEndpointAuthMethod), "status": resolveStatusFromMetadata(updated.Metadata), "scopes": afterScopes, "tenant_access_restricted": readMetadataBoolValue(metadata, "tenant_access_restricted"), "allowed_tenants": afterAllowedTenants, "id_token_claims": afterIDTokenClaims, "token_endpoint_auth_method": resolvedTokenAuthMethod, "jwks_uri": resolvedJWKSURI, "backchannel_logout_uri": strings.TrimSpace(resolvedBackchannelLogoutURI), "backchannel_logout_session_required": resolvedBackchannelLogoutSessionRequired, "headless_login_enabled": readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled), "headless_token_endpoint_auth_method": readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod), "headless_jwks_uri": readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI), }, }) updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if tenantPolicyChanged { if err := h.revokeClientConsentsForPolicyChange(c.Context(), clientID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to revoke existing consents after tenant policy update: "+err.Error()) } } h.syncHeadlessJWKSCache(c.Context(), *updatedClient, "client_update") if updatedClient.ClientSecret != "" { if h.SecretRepo != nil { _ = h.SecretRepo.Upsert(c.Context(), updatedClient.ClientID, updatedClient.ClientSecret) } if h.Redis != nil { _ = h.Redis.Set("client_secret:"+updatedClient.ClientID, updatedClient.ClientSecret, 0) } } summary := h.mapClientSummary(*updatedClient) cacheState, _ := h.publicHeadlessJWKSCacheState(summary.ID) return c.JSON(clientDetailResponse{ Client: summary, HeadlessJWKSCache: cacheState, 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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "manage") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } // [Security] Check permission for private clients if summary.Type == "private" { if !h.canBypassPrivateClientRestriction(c, profile, summary, "manage") { 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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } 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()) } summary := h.mapClientSummary(*client) canViewConsentsByPermit := h.canOperateClientByPermit(c, profile, summary, "view_consents") if !canAccessClientByLegacyScope(profile, summary) && !canViewConsentsByPermit { 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) cursorRaw := strings.TrimSpace(c.Query("cursor")) if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } // [Isolation] Get admin tenant ID from locals or header adminTenantID := "" if profile != nil { if role != domain.RoleSuperAdmin && !canViewConsentsByPermit && 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 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 } } } queryLimit := limit queryOffset := offset if cursorRaw != "" || subject != "" || (statusFilter != "" && statusFilter != "all") { queryLimit = 10000 queryOffset = 0 } if adminTenantID != "" { consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, queryLimit, queryOffset) } else { consents, total, err = h.ConsentRepo.List(c.Context(), clientID, queryLimit, queryOffset) } 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 := "" if h.KratosAdmin != nil { 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 } } } var rpMetadata domain.JSONMap if h.RPUserMetadataRepo != nil { if row, err := h.RPUserMetadataRepo.Get(c.Context(), consent.ClientID, consent.Subject); err == nil && row != nil && len(row.Metadata) > 0 { rpMetadata = row.Metadata } } 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, RPMetadata: rpMetadata, }) } pagination.SortByKeyDesc(items, consentSummaryCursorKey) nextCursor := "" if cursorRaw != "" { items, nextCursor, err = pagination.PageByCursor(items, limit, cursorRaw, consentSummaryCursorKey) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") } offset = 0 } else if queryLimit != limit { if offset > len(items) { offset = len(items) } end := min(offset+limit, len(items)) pageItems := items[offset:end] if len(items) > end && len(pageItems) > 0 { lastTimestamp, lastID := consentSummaryCursorKey(pageItems[len(pageItems)-1]) nextCursor = pagination.Encode(lastTimestamp, lastID) } items = pageItems } else if total > int64(offset+len(items)) && len(items) > 0 { lastTimestamp, lastID := consentSummaryCursorKey(items[len(items)-1]) nextCursor = pagination.Encode(lastTimestamp, lastID) } return c.JSON(consentListResponse{ Items: items, Total: total, Limit: limit, Offset: offset, Cursor: cursorRaw, NextCursor: nextCursor, }) } func consentSummaryCursorKey(consent consentSummary) (time.Time, string) { return consent.CreatedAt, consent.ClientID + ":" + consent.Subject } 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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if clientID != "" { 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()) } summary := h.mapClientSummary(*client) if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "revoke_consents") { 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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } // [Tenant Isolation] if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "rotate_secret") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } // [Security] Check permission for private clients if summary.Type == "private" { if !h.canBypassPrivateClientRestriction(c, profile, summary, "rotate_secret") { 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 cacheState, _ := h.publicHeadlessJWKSCacheState(updatedSummary.ID) return c.JSON(clientDetailResponse{ Client: updatedSummary, HeadlessJWKSCache: cacheState, 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) RefreshHeadlessJWKSCache(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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "operate_jwks") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } if !current.IsHeadlessLoginEnabled() { return errorJSON(c, fiber.StatusBadRequest, "headless login is not enabled for this client") } if h.HeadlessJWKS == nil { h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil) } if h.HeadlessJWKS == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "headless jwks cache service is unavailable") } if _, err := h.HeadlessJWKS.ForceRefresh(c.Context(), *current, "manual_refresh"); err != nil { return errorJSON(c, fiber.StatusBadRequest, headlessClientAssertionErrorMessage(err)) } h.setAuditDetailsExtra(c, map[string]any{ "action": "REFRESH_HEADLESS_JWKS_CACHE", "target_id": clientID, "tenant_id": tenantID, }) cacheState, _ := h.publicHeadlessJWKSCacheState(clientID) return c.JSON(clientDetailResponse{ Client: summary, HeadlessJWKSCache: cacheState, 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) RevokeHeadlessJWKSCache(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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } if !canAccessClientByLegacyScope(profile, summary) && !h.canOperateClientByPermit(c, profile, summary, "operate_jwks") { return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client") } if h.HeadlessJWKS == nil { h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil) } if h.HeadlessJWKS != nil { _ = h.HeadlessJWKS.DeleteState(clientID) } h.setAuditDetailsExtra(c, map[string]any{ "action": "REVOKE_HEADLESS_JWKS_CACHE", "target_id": clientID, "tenant_id": tenantID, }) return c.SendStatus(fiber.StatusNoContent) } 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 !isDevConsoleViewerRole(role) { return errorJSON(c, fiber.StatusForbidden, "forbidden") } 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"))) allowedClientIDs := managedClientIDsFromProfile(profile) allowedClientIDs = mergeStringSets(allowedClientIDs, h.auditClientIDsByPermit(c, profile, clientFilter)) if role != domain.RoleSuperAdmin && len(allowedClientIDs) == 0 && (role == "rp_admin" || role == domain.RoleUser) { return c.JSON(devAuditListResponse{ Items: []domain.AuditLog{}, Limit: limit, Cursor: c.Query("cursor"), }) } tenantFilter := strings.TrimSpace(c.Query("tenant_id")) if tenantFilter == "" { tenantFilter = h.resolveDevTenantScope(c) } if role != domain.RoleSuperAdmin && len(allowedClientIDs) > 0 { tenantFilter = "" } if role != domain.RoleSuperAdmin && tenantFilter == "" && len(allowedClientIDs) == 0 { 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) 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") } visibleClients, err := h.listVisibleClientSummaries(c, profile, 500, 0) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to resolve visible clients") } userTenantID := tenantIDFromProfile(profile) totalClients := int64(len(visibleClients)) visibleClientIDs := clientIDSetFromSummaries(visibleClients) // 2. Auth Failures (24h) var authFailures int64 var activeSessions int64 if h.AuditRepo != nil { 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{ TotalClients: totalClients, ActiveSessions: activeSessions, AuthFailures: authFailures, }) } 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 { 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 client.IsHeadlessLoginEnabled() { clientType = "private" } else 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, TokenEndpointAuthMethod: client.TokenEndpointAuthMethod, SkipConsent: valueOrBool(client.SkipConsent, true), JwksUri: client.JWKSUri, Jwks: client.JWKS, BackchannelLogoutURI: client.BackchannelLogoutURI(), BackchannelLogoutSessionRequired: client.BackchannelLogoutSessionRequiredValue(), Metadata: client.Metadata, } } func readMetadataStringValue(metadata map[string]any, key string) string { if metadata == nil { return "" } raw, _ := metadata[key].(string) return strings.TrimSpace(raw) } func readMetadataBoolValue(metadata map[string]any, key string) bool { if metadata == nil { return false } value, _ := metadata[key].(bool) return value } func readStringSliceMetadata(metadata map[string]any, key string) []string { if metadata == nil { return nil } raw, ok := metadata[key] if !ok || raw == nil { return nil } switch typed := raw.(type) { case []string: result := make([]string, 0, len(typed)) for _, item := range typed { if trimmed := strings.TrimSpace(item); trimmed != "" { result = append(result, trimmed) } } return result case []any: result := make([]string, 0, len(typed)) for _, item := range typed { if str, ok := item.(string); ok { if trimmed := strings.TrimSpace(str); trimmed != "" { result = append(result, trimmed) } } } return result default: return nil } } func readMetadataValueOrNil(metadata map[string]any, key string) any { if metadata == nil { return nil } value, ok := metadata[key] if !ok { return nil } return value } func normalizeBackchannelLogoutMetadata(metadata map[string]any, logoutURI string, sessionRequired bool) (map[string]any, error) { if metadata == nil { metadata = map[string]any{} } trimmedURI := strings.TrimSpace(logoutURI) if err := validateBackchannelLogoutURI(trimmedURI); err != nil { return nil, err } if trimmedURI == "" { delete(metadata, domain.MetadataBackChannelLogoutURI) delete(metadata, domain.MetadataBackChannelLogoutSessionRequired) return metadata, nil } metadata[domain.MetadataBackChannelLogoutURI] = trimmedURI metadata[domain.MetadataBackChannelLogoutSessionRequired] = sessionRequired return metadata, nil } func validateBackchannelLogoutURI(raw string) error { trimmed := strings.TrimSpace(raw) if trimmed == "" { return nil } parsed, err := url.Parse(trimmed) if err != nil || parsed == nil { return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL") } if parsed.Scheme == "" || parsed.Host == "" { return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL") } if parsed.Fragment != "" { return fmt.Errorf("backchannelLogoutUri must not include a fragment") } switch strings.ToLower(parsed.Scheme) { case "https": return nil case "http": if isAllowedLocalBackchannelLogoutHost(parsed.Hostname()) { return nil } return fmt.Errorf("backchannelLogoutUri must use https outside local development") default: return fmt.Errorf("backchannelLogoutUri must use http or https") } } func isAllowedLocalBackchannelLogoutHost(rawHost string) bool { host := strings.ToLower(strings.TrimSpace(rawHost)) if host == "" { return false } switch host { case "localhost", "127.0.0.1", "::1", "host.docker.internal": return true } if ip := net.ParseIP(host); ip != nil { return ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() } // Docker service names and other single-label local hostnames are // permitted only for local HTTP development workflows. return !strings.Contains(host, ".") } func normalizeClientAutoLoginMetadata(metadata map[string]any) (map[string]any, error) { if metadata == nil { return metadata, nil } supported := readMetadataBoolValue(metadata, domain.MetadataAutoLoginSupported) rawURL := strings.TrimSpace(readMetadataStringValue(metadata, domain.MetadataAutoLoginURL)) metadata[domain.MetadataAutoLoginSupported] = supported if !supported { delete(metadata, domain.MetadataAutoLoginURL) return metadata, nil } if rawURL == "" { return nil, errors.New("auto_login_url is required when auto_login_supported is true") } parsed, err := url.Parse(rawURL) if err != nil || parsed.Scheme == "" || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { return nil, errors.New("auto_login_url must be an http or https URL") } metadata[domain.MetadataAutoLoginURL] = rawURL return metadata, nil } func normalizeHeadlessClientConfig( tokenAuthMethod string, jwksURI string, jwks any, metadata map[string]any, ) (string, string, any, map[string]any) { if metadata == nil { metadata = map[string]any{} } delete(metadata, domain.MetadataRequestObjectSigningAlg) headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) if headlessEnabled { headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) if headlessTokenAuthMethod == "" && strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "private_key_jwt") { headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod) } if headlessTokenAuthMethod == "" || strings.EqualFold(headlessTokenAuthMethod, "none") { headlessTokenAuthMethod = "private_key_jwt" } metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod headlessJWKSURI := strings.TrimSpace(jwksURI) if headlessJWKSURI == "" { headlessJWKSURI = readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI) } if headlessJWKSURI != "" { metadata[domain.MetadataHeadlessJWKSURI] = headlessJWKSURI } else { delete(metadata, domain.MetadataHeadlessJWKSURI) } delete(metadata, domain.MetadataHeadlessJWKS) return headlessTokenAuthMethod, headlessJWKSURI, nil, metadata } delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) delete(metadata, domain.MetadataHeadlessJWKSURI) delete(metadata, domain.MetadataHeadlessJWKS) return tokenAuthMethod, jwksURI, jwks, metadata } func validateHeadlessClientInput(jwksURI string, jwks any, metadata map[string]any) error { if !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { return nil } if jwks != nil { return fmt.Errorf("headless login supports jwksUri only; inline jwks is not supported") } resolvedURI := strings.TrimSpace(jwksURI) if resolvedURI == "" { resolvedURI = readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI) } if resolvedURI == "" { return fmt.Errorf("headless login requires jwksUri; inline jwks is not supported") } return nil } func normalizeClientTypeForHeadless(clientType string, metadata map[string]any) string { if readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) { return "private" } return clientType } func normalizeIDTokenClaimsMetadata(metadata map[string]any) (map[string]any, error) { if metadata == nil { return nil, nil } rawClaims, exists := metadata[domain.MetadataIDTokenClaims] if !exists || rawClaims == nil { return metadata, nil } normalized, err := normalizeIDTokenClaimsForDevConsole(rawClaims) if err != nil { return nil, err } metadata[domain.MetadataIDTokenClaims] = normalized return metadata, nil } func normalizeIDTokenClaims(rawClaims any) ([]normalizedIDTokenClaim, error) { return normalizeIDTokenClaimsWithOptions(rawClaims, true) } func normalizeIDTokenClaimsForDevConsole(rawClaims any) ([]normalizedIDTokenClaim, error) { return normalizeIDTokenClaimsWithOptions(rawClaims, false) } func normalizeIDTokenClaimsWithOptions(rawClaims any, allowTopLevel bool) ([]normalizedIDTokenClaim, error) { rawList, ok := rawClaims.([]any) if !ok { if typedList, ok := rawClaims.([]map[string]any); ok { rawList = make([]any, 0, len(typedList)) for _, item := range typedList { rawList = append(rawList, item) } } else if typedList, ok := rawClaims.([]map[string]any); ok { rawList = make([]any, 0, len(typedList)) for _, item := range typedList { rawList = append(rawList, item) } } else { return nil, errors.New("metadata.id_token_claims must be an array") } } normalized := make([]normalizedIDTokenClaim, 0, len(rawList)) seen := make(map[string]struct{}, len(rawList)) for _, item := range rawList { record, ok := item.(map[string]any) if !ok { if typedRecord, ok := item.(map[string]any); ok { record = make(map[string]any, len(typedRecord)) maps.Copy(record, typedRecord) } else { return nil, errors.New("metadata.id_token_claims items must be objects") } } namespace := strings.TrimSpace(readInterfaceString(record["namespace"], "top_level")) if namespace == "" { namespace = "top_level" } if namespace != "top_level" && namespace != "rp_claims" { return nil, fmt.Errorf("metadata.id_token_claims namespace must be top_level or rp_claims: %s", namespace) } if !allowTopLevel && namespace == "top_level" { return nil, errors.New("metadata.id_token_claims top_level namespace is managed from admin user custom claims") } key := strings.TrimSpace(readInterfaceString(record["key"], "")) if key == "" { return nil, errors.New("metadata.id_token_claims key is required") } if namespace == "top_level" && key == "rp_claims" { return nil, errors.New("metadata.id_token_claims top-level key rp_claims is reserved") } valueType := strings.TrimSpace(readInterfaceString(record["valueType"], "text")) if valueType == "" { valueType = "text" } switch valueType { case "text", "number", "boolean", "array", "object", "date", "datetime": default: return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType) } value := strings.TrimSpace(readInterfaceString(record["value"], "")) nullable, _ := record["nullable"].(bool) if !(nullable && value == "") { if _, err := parseConfiguredClaimValue(value, valueType); err != nil { return nil, fmt.Errorf("metadata.id_token_claims %s.%s is invalid: %w", namespace, key, err) } } signature := namespace + ":" + key if _, exists := seen[signature]; exists { return nil, fmt.Errorf("metadata.id_token_claims contains duplicate key: %s.%s", namespace, key) } seen[signature] = struct{}{} normalized = append(normalized, normalizedIDTokenClaim{ Namespace: namespace, Key: key, Value: value, ValueType: valueType, Nullable: nullable, ReadPermission: normalizeCustomClaimPermission(record["readPermission"]), WritePermission: normalizeCustomClaimPermission(record["writePermission"]), }) } return normalized, nil } func readInterfaceString(value any, fallback string) string { if value == nil { return fallback } if text, ok := value.(string); ok { return text } return fallback } func parseConfiguredClaimValue(rawValue string, valueType string) (any, error) { trimmed := strings.TrimSpace(rawValue) switch valueType { case "text": return trimmed, nil case "number": if trimmed == "" { return nil, errors.New("number value is required") } parsed, err := strconv.ParseFloat(trimmed, 64) if err != nil { return nil, errors.New("number value must be a finite number") } return parsed, nil case "boolean": switch strings.ToLower(trimmed) { case "true", "1", "yes", "on": return true, nil case "false", "0", "no", "off": return false, nil default: return nil, errors.New("boolean value must be true/false") } case "array": if trimmed == "" { return []string{}, nil } if strings.HasPrefix(trimmed, "[") { var parsed []any if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { return nil, errors.New("array value must be valid JSON array") } return parsed, nil } parts := strings.Split(trimmed, ",") values := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { values = append(values, part) } } return values, nil case "object": if trimmed == "" { return map[string]any{}, nil } var parsed map[string]any if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { return nil, errors.New("object value must be valid JSON object") } return parsed, nil case "date": if trimmed == "" { return nil, errors.New("date value is required") } if _, err := time.Parse("2006-01-02", trimmed); err != nil { return nil, errors.New("date value must use YYYY-MM-DD") } return trimmed, nil case "datetime": if trimmed == "" { return nil, errors.New("datetime value is required") } if _, err := time.Parse(time.RFC3339, trimmed); err == nil { return trimmed, nil } if _, err := time.Parse("2006-01-02T15:04", trimmed); err == nil { return trimmed, nil } return nil, errors.New("datetime value must use RFC3339 or YYYY-MM-DDTHH:mm") default: return nil, fmt.Errorf("unsupported claim value type: %s", valueType) } } func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool { if req.Jwks != nil { return true } if req.Metadata == nil { return false } value, ok := (*req.Metadata)[domain.MetadataHeadlessJWKS] return ok && value != nil } 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 } //go:fix inline func boolPtr(value bool) *bool { return new(value) } func valueOrBool(ptr *bool, fallback bool) bool { 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]any, incoming *map[string]any) map[string]any { if incoming == nil { return current } merged := map[string]any{} maps.Copy(merged, current) maps.Copy(merged, *incoming) 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 { maps.Copy(m, extra) 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 devAuditClientIDFromEventType(eventType string) string { parts := strings.Split(strings.TrimSpace(eventType), " ") if len(parts) != 2 { return "" } path := strings.Trim(parts[1], "/") segments := strings.Split(path, "/") for idx := 0; idx+1 < len(segments); idx++ { if segments[idx] == "clients" { return strings.TrimSpace(segments[idx+1]) } } return "" } func resolveDevAuditClientID(logItem domain.AuditLog, details map[string]any) string { targetID, _ := details["target_id"].(string) clientID, _ := details["client_id"].(string) resolvedID := strings.TrimSpace(targetID) if resolvedID == "" { resolvedID = strings.TrimSpace(clientID) } if resolvedID == "" { resolvedID = devAuditClientIDFromEventType(logItem.EventType) } return resolvedID } func resolveStatusFromMetadata(metadata map[string]any) 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, _ := utils.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 != "" { if resolveDevAuditClientID(logItem, details) != clientFilter { return false } } if len(allowedClientIDs) > 0 { resolvedID := resolveDevAuditClientID(logItem, details) 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 "" } 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 } func appendDevTenantUnique(tenants []domain.Tenant, tenant domain.Tenant) []domain.Tenant { tenantID := strings.TrimSpace(tenant.ID) tenantSlug := strings.TrimSpace(tenant.Slug) if tenantID == "" && tenantSlug == "" { return tenants } for _, existing := range tenants { if tenantID != "" && strings.EqualFold(existing.ID, tenantID) { return tenants } if tenantSlug != "" && strings.EqualFold(existing.Slug, tenantSlug) { return tenants } } return append(tenants, tenant) } func shouldListDevManageableTenants(role string) bool { switch strings.ToLower(strings.TrimSpace(role)) { case "tenant_admin", "tenantadmin", "tenant-admin", "rp_admin", "admin": return true default: return false } } func resolveDevProfileAppointmentTenants(ctx context.Context, tenantSvc service.TenantService, metadata map[string]any) []domain.Tenant { if tenantSvc == nil || metadata == nil { return nil } appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"]) if len(appointments) == 0 { return nil } tenants := make([]domain.Tenant, 0, len(appointments)) for _, raw := range appointments { appointment, ok := raw.(map[string]any) if !ok { continue } if tenantID := normalizeMetadataString(appointment["tenantId"]); tenantID != "" { if tenant, err := tenantSvc.GetTenant(ctx, tenantID); err == nil && tenant != nil { tenants = appendDevTenantUnique(tenants, *tenant) continue } } if tenantID := normalizeMetadataString(appointment["tenant_id"]); tenantID != "" { if tenant, err := tenantSvc.GetTenant(ctx, tenantID); err == nil && tenant != nil { tenants = appendDevTenantUnique(tenants, *tenant) continue } } if tenantSlug := normalizeMetadataString(appointment["tenantSlug"]); tenantSlug != "" { if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil { tenants = appendDevTenantUnique(tenants, *tenant) continue } } if tenantSlug := normalizeMetadataString(appointment["tenant_slug"]); tenantSlug != "" { if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil { tenants = appendDevTenantUnique(tenants, *tenant) continue } } if tenantSlug := normalizeMetadataString(appointment["slug"]); tenantSlug != "" { if tenant, err := tenantSvc.GetTenantBySlug(ctx, tenantSlug); err == nil && tenant != nil { tenants = appendDevTenantUnique(tenants, *tenant) } } } return tenants } // 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.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 := make([]domain.Tenant, 0, 1+len(profile.JoinedTenants)) if shouldListDevManageableTenants(profile.Role) { manageable, err := h.TenantSvc.ListManageableTenants(c.Context(), profile.ID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error()) } for _, tenant := range manageable { tenants = appendDevTenantUnique(tenants, tenant) } } if profile.TenantID != nil && *profile.TenantID != "" { if primary, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to get tenant") } else if primary != nil { tenants = appendDevTenantUnique(tenants, *primary) } } for _, tenant := range profile.JoinedTenants { tenants = appendDevTenantUnique(tenants, tenant) } for _, tenant := range resolveDevProfileAppointmentTenants(c.Context(), h.TenantSvc, profile.Metadata) { tenants = appendDevTenantUnique(tenants, tenant) } return c.JSON(tenants) } func (h *DevHandler) RequestDeveloperAccess(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } if h.Auth != nil { if enriched, err := h.Auth.GetEnrichedProfile(c); err == nil && enriched != nil { profile = enriched setCurrentProfileContext(c, enriched) } } var req struct { Name string `json:"name"` Organization string `json:"organization"` Reason string `json:"reason"` TenantID string `json:"tenantId"` AccessPages []string `json:"accessPages"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.TenantID == "" && profile.TenantID != nil { req.TenantID = *profile.TenantID } name := strings.TrimSpace(profile.Name) if name == "" { name = strings.TrimSpace(req.Name) } organization := strings.TrimSpace(req.Organization) if organization == "" { organization = strings.TrimSpace(profile.CompanyCode) } if req.TenantID != "" && h.TenantSvc != nil { if tenant, err := h.TenantSvc.GetTenant(c.Context(), req.TenantID); err == nil && tenant != nil && strings.TrimSpace(tenant.Name) != "" { organization = strings.TrimSpace(tenant.Name) } } devReq := domain.DeveloperRequest{ UserID: profile.ID, TenantID: req.TenantID, Name: name, Organization: organization, Email: profile.Email, Phone: profile.Phone, Role: normalizeUserRole(profile.Role), Reason: req.Reason, AccessPages: req.AccessPages, Status: domain.DeveloperRequestStatusPending, } if err := h.DeveloperSvc.RequestAccess(c.Context(), devReq); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.Status(fiber.StatusCreated).JSON(fiber.Map{"status": "ok"}) } func (h *DevHandler) GetDeveloperRequestStatus(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } tenantID := c.Query("tenantId") if tenantID == "" && profile.TenantID != nil { tenantID = *profile.TenantID } status, err := h.DeveloperSvc.GetRequestStatus(c.Context(), profile.ID, tenantID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if status == nil { return c.JSON(domain.DeveloperAccessStatus{Status: "none"}) } if status.Status == domain.DeveloperRequestStatusApproved { h.ensureDeveloperGrantRelation(c, profile.ID, tenantID) } return c.JSON(status) } func (h *DevHandler) ensureDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID string) { if h.KetoOutbox == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(tenantID) == "" { return } subject := "User:" + strings.TrimSpace(userID) for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { if h.hasDirectTenantRelation(c, tenantID, relation, subject) { continue } _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: relation, Subject: subject, Action: domain.KetoOutboxActionCreate, }) if h.Keto != nil { if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, relation, subject); err != nil { slog.Warn("failed to grant immediate developer tenant relation", "tenantID", tenantID, "userID", userID, "relation", relation, "error", err) } } } } func (h *DevHandler) grantCreatorAdminRelation(c *fiber.Ctx, clientID string, subject string) error { clientID = strings.TrimSpace(clientID) subject = strings.TrimSpace(subject) if clientID == "" || subject == "" { return nil } if h.Keto != nil { existing, err := h.Keto.ListRelations(c.Context(), "RelyingParty", clientID, "admins", subject) if err == nil && len(existing) > 0 { return nil } if err == nil { if createErr := h.Keto.CreateRelation(c.Context(), "RelyingParty", clientID, "admins", subject); createErr == nil { return nil } else { slog.Warn("failed to grant immediate admin permission to creator; falling back to outbox", "clientID", clientID, "subject", subject, "error", createErr) } } else { slog.Warn("failed to check existing admin relation for creator; falling back to outbox", "clientID", clientID, "subject", subject, "error", err) } } if h.KetoOutbox == nil { return nil } return h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "RelyingParty", Object: clientID, Relation: "admins", Subject: subject, Action: domain.KetoOutboxActionCreate, }) } func (h *DevHandler) revokeDeveloperGrantRelation(c *fiber.Ctx, userID, tenantID string) { if h.KetoOutbox == nil || strings.TrimSpace(userID) == "" || strings.TrimSpace(tenantID) == "" { return } subject := "User:" + strings.TrimSpace(userID) for _, relation := range []string{"developer_console_grant_manager", "view_dev_console", "grant_dev_permissions"} { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: relation, Subject: subject, Action: domain.KetoOutboxActionDelete, }) if h.Keto != nil { if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, relation, subject); err != nil { slog.Warn("failed to revoke immediate developer tenant relation", "tenantID", tenantID, "userID", userID, "relation", relation, "error", err) } } } } func (h *DevHandler) hasDirectTenantRelation(c *fiber.Ctx, tenantID, relation, subject string) bool { if h.Keto == nil { return false } tuples, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, relation, subject) return err == nil && len(tuples) > 0 } func (h *DevHandler) ListDeveloperRequests(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } role := normalizeUserRole(profile.Role) status := c.Query("status") userID := profile.ID if role == domain.RoleSuperAdmin { // Super Admin can see everyone's requests userID = "" } requests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, status, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(requests) } func (h *DevHandler) ListDeveloperGrants(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") } tenantID := strings.TrimSpace(c.Query("tenantId")) grants, err := h.DeveloperSvc.ListRequests(c.Context(), "", domain.DeveloperRequestStatusApproved, tenantID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(grants) } func (h *DevHandler) CreateDeveloperGrant(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") } var reqBody struct { UserID string `json:"userId"` TenantID string `json:"tenantId"` Reason string `json:"reason"` AdminNotes string `json:"adminNotes"` AccessPages []string `json:"accessPages"` } if err := c.BodyParser(&reqBody); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } userID := strings.TrimSpace(reqBody.UserID) tenantID := strings.TrimSpace(reqBody.TenantID) if userID == "" { return errorJSON(c, fiber.StatusBadRequest, "userId is required") } if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "required services are unavailable") } identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err != nil || identity == nil { return errorJSON(c, fiber.StatusNotFound, "user not found") } name := strings.TrimSpace(extractTraitString(identity.Traits, "name")) if name == "" { name = userID } organization := strings.TrimSpace(extractTraitString(identity.Traits, "companyCode")) if tenantID != "" && h.TenantSvc != nil { tenant, err := h.TenantSvc.GetTenant(c.Context(), tenantID) if err != nil || tenant == nil { return errorJSON(c, fiber.StatusNotFound, "tenant not found") } if strings.TrimSpace(tenant.Name) != "" { organization = strings.TrimSpace(tenant.Name) } else if organization == "" { organization = tenantID } } email := strings.TrimSpace(extractTraitString(identity.Traits, "email")) phone := strings.TrimSpace(extractTraitString(identity.Traits, "phone")) role := normalizeUserRole(extractTraitString(identity.Traits, "role")) if role == "" { role = domain.RoleUser } reason := strings.TrimSpace(reqBody.Reason) if reason == "" { reason = "직접 부여" } existingRequests, err := h.DeveloperSvc.ListRequests(c.Context(), userID, "", tenantID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } for _, existing := range existingRequests { if !developerAccessPagesEqual(existing.AccessPages, reqBody.AccessPages) { continue } switch existing.Status { case domain.DeveloperRequestStatusApproved: h.ensureDeveloperGrantRelation(c, userID, tenantID) return c.JSON(existing) case domain.DeveloperRequestStatusPending: if err := h.DeveloperSvc.ApproveRequest(c.Context(), existing.ID, reqBody.AdminNotes); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } h.ensureDeveloperGrantRelation(c, userID, tenantID) existing.Status = domain.DeveloperRequestStatusApproved existing.AdminNotes = reqBody.AdminNotes return c.JSON(existing) } } grant := domain.DeveloperRequest{ UserID: userID, TenantID: tenantID, Name: name, Organization: organization, Email: email, Phone: phone, Role: role, Reason: reason, AccessPages: reqBody.AccessPages, Status: domain.DeveloperRequestStatusApproved, AdminNotes: strings.TrimSpace(reqBody.AdminNotes), } if err := h.DeveloperSvc.CreateGrant(c.Context(), grant); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } h.ensureDeveloperGrantRelation(c, userID, tenantID) return c.Status(fiber.StatusCreated).JSON(grant) } func (h *DevHandler) RevokeDeveloperGrant(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") } idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid grant id") } var reqBody struct { AdminNotes string `json:"adminNotes"` } if err := c.BodyParser(&reqBody); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id)) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch grant details") } if devReq.Status != domain.DeveloperRequestStatusApproved { return errorJSON(c, fiber.StatusBadRequest, "only approved grants can be revoked") } if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID) return c.JSON(fiber.Map{"status": "ok"}) } func (h *DevHandler) ApproveDeveloperRequest(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") } idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request id") } var reqBody struct { AdminNotes string `json:"adminNotes"` } if err := c.BodyParser(&reqBody); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id)) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch request details") } if err := h.DeveloperSvc.ApproveRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Grant Keto Permissions if h.KetoOutbox != nil { h.ensureDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID) } return c.JSON(fiber.Map{"status": "ok"}) } func (h *DevHandler) RejectDeveloperRequest(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") } idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request id") } var reqBody struct { AdminNotes string `json:"adminNotes"` } if err := c.BodyParser(&reqBody); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if err := h.DeveloperSvc.RejectRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(fiber.Map{"status": "ok"}) } func (h *DevHandler) CancelDeveloperRequestApproval(c *fiber.Ctx) error { profile := h.getCurrentProfile(c) if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: super_admin only") } idStr := c.Params("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request id") } var reqBody struct { AdminNotes string `json:"adminNotes"` } if err := c.BodyParser(&reqBody); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } devReq, err := h.DeveloperSvc.GetRequestByID(c.Context(), uint(id)) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch request details") } if devReq.Status != domain.DeveloperRequestStatusApproved { return errorJSON(c, fiber.StatusBadRequest, "only approved requests can be cancelled") } if err := h.DeveloperSvc.CancelApprovedRequest(c.Context(), uint(id), reqBody.AdminNotes); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } h.revokeDeveloperGrantRelation(c, devReq.UserID, devReq.TenantID) return c.JSON(fiber.Map{"status": "ok"}) }