package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/response" "baron-sso-backend/internal/service" "encoding/json" "errors" "sort" "strings" "github.com/gofiber/fiber/v2" ) const ( clientTenantAccessRestrictedKey = "tenant_access_restricted" clientAllowedTenantsKey = "allowed_tenants" ) func normalizeClientTenantAccessMetadata(metadata map[string]any) (map[string]any, error) { if metadata == nil { metadata = map[string]any{} } restricted := readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey) allowedTenants := normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey]) ownerTenantID := normalizeMetadataString(metadata["tenant_id"]) if len(allowedTenants) > 0 { restricted = true } if !restricted { delete(metadata, clientAllowedTenantsKey) metadata[clientTenantAccessRestrictedKey] = false return metadata, nil } if ownerTenantID != "" { allowedTenants = append(allowedTenants, ownerTenantID) } allowedTenants = uniqueSortedStrings(allowedTenants) if len(allowedTenants) == 0 { return nil, errors.New("allowed_tenants is required when tenant_access_restricted is enabled") } metadata[clientTenantAccessRestrictedKey] = true metadata[clientAllowedTenantsKey] = allowedTenants return metadata, nil } func clientTenantAccessRestricted(metadata map[string]any) bool { if metadata == nil { return false } if readMetadataBoolValue(metadata, clientTenantAccessRestrictedKey) { return true } return len(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])) > 0 } func clientAllowedTenants(metadata map[string]any) []string { if metadata == nil { return nil } if !clientTenantAccessRestricted(metadata) { return nil } return uniqueSortedStrings(normalizeMetadataStringSlice(metadata[clientAllowedTenantsKey])) } func normalizeMetadataStringSlice(raw any) []string { switch value := raw.(type) { case []string: return uniqueSortedStrings(value) case []any: items := make([]string, 0, len(value)) for _, item := range value { if s, ok := item.(string); ok { items = append(items, s) } } return uniqueSortedStrings(items) default: return nil } } func normalizeMetadataString(raw any) string { s, ok := raw.(string) if !ok { return "" } return strings.TrimSpace(s) } func uniqueSortedStrings(values []string) []string { if len(values) == 0 { return nil } seen := make(map[string]struct{}, len(values)) out := make([]string, 0, len(values)) for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } if _, ok := seen[trimmed]; ok { continue } seen[trimmed] = struct{}{} out = append(out, trimmed) } sort.Strings(out) return out } func clientTenantAccessAllowed(profile *domain.UserProfileResponse, client domain.HydraClient) bool { if !clientTenantAccessRestricted(client.Metadata) { return true } allowed := clientAllowedTenants(client.Metadata) if len(allowed) == 0 { return false } keys := manageableTenantKeysFromProfile(profile) if len(keys) == 0 { return false } for _, tenantID := range allowed { if _, ok := keys[strings.ToLower(strings.TrimSpace(tenantID))]; ok { return true } } return false } func clientTenantAccessAllowedForSubtree(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse, client domain.HydraClient) bool { if clientTenantAccessAllowed(profile, client) { return true } if tenantSvc == nil || profile == nil { return false } allowedTenants := make([]domain.Tenant, 0) for _, identifier := range clientAllowedTenants(client.Metadata) { if tenant, ok := resolveTenantAccessTenant(c, tenantSvc, domain.Tenant{ID: identifier, Slug: identifier}); ok { allowedTenants = append(allowedTenants, tenant) } } if len(allowedTenants) == 0 { return false } for _, candidate := range tenantAccessProfileTenants(profile) { resolvedCandidate, ok := resolveTenantAccessTenant(c, tenantSvc, candidate) if !ok { continue } for _, allowed := range allowedTenants { if tenantMatchesOrDescendsFrom(c, tenantSvc, resolvedCandidate, allowed) { return true } } } return false } func tenantAccessProfileTenants(profile *domain.UserProfileResponse) []domain.Tenant { if profile == nil { return nil } seen := make(map[string]struct{}) tenants := make([]domain.Tenant, 0, len(profile.ManageableTenants)+len(profile.JoinedTenants)+2) add := func(tenant domain.Tenant) { key := strings.ToLower(firstNonEmptyString(tenant.ID, tenant.Slug, tenant.Name)) if key == "" { return } if _, ok := seen[key]; ok { return } seen[key] = struct{}{} tenants = append(tenants, tenant) } if profile.Tenant != nil { add(*profile.Tenant) } if profile.TenantID != nil { add(domain.Tenant{ID: strings.TrimSpace(*profile.TenantID)}) } for _, tenant := range profile.ManageableTenants { add(tenant) } for _, tenant := range profile.JoinedTenants { add(tenant) } return tenants } func resolveTenantAccessTenant(c *fiber.Ctx, tenantSvc service.TenantService, tenant domain.Tenant) (domain.Tenant, bool) { if tenantSvc == nil { return tenant, firstNonEmptyString(tenant.ID, tenant.Slug) != "" } if strings.TrimSpace(tenant.ID) != "" { if resolved, err := tenantSvc.GetTenant(c.Context(), strings.TrimSpace(tenant.ID)); err == nil && resolved != nil { return *resolved, true } } if strings.TrimSpace(tenant.Slug) != "" { if resolved, err := tenantSvc.GetTenantBySlug(c.Context(), strings.TrimSpace(tenant.Slug)); err == nil && resolved != nil { return *resolved, true } } return tenant, firstNonEmptyString(tenant.ID, tenant.Slug) != "" } func tenantMatchesOrDescendsFrom(c *fiber.Ctx, tenantSvc service.TenantService, tenant domain.Tenant, ancestor domain.Tenant) bool { if tenantAccessTenantMatches(tenant, ancestor) { return true } if tenantSvc == nil { return false } visited := make(map[string]struct{}) current := tenant for current.ParentID != nil && strings.TrimSpace(*current.ParentID) != "" { parentID := strings.TrimSpace(*current.ParentID) if _, ok := visited[parentID]; ok { return false } visited[parentID] = struct{}{} parent, err := tenantSvc.GetTenant(c.Context(), parentID) if err != nil || parent == nil { return false } if tenantAccessTenantMatches(*parent, ancestor) { return true } current = *parent } return false } func tenantAccessTenantMatches(left, right domain.Tenant) bool { leftID := strings.ToLower(strings.TrimSpace(left.ID)) rightID := strings.ToLower(strings.TrimSpace(right.ID)) if leftID != "" && rightID != "" && leftID == rightID { return true } leftSlug := strings.ToLower(strings.TrimSpace(left.Slug)) rightSlug := strings.ToLower(strings.TrimSpace(right.Slug)) return leftSlug != "" && rightSlug != "" && leftSlug == rightSlug } type tenantAccessDeniedDetails struct { Account tenantAccessDeniedAccount `json:"account"` CurrentTenant tenantAccessDeniedTenant `json:"current_tenant"` AffiliatedTenants []tenantAccessDeniedTenant `json:"affiliated_tenants,omitempty"` AllowedTenants []tenantAccessDeniedTenant `json:"allowed_tenants,omitempty"` } type tenantAccessDeniedAccount struct { Email string `json:"email,omitempty"` } type tenantAccessDeniedTenant struct { ID string `json:"id,omitempty"` Slug string `json:"slug,omitempty"` Name string `json:"name,omitempty"` Identifier string `json:"identifier,omitempty"` } func tenantNotAllowedError(c *fiber.Ctx, details tenantAccessDeniedDetails) error { return response.ErrorWithDetails( c, fiber.StatusForbidden, "tenant_not_allowed", "허용되지 않은 테넌트입니다.", details, ) } func isClientTenantAccessAllowed(profile *domain.UserProfileResponse, client domain.HydraClient) bool { if profile == nil { return false } return clientTenantAccessAllowed(profile, client) } func enforceClientTenantAccess(c *fiber.Ctx, tenantSvc service.TenantService, client domain.HydraClient, profile *domain.UserProfileResponse, resolveErr error) bool { if !clientTenantAccessRestricted(client.Metadata) { return false } details := buildTenantAccessDeniedDetails(c, tenantSvc, client, profile) if resolveErr != nil || profile == nil { _ = tenantNotAllowedError(c, details) return true } if !clientTenantAccessAllowedForSubtree(c, tenantSvc, profile, client) { _ = tenantNotAllowedError(c, details) return true } return false } func buildTenantAccessDeniedDetails(c *fiber.Ctx, tenantSvc service.TenantService, client domain.HydraClient, profile *domain.UserProfileResponse) tenantAccessDeniedDetails { details := tenantAccessDeniedDetails{ Account: tenantAccessDeniedAccount{Email: strings.TrimSpace(profileEmail(profile))}, CurrentTenant: resolveCurrentTenantDetails(c, tenantSvc, profile), AffiliatedTenants: resolveAffiliatedTenantDetails(c, tenantSvc, profile), } for _, identifier := range clientAllowedTenants(client.Metadata) { details.AllowedTenants = append(details.AllowedTenants, resolveAllowedTenantDetails(c, tenantSvc, identifier)) } return details } func resolveAffiliatedTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse) []tenantAccessDeniedTenant { if profile == nil { return nil } seen := make(map[string]struct{}) out := make([]tenantAccessDeniedTenant, 0, len(profile.JoinedTenants)+1) appendTenant := func(tenant tenantAccessDeniedTenant) { key := strings.ToLower(firstNonEmptyString(tenant.ID, tenant.Slug, tenant.Identifier, tenant.Name)) if key == "" { return } if _, ok := seen[key]; ok { return } seen[key] = struct{}{} out = append(out, tenant) } appendTenant(resolveCurrentTenantDetails(c, tenantSvc, profile)) for _, joined := range profile.JoinedTenants { appendTenant(tenantAccessDeniedTenant{ ID: strings.TrimSpace(joined.ID), Slug: strings.TrimSpace(joined.Slug), Name: strings.TrimSpace(joined.Name), Identifier: firstNonEmptyString(strings.TrimSpace(joined.Slug), strings.TrimSpace(joined.ID)), }) } return out } func resolveCurrentTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, profile *domain.UserProfileResponse) tenantAccessDeniedTenant { if profile == nil { return tenantAccessDeniedTenant{} } if profile.Tenant != nil { return tenantAccessDeniedTenant{ ID: strings.TrimSpace(profile.Tenant.ID), Slug: strings.TrimSpace(profile.Tenant.Slug), Name: strings.TrimSpace(profile.Tenant.Name), Identifier: firstNonEmptyString(strings.TrimSpace(profile.Tenant.Slug), strings.TrimSpace(profile.Tenant.ID)), } } if tenantSvc != nil { if profile.TenantID != nil && strings.TrimSpace(*profile.TenantID) != "" { if tenant, err := tenantSvc.GetTenant(c.Context(), strings.TrimSpace(*profile.TenantID)); err == nil && tenant != nil { return tenantAccessDeniedTenant{ ID: strings.TrimSpace(tenant.ID), Slug: strings.TrimSpace(tenant.Slug), Name: strings.TrimSpace(tenant.Name), Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID)), } } } } return tenantAccessDeniedTenant{ ID: strings.TrimSpace(pointerValue(profile.TenantID)), Identifier: strings.TrimSpace(pointerValue(profile.TenantID)), } } func resolveAllowedTenantDetails(c *fiber.Ctx, tenantSvc service.TenantService, identifier string) tenantAccessDeniedTenant { identifier = strings.TrimSpace(identifier) if identifier == "" { return tenantAccessDeniedTenant{} } if tenantSvc != nil { if tenant, err := tenantSvc.GetTenant(c.Context(), identifier); err == nil && tenant != nil { return tenantAccessDeniedTenant{ ID: strings.TrimSpace(tenant.ID), Slug: strings.TrimSpace(tenant.Slug), Name: strings.TrimSpace(tenant.Name), Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID), identifier), } } if tenant, err := tenantSvc.GetTenantBySlug(c.Context(), identifier); err == nil && tenant != nil { return tenantAccessDeniedTenant{ ID: strings.TrimSpace(tenant.ID), Slug: strings.TrimSpace(tenant.Slug), Name: strings.TrimSpace(tenant.Name), Identifier: firstNonEmptyString(strings.TrimSpace(tenant.Slug), strings.TrimSpace(tenant.ID), identifier), } } } return tenantAccessDeniedTenant{Identifier: identifier} } func profileEmail(profile *domain.UserProfileResponse) string { if profile == nil { return "" } return profile.Email } func pointerValue(value *string) string { if value == nil { return "" } return *value } func firstNonEmptyString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } type clientStructuredScope struct { Name string `json:"name"` Mandatory bool `json:"mandatory"` Locked bool `json:"locked"` } func mergeRequestedScopesWithClientRequirements(client domain.HydraClient, requested []string) []string { combined := make([]string, 0, len(requested)+2) combined = append(combined, requested...) combined = append(combined, requiredClientScopes(client)...) return normalizeScopesInConsentOrder(combined) } func normalizeScopesInConsentOrder(scopes []string) []string { combined := make([]string, 0, len(scopes)) combined = append(combined, scopes...) seen := make(map[string]struct{}, len(combined)) out := make([]string, 0, len(combined)) appendIfPresent := func(scope string) { scope = strings.TrimSpace(scope) if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { return } if _, ok := seen[scope]; ok { return } for _, candidate := range combined { if strings.TrimSpace(candidate) != scope { continue } seen[scope] = struct{}{} out = append(out, scope) return } } appendIfPresent("openid") appendIfPresent("tenant") for _, scope := range combined { scope = strings.TrimSpace(scope) if scope == "" || isLegacyRefreshTokenScopeAlias(scope) { continue } if _, ok := seen[scope]; ok { continue } seen[scope] = struct{}{} out = append(out, scope) } return out } func requiredClientScopes(client domain.HydraClient) []string { required := make([]string, 0, 4) if clientTenantAccessRestricted(client.Metadata) { required = append(required, "tenant") } if client.Metadata == nil { return normalizeScopesInConsentOrder(required) } rawStructuredScopes, ok := client.Metadata["structured_scopes"] if !ok || rawStructuredScopes == nil { return normalizeScopesInConsentOrder(required) } rawBytes, err := json.Marshal(rawStructuredScopes) if err != nil { return normalizeScopesInConsentOrder(required) } var scopes []clientStructuredScope if err := json.Unmarshal(rawBytes, &scopes); err != nil { return normalizeScopesInConsentOrder(required) } for _, scope := range scopes { name := strings.TrimSpace(scope.Name) if name == "" { continue } if scope.Mandatory || scope.Locked { required = append(required, name) } } return normalizeScopesInConsentOrder(required) }