From 5640df7f6379673631563e79748a73cda663f940 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:25:37 +0000 Subject: [PATCH 1/5] Add allowed_sources support for mTLS app-to-app routing - Add app_to_app_mtls_routing feature flag (default: false) - Add allowed_sources to RouteOptionsMessage with validation - Validate allowed_sources structure (apps/spaces/orgs arrays, any boolean) - Validate that app/space/org GUIDs exist in database - Enforce mutual exclusivity of 'any' with apps/spaces/orgs lists --- app/messages/route_options_message.rb | 94 ++++++++++++++++++++++++++- app/models/runtime/feature_flag.rb | 3 +- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/app/messages/route_options_message.rb b/app/messages/route_options_message.rb index f79a2bb6a0..b45d0462c9 100644 --- a/app/messages/route_options_message.rb +++ b/app/messages/route_options_message.rb @@ -3,11 +3,12 @@ module VCAP::CloudController class RouteOptionsMessage < BaseMessage # Register all possible keys upfront so attr_accessors are created - register_allowed_keys %i[loadbalancing hash_header hash_balance] + register_allowed_keys %i[loadbalancing hash_header hash_balance allowed_sources] def self.valid_route_options options = %i[loadbalancing] options += %i[hash_header hash_balance] if VCAP::CloudController::FeatureFlag.enabled?(:hash_based_routing) + options += %i[allowed_sources] if VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing) options.freeze end @@ -21,6 +22,7 @@ def self.valid_loadbalancing_algorithms validate :loadbalancing_algorithm_is_valid validate :route_options_are_valid validate :hash_options_are_valid + validate :allowed_sources_options_are_valid def loadbalancing_algorithm_is_valid return if loadbalancing.blank? @@ -82,5 +84,95 @@ def validate_hash_options_with_loadbalancing errors.add(:base, 'Hash header can only be set when loadbalancing is hash') if hash_header.present? && loadbalancing.present? && loadbalancing != 'hash' errors.add(:base, 'Hash balance can only be set when loadbalancing is hash') if hash_balance.present? && loadbalancing.present? && loadbalancing != 'hash' end + + def allowed_sources_options_are_valid + # Only validate allowed_sources when the feature flag is enabled + # If disabled, route_options_are_valid will already report it as unknown field + return unless VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing) + return if allowed_sources.blank? + + validate_allowed_sources_structure + validate_allowed_sources_any_exclusivity + validate_allowed_sources_guids_exist + end + + private + + def validate_allowed_sources_structure + unless allowed_sources.is_a?(Hash) + errors.add(:allowed_sources, 'must be an object') + return + end + + valid_keys = %w[apps spaces orgs any] + invalid_keys = allowed_sources.keys - valid_keys + errors.add(:allowed_sources, "contains invalid keys: #{invalid_keys.join(', ')}") if invalid_keys.any? + + # Validate types + %w[apps spaces orgs].each do |key| + next unless allowed_sources[key].present? + + unless allowed_sources[key].is_a?(Array) && allowed_sources[key].all? { |v| v.is_a?(String) } + errors.add(:allowed_sources, "#{key} must be an array of strings") + end + end + + return unless allowed_sources['any'].present? && ![true, false].include?(allowed_sources['any']) + + errors.add(:allowed_sources, 'any must be a boolean') + end + + def validate_allowed_sources_any_exclusivity + return unless allowed_sources.is_a?(Hash) + + has_any = allowed_sources['any'] == true + has_lists = %w[apps spaces orgs].any? { |key| allowed_sources[key].present? && allowed_sources[key].any? } + + return unless has_any && has_lists + + errors.add(:allowed_sources, 'any is mutually exclusive with apps, spaces, and orgs') + end + + def validate_allowed_sources_guids_exist + return unless allowed_sources.is_a?(Hash) + return if errors[:allowed_sources].any? # Skip if already invalid + + validate_app_guids_exist + validate_space_guids_exist + validate_org_guids_exist + end + + def validate_app_guids_exist + app_guids = allowed_sources['apps'] + return if app_guids.blank? + + existing_guids = AppModel.where(guid: app_guids).select_map(:guid) + missing_guids = app_guids - existing_guids + return if missing_guids.empty? + + errors.add(:allowed_sources, "apps contains non-existent app GUIDs: #{missing_guids.join(', ')}") + end + + def validate_space_guids_exist + space_guids = allowed_sources['spaces'] + return if space_guids.blank? + + existing_guids = Space.where(guid: space_guids).select_map(:guid) + missing_guids = space_guids - existing_guids + return if missing_guids.empty? + + errors.add(:allowed_sources, "spaces contains non-existent space GUIDs: #{missing_guids.join(', ')}") + end + + def validate_org_guids_exist + org_guids = allowed_sources['orgs'] + return if org_guids.blank? + + existing_guids = Organization.where(guid: org_guids).select_map(:guid) + missing_guids = org_guids - existing_guids + return if missing_guids.empty? + + errors.add(:allowed_sources, "orgs contains non-existent organization GUIDs: #{missing_guids.join(', ')}") + end end end diff --git a/app/models/runtime/feature_flag.rb b/app/models/runtime/feature_flag.rb index df991d89f7..da4bc026f2 100644 --- a/app/models/runtime/feature_flag.rb +++ b/app/models/runtime/feature_flag.rb @@ -24,7 +24,8 @@ class UndefinedFeatureFlagError < StandardError hide_marketplace_from_unauthenticated_users: false, resource_matching: true, route_sharing: false, - hash_based_routing: false + hash_based_routing: false, + app_to_app_mtls_routing: false }.freeze ADMIN_SKIPPABLE = %i[ From 223902c0c3ec5f71e90e8b7310d53696e637edef Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:26:35 +0000 Subject: [PATCH 2/5] Add unit tests for allowed_sources validation Tests cover: - Feature flag disabled: allowed_sources rejected as unknown field - Structure validation: object type, valid keys, array types, boolean any - any exclusivity: cannot combine any:true with apps/spaces/orgs lists - GUID existence validation: apps, spaces, orgs must exist in database - Combined options: allowed_sources works with loadbalancing --- .../messages/route_options_message_spec.rb | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/spec/unit/messages/route_options_message_spec.rb b/spec/unit/messages/route_options_message_spec.rb index 57646d2195..aa60e654de 100644 --- a/spec/unit/messages/route_options_message_spec.rb +++ b/spec/unit/messages/route_options_message_spec.rb @@ -37,6 +37,204 @@ module VCAP::CloudController end end + describe 'allowed_sources validations' do + context 'when app_to_app_mtls_routing feature flag is disabled' do + it 'does not allow allowed_sources option' do + message = RouteOptionsMessage.new({ allowed_sources: { apps: ['app-guid-1'] } }) + expect(message).not_to be_valid + expect(message.errors_on(:base)).to include("Unknown field(s): 'allowed_sources'") + end + end + + context 'when app_to_app_mtls_routing feature flag is enabled' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'app_to_app_mtls_routing', enabled: true) + end + + describe 'structure validation' do + it 'allows valid allowed_sources with apps' do + app = AppModel.make + message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => [app.guid] } }) + expect(message).to be_valid + end + + it 'allows valid allowed_sources with spaces' do + space = Space.make + message = RouteOptionsMessage.new({ allowed_sources: { 'spaces' => [space.guid] } }) + expect(message).to be_valid + end + + it 'allows valid allowed_sources with orgs' do + org = Organization.make + message = RouteOptionsMessage.new({ allowed_sources: { 'orgs' => [org.guid] } }) + expect(message).to be_valid + end + + it 'allows valid allowed_sources with any: true' do + message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true } }) + expect(message).to be_valid + end + + it 'allows valid allowed_sources with any: false' do + message = RouteOptionsMessage.new({ allowed_sources: { 'any' => false } }) + expect(message).to be_valid + end + + it 'allows empty allowed_sources object' do + message = RouteOptionsMessage.new({ allowed_sources: {} }) + expect(message).to be_valid + end + + it 'does not allow non-object allowed_sources' do + message = RouteOptionsMessage.new({ allowed_sources: 'invalid' }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('must be an object') + end + + it 'does not allow array allowed_sources' do + message = RouteOptionsMessage.new({ allowed_sources: ['app-guid-1'] }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('must be an object') + end + + it 'does not allow invalid keys in allowed_sources' do + message = RouteOptionsMessage.new({ allowed_sources: { 'invalid_key' => 'value' } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('contains invalid keys: invalid_key') + end + + it 'does not allow non-array apps' do + message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => 'not-an-array' } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('apps must be an array of strings') + end + + it 'does not allow non-string elements in apps array' do + message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => [123, 456] } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('apps must be an array of strings') + end + + it 'does not allow non-array spaces' do + message = RouteOptionsMessage.new({ allowed_sources: { 'spaces' => 'not-an-array' } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('spaces must be an array of strings') + end + + it 'does not allow non-array orgs' do + message = RouteOptionsMessage.new({ allowed_sources: { 'orgs' => 'not-an-array' } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('orgs must be an array of strings') + end + + it 'does not allow non-boolean any' do + message = RouteOptionsMessage.new({ allowed_sources: { 'any' => 'true' } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('any must be a boolean') + end + end + + describe 'any exclusivity validation' do + it 'does not allow any: true with apps list' do + app = AppModel.make + message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true, 'apps' => [app.guid] } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + end + + it 'does not allow any: true with spaces list' do + space = Space.make + message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true, 'spaces' => [space.guid] } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + end + + it 'does not allow any: true with orgs list' do + org = Organization.make + message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true, 'orgs' => [org.guid] } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + end + + it 'allows any: false with apps list' do + app = AppModel.make + message = RouteOptionsMessage.new({ allowed_sources: { 'any' => false, 'apps' => [app.guid] } }) + expect(message).to be_valid + end + + it 'allows any: true with empty apps list' do + message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true, 'apps' => [] } }) + expect(message).to be_valid + end + end + + describe 'GUID existence validation' do + it 'validates that app GUIDs exist' do + message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => ['non-existent-app-guid'] } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('apps contains non-existent app GUIDs: non-existent-app-guid') + end + + it 'validates that space GUIDs exist' do + message = RouteOptionsMessage.new({ allowed_sources: { 'spaces' => ['non-existent-space-guid'] } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('spaces contains non-existent space GUIDs: non-existent-space-guid') + end + + it 'validates that org GUIDs exist' do + message = RouteOptionsMessage.new({ allowed_sources: { 'orgs' => ['non-existent-org-guid'] } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('orgs contains non-existent organization GUIDs: non-existent-org-guid') + end + + it 'reports multiple non-existent app GUIDs' do + message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => ['guid-1', 'guid-2'] } }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('apps contains non-existent app GUIDs: guid-1, guid-2') + end + + it 'allows mix of existing apps, spaces, and orgs' do + app = AppModel.make + space = Space.make + org = Organization.make + message = RouteOptionsMessage.new({ + allowed_sources: { + 'apps' => [app.guid], + 'spaces' => [space.guid], + 'orgs' => [org.guid] + } + }) + expect(message).to be_valid + end + + it 'validates all types of GUIDs when multiple are provided' do + app = AppModel.make + message = RouteOptionsMessage.new({ + allowed_sources: { + 'apps' => [app.guid], + 'spaces' => ['non-existent-space'], + 'orgs' => ['non-existent-org'] + } + }) + expect(message).not_to be_valid + expect(message.errors_on(:allowed_sources)).to include('spaces contains non-existent space GUIDs: non-existent-space') + expect(message.errors_on(:allowed_sources)).to include('orgs contains non-existent organization GUIDs: non-existent-org') + end + end + + describe 'combined with other options' do + it 'allows allowed_sources with loadbalancing' do + app = AppModel.make + message = RouteOptionsMessage.new({ + loadbalancing: 'round-robin', + allowed_sources: { 'apps' => [app.guid] } + }) + expect(message).to be_valid + end + end + end + end + describe 'hash-based routing validations' do context 'when hash_based_routing feature flag is disabled' do it 'does not allow hash_header option' do From f560db569090ecbdacced340e6cb4a2692d7e305 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:39:30 +0000 Subject: [PATCH 3/5] Fix allowed_sources validation to handle symbol keys Rails parses JSON with symbol keys, but validation was comparing against string keys. Add normalized_allowed_sources helper to transform keys to strings for consistent comparison. --- app/messages/route_options_message.rb | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/messages/route_options_message.rb b/app/messages/route_options_message.rb index b45d0462c9..6983d7e701 100644 --- a/app/messages/route_options_message.rb +++ b/app/messages/route_options_message.rb @@ -98,6 +98,11 @@ def allowed_sources_options_are_valid private + # Normalize allowed_sources to use string keys (Rails may parse JSON with symbol keys) + def normalized_allowed_sources + @normalized_allowed_sources ||= allowed_sources.is_a?(Hash) ? allowed_sources.transform_keys(&:to_s) : allowed_sources + end + def validate_allowed_sources_structure unless allowed_sources.is_a?(Hash) errors.add(:allowed_sources, 'must be an object') @@ -105,19 +110,19 @@ def validate_allowed_sources_structure end valid_keys = %w[apps spaces orgs any] - invalid_keys = allowed_sources.keys - valid_keys + invalid_keys = normalized_allowed_sources.keys - valid_keys errors.add(:allowed_sources, "contains invalid keys: #{invalid_keys.join(', ')}") if invalid_keys.any? # Validate types %w[apps spaces orgs].each do |key| - next unless allowed_sources[key].present? + next unless normalized_allowed_sources[key].present? - unless allowed_sources[key].is_a?(Array) && allowed_sources[key].all? { |v| v.is_a?(String) } + unless normalized_allowed_sources[key].is_a?(Array) && normalized_allowed_sources[key].all? { |v| v.is_a?(String) } errors.add(:allowed_sources, "#{key} must be an array of strings") end end - return unless allowed_sources['any'].present? && ![true, false].include?(allowed_sources['any']) + return unless normalized_allowed_sources['any'].present? && ![true, false].include?(normalized_allowed_sources['any']) errors.add(:allowed_sources, 'any must be a boolean') end @@ -125,8 +130,8 @@ def validate_allowed_sources_structure def validate_allowed_sources_any_exclusivity return unless allowed_sources.is_a?(Hash) - has_any = allowed_sources['any'] == true - has_lists = %w[apps spaces orgs].any? { |key| allowed_sources[key].present? && allowed_sources[key].any? } + has_any = normalized_allowed_sources['any'] == true + has_lists = %w[apps spaces orgs].any? { |key| normalized_allowed_sources[key].present? && normalized_allowed_sources[key].any? } return unless has_any && has_lists @@ -143,7 +148,7 @@ def validate_allowed_sources_guids_exist end def validate_app_guids_exist - app_guids = allowed_sources['apps'] + app_guids = normalized_allowed_sources['apps'] return if app_guids.blank? existing_guids = AppModel.where(guid: app_guids).select_map(:guid) @@ -154,7 +159,7 @@ def validate_app_guids_exist end def validate_space_guids_exist - space_guids = allowed_sources['spaces'] + space_guids = normalized_allowed_sources['spaces'] return if space_guids.blank? existing_guids = Space.where(guid: space_guids).select_map(:guid) @@ -165,7 +170,7 @@ def validate_space_guids_exist end def validate_org_guids_exist - org_guids = allowed_sources['orgs'] + org_guids = normalized_allowed_sources['orgs'] return if org_guids.blank? existing_guids = Organization.where(guid: org_guids).select_map(:guid) From 936d4dd8bd0520bfc4a4b4c08d0983f76a103bc3 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 10:01:09 +0000 Subject: [PATCH 4/5] Rename allowed_sources to mtls_allowed_sources for clarity Rename the route options field from allowed_sources to mtls_allowed_sources for better clarity about its purpose in mTLS app-to-app routing. Updates RouteOptionsMessage to use the new field name in: - Allowed keys registration - Feature flag gating - Validation methods - All related tests --- app/messages/route_options_message.rb | 72 +++++------ .../messages/route_options_message_spec.rb | 114 +++++++++--------- 2 files changed, 93 insertions(+), 93 deletions(-) diff --git a/app/messages/route_options_message.rb b/app/messages/route_options_message.rb index 6983d7e701..ab688c6bbb 100644 --- a/app/messages/route_options_message.rb +++ b/app/messages/route_options_message.rb @@ -3,12 +3,12 @@ module VCAP::CloudController class RouteOptionsMessage < BaseMessage # Register all possible keys upfront so attr_accessors are created - register_allowed_keys %i[loadbalancing hash_header hash_balance allowed_sources] + register_allowed_keys %i[loadbalancing hash_header hash_balance mtls_allowed_sources] def self.valid_route_options options = %i[loadbalancing] options += %i[hash_header hash_balance] if VCAP::CloudController::FeatureFlag.enabled?(:hash_based_routing) - options += %i[allowed_sources] if VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing) + options += %i[mtls_allowed_sources] if VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing) options.freeze end @@ -22,7 +22,7 @@ def self.valid_loadbalancing_algorithms validate :loadbalancing_algorithm_is_valid validate :route_options_are_valid validate :hash_options_are_valid - validate :allowed_sources_options_are_valid + validate :mtls_allowed_sources_options_are_valid def loadbalancing_algorithm_is_valid return if loadbalancing.blank? @@ -85,62 +85,62 @@ def validate_hash_options_with_loadbalancing errors.add(:base, 'Hash balance can only be set when loadbalancing is hash') if hash_balance.present? && loadbalancing.present? && loadbalancing != 'hash' end - def allowed_sources_options_are_valid - # Only validate allowed_sources when the feature flag is enabled + def mtls_allowed_sources_options_are_valid + # Only validate mtls_allowed_sources when the feature flag is enabled # If disabled, route_options_are_valid will already report it as unknown field return unless VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing) - return if allowed_sources.blank? + return if mtls_allowed_sources.blank? - validate_allowed_sources_structure - validate_allowed_sources_any_exclusivity - validate_allowed_sources_guids_exist + validate_mtls_allowed_sources_structure + validate_mtls_allowed_sources_any_exclusivity + validate_mtls_allowed_sources_guids_exist end private - # Normalize allowed_sources to use string keys (Rails may parse JSON with symbol keys) - def normalized_allowed_sources - @normalized_allowed_sources ||= allowed_sources.is_a?(Hash) ? allowed_sources.transform_keys(&:to_s) : allowed_sources + # Normalize mtls_allowed_sources to use string keys (Rails may parse JSON with symbol keys) + def normalized_mtls_allowed_sources + @normalized_mtls_allowed_sources ||= mtls_allowed_sources.is_a?(Hash) ? mtls_allowed_sources.transform_keys(&:to_s) : mtls_allowed_sources end - def validate_allowed_sources_structure - unless allowed_sources.is_a?(Hash) - errors.add(:allowed_sources, 'must be an object') + def validate_mtls_allowed_sources_structure + unless mtls_allowed_sources.is_a?(Hash) + errors.add(:mtls_allowed_sources, 'must be an object') return end valid_keys = %w[apps spaces orgs any] - invalid_keys = normalized_allowed_sources.keys - valid_keys - errors.add(:allowed_sources, "contains invalid keys: #{invalid_keys.join(', ')}") if invalid_keys.any? + invalid_keys = normalized_mtls_allowed_sources.keys - valid_keys + errors.add(:mtls_allowed_sources, "contains invalid keys: #{invalid_keys.join(', ')}") if invalid_keys.any? # Validate types %w[apps spaces orgs].each do |key| - next unless normalized_allowed_sources[key].present? + next unless normalized_mtls_allowed_sources[key].present? - unless normalized_allowed_sources[key].is_a?(Array) && normalized_allowed_sources[key].all? { |v| v.is_a?(String) } - errors.add(:allowed_sources, "#{key} must be an array of strings") + unless normalized_mtls_allowed_sources[key].is_a?(Array) && normalized_mtls_allowed_sources[key].all? { |v| v.is_a?(String) } + errors.add(:mtls_allowed_sources, "#{key} must be an array of strings") end end - return unless normalized_allowed_sources['any'].present? && ![true, false].include?(normalized_allowed_sources['any']) + return unless normalized_mtls_allowed_sources['any'].present? && ![true, false].include?(normalized_mtls_allowed_sources['any']) - errors.add(:allowed_sources, 'any must be a boolean') + errors.add(:mtls_allowed_sources, 'any must be a boolean') end - def validate_allowed_sources_any_exclusivity - return unless allowed_sources.is_a?(Hash) + def validate_mtls_allowed_sources_any_exclusivity + return unless mtls_allowed_sources.is_a?(Hash) - has_any = normalized_allowed_sources['any'] == true - has_lists = %w[apps spaces orgs].any? { |key| normalized_allowed_sources[key].present? && normalized_allowed_sources[key].any? } + has_any = normalized_mtls_allowed_sources['any'] == true + has_lists = %w[apps spaces orgs].any? { |key| normalized_mtls_allowed_sources[key].present? && normalized_mtls_allowed_sources[key].any? } return unless has_any && has_lists - errors.add(:allowed_sources, 'any is mutually exclusive with apps, spaces, and orgs') + errors.add(:mtls_allowed_sources, 'any is mutually exclusive with apps, spaces, and orgs') end - def validate_allowed_sources_guids_exist - return unless allowed_sources.is_a?(Hash) - return if errors[:allowed_sources].any? # Skip if already invalid + def validate_mtls_allowed_sources_guids_exist + return unless mtls_allowed_sources.is_a?(Hash) + return if errors[:mtls_allowed_sources].any? # Skip if already invalid validate_app_guids_exist validate_space_guids_exist @@ -148,36 +148,36 @@ def validate_allowed_sources_guids_exist end def validate_app_guids_exist - app_guids = normalized_allowed_sources['apps'] + app_guids = normalized_mtls_allowed_sources['apps'] return if app_guids.blank? existing_guids = AppModel.where(guid: app_guids).select_map(:guid) missing_guids = app_guids - existing_guids return if missing_guids.empty? - errors.add(:allowed_sources, "apps contains non-existent app GUIDs: #{missing_guids.join(', ')}") + errors.add(:mtls_allowed_sources, "apps contains non-existent app GUIDs: #{missing_guids.join(', ')}") end def validate_space_guids_exist - space_guids = normalized_allowed_sources['spaces'] + space_guids = normalized_mtls_allowed_sources['spaces'] return if space_guids.blank? existing_guids = Space.where(guid: space_guids).select_map(:guid) missing_guids = space_guids - existing_guids return if missing_guids.empty? - errors.add(:allowed_sources, "spaces contains non-existent space GUIDs: #{missing_guids.join(', ')}") + errors.add(:mtls_allowed_sources, "spaces contains non-existent space GUIDs: #{missing_guids.join(', ')}") end def validate_org_guids_exist - org_guids = normalized_allowed_sources['orgs'] + org_guids = normalized_mtls_allowed_sources['orgs'] return if org_guids.blank? existing_guids = Organization.where(guid: org_guids).select_map(:guid) missing_guids = org_guids - existing_guids return if missing_guids.empty? - errors.add(:allowed_sources, "orgs contains non-existent organization GUIDs: #{missing_guids.join(', ')}") + errors.add(:mtls_allowed_sources, "orgs contains non-existent organization GUIDs: #{missing_guids.join(', ')}") end end end diff --git a/spec/unit/messages/route_options_message_spec.rb b/spec/unit/messages/route_options_message_spec.rb index aa60e654de..c9d86df339 100644 --- a/spec/unit/messages/route_options_message_spec.rb +++ b/spec/unit/messages/route_options_message_spec.rb @@ -37,12 +37,12 @@ module VCAP::CloudController end end - describe 'allowed_sources validations' do + describe 'mtls_allowed_sources validations' do context 'when app_to_app_mtls_routing feature flag is disabled' do - it 'does not allow allowed_sources option' do - message = RouteOptionsMessage.new({ allowed_sources: { apps: ['app-guid-1'] } }) + it 'does not allow mtls_allowed_sources option' do + message = RouteOptionsMessage.new({ mtls_allowed_sources: { apps: ['app-guid-1'] } }) expect(message).not_to be_valid - expect(message.errors_on(:base)).to include("Unknown field(s): 'allowed_sources'") + expect(message.errors_on(:base)).to include("Unknown field(s): 'mtls_allowed_sources'") end end @@ -52,145 +52,145 @@ module VCAP::CloudController end describe 'structure validation' do - it 'allows valid allowed_sources with apps' do + it 'allows valid mtls_allowed_sources with apps' do app = AppModel.make - message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => [app.guid] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => [app.guid] } }) expect(message).to be_valid end - it 'allows valid allowed_sources with spaces' do + it 'allows valid mtls_allowed_sources with spaces' do space = Space.make - message = RouteOptionsMessage.new({ allowed_sources: { 'spaces' => [space.guid] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'spaces' => [space.guid] } }) expect(message).to be_valid end - it 'allows valid allowed_sources with orgs' do + it 'allows valid mtls_allowed_sources with orgs' do org = Organization.make - message = RouteOptionsMessage.new({ allowed_sources: { 'orgs' => [org.guid] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'orgs' => [org.guid] } }) expect(message).to be_valid end - it 'allows valid allowed_sources with any: true' do - message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true } }) + it 'allows valid mtls_allowed_sources with any: true' do + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true } }) expect(message).to be_valid end - it 'allows valid allowed_sources with any: false' do - message = RouteOptionsMessage.new({ allowed_sources: { 'any' => false } }) + it 'allows valid mtls_allowed_sources with any: false' do + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => false } }) expect(message).to be_valid end - it 'allows empty allowed_sources object' do - message = RouteOptionsMessage.new({ allowed_sources: {} }) + it 'allows empty mtls_allowed_sources object' do + message = RouteOptionsMessage.new({ mtls_allowed_sources: {} }) expect(message).to be_valid end - it 'does not allow non-object allowed_sources' do - message = RouteOptionsMessage.new({ allowed_sources: 'invalid' }) + it 'does not allow non-object mtls_allowed_sources' do + message = RouteOptionsMessage.new({ mtls_allowed_sources: 'invalid' }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('must be an object') + expect(message.errors_on(:mtls_allowed_sources)).to include('must be an object') end - it 'does not allow array allowed_sources' do - message = RouteOptionsMessage.new({ allowed_sources: ['app-guid-1'] }) + it 'does not allow array mtls_allowed_sources' do + message = RouteOptionsMessage.new({ mtls_allowed_sources: ['app-guid-1'] }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('must be an object') + expect(message.errors_on(:mtls_allowed_sources)).to include('must be an object') end - it 'does not allow invalid keys in allowed_sources' do - message = RouteOptionsMessage.new({ allowed_sources: { 'invalid_key' => 'value' } }) + it 'does not allow invalid keys in mtls_allowed_sources' do + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'invalid_key' => 'value' } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('contains invalid keys: invalid_key') + expect(message.errors_on(:mtls_allowed_sources)).to include('contains invalid keys: invalid_key') end it 'does not allow non-array apps' do - message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => 'not-an-array' } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => 'not-an-array' } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('apps must be an array of strings') + expect(message.errors_on(:mtls_allowed_sources)).to include('apps must be an array of strings') end it 'does not allow non-string elements in apps array' do - message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => [123, 456] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => [123, 456] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('apps must be an array of strings') + expect(message.errors_on(:mtls_allowed_sources)).to include('apps must be an array of strings') end it 'does not allow non-array spaces' do - message = RouteOptionsMessage.new({ allowed_sources: { 'spaces' => 'not-an-array' } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'spaces' => 'not-an-array' } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('spaces must be an array of strings') + expect(message.errors_on(:mtls_allowed_sources)).to include('spaces must be an array of strings') end it 'does not allow non-array orgs' do - message = RouteOptionsMessage.new({ allowed_sources: { 'orgs' => 'not-an-array' } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'orgs' => 'not-an-array' } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('orgs must be an array of strings') + expect(message.errors_on(:mtls_allowed_sources)).to include('orgs must be an array of strings') end it 'does not allow non-boolean any' do - message = RouteOptionsMessage.new({ allowed_sources: { 'any' => 'true' } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => 'true' } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('any must be a boolean') + expect(message.errors_on(:mtls_allowed_sources)).to include('any must be a boolean') end end describe 'any exclusivity validation' do it 'does not allow any: true with apps list' do app = AppModel.make - message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true, 'apps' => [app.guid] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true, 'apps' => [app.guid] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + expect(message.errors_on(:mtls_allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') end it 'does not allow any: true with spaces list' do space = Space.make - message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true, 'spaces' => [space.guid] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true, 'spaces' => [space.guid] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + expect(message.errors_on(:mtls_allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') end it 'does not allow any: true with orgs list' do org = Organization.make - message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true, 'orgs' => [org.guid] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true, 'orgs' => [org.guid] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + expect(message.errors_on(:mtls_allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') end it 'allows any: false with apps list' do app = AppModel.make - message = RouteOptionsMessage.new({ allowed_sources: { 'any' => false, 'apps' => [app.guid] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => false, 'apps' => [app.guid] } }) expect(message).to be_valid end it 'allows any: true with empty apps list' do - message = RouteOptionsMessage.new({ allowed_sources: { 'any' => true, 'apps' => [] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true, 'apps' => [] } }) expect(message).to be_valid end end describe 'GUID existence validation' do it 'validates that app GUIDs exist' do - message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => ['non-existent-app-guid'] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => ['non-existent-app-guid'] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('apps contains non-existent app GUIDs: non-existent-app-guid') + expect(message.errors_on(:mtls_allowed_sources)).to include('apps contains non-existent app GUIDs: non-existent-app-guid') end it 'validates that space GUIDs exist' do - message = RouteOptionsMessage.new({ allowed_sources: { 'spaces' => ['non-existent-space-guid'] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'spaces' => ['non-existent-space-guid'] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('spaces contains non-existent space GUIDs: non-existent-space-guid') + expect(message.errors_on(:mtls_allowed_sources)).to include('spaces contains non-existent space GUIDs: non-existent-space-guid') end it 'validates that org GUIDs exist' do - message = RouteOptionsMessage.new({ allowed_sources: { 'orgs' => ['non-existent-org-guid'] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'orgs' => ['non-existent-org-guid'] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('orgs contains non-existent organization GUIDs: non-existent-org-guid') + expect(message.errors_on(:mtls_allowed_sources)).to include('orgs contains non-existent organization GUIDs: non-existent-org-guid') end it 'reports multiple non-existent app GUIDs' do - message = RouteOptionsMessage.new({ allowed_sources: { 'apps' => ['guid-1', 'guid-2'] } }) + message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => ['guid-1', 'guid-2'] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('apps contains non-existent app GUIDs: guid-1, guid-2') + expect(message.errors_on(:mtls_allowed_sources)).to include('apps contains non-existent app GUIDs: guid-1, guid-2') end it 'allows mix of existing apps, spaces, and orgs' do @@ -198,7 +198,7 @@ module VCAP::CloudController space = Space.make org = Organization.make message = RouteOptionsMessage.new({ - allowed_sources: { + mtls_allowed_sources: { 'apps' => [app.guid], 'spaces' => [space.guid], 'orgs' => [org.guid] @@ -210,24 +210,24 @@ module VCAP::CloudController it 'validates all types of GUIDs when multiple are provided' do app = AppModel.make message = RouteOptionsMessage.new({ - allowed_sources: { + mtls_allowed_sources: { 'apps' => [app.guid], 'spaces' => ['non-existent-space'], 'orgs' => ['non-existent-org'] } }) expect(message).not_to be_valid - expect(message.errors_on(:allowed_sources)).to include('spaces contains non-existent space GUIDs: non-existent-space') - expect(message.errors_on(:allowed_sources)).to include('orgs contains non-existent organization GUIDs: non-existent-org') + expect(message.errors_on(:mtls_allowed_sources)).to include('spaces contains non-existent space GUIDs: non-existent-space') + expect(message.errors_on(:mtls_allowed_sources)).to include('orgs contains non-existent organization GUIDs: non-existent-org') end end describe 'combined with other options' do - it 'allows allowed_sources with loadbalancing' do + it 'allows mtls_allowed_sources with loadbalancing' do app = AppModel.make message = RouteOptionsMessage.new({ loadbalancing: 'round-robin', - allowed_sources: { 'apps' => [app.guid] } + mtls_allowed_sources: { 'apps' => [app.guid] } }) expect(message).to be_valid end From 97470469ca3a223e8616e8a588b17b5fe8d41f80 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 15:06:16 +0000 Subject: [PATCH 5/5] Refactor mTLS route options to RFC-0027 compliant flat format Change from nested mtls_allowed_sources object to flat options: - mtls_allowed_apps: comma-separated app GUIDs (string) - mtls_allowed_spaces: comma-separated space GUIDs (string) - mtls_allowed_orgs: comma-separated org GUIDs (string) - mtls_allow_any: boolean (true/false) This complies with RFC-0027 which requires route options to only use numbers, strings, and boolean values (no nested objects or arrays). --- app/messages/route_options_message.rb | 92 ++++--- .../messages/route_options_message_spec.rb | 235 +++++++++--------- 2 files changed, 168 insertions(+), 159 deletions(-) diff --git a/app/messages/route_options_message.rb b/app/messages/route_options_message.rb index ab688c6bbb..c8b6d82a11 100644 --- a/app/messages/route_options_message.rb +++ b/app/messages/route_options_message.rb @@ -3,12 +3,15 @@ module VCAP::CloudController class RouteOptionsMessage < BaseMessage # Register all possible keys upfront so attr_accessors are created - register_allowed_keys %i[loadbalancing hash_header hash_balance mtls_allowed_sources] + # RFC-0027 compliant: only string/number/boolean values (no nested objects/arrays) + # mtls_allowed_apps, mtls_allowed_spaces, mtls_allowed_orgs are comma-separated GUIDs + # mtls_allow_any is a boolean + register_allowed_keys %i[loadbalancing hash_header hash_balance mtls_allowed_apps mtls_allowed_spaces mtls_allowed_orgs mtls_allow_any] def self.valid_route_options options = %i[loadbalancing] options += %i[hash_header hash_balance] if VCAP::CloudController::FeatureFlag.enabled?(:hash_based_routing) - options += %i[mtls_allowed_sources] if VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing) + options += %i[mtls_allowed_apps mtls_allowed_spaces mtls_allowed_orgs mtls_allow_any] if VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing) options.freeze end @@ -86,61 +89,56 @@ def validate_hash_options_with_loadbalancing end def mtls_allowed_sources_options_are_valid - # Only validate mtls_allowed_sources when the feature flag is enabled - # If disabled, route_options_are_valid will already report it as unknown field + # Only validate mtls options when the feature flag is enabled + # If disabled, route_options_are_valid will already report them as unknown fields return unless VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing) - return if mtls_allowed_sources.blank? - validate_mtls_allowed_sources_structure - validate_mtls_allowed_sources_any_exclusivity - validate_mtls_allowed_sources_guids_exist + validate_mtls_string_types + validate_mtls_allow_any_type + validate_mtls_allow_any_exclusivity + validate_mtls_guids_exist end private - # Normalize mtls_allowed_sources to use string keys (Rails may parse JSON with symbol keys) - def normalized_mtls_allowed_sources - @normalized_mtls_allowed_sources ||= mtls_allowed_sources.is_a?(Hash) ? mtls_allowed_sources.transform_keys(&:to_s) : mtls_allowed_sources - end - - def validate_mtls_allowed_sources_structure - unless mtls_allowed_sources.is_a?(Hash) - errors.add(:mtls_allowed_sources, 'must be an object') - return - end + # Parse comma-separated GUIDs into an array + def parse_guid_list(value) + return [] if value.blank? - valid_keys = %w[apps spaces orgs any] - invalid_keys = normalized_mtls_allowed_sources.keys - valid_keys - errors.add(:mtls_allowed_sources, "contains invalid keys: #{invalid_keys.join(', ')}") if invalid_keys.any? + value.to_s.split(',').map(&:strip).reject(&:empty?) + end - # Validate types - %w[apps spaces orgs].each do |key| - next unless normalized_mtls_allowed_sources[key].present? + def validate_mtls_string_types + # These should be strings (comma-separated GUIDs) per RFC-0027 + %i[mtls_allowed_apps mtls_allowed_spaces mtls_allowed_orgs].each do |key| + value = public_send(key) + next if value.blank? - unless normalized_mtls_allowed_sources[key].is_a?(Array) && normalized_mtls_allowed_sources[key].all? { |v| v.is_a?(String) } - errors.add(:mtls_allowed_sources, "#{key} must be an array of strings") + unless value.is_a?(String) + errors.add(key, 'must be a string of comma-separated GUIDs') end end + end - return unless normalized_mtls_allowed_sources['any'].present? && ![true, false].include?(normalized_mtls_allowed_sources['any']) + def validate_mtls_allow_any_type + return if mtls_allow_any.nil? - errors.add(:mtls_allowed_sources, 'any must be a boolean') + unless [true, false, 'true', 'false'].include?(mtls_allow_any) + errors.add(:mtls_allow_any, 'must be a boolean (true or false)') + end end - def validate_mtls_allowed_sources_any_exclusivity - return unless mtls_allowed_sources.is_a?(Hash) - - has_any = normalized_mtls_allowed_sources['any'] == true - has_lists = %w[apps spaces orgs].any? { |key| normalized_mtls_allowed_sources[key].present? && normalized_mtls_allowed_sources[key].any? } + def validate_mtls_allow_any_exclusivity + allow_any = mtls_allow_any == true || mtls_allow_any == 'true' + has_specific = [mtls_allowed_apps, mtls_allowed_spaces, mtls_allowed_orgs].any?(&:present?) - return unless has_any && has_lists + return unless allow_any && has_specific - errors.add(:mtls_allowed_sources, 'any is mutually exclusive with apps, spaces, and orgs') + errors.add(:mtls_allow_any, 'is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs') end - def validate_mtls_allowed_sources_guids_exist - return unless mtls_allowed_sources.is_a?(Hash) - return if errors[:mtls_allowed_sources].any? # Skip if already invalid + def validate_mtls_guids_exist + return if errors.any? # Skip if already invalid validate_app_guids_exist validate_space_guids_exist @@ -148,36 +146,36 @@ def validate_mtls_allowed_sources_guids_exist end def validate_app_guids_exist - app_guids = normalized_mtls_allowed_sources['apps'] - return if app_guids.blank? + app_guids = parse_guid_list(mtls_allowed_apps) + return if app_guids.empty? existing_guids = AppModel.where(guid: app_guids).select_map(:guid) missing_guids = app_guids - existing_guids return if missing_guids.empty? - errors.add(:mtls_allowed_sources, "apps contains non-existent app GUIDs: #{missing_guids.join(', ')}") + errors.add(:mtls_allowed_apps, "contains non-existent app GUIDs: #{missing_guids.join(', ')}") end def validate_space_guids_exist - space_guids = normalized_mtls_allowed_sources['spaces'] - return if space_guids.blank? + space_guids = parse_guid_list(mtls_allowed_spaces) + return if space_guids.empty? existing_guids = Space.where(guid: space_guids).select_map(:guid) missing_guids = space_guids - existing_guids return if missing_guids.empty? - errors.add(:mtls_allowed_sources, "spaces contains non-existent space GUIDs: #{missing_guids.join(', ')}") + errors.add(:mtls_allowed_spaces, "contains non-existent space GUIDs: #{missing_guids.join(', ')}") end def validate_org_guids_exist - org_guids = normalized_mtls_allowed_sources['orgs'] - return if org_guids.blank? + org_guids = parse_guid_list(mtls_allowed_orgs) + return if org_guids.empty? existing_guids = Organization.where(guid: org_guids).select_map(:guid) missing_guids = org_guids - existing_guids return if missing_guids.empty? - errors.add(:mtls_allowed_sources, "orgs contains non-existent organization GUIDs: #{missing_guids.join(', ')}") + errors.add(:mtls_allowed_orgs, "contains non-existent organization GUIDs: #{missing_guids.join(', ')}") end end end diff --git a/spec/unit/messages/route_options_message_spec.rb b/spec/unit/messages/route_options_message_spec.rb index c9d86df339..f081ecc942 100644 --- a/spec/unit/messages/route_options_message_spec.rb +++ b/spec/unit/messages/route_options_message_spec.rb @@ -37,12 +37,30 @@ module VCAP::CloudController end end - describe 'mtls_allowed_sources validations' do + describe 'mTLS allowed sources validations (RFC-0027 compliant flat options)' do context 'when app_to_app_mtls_routing feature flag is disabled' do - it 'does not allow mtls_allowed_sources option' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { apps: ['app-guid-1'] } }) + it 'does not allow mtls_allowed_apps option' do + message = RouteOptionsMessage.new({ mtls_allowed_apps: 'app-guid-1' }) expect(message).not_to be_valid - expect(message.errors_on(:base)).to include("Unknown field(s): 'mtls_allowed_sources'") + expect(message.errors_on(:base)).to include("Unknown field(s): 'mtls_allowed_apps'") + end + + it 'does not allow mtls_allowed_spaces option' do + message = RouteOptionsMessage.new({ mtls_allowed_spaces: 'space-guid-1' }) + expect(message).not_to be_valid + expect(message.errors_on(:base)).to include("Unknown field(s): 'mtls_allowed_spaces'") + end + + it 'does not allow mtls_allowed_orgs option' do + message = RouteOptionsMessage.new({ mtls_allowed_orgs: 'org-guid-1' }) + expect(message).not_to be_valid + expect(message.errors_on(:base)).to include("Unknown field(s): 'mtls_allowed_orgs'") + end + + it 'does not allow mtls_allow_any option' do + message = RouteOptionsMessage.new({ mtls_allow_any: true }) + expect(message).not_to be_valid + expect(message.errors_on(:base)).to include("Unknown field(s): 'mtls_allow_any'") end end @@ -51,183 +69,176 @@ module VCAP::CloudController VCAP::CloudController::FeatureFlag.make(name: 'app_to_app_mtls_routing', enabled: true) end - describe 'structure validation' do - it 'allows valid mtls_allowed_sources with apps' do - app = AppModel.make - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => [app.guid] } }) - expect(message).to be_valid - end - - it 'allows valid mtls_allowed_sources with spaces' do - space = Space.make - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'spaces' => [space.guid] } }) + describe 'mtls_allowed_apps validation' do + it 'allows valid comma-separated app GUIDs' do + app1 = AppModel.make + app2 = AppModel.make + message = RouteOptionsMessage.new({ mtls_allowed_apps: "#{app1.guid},#{app2.guid}" }) expect(message).to be_valid end - it 'allows valid mtls_allowed_sources with orgs' do - org = Organization.make - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'orgs' => [org.guid] } }) + it 'allows single app GUID' do + app = AppModel.make + message = RouteOptionsMessage.new({ mtls_allowed_apps: app.guid }) expect(message).to be_valid end - it 'allows valid mtls_allowed_sources with any: true' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true } }) + it 'allows app GUIDs with whitespace around commas' do + app1 = AppModel.make + app2 = AppModel.make + message = RouteOptionsMessage.new({ mtls_allowed_apps: "#{app1.guid} , #{app2.guid}" }) expect(message).to be_valid end - it 'allows valid mtls_allowed_sources with any: false' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => false } }) - expect(message).to be_valid + it 'rejects non-existent app GUIDs' do + message = RouteOptionsMessage.new({ mtls_allowed_apps: 'non-existent-guid' }) + expect(message).not_to be_valid + expect(message.errors_on(:mtls_allowed_apps)).to include('contains non-existent app GUIDs: non-existent-guid') end - it 'allows empty mtls_allowed_sources object' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: {} }) - expect(message).to be_valid + it 'reports multiple non-existent app GUIDs' do + message = RouteOptionsMessage.new({ mtls_allowed_apps: 'guid-1,guid-2' }) + expect(message).not_to be_valid + expect(message.errors_on(:mtls_allowed_apps)).to include('contains non-existent app GUIDs: guid-1, guid-2') end - it 'does not allow non-object mtls_allowed_sources' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: 'invalid' }) + it 'rejects non-string values' do + message = RouteOptionsMessage.new({ mtls_allowed_apps: ['array-not-string'] }) expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('must be an object') + expect(message.errors_on(:mtls_allowed_apps)).to include('must be a string of comma-separated GUIDs') end + end - it 'does not allow array mtls_allowed_sources' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: ['app-guid-1'] }) - expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('must be an object') + describe 'mtls_allowed_spaces validation' do + it 'allows valid comma-separated space GUIDs' do + space1 = Space.make + space2 = Space.make + message = RouteOptionsMessage.new({ mtls_allowed_spaces: "#{space1.guid},#{space2.guid}" }) + expect(message).to be_valid end - it 'does not allow invalid keys in mtls_allowed_sources' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'invalid_key' => 'value' } }) - expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('contains invalid keys: invalid_key') + it 'allows single space GUID' do + space = Space.make + message = RouteOptionsMessage.new({ mtls_allowed_spaces: space.guid }) + expect(message).to be_valid end - it 'does not allow non-array apps' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => 'not-an-array' } }) + it 'rejects non-existent space GUIDs' do + message = RouteOptionsMessage.new({ mtls_allowed_spaces: 'non-existent-space' }) expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('apps must be an array of strings') + expect(message.errors_on(:mtls_allowed_spaces)).to include('contains non-existent space GUIDs: non-existent-space') end - it 'does not allow non-string elements in apps array' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => [123, 456] } }) + it 'rejects non-string values' do + message = RouteOptionsMessage.new({ mtls_allowed_spaces: { 'nested' => 'object' } }) expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('apps must be an array of strings') + expect(message.errors_on(:mtls_allowed_spaces)).to include('must be a string of comma-separated GUIDs') end + end - it 'does not allow non-array spaces' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'spaces' => 'not-an-array' } }) - expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('spaces must be an array of strings') + describe 'mtls_allowed_orgs validation' do + it 'allows valid comma-separated org GUIDs' do + org1 = Organization.make + org2 = Organization.make + message = RouteOptionsMessage.new({ mtls_allowed_orgs: "#{org1.guid},#{org2.guid}" }) + expect(message).to be_valid end - it 'does not allow non-array orgs' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'orgs' => 'not-an-array' } }) - expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('orgs must be an array of strings') + it 'allows single org GUID' do + org = Organization.make + message = RouteOptionsMessage.new({ mtls_allowed_orgs: org.guid }) + expect(message).to be_valid end - it 'does not allow non-boolean any' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => 'true' } }) + it 'rejects non-existent org GUIDs' do + message = RouteOptionsMessage.new({ mtls_allowed_orgs: 'non-existent-org' }) expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('any must be a boolean') + expect(message.errors_on(:mtls_allowed_orgs)).to include('contains non-existent organization GUIDs: non-existent-org') end end - describe 'any exclusivity validation' do - it 'does not allow any: true with apps list' do - app = AppModel.make - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true, 'apps' => [app.guid] } }) - expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + describe 'mtls_allow_any validation' do + it 'allows true value' do + message = RouteOptionsMessage.new({ mtls_allow_any: true }) + expect(message).to be_valid end - it 'does not allow any: true with spaces list' do - space = Space.make - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true, 'spaces' => [space.guid] } }) - expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + it 'allows false value' do + message = RouteOptionsMessage.new({ mtls_allow_any: false }) + expect(message).to be_valid end - it 'does not allow any: true with orgs list' do - org = Organization.make - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true, 'orgs' => [org.guid] } }) - expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('any is mutually exclusive with apps, spaces, and orgs') + it 'allows string "true"' do + message = RouteOptionsMessage.new({ mtls_allow_any: 'true' }) + expect(message).to be_valid end - it 'allows any: false with apps list' do - app = AppModel.make - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => false, 'apps' => [app.guid] } }) + it 'allows string "false"' do + message = RouteOptionsMessage.new({ mtls_allow_any: 'false' }) expect(message).to be_valid end - it 'allows any: true with empty apps list' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'any' => true, 'apps' => [] } }) - expect(message).to be_valid + it 'rejects non-boolean values' do + message = RouteOptionsMessage.new({ mtls_allow_any: 'yes' }) + expect(message).not_to be_valid + expect(message.errors_on(:mtls_allow_any)).to include('must be a boolean (true or false)') end end - describe 'GUID existence validation' do - it 'validates that app GUIDs exist' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => ['non-existent-app-guid'] } }) + describe 'mtls_allow_any exclusivity validation' do + it 'does not allow mtls_allow_any with mtls_allowed_apps' do + app = AppModel.make + message = RouteOptionsMessage.new({ mtls_allow_any: true, mtls_allowed_apps: app.guid }) expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('apps contains non-existent app GUIDs: non-existent-app-guid') + expect(message.errors_on(:mtls_allow_any)).to include('is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs') end - it 'validates that space GUIDs exist' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'spaces' => ['non-existent-space-guid'] } }) + it 'does not allow mtls_allow_any with mtls_allowed_spaces' do + space = Space.make + message = RouteOptionsMessage.new({ mtls_allow_any: true, mtls_allowed_spaces: space.guid }) expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('spaces contains non-existent space GUIDs: non-existent-space-guid') + expect(message.errors_on(:mtls_allow_any)).to include('is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs') end - it 'validates that org GUIDs exist' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'orgs' => ['non-existent-org-guid'] } }) + it 'does not allow mtls_allow_any with mtls_allowed_orgs' do + org = Organization.make + message = RouteOptionsMessage.new({ mtls_allow_any: true, mtls_allowed_orgs: org.guid }) expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('orgs contains non-existent organization GUIDs: non-existent-org-guid') + expect(message.errors_on(:mtls_allow_any)).to include('is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs') end - it 'reports multiple non-existent app GUIDs' do - message = RouteOptionsMessage.new({ mtls_allowed_sources: { 'apps' => ['guid-1', 'guid-2'] } }) + it 'allows mtls_allow_any: false with specific GUIDs' do + app = AppModel.make + message = RouteOptionsMessage.new({ mtls_allow_any: false, mtls_allowed_apps: app.guid }) + expect(message).to be_valid + end + + it 'allows string "true" exclusivity check' do + app = AppModel.make + message = RouteOptionsMessage.new({ mtls_allow_any: 'true', mtls_allowed_apps: app.guid }) expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('apps contains non-existent app GUIDs: guid-1, guid-2') + expect(message.errors_on(:mtls_allow_any)).to include('is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs') end + end - it 'allows mix of existing apps, spaces, and orgs' do + describe 'combined options' do + it 'allows all mTLS options together (without mtls_allow_any)' do app = AppModel.make space = Space.make org = Organization.make message = RouteOptionsMessage.new({ - mtls_allowed_sources: { - 'apps' => [app.guid], - 'spaces' => [space.guid], - 'orgs' => [org.guid] - } + mtls_allowed_apps: app.guid, + mtls_allowed_spaces: space.guid, + mtls_allowed_orgs: org.guid }) expect(message).to be_valid end - it 'validates all types of GUIDs when multiple are provided' do - app = AppModel.make - message = RouteOptionsMessage.new({ - mtls_allowed_sources: { - 'apps' => [app.guid], - 'spaces' => ['non-existent-space'], - 'orgs' => ['non-existent-org'] - } - }) - expect(message).not_to be_valid - expect(message.errors_on(:mtls_allowed_sources)).to include('spaces contains non-existent space GUIDs: non-existent-space') - expect(message.errors_on(:mtls_allowed_sources)).to include('orgs contains non-existent organization GUIDs: non-existent-org') - end - end - - describe 'combined with other options' do - it 'allows mtls_allowed_sources with loadbalancing' do + it 'allows mTLS options with loadbalancing' do app = AppModel.make message = RouteOptionsMessage.new({ loadbalancing: 'round-robin', - mtls_allowed_sources: { 'apps' => [app.guid] } + mtls_allowed_apps: app.guid }) expect(message).to be_valid end