Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion app/messages/route_options_message.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +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]
# 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_apps mtls_allowed_spaces mtls_allowed_orgs mtls_allow_any] if VCAP::CloudController::FeatureFlag.enabled?(:app_to_app_mtls_routing)
options.freeze
end

Expand All @@ -21,6 +25,7 @@ def self.valid_loadbalancing_algorithms
validate :loadbalancing_algorithm_is_valid
validate :route_options_are_valid
validate :hash_options_are_valid
validate :mtls_allowed_sources_options_are_valid

def loadbalancing_algorithm_is_valid
return if loadbalancing.blank?
Expand Down Expand Up @@ -82,5 +87,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 mtls_allowed_sources_options_are_valid
# 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)

validate_mtls_string_types
validate_mtls_allow_any_type
validate_mtls_allow_any_exclusivity
validate_mtls_guids_exist
end

private

# Parse comma-separated GUIDs into an array
def parse_guid_list(value)
return [] if value.blank?

value.to_s.split(',').map(&:strip).reject(&:empty?)
end

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 value.is_a?(String)
errors.add(key, 'must be a string of comma-separated GUIDs')
end
end
end

def validate_mtls_allow_any_type
return if mtls_allow_any.nil?

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_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 allow_any && has_specific

errors.add(:mtls_allow_any, 'is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs')
end

def validate_mtls_guids_exist
return if errors.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 = 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_apps, "contains non-existent app GUIDs: #{missing_guids.join(', ')}")
end

def validate_space_guids_exist
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_spaces, "contains non-existent space GUIDs: #{missing_guids.join(', ')}")
end

def validate_org_guids_exist
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_orgs, "contains non-existent organization GUIDs: #{missing_guids.join(', ')}")
end
end
end
3 changes: 2 additions & 1 deletion app/models/runtime/feature_flag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
209 changes: 209 additions & 0 deletions spec/unit/messages/route_options_message_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,215 @@ module VCAP::CloudController
end
end

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_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_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

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 '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 single app GUID' do
app = AppModel.make
message = RouteOptionsMessage.new({ mtls_allowed_apps: app.guid })
expect(message).to be_valid
end

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 '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 '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 '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_apps)).to include('must be a string of comma-separated GUIDs')
end
end

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 'allows single space GUID' do
space = Space.make
message = RouteOptionsMessage.new({ mtls_allowed_spaces: space.guid })
expect(message).to be_valid
end

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_spaces)).to include('contains non-existent space GUIDs: non-existent-space')
end

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_spaces)).to include('must be a string of comma-separated GUIDs')
end
end

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 'allows single org GUID' do
org = Organization.make
message = RouteOptionsMessage.new({ mtls_allowed_orgs: org.guid })
expect(message).to be_valid
end

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_orgs)).to include('contains non-existent organization GUIDs: non-existent-org')
end
end

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 'allows false value' do
message = RouteOptionsMessage.new({ mtls_allow_any: false })
expect(message).to be_valid
end

it 'allows string "true"' do
message = RouteOptionsMessage.new({ mtls_allow_any: 'true' })
expect(message).to be_valid
end

it 'allows string "false"' do
message = RouteOptionsMessage.new({ mtls_allow_any: 'false' })
expect(message).to be_valid
end

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 '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_allow_any)).to include('is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs')
end

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_allow_any)).to include('is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs')
end

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_allow_any)).to include('is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs')
end

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_allow_any)).to include('is mutually exclusive with mtls_allowed_apps, mtls_allowed_spaces, and mtls_allowed_orgs')
end
end

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_apps: app.guid,
mtls_allowed_spaces: space.guid,
mtls_allowed_orgs: org.guid
})
expect(message).to be_valid
end

it 'allows mTLS options with loadbalancing' do
app = AppModel.make
message = RouteOptionsMessage.new({
loadbalancing: 'round-robin',
mtls_allowed_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
Expand Down
Loading