forked from baron/baron-sso
3475 lines
106 KiB
Go
3475 lines
106 KiB
Go
package handler
|
|
|
|
import (
|
|
"baron-sso-backend/internal/domain"
|
|
"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"
|
|
"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
|
|
ConsentRepo repository.ClientConsentRepository
|
|
Keto service.KetoService
|
|
KetoOutbox repository.KetoOutboxRepository
|
|
RPSvc service.RelyingPartyService
|
|
TenantSvc service.TenantService
|
|
DeveloperSvc *service.DeveloperService
|
|
RPUserMetadataRepo repository.RPUserMetadataRepository
|
|
Auth interface {
|
|
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
|
}
|
|
}
|
|
|
|
func NewDevHandler(
|
|
redis domain.RedisRepository,
|
|
secretRepo domain.ClientSecretRepository,
|
|
consentRepo repository.ClientConsentRepository,
|
|
rpSvc service.RelyingPartyService,
|
|
keto service.KetoService,
|
|
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]
|
|
}
|
|
|
|
return &DevHandler{
|
|
Hydra: service.NewHydraAdminService(),
|
|
Redis: redis,
|
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redis, nil),
|
|
SecretRepo: secretRepo,
|
|
AuditRepo: nil,
|
|
KratosAdmin: service.NewKratosAdminService(),
|
|
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 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 interface{} `json:"jwks,omitempty"`
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
type clientListResponse struct {
|
|
Items []clientSummary `json:"items"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
type clientDetailResponse struct {
|
|
Client clientSummary `json:"client"`
|
|
Endpoints clientEndpoints `json:"endpoints"`
|
|
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"`
|
|
}
|
|
|
|
type consentListResponse struct {
|
|
Items []consentSummary `json:"items"`
|
|
}
|
|
|
|
type clientUpsertRequest struct {
|
|
ID *string `json:"id"`
|
|
Name *string `json:"name"`
|
|
Type *string `json:"type"`
|
|
Status *string `json:"status"`
|
|
RedirectURIs *[]string `json:"redirectUris"`
|
|
Scopes *[]string `json:"scopes"`
|
|
GrantTypes *[]string `json:"grantTypes"`
|
|
ResponseTypes *[]string `json:"responseTypes"`
|
|
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
|
SkipConsent *bool `json:"skipConsent"`
|
|
JwksUri *string `json:"jwksUri"`
|
|
Jwks interface{} `json:"jwks"`
|
|
Metadata *map[string]interface{} `json:"metadata"`
|
|
}
|
|
|
|
type normalizedIDTokenClaim struct {
|
|
Namespace string `json:"namespace"`
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
ValueType string `json:"valueType"`
|
|
}
|
|
|
|
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 {
|
|
switch normalizeUserRole(role) {
|
|
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isDevConsoleViewerRole(role string) bool {
|
|
switch normalizeUserRole(role) {
|
|
case domain.RoleSuperAdmin, domain.RoleTenantAdmin, domain.RoleRPAdmin, domain.RoleUser:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (h *DevHandler) getCurrentProfile(c *fiber.Ctx) *domain.UserProfileResponse {
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
return profile
|
|
}
|
|
if h.Auth != nil {
|
|
enriched, err := h.Auth.GetEnrichedProfile(c)
|
|
if err == nil && enriched != nil {
|
|
c.Locals("user_profile", enriched)
|
|
return enriched
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func tenantIDFromProfile(profile *domain.UserProfileResponse) string {
|
|
if profile == nil || profile.TenantID == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(*profile.TenantID)
|
|
}
|
|
|
|
func addClientIDToSet(set map[string]struct{}, raw any) {
|
|
switch value := raw.(type) {
|
|
case string:
|
|
for _, chunk := range strings.Split(value, ",") {
|
|
id := strings.TrimSpace(chunk)
|
|
if id != "" {
|
|
set[id] = struct{}{}
|
|
}
|
|
}
|
|
case []string:
|
|
for _, item := range value {
|
|
id := strings.TrimSpace(item)
|
|
if id != "" {
|
|
set[id] = struct{}{}
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range value {
|
|
if str, ok := item.(string); ok {
|
|
id := strings.TrimSpace(str)
|
|
if id != "" {
|
|
set[id] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func managedClientIDsFromProfile(profile *domain.UserProfileResponse) map[string]struct{} {
|
|
ids := make(map[string]struct{})
|
|
if profile == nil {
|
|
return ids
|
|
}
|
|
|
|
if profile.RelyingPartyID != nil {
|
|
if id := strings.TrimSpace(*profile.RelyingPartyID); id != "" {
|
|
ids[id] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if profile.Metadata == nil {
|
|
return ids
|
|
}
|
|
|
|
for _, key := range []string{
|
|
"managed_client_ids",
|
|
"managedClientIds",
|
|
"relying_party_id",
|
|
"relyingPartyId",
|
|
"client_id",
|
|
"clientId",
|
|
} {
|
|
if raw, ok := profile.Metadata[key]; ok {
|
|
addClientIDToSet(ids, raw)
|
|
}
|
|
}
|
|
|
|
return ids
|
|
}
|
|
|
|
func 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 h.hasDirectRelyingPartyOperatorRelation(c, profile, summary.ID) {
|
|
return true
|
|
}
|
|
|
|
clientTenantID := resolveClientTenantID(summary)
|
|
if role != domain.RoleUser && clientTenantID != "" {
|
|
if allowed, err := h.checkProfileKetoPermission(c, profile, "Tenant", clientTenantID, "view_dev_console"); err == nil && allowed {
|
|
return true
|
|
}
|
|
}
|
|
|
|
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")
|
|
return err == nil && allowed
|
|
}
|
|
|
|
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) 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 canAccessClientByLegacyScope(profile *domain.UserProfileResponse, summary clientSummary) bool {
|
|
if profile == nil {
|
|
return false
|
|
}
|
|
|
|
role := normalizeUserRole(profile.Role)
|
|
if role == domain.RoleSuperAdmin {
|
|
return true
|
|
}
|
|
if !isDevConsoleRoleAllowed(role) {
|
|
return false
|
|
}
|
|
|
|
userTenantID := tenantIDFromProfile(profile)
|
|
clientTenantID := resolveClientTenantID(summary)
|
|
if userTenantID != "" && clientTenantID != "" && clientTenantID != userTenantID {
|
|
return false
|
|
}
|
|
|
|
return isRPAdminClientAllowed(profile, summary.ID)
|
|
}
|
|
|
|
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 {
|
|
role := normalizeUserRole(profileRole(profile))
|
|
if role == domain.RoleUser {
|
|
return false
|
|
}
|
|
if role != domain.RoleRPAdmin {
|
|
return true
|
|
}
|
|
allowed := managedClientIDsFromProfile(profile)
|
|
if len(allowed) == 0 {
|
|
return false
|
|
}
|
|
_, ok := allowed[strings.TrimSpace(clientID)]
|
|
return ok
|
|
}
|
|
|
|
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{}{}
|
|
}
|
|
}
|
|
|
|
addKey(profile.CompanyCode)
|
|
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"),
|
|
extractTraitString(identity.Traits, "companyCode"),
|
|
extractTraitString(identity.Traits, "company_code"),
|
|
} {
|
|
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]interface{}) 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 {
|
|
if h.ConsentRepo == nil || h.Hydra == nil {
|
|
return nil
|
|
}
|
|
|
|
subjects, err := h.ConsentRepo.ListSubjectsByClient(ctx, clientID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, subject := range subjects {
|
|
subject = strings.TrimSpace(subject)
|
|
if subject == "" {
|
|
continue
|
|
}
|
|
if err := h.Hydra.RevokeConsentSessions(ctx, subject, clientID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return h.ConsentRepo.DeleteByClient(ctx, 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:<id> 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 (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
|
|
c.Locals("user_profile", enriched)
|
|
}
|
|
}
|
|
if ok && profile != nil {
|
|
role := normalizeUserRole(profile.Role)
|
|
switch role {
|
|
case domain.RoleSuperAdmin:
|
|
slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID)
|
|
return true, nil
|
|
case domain.RoleTenantAdmin, domain.RoleRPAdmin:
|
|
slog.Info("Dev private permission granted by role", "user_id", profile.ID, "role", role)
|
|
return true, nil
|
|
case domain.RoleUser:
|
|
return false, nil
|
|
}
|
|
|
|
// Super Admin bypass
|
|
if isAdminEmail(profile.Email) {
|
|
slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email)
|
|
return true, nil
|
|
}
|
|
|
|
subject := strings.TrimSpace(profile.ID)
|
|
if subject == "" && strings.TrimSpace(profile.Email) != "" && h.KratosAdmin != nil {
|
|
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), strings.TrimSpace(profile.Email))
|
|
if err == nil && strings.TrimSpace(resolved) != "" {
|
|
subject = strings.TrimSpace(resolved)
|
|
}
|
|
}
|
|
if subject == "" {
|
|
slog.Warn("Dev private permission denied: missing subject in profile", "email", profile.Email)
|
|
return false, nil
|
|
}
|
|
if h.Keto == nil {
|
|
slog.Warn("Dev private permission denied: keto service unavailable")
|
|
return false, nil
|
|
}
|
|
|
|
// Check with Keto: System: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 tokenRole == domain.RoleTenantAdmin || tokenRole == domain.RoleRPAdmin {
|
|
slog.Info("Dev private permission granted by token role", "role", tokenRole)
|
|
return true, nil
|
|
}
|
|
if tokenRole == domain.RoleUser {
|
|
return false, nil
|
|
}
|
|
if isAdminEmail(tokenEmail) {
|
|
slog.Info("Dev private permission granted by token email", "email", tokenEmail)
|
|
return true, nil
|
|
}
|
|
|
|
// If subject is missing, resolve it from Kratos by identifier(email) so Keto checks can still run.
|
|
if tokenSubject == "" && tokenEmail != "" && h.KratosAdmin != nil {
|
|
resolved, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), tokenEmail)
|
|
if err == nil && strings.TrimSpace(resolved) != "" {
|
|
tokenSubject = strings.TrimSpace(resolved)
|
|
}
|
|
}
|
|
if tokenSubject == "" {
|
|
return false, nil
|
|
}
|
|
if h.Keto == nil {
|
|
slog.Warn("Dev private permission denied: keto service unavailable")
|
|
return false, nil
|
|
}
|
|
|
|
// Fallback: resolve role from Kratos identity traits when user_profile is not injected.
|
|
if h.KratosAdmin != nil {
|
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject)
|
|
if err == nil && identity != nil {
|
|
if rawRole, ok := identity.Traits["role"].(string); ok && normalizeUserRole(rawRole) == domain.RoleSuperAdmin {
|
|
slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject)
|
|
return true, nil
|
|
}
|
|
if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) {
|
|
slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email)
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check with Keto: System: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 extractAuthClaimsFromBearer(authHeader string) (string, string) {
|
|
authHeader = strings.TrimSpace(authHeader)
|
|
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
|
return "", ""
|
|
}
|
|
|
|
token := strings.TrimSpace(authHeader[len("Bearer "):])
|
|
if token == "" || strings.Count(token, ".") != 2 {
|
|
return "", ""
|
|
}
|
|
|
|
parts := strings.Split(token, ".")
|
|
if len(parts) != 3 {
|
|
return "", ""
|
|
}
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
payload, err = base64.URLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
}
|
|
|
|
var claims map[string]interface{}
|
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
|
return "", ""
|
|
}
|
|
sub := ""
|
|
if sub, ok := claims["sub"].(string); ok {
|
|
sub = strings.TrimSpace(sub)
|
|
}
|
|
email := ""
|
|
if claimEmail, ok := claims["email"].(string); ok {
|
|
email = strings.TrimSpace(claimEmail)
|
|
}
|
|
|
|
return sub, email
|
|
}
|
|
|
|
func isAdminEmail(email string) bool {
|
|
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
|
return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail)
|
|
}
|
|
|
|
func extractBearerToken(authHeader string) string {
|
|
authHeader = strings.TrimSpace(authHeader)
|
|
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(authHeader[len("Bearer "):])
|
|
}
|
|
|
|
type oidcUserInfo struct {
|
|
Sub string `json:"sub"`
|
|
Email string `json:"email"`
|
|
TenantID string `json:"tenant_id"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
func (h *DevHandler) fetchOIDCUserInfo(ctx context.Context, accessToken string) (*oidcUserInfo, error) {
|
|
if strings.TrimSpace(accessToken) == "" {
|
|
return nil, fmt.Errorf("missing access token")
|
|
}
|
|
if h.Hydra == nil || strings.TrimSpace(h.Hydra.PublicURL) == "" {
|
|
return nil, fmt.Errorf("hydra public url is not configured")
|
|
}
|
|
|
|
endpoint := strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
|
return nil, fmt.Errorf("userinfo failed status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pick := func(keys ...string) string {
|
|
for _, key := range keys {
|
|
if raw, ok := payload[key]; ok {
|
|
if value, ok := raw.(string); ok {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return &oidcUserInfo{
|
|
Sub: pick("sub"),
|
|
Email: pick("email"),
|
|
TenantID: pick("tenant_id", "tenantId"),
|
|
Role: pick("role"),
|
|
}, nil
|
|
}
|
|
|
|
func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|
h.injectTenantContextFromHeader(c)
|
|
limit := c.QueryInt("limit", 50)
|
|
offset := c.QueryInt("offset", 0)
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
profile := h.getCurrentProfile(c)
|
|
if profile == nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
|
}
|
|
role := normalizeUserRole(profile.Role)
|
|
if !isDevConsoleRoleAllowed(role) {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
|
}
|
|
|
|
userTenantID := tenantIDFromProfile(profile)
|
|
isSuperAdmin := role == domain.RoleSuperAdmin
|
|
allowedClientIDs := managedClientIDsFromProfile(profile)
|
|
|
|
isAppManager, err := h.checkAppManagerPermission(c)
|
|
if err != nil {
|
|
slog.Error("Failed to check app manager permission", "error", err)
|
|
}
|
|
|
|
clients, err := h.Hydra.ListClients(c.Context(), limit, offset)
|
|
if err != nil {
|
|
if errors.Is(err, service.ErrHydraNotFound) {
|
|
return errorJSON(c, fiber.StatusNotFound, "clients not found")
|
|
}
|
|
errMsg := err.Error()
|
|
if strings.Contains(errMsg, "connection refused") || strings.Contains(errMsg, "dial tcp") {
|
|
return errorJSON(c, fiber.StatusServiceUnavailable, "Hydra service is unavailable. Please check if Ory Hydra is running.")
|
|
}
|
|
return errorJSON(c, fiber.StatusInternalServerError, errMsg)
|
|
}
|
|
|
|
items := make([]clientSummary, 0, len(clients))
|
|
for _, client := range clients {
|
|
if isHiddenSystemClient(client) {
|
|
continue
|
|
}
|
|
|
|
summary := h.mapClientSummary(client)
|
|
|
|
// 1. [Security] Filter out 'private' clients if user is not an AppManager
|
|
canViewByPermit := h.canViewClientByPermit(c, profile, summary)
|
|
|
|
if summary.Type == "private" && !isAppManager && !canViewByPermit {
|
|
continue
|
|
}
|
|
|
|
// 2. [Isolation] If not SuperAdmin, only show clients belonging to the same tenant
|
|
if !isSuperAdmin {
|
|
clientTenantID, _ := summary.Metadata["tenant_id"].(string)
|
|
if clientTenantID != userTenantID && !canViewByPermit {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// 3. [Role Scope] RP Admin can only access managed RP IDs unless explicit Keto permit exists
|
|
if role == domain.RoleRPAdmin && len(allowedClientIDs) > 0 {
|
|
if _, ok := allowedClientIDs[summary.ID]; !ok {
|
|
if !canViewByPermit {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if !isSuperAdmin && !canAccessClientByLegacyScope(profile, summary) && !canViewByPermit {
|
|
continue
|
|
}
|
|
|
|
items = append(items, h.redactClientSecretUnlessAllowed(c, profile, summary))
|
|
}
|
|
|
|
return c.JSON(clientListResponse{
|
|
Items: items,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
}
|
|
|
|
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())
|
|
}
|
|
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 {
|
|
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())
|
|
}
|
|
|
|
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 {
|
|
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())
|
|
}
|
|
|
|
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.canManageClientRelations(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{}
|
|
}
|
|
|
|
row := &domain.RPUserMetadata{
|
|
ClientID: clientID,
|
|
UserID: userID,
|
|
Metadata: domain.JSONMap(req.Metadata),
|
|
}
|
|
if err := h.RPUserMetadataRepo.Upsert(c.Context(), row); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
return c.JSON(row)
|
|
}
|
|
|
|
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")
|
|
if !canAccessClientByLegacyScope(profile, summary) && !canChangeStatusByPermit {
|
|
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") {
|
|
if !canChangeStatusByPermit {
|
|
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 == domain.RoleRPAdmin || 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")
|
|
}
|
|
|
|
// [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")
|
|
}
|
|
}
|
|
|
|
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]interface{}{}
|
|
}
|
|
|
|
// [Tenant Isolation] Record owner information
|
|
if profile != nil {
|
|
metadata["user_id"] = profile.ID
|
|
if tenantID == "" && profile.TenantID != nil {
|
|
tenantID = *profile.TenantID
|
|
}
|
|
}
|
|
|
|
if tenantID != "" {
|
|
metadata["tenant_id"] = tenantID
|
|
}
|
|
metadata["status"] = status
|
|
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
|
var 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())
|
|
}
|
|
|
|
tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, ""))
|
|
if tokenAuthMethod == "" {
|
|
if clientType == "pkce" {
|
|
tokenAuthMethod = "none"
|
|
} else {
|
|
tokenAuthMethod = "client_secret_basic"
|
|
}
|
|
}
|
|
if err := validateHeadlessClientInput(clientType, valueOr(req.JwksUri, ""), req.Jwks, metadata); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
|
|
clientType,
|
|
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: boolPtr(valueOrBool(req.SkipConsent, true)),
|
|
JWKSUri: jwksURI,
|
|
JWKS: jwks,
|
|
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
|
|
err := h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
|
Namespace: "RelyingParty",
|
|
Object: created.ClientID,
|
|
Relation: "admins",
|
|
Subject: subject,
|
|
Action: domain.KetoOutboxActionCreate,
|
|
})
|
|
if 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)
|
|
}
|
|
if h.Keto != nil {
|
|
if err := h.Keto.CreateRelation(c.Context(), "RelyingParty", created.ClientID, "admins", subject); err != nil {
|
|
slog.Warn("failed to grant immediate admin permission to creator", "clientID", created.ClientID, "userID", profile.ID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
if !canAccessClientByLegacyScope(profile, currentSummary) && !h.canOperateClientByPermit(c, profile, currentSummary, "edit_config") {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
|
|
}
|
|
|
|
clientType := ""
|
|
if req.Type != nil {
|
|
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
|
|
if clientType != "pkce" && clientType != "private" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "type must be pkce or private")
|
|
}
|
|
}
|
|
|
|
// [Security] Check permission for private clients (both current and new type)
|
|
if currentSummary.Type == "private" || clientType == "private" {
|
|
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]interface{}{}
|
|
}
|
|
metadata["status"] = status
|
|
}
|
|
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
|
|
}
|
|
resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod)
|
|
resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri)
|
|
resolvedJWKS := req.Jwks
|
|
if req.Jwks == nil {
|
|
if resolvedClientType == "pkce" && readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
|
|
resolvedJWKS = nil
|
|
} else {
|
|
resolvedJWKS = current.JWKS
|
|
}
|
|
}
|
|
if err := validateHeadlessClientInput(resolvedClientType, resolvedJWKSURI, resolvedJWKS, metadata); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
|
}
|
|
resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig(
|
|
resolvedClientType,
|
|
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: boolPtr(resolvedSkipConsent),
|
|
JWKSUri: resolvedJWKSURI,
|
|
JWKS: resolvedJWKS,
|
|
Metadata: metadata,
|
|
}
|
|
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
|
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
|
}
|
|
tenantPolicyChanged := tenantAccessPolicyChanged(current.Metadata, updated.Metadata)
|
|
|
|
h.setAuditDetailsExtra(c, map[string]any{
|
|
"action": "UPDATE_CLIENT",
|
|
"target_id": clientID,
|
|
"tenant_id": tenantID,
|
|
"before": map[string]any{
|
|
"name": currentSummary.Name,
|
|
"type": currentSummary.Type,
|
|
"status": currentSummary.Status,
|
|
},
|
|
"after": map[string]any{
|
|
"name": strings.TrimSpace(updated.ClientName),
|
|
"type": clientTypeOrDefault(updated.TokenEndpointAuthMethod),
|
|
"status": resolveStatusFromMetadata(updated.Metadata),
|
|
},
|
|
})
|
|
|
|
updatedClient, err := h.Hydra.UpdateClient(c.Context(), clientID, updated)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
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)
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
|
|
// [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
|
|
}
|
|
}
|
|
}
|
|
|
|
if adminTenantID != "" {
|
|
consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, limit, offset)
|
|
} else {
|
|
consents, total, err = h.ConsentRepo.List(c.Context(), clientID, limit, offset)
|
|
}
|
|
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
items := make([]consentSummary, 0, len(consents))
|
|
for _, consent := range consents {
|
|
// Filter by subject if search is active
|
|
if subject != "" && consent.Subject != subject {
|
|
continue
|
|
}
|
|
|
|
var deletedAt *time.Time
|
|
status := "active"
|
|
if consent.DeletedAt.Valid {
|
|
deletedAt = &consent.DeletedAt.Time
|
|
status = "revoked"
|
|
}
|
|
|
|
// Filter by status if requested
|
|
if statusFilter != "" && statusFilter != "all" {
|
|
if statusFilter == "active" && status != "active" {
|
|
continue
|
|
}
|
|
if statusFilter == "revoked" && status != "revoked" {
|
|
continue
|
|
}
|
|
}
|
|
|
|
userName := ""
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
items = append(items, consentSummary{
|
|
Subject: consent.Subject,
|
|
UserName: userName,
|
|
ClientID: consent.ClientID,
|
|
GrantedScopes: consent.GrantedScopes,
|
|
AuthenticatedAt: consent.UpdatedAt.Format(time.RFC3339),
|
|
CreatedAt: consent.CreatedAt,
|
|
DeletedAt: deletedAt,
|
|
Status: status,
|
|
TenantID: consent.TenantID,
|
|
TenantName: consent.TenantName,
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"items": items,
|
|
"total": total,
|
|
})
|
|
}
|
|
|
|
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
|
tenantID := h.injectTenantContextFromHeader(c)
|
|
subject := strings.TrimSpace(c.Query("subject"))
|
|
if subject == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "subject is required")
|
|
}
|
|
clientID := strings.TrimSpace(c.Query("client_id"))
|
|
profile := h.getCurrentProfile(c)
|
|
if profile == nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
|
}
|
|
role := normalizeUserRole(profile.Role)
|
|
if !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 == domain.RoleRPAdmin || 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)
|
|
|
|
// [Security] Check permission
|
|
allowed, err := h.checkAppManagerPermission(c)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
|
}
|
|
if !allowed {
|
|
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
|
}
|
|
|
|
userTenantID := ""
|
|
isSuperAdmin := false
|
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
|
|
isSuperAdmin = normalizeUserRole(profile.Role) == domain.RoleSuperAdmin
|
|
if profile.TenantID != nil {
|
|
userTenantID = *profile.TenantID
|
|
}
|
|
}
|
|
|
|
// 1. Total Clients (Tenant Scoped)
|
|
// Hydra doesn't support tenant filtering natively, so we list and filter.
|
|
// For stats, we might want to fetch a larger batch or use a cached count.
|
|
clients, err := h.Hydra.ListClients(c.Context(), 500, 0)
|
|
var totalClients int64
|
|
if err == nil {
|
|
for _, client := range clients {
|
|
if isHiddenSystemClient(client) {
|
|
continue
|
|
}
|
|
if isSuperAdmin {
|
|
totalClients++
|
|
continue
|
|
}
|
|
if client.Metadata != nil {
|
|
if tid, ok := client.Metadata["tenant_id"].(string); ok && tid == userTenantID {
|
|
totalClients++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Auth Failures (24h)
|
|
var authFailures int64
|
|
if h.AuditRepo != nil {
|
|
since := time.Now().Add(-24 * time.Hour)
|
|
authFailures, _ = h.AuditRepo.CountFailuresSince(c.Context(), since, userTenantID)
|
|
}
|
|
|
|
// 3. Active Sessions (1h)
|
|
var activeSessions int64
|
|
if h.AuditRepo != nil {
|
|
since := time.Now().Add(-1 * time.Hour)
|
|
activeSessions, _ = h.AuditRepo.CountActiveSessionsSince(c.Context(), since, userTenantID)
|
|
}
|
|
|
|
return c.JSON(devStatsResponse{
|
|
TotalClients: totalClients,
|
|
ActiveSessions: activeSessions,
|
|
AuthFailures: authFailures,
|
|
})
|
|
}
|
|
|
|
func generateRandomSecret(length int) (string, error) {
|
|
bytes := make([]byte, length)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
// Use Base64 URL encoding (no padding) to look like Hydra's native secrets
|
|
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
|
}
|
|
|
|
func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
|
status := "active"
|
|
var createdAt *time.Time
|
|
|
|
if client.Metadata != nil {
|
|
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
|
|
status = "inactive"
|
|
}
|
|
if value, ok := client.Metadata["created_at"].(string); ok {
|
|
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
|
createdAt = &t
|
|
}
|
|
}
|
|
}
|
|
|
|
clientType := "private"
|
|
if strings.EqualFold(client.TokenEndpointAuthMethod, "none") {
|
|
clientType = "pkce"
|
|
} else if strings.EqualFold(client.TokenEndpointAuthMethod, "private_key_jwt") && client.Metadata != nil {
|
|
if val, ok := client.Metadata["headless_login_enabled"].(bool); ok && val {
|
|
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,
|
|
Metadata: client.Metadata,
|
|
}
|
|
}
|
|
|
|
func readMetadataStringValue(metadata map[string]interface{}, key string) string {
|
|
if metadata == nil {
|
|
return ""
|
|
}
|
|
raw, _ := metadata[key].(string)
|
|
return strings.TrimSpace(raw)
|
|
}
|
|
|
|
func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
|
|
if metadata == nil {
|
|
return false
|
|
}
|
|
value, _ := metadata[key].(bool)
|
|
return value
|
|
}
|
|
|
|
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, 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(
|
|
clientType string,
|
|
tokenAuthMethod string,
|
|
jwksURI string,
|
|
jwks interface{},
|
|
metadata map[string]interface{},
|
|
) (string, string, interface{}, map[string]interface{}) {
|
|
if metadata == nil {
|
|
metadata = map[string]interface{}{}
|
|
}
|
|
delete(metadata, domain.MetadataRequestObjectSigningAlg)
|
|
|
|
headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled)
|
|
if clientType == "pkce" && headlessEnabled {
|
|
headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
|
|
if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") {
|
|
headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod)
|
|
}
|
|
if headlessTokenAuthMethod == "" {
|
|
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 "none", "", nil, metadata
|
|
}
|
|
|
|
delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod)
|
|
delete(metadata, domain.MetadataHeadlessJWKSURI)
|
|
delete(metadata, domain.MetadataHeadlessJWKS)
|
|
return tokenAuthMethod, jwksURI, jwks, metadata
|
|
}
|
|
|
|
func validateHeadlessClientInput(clientType string, jwksURI string, jwks interface{}, metadata map[string]interface{}) error {
|
|
if clientType != "pkce" || !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 normalizeIDTokenClaimsMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
|
if metadata == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
rawClaims, exists := metadata[domain.MetadataIDTokenClaims]
|
|
if !exists || rawClaims == nil {
|
|
return metadata, nil
|
|
}
|
|
|
|
normalized, err := normalizeIDTokenClaims(rawClaims)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
metadata[domain.MetadataIDTokenClaims] = normalized
|
|
return metadata, nil
|
|
}
|
|
|
|
func normalizeIDTokenClaims(rawClaims interface{}) ([]normalizedIDTokenClaim, error) {
|
|
rawList, ok := rawClaims.([]interface{})
|
|
if !ok {
|
|
if typedList, ok := rawClaims.([]map[string]interface{}); ok {
|
|
rawList = make([]interface{}, 0, len(typedList))
|
|
for _, item := range typedList {
|
|
rawList = append(rawList, item)
|
|
}
|
|
} else if typedList, ok := rawClaims.([]map[string]any); ok {
|
|
rawList = make([]interface{}, 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]interface{})
|
|
if !ok {
|
|
if typedRecord, ok := item.(map[string]any); ok {
|
|
record = make(map[string]interface{}, len(typedRecord))
|
|
for key, value := range typedRecord {
|
|
record[key] = value
|
|
}
|
|
} 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)
|
|
}
|
|
|
|
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":
|
|
default:
|
|
return nil, fmt.Errorf("metadata.id_token_claims valueType is invalid: %s", valueType)
|
|
}
|
|
|
|
value := strings.TrimSpace(readInterfaceString(record["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,
|
|
})
|
|
}
|
|
|
|
return normalized, nil
|
|
}
|
|
|
|
func readInterfaceString(value interface{}, 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
|
|
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
|
|
}
|
|
|
|
func boolPtr(value bool) *bool {
|
|
return &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]interface{}, incoming *map[string]interface{}) map[string]interface{} {
|
|
if incoming == nil {
|
|
return current
|
|
}
|
|
merged := map[string]interface{}{}
|
|
for k, v := range current {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range *incoming {
|
|
merged[k] = v
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func resolveTokenAuthMethod(requested, fallback string) string {
|
|
if strings.TrimSpace(requested) == "" {
|
|
return fallback
|
|
}
|
|
return requested
|
|
}
|
|
|
|
func (h *DevHandler) injectTenantContextFromHeader(c *fiber.Ctx) string {
|
|
tenantID := strings.TrimSpace(c.Get("X-Tenant-ID"))
|
|
if tenantID != "" {
|
|
c.Locals("tenant_id", tenantID)
|
|
}
|
|
return tenantID
|
|
}
|
|
|
|
func (h *DevHandler) setAuditDetailsExtra(c *fiber.Ctx, extra map[string]any) {
|
|
if c == nil || len(extra) == 0 {
|
|
return
|
|
}
|
|
if existing := c.Locals("audit_details_extra"); existing != nil {
|
|
if m, ok := existing.(map[string]any); ok {
|
|
for k, v := range extra {
|
|
m[k] = v
|
|
}
|
|
c.Locals("audit_details_extra", m)
|
|
return
|
|
}
|
|
}
|
|
c.Locals("audit_details_extra", extra)
|
|
}
|
|
|
|
func normalizeAuditAction(eventType string, details map[string]any) string {
|
|
if raw, ok := details["action"].(string); ok && strings.TrimSpace(raw) != "" {
|
|
return strings.ToUpper(strings.TrimSpace(raw))
|
|
}
|
|
normalized := strings.TrimSpace(eventType)
|
|
switch {
|
|
case normalized == "POST /api/v1/dev/clients":
|
|
return "CREATE_CLIENT"
|
|
case strings.HasPrefix(normalized, "PUT /api/v1/dev/clients/"):
|
|
return "UPDATE_CLIENT"
|
|
case strings.HasPrefix(normalized, "PATCH /api/v1/dev/clients/") && strings.HasSuffix(normalized, "/status"):
|
|
return "UPDATE_CLIENT_STATUS"
|
|
case strings.HasPrefix(normalized, "POST /api/v1/dev/clients/") && strings.HasSuffix(normalized, "/secret/rotate"):
|
|
return "ROTATE_SECRET"
|
|
case strings.HasPrefix(normalized, "DELETE /api/v1/dev/clients/"):
|
|
return "DELETE_CLIENT"
|
|
case normalized == "DELETE /api/v1/dev/consents":
|
|
return "REVOKE_CONSENT"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func 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]interface{}) string {
|
|
if metadata != nil {
|
|
if value, ok := metadata["status"].(string); ok && strings.ToLower(strings.TrimSpace(value)) == "inactive" {
|
|
return "inactive"
|
|
}
|
|
}
|
|
return "active"
|
|
}
|
|
|
|
func clientTypeOrDefault(tokenEndpointAuthMethod string) string {
|
|
if strings.EqualFold(tokenEndpointAuthMethod, "none") {
|
|
return "pkce"
|
|
}
|
|
return "private"
|
|
}
|
|
|
|
func (h *DevHandler) matchesDevAuditFilter(
|
|
logItem domain.AuditLog,
|
|
tenantFilter, clientFilter, actionFilter, statusFilter string,
|
|
allowedClientIDs map[string]struct{},
|
|
) bool {
|
|
if !strings.Contains(logItem.EventType, "/api/v1/dev/") {
|
|
return false
|
|
}
|
|
details, _ := 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 ""
|
|
}
|
|
|
|
// ListMyTenants returns the list of tenants the current user manages or belongs to.
|
|
func (h *DevHandler) ListMyTenants(c *fiber.Ctx) error {
|
|
profile, err := h.Auth.GetEnrichedProfile(c)
|
|
if err != nil || profile == nil {
|
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
|
}
|
|
|
|
role := normalizeUserRole(profile.Role)
|
|
if role == domain.RoleUser {
|
|
if profile.TenantID == nil || strings.TrimSpace(*profile.TenantID) == "" {
|
|
return c.JSON([]domain.Tenant{})
|
|
}
|
|
tenant, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to get tenant")
|
|
}
|
|
if tenant == nil {
|
|
return c.JSON([]domain.Tenant{})
|
|
}
|
|
return c.JSON([]domain.Tenant{*tenant})
|
|
}
|
|
|
|
if role == domain.RoleSuperAdmin {
|
|
tenants, _, err := h.TenantSvc.ListTenants(c.Context(), 100, 0, "")
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to list tenants")
|
|
}
|
|
return c.JSON(tenants)
|
|
}
|
|
|
|
tenants, err := h.TenantSvc.ListManageableTenants(c.Context(), profile.ID)
|
|
if err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error())
|
|
}
|
|
|
|
if profile.TenantID != nil && *profile.TenantID != "" {
|
|
found := false
|
|
for _, t := range tenants {
|
|
if t.ID == *profile.TenantID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
if primary, err := h.TenantSvc.GetTenant(c.Context(), *profile.TenantID); err == nil && primary != nil {
|
|
tenants = append(tenants, *primary)
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(tenants)
|
|
}
|
|
|
|
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
|
|
c.Locals("user_profile", enriched)
|
|
}
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Organization string `json:"organization"`
|
|
Reason string `json:"reason"`
|
|
TenantID string `json:"tenantId"`
|
|
}
|
|
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
|
|
}
|
|
if req.TenantID == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
|
}
|
|
|
|
name := strings.TrimSpace(profile.Name)
|
|
if name == "" {
|
|
name = strings.TrimSpace(req.Name)
|
|
}
|
|
organization := strings.TrimSpace(req.Organization)
|
|
if 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,
|
|
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
|
|
}
|
|
if tenantID == "" {
|
|
return errorJSON(c, fiber.StatusBadRequest, "tenantId is required")
|
|
}
|
|
|
|
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(fiber.Map{"status": "none"})
|
|
}
|
|
if status.Status == domain.DeveloperRequestStatusApproved {
|
|
h.ensureDeveloperGrantRelation(c, status.UserID, status.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) 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) 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"})
|
|
}
|