From a08ada118825f6dc0c39b9b030bbb1b1571afc1a Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:25:37 +0000 Subject: [PATCH 01/64] 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 f79a2bb6a0c..b45d0462c95 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 e64b7d60e7b..4dece1d2df0 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 0d6eb534b598a6ea3f273e4935f1b221c35aefbe Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:26:35 +0000 Subject: [PATCH 02/64] 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 57646d21950..aa60e654deb 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 c04068fe6a9648d764956b64f86d829fc865d44b Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:39:30 +0000 Subject: [PATCH 03/64] 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 b45d0462c95..6983d7e7012 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 52a53c406a02e4c3bddc2367df015d52547b0e2f Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 10:01:09 +0000 Subject: [PATCH 04/64] 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 6983d7e7012..ab688c6bbb6 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 aa60e654deb..c9d86df339c 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 cd5f1cb837145570ec0e52d701a534dfd9e37d2c Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 15:06:16 +0000 Subject: [PATCH 05/64] 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 ab688c6bbb6..c8b6d82a115 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 c9d86df339c..f081ecc942b 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 From 76ffb9bdc532e36825516e4f87f7b0c01c3d7ab8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 9 Apr 2026 07:52:15 +0000 Subject: [PATCH 06/64] Implement RFC domain-scoped mTLS routing with /v3/access_rules API Replace POC route-options-based mTLS implementation with RFC-compliant architecture: Domain model changes: - Add enforce_access_rules (boolean) and access_rules_scope (any/org/space) to domains - Fields are immutable after domain creation - Update DomainCreateMessage, DomainPresenter, and DomainCreate action Access Rules resource: - New /v3/access_rules API with full CRUD operations - RouteAccessRule model with guid, name, selector, route_id - Selector format: cf:app:, cf:space:, cf:org:, or cf:any - Enforce cf:any exclusivity and per-route name/selector uniqueness - Space Developer can manage rules for routes in their space Diego sync path: - Inject access_scope and access_rules into route options for GoRouter - Filter internal mTLS keys (access_scope, access_rules) from public /v3/routes API - Add access_rules to eager load to avoid N+1 queries Tests: - Unit tests for AccessRuleCreateMessage (selector validation, cf:any rules) - Request specs for /v3/access_rules CRUD (create, show, list, delete, metadata update) - Updated domain_create_message_spec for enforce_access_rules validation - Updated routing_info_spec to verify mTLS options injection - Updated route_presenter_spec to verify internal keys are filtered Remove POC artifacts: - Remove app_to_app_mtls_routing feature flag - Remove mtls_allowed_* keys from route_options_message --- app/access/access_rule_access.rb | 66 ++++ app/actions/domain_create.rb | 2 + app/controllers/v3/access_rules_controller.rb | 129 +++++++ ...access_rule_selector_resource_decorator.rb | 40 ++ app/messages/access_rule_create_message.rb | 52 +++ app/messages/access_rule_update_message.rb | 9 + app/messages/access_rules_list_message.rb | 17 + app/messages/domain_create_message.rb | 22 ++ app/messages/route_options_message.rb | 98 +---- app/models/runtime/domain.rb | 4 +- app/models/runtime/feature_flag.rb | 3 +- app/models/runtime/route.rb | 3 + app/models/runtime/route_access_rule.rb | 15 + app/presenters/v3/access_rule_presenter.rb | 47 +++ app/presenters/v3/domain_presenter.rb | 2 + app/presenters/v3/route_presenter.rb | 7 +- config/routes.rb | 7 + ...000_add_enforce_access_rules_to_domains.rb | 15 + ...0260407100001_create_route_access_rules.rb | 24 ++ .../diego/protocol/routing_info.rb | 13 +- spec/request/access_rules_spec.rb | 357 ++++++++++++++++++ .../diego/protocol/routing_info_spec.rb | 58 +++ .../access_rule_create_message_spec.rb | 248 ++++++++++++ .../messages/domain_create_message_spec.rb | 87 +++++ .../messages/route_options_message_spec.rb | 209 ---------- .../presenters/v3/route_presenter_spec.rb | 38 ++ 26 files changed, 1260 insertions(+), 312 deletions(-) create mode 100644 app/access/access_rule_access.rb create mode 100644 app/controllers/v3/access_rules_controller.rb create mode 100644 app/decorators/include_access_rule_selector_resource_decorator.rb create mode 100644 app/messages/access_rule_create_message.rb create mode 100644 app/messages/access_rule_update_message.rb create mode 100644 app/messages/access_rules_list_message.rb create mode 100644 app/models/runtime/route_access_rule.rb create mode 100644 app/presenters/v3/access_rule_presenter.rb create mode 100644 db/migrations/20260407100000_add_enforce_access_rules_to_domains.rb create mode 100644 db/migrations/20260407100001_create_route_access_rules.rb create mode 100644 spec/request/access_rules_spec.rb create mode 100644 spec/unit/messages/access_rule_create_message_spec.rb diff --git a/app/access/access_rule_access.rb b/app/access/access_rule_access.rb new file mode 100644 index 00000000000..72fff7ebf30 --- /dev/null +++ b/app/access/access_rule_access.rb @@ -0,0 +1,66 @@ +module VCAP::CloudController + class AccessRuleAccess < BaseAccess + # Space Developer of the route's space can manage access rules. + # No bilateral requirement — destination-controlled auth only. + + def create?(access_rule, _params=nil) + return true if admin_user? + + route = access_rule.route + return false unless route + + space = route.space + context.user_email && context.user.is_a?(User) && + space.developers.include?(context.user) + end + + def read?(access_rule) + return true if admin_user? || admin_read_only_user? || global_auditor? + + route = access_rule.route + return false unless route + + object_is_visible_to_user?(access_rule, context.user) + end + + def update?(access_rule, _params=nil) + create?(access_rule) + end + + def delete?(access_rule) + create?(access_rule) + end + + def index?(_object_class, _params=nil) + admin_user? || admin_read_only_user? || has_read_scope? || global_auditor? + end + + def read_with_token?(_) + admin_user? || admin_read_only_user? || has_read_scope? || global_auditor? + end + + def create_with_token?(_) + admin_user? || has_write_scope? + end + + def read_for_update_with_token?(_) + admin_user? || has_write_scope? + end + + def can_remove_related_object_with_token?(*args) + read_for_update_with_token?(*args) + end + + def read_related_object_for_update_with_token?(*args) + read_for_update_with_token?(*args) + end + + def update_with_token?(_) + admin_user? || has_write_scope? + end + + def delete_with_token?(_) + admin_user? || has_write_scope? + end + end +end diff --git a/app/actions/domain_create.rb b/app/actions/domain_create.rb index f69d05cd7a5..2ebbe778c14 100644 --- a/app/actions/domain_create.rb +++ b/app/actions/domain_create.rb @@ -21,6 +21,8 @@ def create(message:, shared_organizations: []) end domain.router_group_guid = message.router_group_guid + domain.enforce_access_rules = message.enforce_access_rules || false + domain.access_rules_scope = message.access_rules_scope Domain.db.transaction do domain.save diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb new file mode 100644 index 00000000000..a45b982bb64 --- /dev/null +++ b/app/controllers/v3/access_rules_controller.rb @@ -0,0 +1,129 @@ +require 'messages/access_rule_create_message' +require 'messages/access_rule_update_message' +require 'messages/access_rules_list_message' +require 'presenters/v3/access_rule_presenter' + +class AccessRulesController < ApplicationController + def index + message = AccessRulesListMessage.from_params(query_params) + invalid_param!(message.errors.full_messages) unless message.valid? + + dataset = build_dataset(message) + + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( + presenter: Presenters::V3::AccessRulePresenter, + paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)), + path: '/v3/access_rules', + message: message + ) + end + + def show + access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid]) + resource_not_found!(:access_rule) unless access_rule + + route = access_rule.route + resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + + render status: :ok, json: Presenters::V3::AccessRulePresenter.new(access_rule) + end + + def create + message = AccessRuleCreateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + route = VCAP::CloudController::Route.find(guid: message.route_guid) + resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) + suspended! unless permission_queryer.is_space_active?(route.space.id) + + unless route.domain.enforce_access_rules + unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") + end + + # Enforce cf:any exclusivity: if route already has a cf:any rule, reject new rules; + # if new rule is cf:any, reject if route already has any rules. + existing_selectors = route.access_rules.map(&:selector) + if message.selector == 'cf:any' && existing_selectors.any? + unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.") + end + if existing_selectors.include?('cf:any') && message.selector != 'cf:any' + unprocessable!("Cannot add selector '#{message.selector}': route already has a 'cf:any' rule.") + end + + # Uniqueness: name and selector must be unique per route + if route.access_rules.any? { |r| r.name == message.name } + unprocessable!("An access rule with name '#{message.name}' already exists for this route.") + end + if existing_selectors.include?(message.selector) + unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.") + end + + access_rule = VCAP::CloudController::RouteAccessRule.new( + guid: SecureRandom.uuid, + name: message.name, + selector: message.selector, + route_id: route.id, + created_at: Time.now.utc, + updated_at: Time.now.utc + ) + access_rule.save + + render status: :created, json: Presenters::V3::AccessRulePresenter.new(access_rule) + end + + def update + access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid]) + resource_not_found!(:access_rule) unless access_rule + + route = access_rule.route + resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) + suspended! unless permission_queryer.is_space_active?(route.space.id) + + message = AccessRuleUpdateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + VCAP::CloudController::MetadataUpdate.update(access_rule, message) + + render status: :ok, json: Presenters::V3::AccessRulePresenter.new(access_rule.reload) + end + + def destroy + access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid]) + resource_not_found!(:access_rule) unless access_rule + + route = access_rule.route + resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) + suspended! unless permission_queryer.is_space_active?(route.space.id) + + access_rule.destroy + head :no_content + end + + private + + def build_dataset(message) + dataset = VCAP::CloudController::RouteAccessRule.dataset + + readable_route_ids = VCAP::CloudController::Route. + join(:spaces, id: :space_id). + where(Sequel.lit(permission_queryer.readable_space_scoped_space_guids_query)). + select(:routes__id) + + dataset = dataset.where(route_id: readable_route_ids) + + if message.requested?(:route_guids) + dataset = dataset. + join(:routes, id: :route_id). + where(routes__guid: message.route_guids). + select_all(:route_access_rules) + end + + dataset = dataset.where(name: message.names) if message.requested?(:names) + dataset = dataset.where(selector: message.selectors) if message.requested?(:selectors) + + dataset + end +end diff --git a/app/decorators/include_access_rule_selector_resource_decorator.rb b/app/decorators/include_access_rule_selector_resource_decorator.rb new file mode 100644 index 00000000000..c5ac7552860 --- /dev/null +++ b/app/decorators/include_access_rule_selector_resource_decorator.rb @@ -0,0 +1,40 @@ +module VCAP::CloudController + class IncludeAccessRuleSelectorResourceDecorator + # Handles `?include=selector_resource` for GET /v3/access_rules + # Stale/missing resources (selector GUIDs that no longer exist) are silently absent. + + SELECTOR_REGEX = /\Acf:(app|space|org):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\z/ + + def self.match?(include_params) + include_params&.include?('selector_resource') + end + + def self.decorate(hash, access_rules) + included = [] + + access_rules.each do |rule| + match = SELECTOR_REGEX.match(rule.selector) + next unless match + + resource_type = match[1] + resource_guid = match[2] + + resource = case resource_type + when 'app' + VCAP::CloudController::AppModel.find(guid: resource_guid) + when 'space' + VCAP::CloudController::Space.find(guid: resource_guid) + when 'org' + VCAP::CloudController::Organization.find(guid: resource_guid) + end + + next if resource.nil? + + included << { type: resource_type, guid: resource.guid } + end + + hash[:included] = { selector_resources: included } + hash + end + end +end diff --git a/app/messages/access_rule_create_message.rb b/app/messages/access_rule_create_message.rb new file mode 100644 index 00000000000..f3086bf95ee --- /dev/null +++ b/app/messages/access_rule_create_message.rb @@ -0,0 +1,52 @@ +require 'messages/metadata_base_message' + +module VCAP::CloudController + class AccessRuleCreateMessage < MetadataBaseMessage + SELECTOR_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/ + + register_allowed_keys %i[ + name + selector + relationships + ] + + validates_with NoAdditionalKeysValidator + validates_with RelationshipValidator + + validates :name, presence: true, string: true + validates :selector, presence: true, string: true + + validate :selector_format_valid + validate :selector_not_cf_any_with_others + + delegate :route_guid, to: :relationships_message + + def relationships_message + @relationships_message ||= Relationships.new(relationships&.deep_symbolize_keys) + end + + private + + def selector_format_valid + return unless selector.is_a?(String) + return if SELECTOR_REGEX.match?(selector) + + errors.add(:selector, "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'") + end + + def selector_not_cf_any_with_others + # enforced at the controller level when checking existing rules on the route + end + + class Relationships < BaseMessage + register_allowed_keys [:route] + + validates_with NoAdditionalKeysValidator + validates :route, presence: true, to_one_relationship: true + + def route_guid + HashUtils.dig(route, :data, :guid) + end + end + end +end diff --git a/app/messages/access_rule_update_message.rb b/app/messages/access_rule_update_message.rb new file mode 100644 index 00000000000..b9adcf62a4a --- /dev/null +++ b/app/messages/access_rule_update_message.rb @@ -0,0 +1,9 @@ +require 'messages/metadata_base_message' + +module VCAP::CloudController + class AccessRuleUpdateMessage < MetadataBaseMessage + register_allowed_keys [] + + validates_with NoAdditionalKeysValidator + end +end diff --git a/app/messages/access_rules_list_message.rb b/app/messages/access_rules_list_message.rb new file mode 100644 index 00000000000..7c7973fda97 --- /dev/null +++ b/app/messages/access_rules_list_message.rb @@ -0,0 +1,17 @@ +require 'messages/list_message' + +module VCAP::CloudController + class AccessRulesListMessage < ListMessage + register_allowed_keys %i[ + route_guids + names + selectors + ] + + validates_with NoAdditionalParamsValidator + + def self.from_params(params) + super(params, %w[route_guids names selectors]) + end + end +end diff --git a/app/messages/domain_create_message.rb b/app/messages/domain_create_message.rb index 110bc0d499b..b10d065b553 100644 --- a/app/messages/domain_create_message.rb +++ b/app/messages/domain_create_message.rb @@ -16,6 +16,8 @@ class DomainCreateMessage < MetadataBaseMessage internal relationships router_group + enforce_access_rules + access_rules_scope ] def self.relationships_requested? @@ -59,6 +61,12 @@ def self.relationships_requested? allow_nil: true, boolean: true + validates :enforce_access_rules, + allow_nil: true, + boolean: true + + validate :access_rules_scope_validation + delegate :organization_guid, to: :relationships_message delegate :shared_organizations_guids, to: :relationships_message @@ -97,6 +105,20 @@ def router_group_validation errors.add(:router_group, 'guid must be a string') unless router_group_guid.is_a?(String) end + def access_rules_scope_validation + if requested?(:access_rules_scope) + unless access_rules_scope.nil? || %w[any org space].include?(access_rules_scope) + errors.add(:access_rules_scope, "must be one of 'any', 'org', 'space'") + end + end + + if requested?(:enforce_access_rules) && enforce_access_rules == true + if !requested?(:access_rules_scope) || access_rules_scope.nil? + errors.add(:access_rules_scope, 'is required when enforce_access_rules is true') + end + end + end + class Relationships < BaseMessage def self.shared_organizations_requested? @shared_organizations_requested ||= proc { |a| a.requested?(:shared_organizations) } diff --git a/app/messages/route_options_message.rb b/app/messages/route_options_message.rb index c8b6d82a115..7371b391558 100644 --- a/app/messages/route_options_message.rb +++ b/app/messages/route_options_message.rb @@ -2,16 +2,11 @@ module VCAP::CloudController class RouteOptionsMessage < BaseMessage - # Register all possible keys upfront so attr_accessors are created - # 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] + register_allowed_keys %i[loadbalancing hash_header hash_balance] 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 @@ -25,7 +20,6 @@ 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? @@ -87,95 +81,5 @@ 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 diff --git a/app/models/runtime/domain.rb b/app/models/runtime/domain.rb index 4ca18ef9b6f..16b2435aaeb 100644 --- a/app/models/runtime/domain.rb +++ b/app/models/runtime/domain.rb @@ -79,8 +79,8 @@ def shared_or_owned_by(organization_ids) one_to_many :labels, class: 'VCAP::CloudController::DomainLabelModel', key: :resource_guid, primary_key: :guid one_to_many :annotations, class: 'VCAP::CloudController::DomainAnnotationModel', key: :resource_guid, primary_key: :guid - export_attributes :name, :owning_organization_guid, :shared_organizations - import_attributes :name, :owning_organization_guid + export_attributes :name, :owning_organization_guid, :shared_organizations, :enforce_access_rules, :access_rules_scope + import_attributes :name, :owning_organization_guid, :enforce_access_rules, :access_rules_scope strip_attributes :name add_association_dependencies labels: :destroy diff --git a/app/models/runtime/feature_flag.rb b/app/models/runtime/feature_flag.rb index 4dece1d2df0..e64b7d60e7b 100644 --- a/app/models/runtime/feature_flag.rb +++ b/app/models/runtime/feature_flag.rb @@ -24,8 +24,7 @@ class UndefinedFeatureFlagError < StandardError hide_marketplace_from_unauthenticated_users: false, resource_matching: true, route_sharing: false, - hash_based_routing: false, - app_to_app_mtls_routing: false + hash_based_routing: false }.freeze ADMIN_SKIPPABLE = %i[ diff --git a/app/models/runtime/route.rb b/app/models/runtime/route.rb index bdefff78c41..84032473a23 100644 --- a/app/models/runtime/route.rb +++ b/app/models/runtime/route.rb @@ -39,6 +39,9 @@ class InvalidOrganizationRelation < CloudController::Errors::InvalidRelation; en add_association_dependencies route_mappings: :destroy + one_to_many :access_rules, class: 'VCAP::CloudController::RouteAccessRule', key: :route_id, primary_key: :id + add_association_dependencies access_rules: :destroy + export_attributes :host, :path, :domain_guid, :space_guid, :service_instance_guid, :port, :options import_attributes :host, :path, :domain_guid, :space_guid, :app_guids, :port, :options diff --git a/app/models/runtime/route_access_rule.rb b/app/models/runtime/route_access_rule.rb new file mode 100644 index 00000000000..08ed6c6e3e2 --- /dev/null +++ b/app/models/runtime/route_access_rule.rb @@ -0,0 +1,15 @@ +module VCAP::CloudController + class RouteAccessRule < Sequel::Model(:route_access_rules) + many_to_one :route, + class: 'VCAP::CloudController::Route', + key: :route_id, + primary_key: :id, + without_guid_generation: true + + def validate + validates_presence :name + validates_presence :selector + validates_presence :route_id + end + end +end diff --git a/app/presenters/v3/access_rule_presenter.rb b/app/presenters/v3/access_rule_presenter.rb new file mode 100644 index 00000000000..cd5f18d2c47 --- /dev/null +++ b/app/presenters/v3/access_rule_presenter.rb @@ -0,0 +1,47 @@ +require 'presenters/v3/base_presenter' +require 'presenters/mixins/metadata_presentation_helpers' + +module VCAP::CloudController + module Presenters + module V3 + class AccessRulePresenter < BasePresenter + include VCAP::CloudController::Presenters::Mixins::MetadataPresentationHelpers + + def to_hash + { + guid: access_rule.guid, + created_at: access_rule.created_at, + updated_at: access_rule.updated_at, + name: access_rule.name, + selector: access_rule.selector, + relationships: { + route: { + data: { + guid: access_rule.route.guid + } + } + }, + links: build_links + } + end + + private + + def access_rule + @resource + end + + def build_links + { + self: { + href: url_builder.build_url(path: "/v3/access_rules/#{access_rule.guid}") + }, + route: { + href: url_builder.build_url(path: "/v3/routes/#{access_rule.route.guid}") + } + } + end + end + end + end +end diff --git a/app/presenters/v3/domain_presenter.rb b/app/presenters/v3/domain_presenter.rb index 9ffa51fa951..8f655fa9927 100644 --- a/app/presenters/v3/domain_presenter.rb +++ b/app/presenters/v3/domain_presenter.rb @@ -28,6 +28,8 @@ def to_hash internal: domain.internal, router_group: hashified_router_group(domain.router_group_guid), supported_protocols: domain.protocols, + enforce_access_rules: domain.enforce_access_rules || false, + access_rules_scope: domain.access_rules_scope, relationships: { organization: { data: owning_org_guid diff --git a/app/presenters/v3/route_presenter.rb b/app/presenters/v3/route_presenter.rb index c090fafae5b..8eab8b790c3 100644 --- a/app/presenters/v3/route_presenter.rb +++ b/app/presenters/v3/route_presenter.rb @@ -48,13 +48,18 @@ def to_hash }, links: build_links } - hash.merge!(options: route.options) unless route.options.nil? + unless route.options.nil? + public_options = route.options.reject { |k, _| INTERNAL_ROUTE_OPTIONS.include?(k.to_s) } + hash.merge!(options: public_options) unless public_options.empty? + end @decorators.reduce(hash) { |memo, d| d.decorate(memo, [route]) } end private + INTERNAL_ROUTE_OPTIONS = %w[access_scope access_rules].freeze + def route @resource end diff --git a/config/routes.rb b/config/routes.rb index dc1039c54c4..e6822b973a6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -338,6 +338,13 @@ post '/roles', to: 'roles#create' delete '/roles/:guid', to: 'roles#destroy' + # access_rules + get '/access_rules', to: 'access_rules#index' + get '/access_rules/:guid', to: 'access_rules#show' + post '/access_rules', to: 'access_rules#create' + patch '/access_rules/:guid', to: 'access_rules#update' + delete '/access_rules/:guid', to: 'access_rules#destroy' + # info get '/info', to: 'info#v3_info' get '/info/usage_summary', to: 'info#show_usage_summary' diff --git a/db/migrations/20260407100000_add_enforce_access_rules_to_domains.rb b/db/migrations/20260407100000_add_enforce_access_rules_to_domains.rb new file mode 100644 index 00000000000..5f2df5e415b --- /dev/null +++ b/db/migrations/20260407100000_add_enforce_access_rules_to_domains.rb @@ -0,0 +1,15 @@ +Sequel.migration do + up do + alter_table :domains do + add_column :enforce_access_rules, :boolean, default: false, null: false unless @db.schema(:domains).map(&:first).include?(:enforce_access_rules) + add_column :access_rules_scope, String, null: true, size: 255 unless @db.schema(:domains).map(&:first).include?(:access_rules_scope) + end + end + + down do + alter_table :domains do + drop_column :enforce_access_rules if @db.schema(:domains).map(&:first).include?(:enforce_access_rules) + drop_column :access_rules_scope if @db.schema(:domains).map(&:first).include?(:access_rules_scope) + end + end +end diff --git a/db/migrations/20260407100001_create_route_access_rules.rb b/db/migrations/20260407100001_create_route_access_rules.rb new file mode 100644 index 00000000000..4c8c78f4216 --- /dev/null +++ b/db/migrations/20260407100001_create_route_access_rules.rb @@ -0,0 +1,24 @@ +Sequel.migration do + up do + unless table_exists?(:route_access_rules) + create_table :route_access_rules do + String :guid, size: 255, null: false + primary_key :id + String :name, size: 255, null: false + String :selector, size: 255, null: false + Integer :route_id, null: false + DateTime :created_at, null: false + DateTime :updated_at, null: false + + index :guid, unique: true, name: :route_access_rules_guid_index + index %i[route_id name], unique: true, name: :route_access_rules_route_id_name_index + index %i[route_id selector], unique: true, name: :route_access_rules_route_id_selector_index + foreign_key [:route_id], :routes, on_delete: :cascade, name: :fk_route_access_rules_route_id + end + end + end + + down do + drop_table(:route_access_rules) if table_exists?(:route_access_rules) + end +end diff --git a/lib/cloud_controller/diego/protocol/routing_info.rb b/lib/cloud_controller/diego/protocol/routing_info.rb index e85c061a4fd..27908728008 100644 --- a/lib/cloud_controller/diego/protocol/routing_info.rb +++ b/lib/cloud_controller/diego/protocol/routing_info.rb @@ -9,7 +9,7 @@ def initialize(process) end def routing_info - process_eager = ProcessModel.eager(route_mappings: { route: %i[domain route_binding] }).where(id: process.id).all + process_eager = ProcessModel.eager(route_mappings: { route: %i[domain route_binding access_rules] }).where(id: process.id).all return {} if process_eager.empty? @@ -44,6 +44,17 @@ def http_info(process_eager) info['port'] = get_port_to_use(route_mapping) info['protocol'] = route_mapping.protocol info['options'] = r.options if r.options + + # Inject mTLS access control options for enforce_access_rules domains. + # These are GoRouter-internal keys and are filtered from the /v3/routes API. + if r.domain.enforce_access_rules + mtls_options = info['options']&.dup || {} + mtls_options['access_scope'] = r.domain.access_rules_scope if r.domain.access_rules_scope + selectors = r.access_rules.map(&:selector) + mtls_options['access_rules'] = selectors.join(',') unless selectors.empty? + info['options'] = mtls_options + end + info end end diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb new file mode 100644 index 00000000000..3962cc59a66 --- /dev/null +++ b/spec/request/access_rules_spec.rb @@ -0,0 +1,357 @@ +require 'spec_helper' + +RSpec.describe 'Access Rules' do + let(:user) { VCAP::CloudController::User.make } + let(:admin_header) { admin_headers_for(user) } + let(:org) { VCAP::CloudController::Organization.make } + let(:space) { VCAP::CloudController::Space.make(organization: org) } + + let(:mtls_domain) do + VCAP::CloudController::PrivateDomain.make( + owning_organization: org, + enforce_access_rules: true, + access_rules_scope: 'space' + ) + end + let(:regular_domain) do + VCAP::CloudController::PrivateDomain.make(owning_organization: org) + end + + let(:mtls_route) { VCAP::CloudController::Route.make(space: space, domain: mtls_domain) } + let(:regular_route) { VCAP::CloudController::Route.make(space: space, domain: regular_domain) } + + let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } + + def expected_rule_json(rule) + { + guid: rule.guid, + created_at: iso8601, + updated_at: iso8601, + name: rule.name, + selector: rule.selector, + relationships: { + route: { data: { guid: rule.route.guid } } + }, + links: { + self: { href: %r{/v3/access_rules/#{rule.guid}} }, + route: { href: %r{/v3/routes/#{rule.route.guid}} } + } + } + end + + before do + TestConfig.override(kubernetes: {}) + space.organization.add_user(user) + space.add_developer(user) + end + + describe 'POST /v3/access_rules' do + let(:request_body) do + { + name: 'allow-frontend', + selector: "cf:app:#{valid_uuid}", + relationships: { + route: { data: { guid: mtls_route.guid } } + } + } + end + + context 'as admin' do + it 'creates an access rule and returns 201' do + post '/v3/access_rules', request_body.to_json, admin_header + + expect(last_response.status).to eq(201) + parsed = Oj.load(last_response.body) + expect(parsed['name']).to eq('allow-frontend') + expect(parsed['selector']).to eq("cf:app:#{valid_uuid}") + expect(parsed['relationships']['route']['data']['guid']).to eq(mtls_route.guid) + end + end + + context 'as space developer' do + let(:user_headers) { headers_for(user) } + + it 'creates an access rule' do + post '/v3/access_rules', request_body.to_json, user_headers + + expect(last_response.status).to eq(201) + end + end + + context 'when the domain does not have enforce_access_rules enabled' do + let(:request_body) do + { + name: 'disallowed-rule', + selector: "cf:app:#{valid_uuid}", + relationships: { + route: { data: { guid: regular_route.guid } } + } + } + end + + it 'returns 422' do + post '/v3/access_rules', request_body.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('enforce_access_rules') + end + end + + context 'when the route does not exist' do + let(:request_body) do + { + name: 'bad-rule', + selector: "cf:app:#{valid_uuid}", + relationships: { + route: { data: { guid: 'nonexistent-guid' } } + } + } + end + + it 'returns 404' do + post '/v3/access_rules', request_body.to_json, admin_header + + expect(last_response.status).to eq(404) + end + end + + context 'cf:any exclusivity' do + before do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'existing-rule', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'rejects cf:any when other rules exist' do + post '/v3/access_rules', { + name: 'any-rule', + selector: 'cf:any', + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include("cf:any") + end + end + + context 'when a cf:any rule already exists' do + before do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'any-rule', + selector: 'cf:any', + route_id: mtls_route.id + ) + end + + it 'rejects adding a specific selector' do + post '/v3/access_rules', { + name: 'specific-rule', + selector: "cf:space:#{valid_uuid}", + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include("cf:any") + end + end + + context 'duplicate name per route' do + before do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'allow-frontend', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'returns 422' do + other_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + post '/v3/access_rules', { + name: 'allow-frontend', + selector: "cf:space:#{other_uuid}", + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('allow-frontend') + end + end + + context 'duplicate selector per route' do + before do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'first-rule', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'returns 422' do + post '/v3/access_rules', { + name: 'second-rule', + selector: "cf:app:#{valid_uuid}", + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + end + end + + context 'invalid selector format' do + it 'returns 422' do + post '/v3/access_rules', { + name: 'bad-rule', + selector: 'not-valid', + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('selector') + end + end + end + + describe 'GET /v3/access_rules/:guid' do + let!(:access_rule) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'allow-frontend', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'returns the access rule' do + get "/v3/access_rules/#{access_rule.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['guid']).to eq(access_rule.guid) + expect(parsed['name']).to eq('allow-frontend') + expect(parsed['selector']).to eq("cf:app:#{valid_uuid}") + end + + context 'when the access rule does not exist' do + it 'returns 404' do + get '/v3/access_rules/nonexistent-guid', nil, admin_header + + expect(last_response.status).to eq(404) + end + end + end + + describe 'GET /v3/access_rules' do + let!(:rule1) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'rule-one', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + let!(:rule2) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'rule-two', + selector: 'cf:any', + route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id + ) + end + + it 'lists all accessible access rules' do + get '/v3/access_rules', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].map { |r| r['guid'] } + expect(guids).to include(rule1.guid, rule2.guid) + end + + it 'filters by route_guids' do + get "/v3/access_rules?route_guids=#{mtls_route.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].map { |r| r['guid'] } + expect(guids).to include(rule1.guid) + expect(guids).not_to include(rule2.guid) + end + + it 'filters by names' do + get '/v3/access_rules?names=rule-one', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(1) + expect(parsed['resources'][0]['name']).to eq('rule-one') + end + + it 'filters by selectors' do + get '/v3/access_rules?selectors=cf:any', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(1) + expect(parsed['resources'][0]['selector']).to eq('cf:any') + end + end + + describe 'DELETE /v3/access_rules/:guid' do + let!(:access_rule) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'to-delete', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'deletes the access rule and returns 204' do + delete "/v3/access_rules/#{access_rule.guid}", nil, admin_header + + expect(last_response.status).to eq(204) + expect(VCAP::CloudController::RouteAccessRule.find(guid: access_rule.guid)).to be_nil + end + + context 'when the access rule does not exist' do + it 'returns 404' do + delete '/v3/access_rules/nonexistent-guid', nil, admin_header + + expect(last_response.status).to eq(404) + end + end + end + + describe 'PATCH /v3/access_rules/:guid (metadata update)' do + let!(:access_rule) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'patchable', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + it 'returns 200' do + patch "/v3/access_rules/#{access_rule.guid}", { + metadata: { labels: { env: 'production' } } + }.to_json, admin_header + + expect(last_response.status).to eq(200) + end + + context 'when the access rule does not exist' do + it 'returns 404' do + patch '/v3/access_rules/nonexistent-guid', {}.to_json, admin_header + + expect(last_response.status).to eq(404) + end + end + end +end diff --git a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb index d74502cf615..95c39e1356f 100644 --- a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb @@ -250,6 +250,64 @@ class Protocol it 'does not include the internal routes' do end end + + context 'when the route domain has enforce_access_rules enabled' do + let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } + let(:enforce_domain) do + PrivateDomain.make( + name: 'mtls.example.com', + owning_organization: org, + enforce_access_rules: true, + access_rules_scope: 'space' + ) + end + let(:mtls_route) { Route.make(host: 'myapp', domain: enforce_domain, space: space) } + let!(:access_rule1) do + RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'allow-app', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + end + let!(:access_rule2) do + RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'allow-space', + selector: "cf:space:#{valid_uuid}", + route_id: mtls_route.id + ) + end + + before do + RouteMappingModel.make(app: process.app, route: mtls_route, process_type: process.type) + end + + it 'injects access_scope and access_rules into route options' do + http_routes = ri['http_routes'] + mtls_entry = http_routes.find { |r| r['hostname'] == "myapp.mtls.example.com" } + + expect(mtls_entry).not_to be_nil + expect(mtls_entry['options']['access_scope']).to eq('space') + expect(mtls_entry['options']['access_rules']).to include("cf:app:#{valid_uuid}") + expect(mtls_entry['options']['access_rules']).to include("cf:space:#{valid_uuid}") + end + + context 'when the route has no access rules' do + before do + access_rule1.destroy + access_rule2.destroy + end + + it 'injects access_scope but omits access_rules key' do + http_routes = ri['http_routes'] + mtls_entry = http_routes.find { |r| r['hostname'] == "myapp.mtls.example.com" } + + expect(mtls_entry['options']['access_scope']).to eq('space') + expect(mtls_entry['options']).not_to have_key('access_rules') + end + end + end end context 'tcp routes' do diff --git a/spec/unit/messages/access_rule_create_message_spec.rb b/spec/unit/messages/access_rule_create_message_spec.rb new file mode 100644 index 00000000000..4d7adc60757 --- /dev/null +++ b/spec/unit/messages/access_rule_create_message_spec.rb @@ -0,0 +1,248 @@ +require 'spec_helper' +require 'messages/access_rule_create_message' + +module VCAP::CloudController + RSpec.describe AccessRuleCreateMessage do + let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } + let(:valid_route_relationship) do + { relationships: { route: { data: { guid: valid_uuid } } } } + end + + subject { AccessRuleCreateMessage.new(params) } + + describe 'validations' do + context 'when all valid params are given' do + let(:params) do + { + name: 'allow-frontend', + selector: "cf:app:#{valid_uuid}", + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when unexpected keys are provided' do + let(:params) do + { + name: 'allow-frontend', + selector: "cf:app:#{valid_uuid}", + unexpected: 'field', + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors.full_messages[0]).to include("Unknown field(s): 'unexpected'") + end + end + + describe 'name' do + context 'when name is missing' do + let(:params) do + { + selector: "cf:app:#{valid_uuid}", + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:name]).to include("can't be blank") + end + end + + context 'when name is not a string' do + let(:params) do + { + name: 42, + selector: "cf:app:#{valid_uuid}", + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:name]).to include('must be a string') + end + end + end + + describe 'selector' do + context 'when selector is missing' do + let(:params) do + { + name: 'allow-frontend', + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:selector]).to include("can't be blank") + end + end + + context 'when selector is not a string' do + let(:params) do + { + name: 'allow-frontend', + selector: 123, + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:selector]).to include('must be a string') + end + end + + context 'selector format' do + context 'cf:app:' do + let(:params) do + { + name: 'allow-app', + selector: "cf:app:#{valid_uuid}", + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'cf:space:' do + let(:params) do + { + name: 'allow-space', + selector: "cf:space:#{valid_uuid}", + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'cf:org:' do + let(:params) do + { + name: 'allow-org', + selector: "cf:org:#{valid_uuid}", + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'cf:any' do + let(:params) do + { + name: 'allow-any', + selector: 'cf:any', + }.merge(valid_route_relationship) + end + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'invalid format' do + let(:params) do + { + name: 'bad-rule', + selector: 'not-valid', + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:selector]).to include( + "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" + ) + end + end + + context 'cf:app: with invalid uuid' do + let(:params) do + { + name: 'bad-rule', + selector: 'cf:app:not-a-uuid', + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:selector]).to include( + "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" + ) + end + end + + context 'cf:unknown type' do + let(:params) do + { + name: 'bad-rule', + selector: "cf:team:#{valid_uuid}", + }.merge(valid_route_relationship) + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:selector]).to include( + "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" + ) + end + end + end + end + + describe 'relationships' do + context 'when relationships is missing' do + let(:params) do + { + name: 'allow-frontend', + selector: "cf:app:#{valid_uuid}", + } + end + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:relationships]).to be_present + end + end + + context 'when route relationship is missing' do + let(:params) do + { + name: 'allow-frontend', + selector: "cf:app:#{valid_uuid}", + relationships: {}, + } + end + + it 'is not valid' do + expect(subject).not_to be_valid + end + end + + context 'when route guid is provided' do + let(:params) do + { + name: 'allow-frontend', + selector: "cf:app:#{valid_uuid}", + relationships: { route: { data: { guid: 'some-route-guid' } } }, + } + end + + it 'exposes the route_guid' do + expect(subject).to be_valid + expect(subject.route_guid).to eq('some-route-guid') + end + end + end + end + end +end diff --git a/spec/unit/messages/domain_create_message_spec.rb b/spec/unit/messages/domain_create_message_spec.rb index f7dae8db280..8caab439a11 100644 --- a/spec/unit/messages/domain_create_message_spec.rb +++ b/spec/unit/messages/domain_create_message_spec.rb @@ -403,6 +403,93 @@ module VCAP::CloudController expect(subject).to be_valid end end + + context 'enforce_access_rules' do + context 'when not a boolean' do + let(:params) { { name: 'name.com', enforce_access_rules: 'yes' } } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:enforce_access_rules]).to include('must be a boolean') + end + end + + context 'when true without access_rules_scope' do + let(:params) { { name: 'name.com', enforce_access_rules: true } } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:access_rules_scope]).to include('is required when enforce_access_rules is true') + end + end + + context 'when true with a valid access_rules_scope' do + let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'space' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when false without access_rules_scope' do + let(:params) { { name: 'name.com', enforce_access_rules: false } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when omitted' do + let(:params) { { name: 'name.com' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + end + + context 'access_rules_scope' do + context 'when set to an invalid value' do + let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'invalid' } } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:access_rules_scope]).to include("must be one of 'any', 'org', 'space'") + end + end + + context "when set to 'any'" do + let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'any' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context "when set to 'org'" do + let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'org' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context "when set to 'space'" do + let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'space' } } + + it 'is valid' do + expect(subject).to be_valid + end + end + + context 'when provided without enforce_access_rules' do + let(:params) { { name: 'name.com', access_rules_scope: 'space' } } + + it 'is valid (scope alone is permissible)' do + expect(subject).to be_valid + end + end + end end describe 'accessor methods' do diff --git a/spec/unit/messages/route_options_message_spec.rb b/spec/unit/messages/route_options_message_spec.rb index f081ecc942b..57646d21950 100644 --- a/spec/unit/messages/route_options_message_spec.rb +++ b/spec/unit/messages/route_options_message_spec.rb @@ -37,215 +37,6 @@ 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 diff --git a/spec/unit/presenters/v3/route_presenter_spec.rb b/spec/unit/presenters/v3/route_presenter_spec.rb index 684b132e407..3c78892c26e 100644 --- a/spec/unit/presenters/v3/route_presenter_spec.rb +++ b/spec/unit/presenters/v3/route_presenter_spec.rb @@ -147,6 +147,44 @@ module VCAP::CloudController::Presenters::V3 end end + context 'when options contains only internal mTLS keys' do + let(:route) do + VCAP::CloudController::Route.make( + host: 'foobar', + path: path, + space: space, + domain: domain, + options: { 'access_scope' => 'space', 'access_rules' => 'cf:app:some-guid' } + ) + end + + it 'omits the options key entirely from the response' do + expect(subject).not_to have_key(:options) + end + end + + context 'when options contains a mix of public and internal keys' do + let(:route) do + VCAP::CloudController::Route.make( + host: 'foobar', + path: path, + space: space, + domain: domain, + options: { + 'loadbalancing' => 'round-robin', + 'access_scope' => 'space', + 'access_rules' => 'cf:app:some-guid' + } + ) + end + + it 'exposes only the public options' do + expect(subject[:options]).to eq('loadbalancing' => 'round-robin') + expect(subject[:options]).not_to have_key('access_scope') + expect(subject[:options]).not_to have_key('access_rules') + end + end + context 'when there are decorators' do let(:banana_decorator) do Class.new do From 8c2b6b154cf0a6464214ff2da3e9239f710602f7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 9 Apr 2026 12:35:18 +0000 Subject: [PATCH 07/64] Fix access_rules_controller permissions query - Replace non-existent readable_space_scoped_space_guids_query with proper subquery - Use readable_space_scoped_spaces_query for non-global readers - Handle global readers separately with all routes --- app/controllers/v3/access_rules_controller.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index a45b982bb64..ac84d80f449 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -107,10 +107,12 @@ def destroy def build_dataset(message) dataset = VCAP::CloudController::RouteAccessRule.dataset - readable_route_ids = VCAP::CloudController::Route. - join(:spaces, id: :space_id). - where(Sequel.lit(permission_queryer.readable_space_scoped_space_guids_query)). - select(:routes__id) + if permission_queryer.can_read_globally? + readable_route_ids = VCAP::CloudController::Route.select(:id) + else + readable_space_ids = permission_queryer.readable_space_scoped_spaces_query.select(:id) + readable_route_ids = VCAP::CloudController::Route.where(space_id: readable_space_ids).select(:id) + end dataset = dataset.where(route_id: readable_route_ids) From e7fb1bf0b8cc3142e6a0d42aa9a9a96d35f01f1f Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 9 Apr 2026 13:44:57 +0000 Subject: [PATCH 08/64] Add automatic Diego sync callbacks to RouteAccessRule - Add after_create and after_destroy callbacks to touch associated processes - Updates process.updated_at to trigger Diego ProcessesSync immediately - Eliminates 30-second wait for access rule changes to propagate to GoRouter - Add comprehensive unit tests for callbacks and validations - Ensure RouteAccessRule model is loaded in app/models.rb This enables automatic synchronization of access rules to Diego/GoRouter within seconds instead of requiring manual app restarts or waiting for the next sync cycle. --- app/models.rb | 1 + app/models/runtime/route_access_rule.rb | 22 ++++ .../models/runtime/route_access_rule_spec.rb | 112 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 spec/unit/models/runtime/route_access_rule_spec.rb diff --git a/app/models.rb b/app/models.rb index d6f1418c79e..b91140b64d0 100644 --- a/app/models.rb +++ b/app/models.rb @@ -68,6 +68,7 @@ require 'models/runtime/revision_sidecar_model' require 'models/runtime/revision_sidecar_process_type_model' require 'models/runtime/route' +require 'models/runtime/route_access_rule' require 'models/runtime/space_routes' require 'models/runtime/space_quota_definition' require 'models/runtime/stack' diff --git a/app/models/runtime/route_access_rule.rb b/app/models/runtime/route_access_rule.rb index 08ed6c6e3e2..cf554de3fd8 100644 --- a/app/models/runtime/route_access_rule.rb +++ b/app/models/runtime/route_access_rule.rb @@ -11,5 +11,27 @@ def validate validates_presence :selector validates_presence :route_id end + + def after_create + super + touch_associated_processes + end + + def after_destroy + super + touch_associated_processes + end + + private + + def touch_associated_processes + # Update the timestamp on all processes associated with this route + # This triggers Diego's ProcessesSync to pick up the route changes + return unless route + + route.apps.each do |process| + process.update(updated_at: Time.now) + end + end end end diff --git a/spec/unit/models/runtime/route_access_rule_spec.rb b/spec/unit/models/runtime/route_access_rule_spec.rb new file mode 100644 index 00000000000..89e1a536f47 --- /dev/null +++ b/spec/unit/models/runtime/route_access_rule_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper' + +module VCAP::CloudController + RSpec.describe RouteAccessRule, type: :model do + let(:space) { Space.make } + let(:domain) { SharedDomain.make(name: 'apps.identity') } + let(:route) { Route.make(space: space, domain: domain) } + let(:process) { ProcessModelFactory.make(space: space) } + let(:app_guid) { SecureRandom.uuid } + + before do + RouteMappingModel.make(app: process, route: route, process_type: 'web') + end + + describe 'validations' do + it 'requires a name' do + rule = RouteAccessRule.new(selector: 'cf:app:123', route: route) + expect(rule.valid?).to be false + expect(rule.errors[:name]).to include("can't be blank") + end + + it 'requires a selector' do + rule = RouteAccessRule.new(name: 'test-rule', route: route) + expect(rule.valid?).to be false + expect(rule.errors[:selector]).to include("can't be blank") + end + + it 'requires a route_id' do + rule = RouteAccessRule.new(name: 'test-rule', selector: 'cf:app:123') + expect(rule.valid?).to be false + expect(rule.errors[:route_id]).to include("can't be blank") + end + end + + describe 'associations' do + it 'belongs to a route' do + rule = RouteAccessRule.create( + name: 'test-rule', + selector: 'cf:app:123', + route: route + ) + expect(rule.route).to eq(route) + end + end + + describe 'callbacks' do + describe 'after_create' do + it 'touches associated processes to trigger Diego sync' do + initial_updated_at = process.updated_at + + # Sleep to ensure timestamp difference + sleep 0.1 + + RouteAccessRule.create( + name: 'test-rule', + selector: "cf:app:#{app_guid}", + route: route + ) + + process.reload + expect(process.updated_at).to be > initial_updated_at + end + + it 'does not fail if route has no associated processes' do + route_without_processes = Route.make(space: space, domain: domain) + + expect { + RouteAccessRule.create( + name: 'test-rule', + selector: "cf:app:#{app_guid}", + route: route_without_processes + ) + }.not_to raise_error + end + end + + describe 'after_destroy' do + it 'touches associated processes to trigger Diego sync' do + rule = RouteAccessRule.create( + name: 'test-rule', + selector: "cf:app:#{app_guid}", + route: route + ) + + process.reload + initial_updated_at = process.updated_at + + # Sleep to ensure timestamp difference + sleep 0.1 + + rule.destroy + + process.reload + expect(process.updated_at).to be > initial_updated_at + end + + it 'does not fail if route has no associated processes' do + route_without_processes = Route.make(space: space, domain: domain) + rule = RouteAccessRule.create( + name: 'test-rule', + selector: "cf:app:#{app_guid}", + route: route_without_processes + ) + + expect { + rule.destroy + }.not_to raise_error + end + end + end + end +end From 16e39d3f491a15a1a16389acc2b1276871446391 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 10 Apr 2026 06:38:05 +0000 Subject: [PATCH 09/64] Implement include=selector_resource for /v3/access_rules endpoint - Add include parameter support to AccessRulesListMessage - Refactor IncludeAccessRuleSelectorResourceDecorator to match RFC format: - Return separate arrays for apps, spaces, organizations instead of selector_resources - Include full resource details using appropriate presenters - Batch resource fetching by type with eager loading - Auto-deduplicate resources - Gracefully handle stale/missing resources - Wire up decorator to AccessRulesController - Add comprehensive request specs for include=selector_resource Fixes: uninitialized constant error by adding proper require statement --- app/controllers/v3/access_rules_controller.rb | 7 +- ...access_rule_selector_resource_decorator.rb | 60 ++++++--- app/messages/access_rules_list_message.rb | 4 +- spec/request/access_rules_spec.rb | 114 ++++++++++++++++++ 4 files changed, 169 insertions(+), 16 deletions(-) diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index ac84d80f449..a64fb16e66f 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -2,6 +2,7 @@ require 'messages/access_rule_update_message' require 'messages/access_rules_list_message' require 'presenters/v3/access_rule_presenter' +require 'decorators/include_access_rule_selector_resource_decorator' class AccessRulesController < ApplicationController def index @@ -10,11 +11,15 @@ def index dataset = build_dataset(message) + decorators = [] + decorators << IncludeAccessRuleSelectorResourceDecorator if IncludeAccessRuleSelectorResourceDecorator.match?(message.include) + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( presenter: Presenters::V3::AccessRulePresenter, paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)), path: '/v3/access_rules', - message: message + message: message, + decorators: decorators ) end diff --git a/app/decorators/include_access_rule_selector_resource_decorator.rb b/app/decorators/include_access_rule_selector_resource_decorator.rb index c5ac7552860..cd85dd0ef1c 100644 --- a/app/decorators/include_access_rule_selector_resource_decorator.rb +++ b/app/decorators/include_access_rule_selector_resource_decorator.rb @@ -10,7 +10,12 @@ def self.match?(include_params) end def self.decorate(hash, access_rules) - included = [] + hash[:included] ||= {} + + # Collect all GUIDs by type + app_guids = [] + space_guids = [] + org_guids = [] access_rules.each do |rule| match = SELECTOR_REGEX.match(rule.selector) @@ -19,22 +24,49 @@ def self.decorate(hash, access_rules) resource_type = match[1] resource_guid = match[2] - resource = case resource_type - when 'app' - VCAP::CloudController::AppModel.find(guid: resource_guid) - when 'space' - VCAP::CloudController::Space.find(guid: resource_guid) - when 'org' - VCAP::CloudController::Organization.find(guid: resource_guid) - end - - next if resource.nil? - - included << { type: resource_type, guid: resource.guid } + case resource_type + when 'app' + app_guids << resource_guid + when 'space' + space_guids << resource_guid + when 'org' + org_guids << resource_guid + end end - hash[:included] = { selector_resources: included } + # Fetch and present resources + hash[:included][:apps] = fetch_and_present_apps(app_guids.uniq) + hash[:included][:spaces] = fetch_and_present_spaces(space_guids.uniq) + hash[:included][:organizations] = fetch_and_present_organizations(org_guids.uniq) + hash end + + private_class_method def self.fetch_and_present_apps(guids) + return [] if guids.empty? + + apps = VCAP::CloudController::AppModel.where(guid: guids). + order(:created_at, :guid). + eager(VCAP::CloudController::Presenters::V3::AppPresenter.associated_resources).all + apps.map { |app| VCAP::CloudController::Presenters::V3::AppPresenter.new(app).to_hash } + end + + private_class_method def self.fetch_and_present_spaces(guids) + return [] if guids.empty? + + spaces = VCAP::CloudController::Space.where(guid: guids). + order(:created_at, :guid). + eager(VCAP::CloudController::Presenters::V3::SpacePresenter.associated_resources).all + spaces.map { |space| VCAP::CloudController::Presenters::V3::SpacePresenter.new(space).to_hash } + end + + private_class_method def self.fetch_and_present_organizations(guids) + return [] if guids.empty? + + orgs = VCAP::CloudController::Organization.where(guid: guids). + order(:created_at, :guid). + eager(VCAP::CloudController::Presenters::V3::OrganizationPresenter.associated_resources).all + orgs.map { |org| VCAP::CloudController::Presenters::V3::OrganizationPresenter.new(org).to_hash } + end end end diff --git a/app/messages/access_rules_list_message.rb b/app/messages/access_rules_list_message.rb index 7c7973fda97..b2eb08002bf 100644 --- a/app/messages/access_rules_list_message.rb +++ b/app/messages/access_rules_list_message.rb @@ -6,12 +6,14 @@ class AccessRulesListMessage < ListMessage route_guids names selectors + include ] validates_with NoAdditionalParamsValidator + validates_with IncludeParamValidator, valid_values: ['selector_resource', 'route'] def self.from_params(params) - super(params, %w[route_guids names selectors]) + super(params, %w[route_guids names selectors include]) end end end diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb index 3962cc59a66..4828cac2660 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/access_rules_spec.rb @@ -300,6 +300,120 @@ def expected_rule_json(rule) expect(parsed['resources'].length).to eq(1) expect(parsed['resources'][0]['selector']).to eq('cf:any') end + + context 'with include=selector_resource' do + let!(:app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } + let!(:other_space) { VCAP::CloudController::Space.make(organization: org, name: 'other-space') } + let!(:other_org) { VCAP::CloudController::Organization.make(name: 'other-org') } + + let!(:app_rule) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'app-rule', + selector: "cf:app:#{app.guid}", + route_id: mtls_route.id + ) + end + + let!(:space_rule) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'space-rule', + selector: "cf:space:#{other_space.guid}", + route_id: mtls_route.id + ) + end + + let!(:org_rule) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'org-rule', + selector: "cf:org:#{other_org.guid}", + route_id: mtls_route.id + ) + end + + it 'includes resolved selector resources' do + get '/v3/access_rules?include=selector_resource', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Check included structure + expect(parsed['included']).to be_a(Hash) + expect(parsed['included']['apps']).to be_an(Array) + expect(parsed['included']['spaces']).to be_an(Array) + expect(parsed['included']['organizations']).to be_an(Array) + + # Check app is included with full details + app_included = parsed['included']['apps'].find { |a| a['guid'] == app.guid } + expect(app_included).to be_present + expect(app_included['name']).to eq('frontend-app') + expect(app_included['guid']).to eq(app.guid) + + # Check space is included + space_included = parsed['included']['spaces'].find { |s| s['guid'] == other_space.guid } + expect(space_included).to be_present + expect(space_included['name']).to eq('other-space') + + # Check org is included + org_included = parsed['included']['organizations'].find { |o| o['guid'] == other_org.guid } + expect(org_included).to be_present + expect(org_included['name']).to eq('other-org') + end + + it 'handles stale resources (missing GUIDs) gracefully' do + stale_guid = '99999999-9999-9999-9999-999999999999' + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'stale-rule', + selector: "cf:app:#{stale_guid}", + route_id: mtls_route.id + ) + + get '/v3/access_rules?include=selector_resource', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Stale resource should not appear in included + stale_app = parsed['included']['apps'].find { |a| a['guid'] == stale_guid } + expect(stale_app).to be_nil + end + + it 'includes only unique resources when multiple rules reference the same resource' do + # Create another rule referencing the same app + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'another-app-rule', + selector: "cf:app:#{app.guid}", + route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id + ) + + get '/v3/access_rules?include=selector_resource', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # App should appear only once + app_count = parsed['included']['apps'].count { |a| a['guid'] == app.guid } + expect(app_count).to eq(1) + end + + it 'does not include resources for cf:any selectors' do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'any-rule', + selector: 'cf:any', + route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id + ) + + get '/v3/access_rules?include=selector_resource', nil, admin_header + + expect(last_response.status).to eq(200) + # Should succeed without error even with cf:any selector + end + end end describe 'DELETE /v3/access_rules/:guid' do From c4a5efc1e0b4b6b7f5d9ac7f37be7fb417ff7eac Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 10 Apr 2026 08:18:47 +0000 Subject: [PATCH 10/64] Add space_guids filtering to /v3/access_rules endpoint Implement space-based filtering for access rules endpoint to enable querying all access rules within a given space using ?space_guids= query parameter. Changes: - Add space_guids to AccessRulesListMessage with array validation - Implement space filtering in AccessRulesController#build_dataset - Add comprehensive unit tests for AccessRulesListMessage - Add request specs for single/multiple space filtering and combinations - Follow existing CAPI patterns for space_guids filtering The filter joins through the routes table to filter access rules by the space_id of their associated routes. --- app/controllers/v3/access_rules_controller.rb | 7 + app/messages/access_rules_list_message.rb | 5 +- spec/request/access_rules_spec.rb | 67 +++++++++ .../access_rules_list_message_spec.rb | 135 ++++++++++++++++++ 4 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 spec/unit/messages/access_rules_list_message_spec.rb diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index a64fb16e66f..eb6ff20aa0e 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -128,6 +128,13 @@ def build_dataset(message) select_all(:route_access_rules) end + if message.requested?(:space_guids) + dataset = dataset. + join(:routes, id: :route_id). + where(routes__space_id: VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)). + select_all(:route_access_rules) + end + dataset = dataset.where(name: message.names) if message.requested?(:names) dataset = dataset.where(selector: message.selectors) if message.requested?(:selectors) diff --git a/app/messages/access_rules_list_message.rb b/app/messages/access_rules_list_message.rb index b2eb08002bf..ddf22935f51 100644 --- a/app/messages/access_rules_list_message.rb +++ b/app/messages/access_rules_list_message.rb @@ -4,6 +4,7 @@ module VCAP::CloudController class AccessRulesListMessage < ListMessage register_allowed_keys %i[ route_guids + space_guids names selectors include @@ -12,8 +13,10 @@ class AccessRulesListMessage < ListMessage validates_with NoAdditionalParamsValidator validates_with IncludeParamValidator, valid_values: ['selector_resource', 'route'] + validates :space_guids, array: true, allow_nil: true + def self.from_params(params) - super(params, %w[route_guids names selectors include]) + super(params, %w[route_guids space_guids names selectors include]) end end end diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb index 4828cac2660..95e90b51cca 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/access_rules_spec.rb @@ -301,6 +301,73 @@ def expected_rule_json(rule) expect(parsed['resources'][0]['selector']).to eq('cf:any') end + describe 'filtering by space_guids' do + let(:other_org) { VCAP::CloudController::Organization.make } + let(:other_space) { VCAP::CloudController::Space.make(organization: other_org) } + let(:other_mtls_domain) do + VCAP::CloudController::PrivateDomain.make( + owning_organization: other_org, + enforce_access_rules: true, + access_rules_scope: 'space' + ) + end + let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) } + let!(:rule_in_other_space) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'rule-in-other-space', + selector: 'cf:any', + route_id: other_route.id + ) + end + + before do + other_org.add_user(user) + other_space.add_developer(user) + end + + it 'filters by single space_guid' do + get "/v3/access_rules?space_guids=#{space.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].map { |r| r['guid'] } + expect(guids).to include(rule1.guid, rule2.guid) + expect(guids).not_to include(rule_in_other_space.guid) + end + + it 'filters by multiple space_guids' do + get "/v3/access_rules?space_guids=#{space.guid},#{other_space.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].map { |r| r['guid'] } + expect(guids).to include(rule1.guid, rule2.guid, rule_in_other_space.guid) + end + + it 'combines space_guids with other filters' do + get "/v3/access_rules?space_guids=#{space.guid}&names=rule-one", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(1) + expect(parsed['resources'][0]['guid']).to eq(rule1.guid) + expect(parsed['resources'][0]['name']).to eq('rule-one') + end + + it 'returns empty when space has no access rules' do + empty_space = VCAP::CloudController::Space.make(organization: org) + org.add_user(user) + empty_space.add_developer(user) + + get "/v3/access_rules?space_guids=#{empty_space.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + expect(parsed['resources'].length).to eq(0) + end + end + context 'with include=selector_resource' do let!(:app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } let!(:other_space) { VCAP::CloudController::Space.make(organization: org, name: 'other-space') } diff --git a/spec/unit/messages/access_rules_list_message_spec.rb b/spec/unit/messages/access_rules_list_message_spec.rb new file mode 100644 index 00000000000..443fdf70bfd --- /dev/null +++ b/spec/unit/messages/access_rules_list_message_spec.rb @@ -0,0 +1,135 @@ +require 'spec_helper' +require 'messages/access_rules_list_message' + +module VCAP::CloudController + RSpec.describe AccessRulesListMessage do + describe '.from_params' do + let(:params) do + { + 'route_guids' => 'route1,route2', + 'space_guids' => 'space1,space2', + 'names' => 'name1,name2', + 'selectors' => 'selector1,selector2', + 'page' => 1, + 'per_page' => 5, + 'order_by' => 'created_at', + 'include' => 'selector_resource,route' + } + end + + it 'returns the correct AccessRulesListMessage' do + message = AccessRulesListMessage.from_params(params) + + expect(message).to be_a(AccessRulesListMessage) + expect(message.route_guids).to eq(%w[route1 route2]) + expect(message.space_guids).to eq(%w[space1 space2]) + expect(message.names).to eq(%w[name1 name2]) + expect(message.selectors).to eq(%w[selector1 selector2]) + expect(message.page).to eq(1) + expect(message.per_page).to eq(5) + expect(message.order_by).to eq('created_at') + expect(message.include).to eq(%w[selector_resource route]) + end + + it 'converts requested keys to symbols' do + message = AccessRulesListMessage.from_params(params) + + expect(message).to be_requested(:route_guids) + expect(message).to be_requested(:space_guids) + expect(message).to be_requested(:names) + expect(message).to be_requested(:selectors) + expect(message).to be_requested(:page) + expect(message).to be_requested(:per_page) + expect(message).to be_requested(:order_by) + expect(message).to be_requested(:include) + end + end + + describe '#to_param_hash' do + let(:opts) do + { + route_guids: %w[route1 route2], + space_guids: %w[space1 space2], + names: %w[name1 name2], + selectors: %w[selector1 selector2], + page: 1, + per_page: 5, + order_by: 'created_at', + include: %w[selector_resource route] + } + end + + it 'excludes the pagination keys' do + expected_params = %i[route_guids space_guids names selectors include] + expect(AccessRulesListMessage.from_params(opts).to_param_hash.keys).to match_array(expected_params) + end + end + + describe 'fields' do + it 'accepts a set of fields' do + expect do + AccessRulesListMessage.from_params({ + route_guids: [], + space_guids: [], + names: [], + selectors: [], + page: 1, + per_page: 5, + order_by: 'created_at', + include: ['selector_resource', 'route'] + }) + end.not_to raise_error + end + + it 'accepts an empty set' do + message = AccessRulesListMessage.from_params({}) + expect(message).to be_valid + end + + it 'does not accept a field not in this set' do + message = AccessRulesListMessage.from_params({ foobar: 'pants' }) + + expect(message).not_to be_valid + expect(message.errors[:base][0]).to include("Unknown query parameter(s): 'foobar'") + end + + describe 'include validations' do + it 'accepts valid include values' do + message = AccessRulesListMessage.from_params({ 'include' => 'selector_resource' }) + expect(message).to be_valid + + message = AccessRulesListMessage.from_params({ 'include' => 'route' }) + expect(message).to be_valid + + message = AccessRulesListMessage.from_params({ 'include' => 'selector_resource,route' }) + expect(message).to be_valid + end + + it 'rejects invalid include values' do + message = AccessRulesListMessage.from_params({ 'include' => 'invalid' }) + expect(message).not_to be_valid + end + end + + describe 'validations' do + it 'validates space_guids is an array' do + message = AccessRulesListMessage.from_params space_guids: 'not array' + expect(message).not_to be_valid + expect(message.errors[:space_guids].length).to eq 1 + end + + it 'allows space_guids to be nil' do + message = AccessRulesListMessage.from_params({}) + expect(message).to be_valid + expect(message.space_guids).to be_nil + end + + it 'allows space_guids to be an array' do + message = AccessRulesListMessage.from_params space_guids: %w[space1 space2] + expect(message).to be_valid + expect(message.space_guids).to eq(%w[space1 space2]) + end + end + end + end +end From 0143a26705d27b860820b3f15357ff192e0bcca2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 10 Apr 2026 08:35:29 +0000 Subject: [PATCH 11/64] Implement include=route for /v3/access_rules endpoint Add support for including route resources when listing access rules via the ?include=route query parameter. Changes: - Create IncludeAccessRuleRouteDecorator to handle route inclusion - Wire up decorator in AccessRulesController - Add comprehensive request specs for include=route - Test single/multiple routes, uniqueness, and combining with selector_resource - Follow existing CAPI decorator patterns for resource inclusion The decorator fetches and presents Route resources referenced by the access rules, adding them to the 'included' section of the response. --- app/controllers/v3/access_rules_controller.rb | 34 +++---- .../include_access_rule_route_decorator.rb | 27 ++++++ spec/request/access_rules_spec.rb | 94 ++++++++++++++++++- 3 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 app/decorators/include_access_rule_route_decorator.rb diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index eb6ff20aa0e..73876128299 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -3,6 +3,7 @@ require 'messages/access_rules_list_message' require 'presenters/v3/access_rule_presenter' require 'decorators/include_access_rule_selector_resource_decorator' +require 'decorators/include_access_rule_route_decorator' class AccessRulesController < ApplicationController def index @@ -13,6 +14,7 @@ def index decorators = [] decorators << IncludeAccessRuleSelectorResourceDecorator if IncludeAccessRuleSelectorResourceDecorator.match?(message.include) + decorators << IncludeAccessRuleRouteDecorator if IncludeAccessRuleRouteDecorator.match?(message.include) render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( presenter: Presenters::V3::AccessRulePresenter, @@ -42,27 +44,17 @@ def create unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) suspended! unless permission_queryer.is_space_active?(route.space.id) - unless route.domain.enforce_access_rules - unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") - end + unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") unless route.domain.enforce_access_rules # Enforce cf:any exclusivity: if route already has a cf:any rule, reject new rules; # if new rule is cf:any, reject if route already has any rules. existing_selectors = route.access_rules.map(&:selector) - if message.selector == 'cf:any' && existing_selectors.any? - unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.") - end - if existing_selectors.include?('cf:any') && message.selector != 'cf:any' - unprocessable!("Cannot add selector '#{message.selector}': route already has a 'cf:any' rule.") - end + unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.") if message.selector == 'cf:any' && existing_selectors.any? + unprocessable!("Cannot add selector '#{message.selector}': route already has a 'cf:any' rule.") if existing_selectors.include?('cf:any') && message.selector != 'cf:any' # Uniqueness: name and selector must be unique per route - if route.access_rules.any? { |r| r.name == message.name } - unprocessable!("An access rule with name '#{message.name}' already exists for this route.") - end - if existing_selectors.include?(message.selector) - unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.") - end + unprocessable!("An access rule with name '#{message.name}' already exists for this route.") if route.access_rules.any? { |r| r.name == message.name } + unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.") if existing_selectors.include?(message.selector) access_rule = VCAP::CloudController::RouteAccessRule.new( guid: SecureRandom.uuid, @@ -123,16 +115,16 @@ def build_dataset(message) if message.requested?(:route_guids) dataset = dataset. - join(:routes, id: :route_id). - where(routes__guid: message.route_guids). - select_all(:route_access_rules) + join(:routes, id: :route_id). + where(routes__guid: message.route_guids). + select_all(:route_access_rules) end if message.requested?(:space_guids) dataset = dataset. - join(:routes, id: :route_id). - where(routes__space_id: VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)). - select_all(:route_access_rules) + join(:routes, id: :route_id). + where(routes__space_id: VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)). + select_all(:route_access_rules) end dataset = dataset.where(name: message.names) if message.requested?(:names) diff --git a/app/decorators/include_access_rule_route_decorator.rb b/app/decorators/include_access_rule_route_decorator.rb new file mode 100644 index 00000000000..178da8be3db --- /dev/null +++ b/app/decorators/include_access_rule_route_decorator.rb @@ -0,0 +1,27 @@ +module VCAP::CloudController + class IncludeAccessRuleRouteDecorator + # Handles `?include=route` for GET /v3/access_rules + # Includes the route resources associated with the access rules + + def self.match?(include_params) + include_params&.include?('route') + end + + def self.decorate(hash, access_rules) + hash[:included] ||= {} + + # Collect all unique route IDs from access rules + route_ids = access_rules.map(&:route_id).uniq + + # Fetch routes with their associations + routes = VCAP::CloudController::Route.where(id: route_ids). + order(:created_at, :guid). + eager(VCAP::CloudController::Presenters::V3::RoutePresenter.associated_resources).all + + # Present routes + hash[:included][:routes] = routes.map { |route| VCAP::CloudController::Presenters::V3::RoutePresenter.new(route).to_hash } + + hash + end + end +end diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb index 95e90b51cca..4fdd65f5736 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/access_rules_spec.rb @@ -133,7 +133,7 @@ def expected_rule_json(rule) }.to_json, admin_header expect(last_response.status).to eq(422) - expect(last_response.body).to include("cf:any") + expect(last_response.body).to include('cf:any') end end @@ -155,7 +155,7 @@ def expected_rule_json(rule) }.to_json, admin_header expect(last_response.status).to eq(422) - expect(last_response.body).to include("cf:any") + expect(last_response.body).to include('cf:any') end end @@ -481,6 +481,96 @@ def expected_rule_json(rule) # Should succeed without error even with cf:any selector end end + + context 'with include=route' do + let(:route2) { VCAP::CloudController::Route.make(space: space, domain: mtls_domain) } + + let!(:rule_on_route1) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'rule-on-route1', + selector: 'cf:any', + route_id: mtls_route.id + ) + end + + let!(:rule_on_route2) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'rule-on-route2', + selector: "cf:app:#{valid_uuid}", + route_id: route2.id + ) + end + + it 'includes route resources' do + get '/v3/access_rules?include=route', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Check included structure + expect(parsed['included']).to be_a(Hash) + expect(parsed['included']['routes']).to be_an(Array) + expect(parsed['included']['routes'].length).to be >= 2 + + # Check routes are included with full details + route1_included = parsed['included']['routes'].find { |r| r['guid'] == mtls_route.guid } + expect(route1_included).to be_present + expect(route1_included['guid']).to eq(mtls_route.guid) + expect(route1_included['url']).to be_present + + route2_included = parsed['included']['routes'].find { |r| r['guid'] == route2.guid } + expect(route2_included).to be_present + expect(route2_included['guid']).to eq(route2.guid) + end + + it 'includes only unique routes when multiple rules reference the same route' do + # Create another rule on the same route + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'another-rule-on-route1', + selector: "cf:app:#{valid_uuid}", + route_id: mtls_route.id + ) + + get '/v3/access_rules?include=route', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Route should appear only once + route_count = parsed['included']['routes'].count { |r| r['guid'] == mtls_route.guid } + expect(route_count).to eq(1) + end + + it 'combines include=route with include=selector_resource' do + app = VCAP::CloudController::AppModel.make(space: space, name: 'test-app') + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + name: 'combined-rule', + selector: "cf:app:#{app.guid}", + route_id: mtls_route.id + ) + + get '/v3/access_rules?include=route,selector_resource', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + # Both routes and selector resources should be included + expect(parsed['included']['routes']).to be_an(Array) + expect(parsed['included']['apps']).to be_an(Array) + + # Verify route is present + route_included = parsed['included']['routes'].find { |r| r['guid'] == mtls_route.guid } + expect(route_included).to be_present + + # Verify app is present + app_included = parsed['included']['apps'].find { |a| a['guid'] == app.guid } + expect(app_included).to be_present + end + end end describe 'DELETE /v3/access_rules/:guid' do From b8a40e63ab82b72263904ce1db6960d76c3189dd Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 07:41:12 +0000 Subject: [PATCH 12/64] Remove name field from access rules, add read-only relationships per RFC updates Update /v3/access_rules API to align with latest RFC changes: - Remove 'name' field from RouteAccessRule model and API - Add database migration to drop name column and unique index - Use labels/annotations for metadata instead of name field - Add read-only relationships (app, space, organization) to responses extracted from selector (cf:app:X, cf:space:X, cf:org:X, cf:any) - Replace 'names' filter with 'guids' filter - Add 'selector_resource_guids' filter for text-match against selectors - Update include support: add individual app, space, organization (in addition to existing selector_resource and route) - Remove name-based uniqueness validation (keep selector uniqueness) - Update all tests to remove name references Breaking changes: - POST /v3/access_rules no longer accepts 'name' field - GET /v3/access_rules responses no longer include 'name' field - Filter parameter 'names' removed, use 'guids' instead - Access rule responses now include app/space/organization relationships --- app/controllers/v3/access_rules_controller.rb | 15 ++++-- ...access_rule_selector_resource_decorator.rb | 9 ++-- app/messages/access_rule_create_message.rb | 2 - app/messages/access_rules_list_message.rb | 8 +-- app/models/runtime/route_access_rule.rb | 1 - app/presenters/v3/access_rule_presenter.rb | 50 ++++++++++++++--- ...001_remove_name_from_route_access_rules.rb | 15 ++++++ spec/request/access_rules_spec.rb | 44 +-------------- .../access_rules_list_message_spec.rb | 54 +++++++++++++++---- 9 files changed, 125 insertions(+), 73 deletions(-) create mode 100644 db/migrations/20260415000001_remove_name_from_route_access_rules.rb diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index 73876128299..5e1496cf58d 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -52,13 +52,11 @@ def create unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.") if message.selector == 'cf:any' && existing_selectors.any? unprocessable!("Cannot add selector '#{message.selector}': route already has a 'cf:any' rule.") if existing_selectors.include?('cf:any') && message.selector != 'cf:any' - # Uniqueness: name and selector must be unique per route - unprocessable!("An access rule with name '#{message.name}' already exists for this route.") if route.access_rules.any? { |r| r.name == message.name } + # Uniqueness: selector must be unique per route unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.") if existing_selectors.include?(message.selector) access_rule = VCAP::CloudController::RouteAccessRule.new( guid: SecureRandom.uuid, - name: message.name, selector: message.selector, route_id: route.id, created_at: Time.now.utc, @@ -127,9 +125,18 @@ def build_dataset(message) select_all(:route_access_rules) end - dataset = dataset.where(name: message.names) if message.requested?(:names) + dataset = dataset.where(guid: message.guids) if message.requested?(:guids) dataset = dataset.where(selector: message.selectors) if message.requested?(:selectors) + if message.requested?(:selector_resource_guids) + # Text-match against selector string for resource GUIDs + # Handles cf:app:, cf:space:, cf:org: + conditions = message.selector_resource_guids.map do |guid| + Sequel.like(:selector, "%#{guid}%") + end + dataset = dataset.where(Sequel.|(*conditions)) + end + dataset end end diff --git a/app/decorators/include_access_rule_selector_resource_decorator.rb b/app/decorators/include_access_rule_selector_resource_decorator.rb index cd85dd0ef1c..9db7a079679 100644 --- a/app/decorators/include_access_rule_selector_resource_decorator.rb +++ b/app/decorators/include_access_rule_selector_resource_decorator.rb @@ -6,12 +6,15 @@ class IncludeAccessRuleSelectorResourceDecorator SELECTOR_REGEX = /\Acf:(app|space|org):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\z/ def self.match?(include_params) - include_params&.include?('selector_resource') + return false unless include_params + + # Match if any of: selector_resource, app, space, organization + (include_params & %w[selector_resource app space organization]).any? end def self.decorate(hash, access_rules) hash[:included] ||= {} - + # Collect all GUIDs by type app_guids = [] space_guids = [] @@ -38,7 +41,7 @@ def self.decorate(hash, access_rules) hash[:included][:apps] = fetch_and_present_apps(app_guids.uniq) hash[:included][:spaces] = fetch_and_present_spaces(space_guids.uniq) hash[:included][:organizations] = fetch_and_present_organizations(org_guids.uniq) - + hash end diff --git a/app/messages/access_rule_create_message.rb b/app/messages/access_rule_create_message.rb index f3086bf95ee..d615e0a1029 100644 --- a/app/messages/access_rule_create_message.rb +++ b/app/messages/access_rule_create_message.rb @@ -5,7 +5,6 @@ class AccessRuleCreateMessage < MetadataBaseMessage SELECTOR_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/ register_allowed_keys %i[ - name selector relationships ] @@ -13,7 +12,6 @@ class AccessRuleCreateMessage < MetadataBaseMessage validates_with NoAdditionalKeysValidator validates_with RelationshipValidator - validates :name, presence: true, string: true validates :selector, presence: true, string: true validate :selector_format_valid diff --git a/app/messages/access_rules_list_message.rb b/app/messages/access_rules_list_message.rb index ddf22935f51..3b7c84b99f3 100644 --- a/app/messages/access_rules_list_message.rb +++ b/app/messages/access_rules_list_message.rb @@ -3,20 +3,22 @@ module VCAP::CloudController class AccessRulesListMessage < ListMessage register_allowed_keys %i[ + guids route_guids space_guids - names selectors + selector_resource_guids include ] validates_with NoAdditionalParamsValidator - validates_with IncludeParamValidator, valid_values: ['selector_resource', 'route'] + validates_with IncludeParamValidator, valid_values: %w[selector_resource route app space organization] validates :space_guids, array: true, allow_nil: true + validates :selector_resource_guids, array: true, allow_nil: true def self.from_params(params) - super(params, %w[route_guids space_guids names selectors include]) + super(params, %w[guids route_guids space_guids selectors selector_resource_guids include]) end end end diff --git a/app/models/runtime/route_access_rule.rb b/app/models/runtime/route_access_rule.rb index cf554de3fd8..e9b29756e39 100644 --- a/app/models/runtime/route_access_rule.rb +++ b/app/models/runtime/route_access_rule.rb @@ -7,7 +7,6 @@ class RouteAccessRule < Sequel::Model(:route_access_rules) without_guid_generation: true def validate - validates_presence :name validates_presence :selector validates_presence :route_id end diff --git a/app/presenters/v3/access_rule_presenter.rb b/app/presenters/v3/access_rule_presenter.rb index cd5f18d2c47..b1d038fb87a 100644 --- a/app/presenters/v3/access_rule_presenter.rb +++ b/app/presenters/v3/access_rule_presenter.rb @@ -12,15 +12,12 @@ def to_hash guid: access_rule.guid, created_at: access_rule.created_at, updated_at: access_rule.updated_at, - name: access_rule.name, selector: access_rule.selector, - relationships: { - route: { - data: { - guid: access_rule.route.guid - } - } + metadata: { + labels: hashified_labels(access_rule.labels), + annotations: hashified_annotations(access_rule.annotations) }, + relationships: build_relationships, links: build_links } end @@ -31,6 +28,45 @@ def access_rule @resource end + def build_relationships + relationships = { + route: { + data: { + guid: access_rule.route.guid + } + } + } + + # Extract resource GUID from selector and populate read-only relationships + selector_match = access_rule.selector.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) + if selector_match + resource_type = selector_match[1] + resource_guid = selector_match[2] + + case resource_type + when 'app' + relationships[:app] = { data: { guid: resource_guid } } + relationships[:space] = { data: nil } + relationships[:organization] = { data: nil } + when 'space' + relationships[:app] = { data: nil } + relationships[:space] = { data: { guid: resource_guid } } + relationships[:organization] = { data: nil } + when 'org' + relationships[:app] = { data: nil } + relationships[:space] = { data: nil } + relationships[:organization] = { data: { guid: resource_guid } } + end + else + # cf:any or malformed - all relationships are null + relationships[:app] = { data: nil } + relationships[:space] = { data: nil } + relationships[:organization] = { data: nil } + end + + relationships + end + def build_links { self: { diff --git a/db/migrations/20260415000001_remove_name_from_route_access_rules.rb b/db/migrations/20260415000001_remove_name_from_route_access_rules.rb new file mode 100644 index 00000000000..5763c0150ac --- /dev/null +++ b/db/migrations/20260415000001_remove_name_from_route_access_rules.rb @@ -0,0 +1,15 @@ +Sequel.migration do + up do + alter_table :route_access_rules do + drop_index %i[route_id name], name: :route_access_rules_route_id_name_index + drop_column :name + end + end + + down do + alter_table :route_access_rules do + add_column :name, String, size: 255 + add_index %i[route_id name], unique: true, name: :route_access_rules_route_id_name_index + end + end +end diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb index 4fdd65f5736..a14410f1807 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/access_rules_spec.rb @@ -27,7 +27,6 @@ def expected_rule_json(rule) guid: rule.guid, created_at: iso8601, updated_at: iso8601, - name: rule.name, selector: rule.selector, relationships: { route: { data: { guid: rule.route.guid } } @@ -48,7 +47,6 @@ def expected_rule_json(rule) describe 'POST /v3/access_rules' do let(:request_body) do { - name: 'allow-frontend', selector: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: mtls_route.guid } } @@ -62,7 +60,6 @@ def expected_rule_json(rule) expect(last_response.status).to eq(201) parsed = Oj.load(last_response.body) - expect(parsed['name']).to eq('allow-frontend') expect(parsed['selector']).to eq("cf:app:#{valid_uuid}") expect(parsed['relationships']['route']['data']['guid']).to eq(mtls_route.guid) end @@ -81,7 +78,6 @@ def expected_rule_json(rule) context 'when the domain does not have enforce_access_rules enabled' do let(:request_body) do { - name: 'disallowed-rule', selector: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: regular_route.guid } } @@ -100,7 +96,6 @@ def expected_rule_json(rule) context 'when the route does not exist' do let(:request_body) do { - name: 'bad-rule', selector: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: 'nonexistent-guid' } } @@ -119,7 +114,6 @@ def expected_rule_json(rule) before do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'existing-rule', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) @@ -127,7 +121,6 @@ def expected_rule_json(rule) it 'rejects cf:any when other rules exist' do post '/v3/access_rules', { - name: 'any-rule', selector: 'cf:any', relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -141,7 +134,6 @@ def expected_rule_json(rule) before do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'any-rule', selector: 'cf:any', route_id: mtls_route.id ) @@ -149,7 +141,6 @@ def expected_rule_json(rule) it 'rejects adding a specific selector' do post '/v3/access_rules', { - name: 'specific-rule', selector: "cf:space:#{valid_uuid}", relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -163,7 +154,6 @@ def expected_rule_json(rule) before do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'allow-frontend', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) @@ -172,7 +162,6 @@ def expected_rule_json(rule) it 'returns 422' do other_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' post '/v3/access_rules', { - name: 'allow-frontend', selector: "cf:space:#{other_uuid}", relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -186,7 +175,6 @@ def expected_rule_json(rule) before do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'first-rule', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) @@ -194,7 +182,6 @@ def expected_rule_json(rule) it 'returns 422' do post '/v3/access_rules', { - name: 'second-rule', selector: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -206,7 +193,6 @@ def expected_rule_json(rule) context 'invalid selector format' do it 'returns 422' do post '/v3/access_rules', { - name: 'bad-rule', selector: 'not-valid', relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -221,7 +207,6 @@ def expected_rule_json(rule) let!(:access_rule) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'allow-frontend', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) @@ -233,7 +218,6 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) expect(parsed['guid']).to eq(access_rule.guid) - expect(parsed['name']).to eq('allow-frontend') expect(parsed['selector']).to eq("cf:app:#{valid_uuid}") end @@ -250,7 +234,6 @@ def expected_rule_json(rule) let!(:rule1) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'rule-one', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) @@ -258,7 +241,6 @@ def expected_rule_json(rule) let!(:rule2) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'rule-two', selector: 'cf:any', route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) @@ -283,15 +265,6 @@ def expected_rule_json(rule) expect(guids).not_to include(rule2.guid) end - it 'filters by names' do - get '/v3/access_rules?names=rule-one', nil, admin_header - - expect(last_response.status).to eq(200) - parsed = Oj.load(last_response.body) - expect(parsed['resources'].length).to eq(1) - expect(parsed['resources'][0]['name']).to eq('rule-one') - end - it 'filters by selectors' do get '/v3/access_rules?selectors=cf:any', nil, admin_header @@ -315,7 +288,6 @@ def expected_rule_json(rule) let!(:rule_in_other_space) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'rule-in-other-space', selector: 'cf:any', route_id: other_route.id ) @@ -346,13 +318,13 @@ def expected_rule_json(rule) end it 'combines space_guids with other filters' do - get "/v3/access_rules?space_guids=#{space.guid}&names=rule-one", nil, admin_header + get "/v3/access_rules?space_guids=#{space.guid}&selectors=cf:app:#{valid_uuid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) expect(parsed['resources'].length).to eq(1) expect(parsed['resources'][0]['guid']).to eq(rule1.guid) - expect(parsed['resources'][0]['name']).to eq('rule-one') + expect(parsed['resources'][0]['selector']).to eq("cf:app:#{valid_uuid}") end it 'returns empty when space has no access rules' do @@ -376,7 +348,6 @@ def expected_rule_json(rule) let!(:app_rule) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'app-rule', selector: "cf:app:#{app.guid}", route_id: mtls_route.id ) @@ -385,7 +356,6 @@ def expected_rule_json(rule) let!(:space_rule) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'space-rule', selector: "cf:space:#{other_space.guid}", route_id: mtls_route.id ) @@ -394,7 +364,6 @@ def expected_rule_json(rule) let!(:org_rule) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'org-rule', selector: "cf:org:#{other_org.guid}", route_id: mtls_route.id ) @@ -433,7 +402,6 @@ def expected_rule_json(rule) stale_guid = '99999999-9999-9999-9999-999999999999' VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'stale-rule', selector: "cf:app:#{stale_guid}", route_id: mtls_route.id ) @@ -452,7 +420,6 @@ def expected_rule_json(rule) # Create another rule referencing the same app VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'another-app-rule', selector: "cf:app:#{app.guid}", route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) @@ -470,7 +437,6 @@ def expected_rule_json(rule) it 'does not include resources for cf:any selectors' do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'any-rule', selector: 'cf:any', route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) @@ -488,7 +454,6 @@ def expected_rule_json(rule) let!(:rule_on_route1) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'rule-on-route1', selector: 'cf:any', route_id: mtls_route.id ) @@ -497,7 +462,6 @@ def expected_rule_json(rule) let!(:rule_on_route2) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'rule-on-route2', selector: "cf:app:#{valid_uuid}", route_id: route2.id ) @@ -529,7 +493,6 @@ def expected_rule_json(rule) # Create another rule on the same route VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'another-rule-on-route1', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) @@ -548,7 +511,6 @@ def expected_rule_json(rule) app = VCAP::CloudController::AppModel.make(space: space, name: 'test-app') VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'combined-rule', selector: "cf:app:#{app.guid}", route_id: mtls_route.id ) @@ -577,7 +539,6 @@ def expected_rule_json(rule) let!(:access_rule) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'to-delete', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) @@ -603,7 +564,6 @@ def expected_rule_json(rule) let!(:access_rule) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'patchable', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) diff --git a/spec/unit/messages/access_rules_list_message_spec.rb b/spec/unit/messages/access_rules_list_message_spec.rb index 443fdf70bfd..4790229787e 100644 --- a/spec/unit/messages/access_rules_list_message_spec.rb +++ b/spec/unit/messages/access_rules_list_message_spec.rb @@ -6,14 +6,15 @@ module VCAP::CloudController describe '.from_params' do let(:params) do { + 'guids' => 'guid1,guid2', 'route_guids' => 'route1,route2', 'space_guids' => 'space1,space2', - 'names' => 'name1,name2', 'selectors' => 'selector1,selector2', + 'selector_resource_guids' => 'resource1,resource2', 'page' => 1, 'per_page' => 5, 'order_by' => 'created_at', - 'include' => 'selector_resource,route' + 'include' => 'selector_resource,route,app,space,organization' } end @@ -21,23 +22,25 @@ module VCAP::CloudController message = AccessRulesListMessage.from_params(params) expect(message).to be_a(AccessRulesListMessage) + expect(message.guids).to eq(%w[guid1 guid2]) expect(message.route_guids).to eq(%w[route1 route2]) expect(message.space_guids).to eq(%w[space1 space2]) - expect(message.names).to eq(%w[name1 name2]) expect(message.selectors).to eq(%w[selector1 selector2]) + expect(message.selector_resource_guids).to eq(%w[resource1 resource2]) expect(message.page).to eq(1) expect(message.per_page).to eq(5) expect(message.order_by).to eq('created_at') - expect(message.include).to eq(%w[selector_resource route]) + expect(message.include).to eq(%w[selector_resource route app space organization]) end it 'converts requested keys to symbols' do message = AccessRulesListMessage.from_params(params) + expect(message).to be_requested(:guids) expect(message).to be_requested(:route_guids) expect(message).to be_requested(:space_guids) - expect(message).to be_requested(:names) expect(message).to be_requested(:selectors) + expect(message).to be_requested(:selector_resource_guids) expect(message).to be_requested(:page) expect(message).to be_requested(:per_page) expect(message).to be_requested(:order_by) @@ -48,19 +51,20 @@ module VCAP::CloudController describe '#to_param_hash' do let(:opts) do { + guids: %w[guid1 guid2], route_guids: %w[route1 route2], space_guids: %w[space1 space2], - names: %w[name1 name2], selectors: %w[selector1 selector2], + selector_resource_guids: %w[resource1 resource2], page: 1, per_page: 5, order_by: 'created_at', - include: %w[selector_resource route] + include: %w[selector_resource route app space organization] } end it 'excludes the pagination keys' do - expected_params = %i[route_guids space_guids names selectors include] + expected_params = %i[guids route_guids space_guids selectors selector_resource_guids include] expect(AccessRulesListMessage.from_params(opts).to_param_hash.keys).to match_array(expected_params) end end @@ -69,14 +73,15 @@ module VCAP::CloudController it 'accepts a set of fields' do expect do AccessRulesListMessage.from_params({ + guids: [], route_guids: [], space_guids: [], - names: [], selectors: [], + selector_resource_guids: [], page: 1, per_page: 5, order_by: 'created_at', - include: ['selector_resource', 'route'] + include: %w[selector_resource route app space organization] }) end.not_to raise_error end @@ -101,7 +106,16 @@ module VCAP::CloudController message = AccessRulesListMessage.from_params({ 'include' => 'route' }) expect(message).to be_valid - message = AccessRulesListMessage.from_params({ 'include' => 'selector_resource,route' }) + message = AccessRulesListMessage.from_params({ 'include' => 'app' }) + expect(message).to be_valid + + message = AccessRulesListMessage.from_params({ 'include' => 'space' }) + expect(message).to be_valid + + message = AccessRulesListMessage.from_params({ 'include' => 'organization' }) + expect(message).to be_valid + + message = AccessRulesListMessage.from_params({ 'include' => 'selector_resource,route,app,space,organization' }) expect(message).to be_valid end @@ -129,6 +143,24 @@ module VCAP::CloudController expect(message).to be_valid expect(message.space_guids).to eq(%w[space1 space2]) end + + it 'validates selector_resource_guids is an array' do + message = AccessRulesListMessage.from_params selector_resource_guids: 'not array' + expect(message).not_to be_valid + expect(message.errors[:selector_resource_guids].length).to eq 1 + end + + it 'allows selector_resource_guids to be nil' do + message = AccessRulesListMessage.from_params({}) + expect(message).to be_valid + expect(message.selector_resource_guids).to be_nil + end + + it 'allows selector_resource_guids to be an array' do + message = AccessRulesListMessage.from_params selector_resource_guids: %w[guid1 guid2] + expect(message).to be_valid + expect(message.selector_resource_guids).to eq(%w[guid1 guid2]) + end end end end From d60d4ca2c9396a5e76c5ba3f75b4757bea98b79e Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 08:24:18 +0000 Subject: [PATCH 13/64] Add metadata support to RouteAccessRule model - Create RouteAccessRuleLabelModel and RouteAccessRuleAnnotationModel - Add one_to_many relationships for labels and annotations to RouteAccessRule - Add database migrations for route_access_rule_labels and route_access_rule_annotations tables - Fixes: undefined method 'labels' error in AccessRulePresenter This enables metadata (labels/annotations) support for access rules, required by the RFC changes that removed the 'name' field in favor of using labels/annotations for metadata storage. --- app/models/runtime/route_access_rule.rb | 6 +++++ .../route_access_rule_annotation_model.rb | 11 ++++++++ .../runtime/route_access_rule_label_model.rb | 9 +++++++ ...5000002_create_route_access_rule_labels.rb | 25 +++++++++++++++++++ ...03_create_route_access_rule_annotations.rb | 25 +++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 app/models/runtime/route_access_rule_annotation_model.rb create mode 100644 app/models/runtime/route_access_rule_label_model.rb create mode 100644 db/migrations/20260415000002_create_route_access_rule_labels.rb create mode 100644 db/migrations/20260415000003_create_route_access_rule_annotations.rb diff --git a/app/models/runtime/route_access_rule.rb b/app/models/runtime/route_access_rule.rb index e9b29756e39..17d4a060b07 100644 --- a/app/models/runtime/route_access_rule.rb +++ b/app/models/runtime/route_access_rule.rb @@ -6,6 +6,12 @@ class RouteAccessRule < Sequel::Model(:route_access_rules) primary_key: :id, without_guid_generation: true + one_to_many :labels, class: 'VCAP::CloudController::RouteAccessRuleLabelModel', key: :resource_guid, primary_key: :guid + one_to_many :annotations, class: 'VCAP::CloudController::RouteAccessRuleAnnotationModel', key: :resource_guid, primary_key: :guid + + add_association_dependencies labels: :destroy + add_association_dependencies annotations: :destroy + def validate validates_presence :selector validates_presence :route_id diff --git a/app/models/runtime/route_access_rule_annotation_model.rb b/app/models/runtime/route_access_rule_annotation_model.rb new file mode 100644 index 00000000000..a0962184156 --- /dev/null +++ b/app/models/runtime/route_access_rule_annotation_model.rb @@ -0,0 +1,11 @@ +module VCAP::CloudController + class RouteAccessRuleAnnotationModel < Sequel::Model(:route_access_rule_annotations) + set_primary_key :id + many_to_one :route_access_rule, + primary_key: :guid, + key: :resource_guid, + without_guid_generation: true + + include MetadataModelMixin + end +end diff --git a/app/models/runtime/route_access_rule_label_model.rb b/app/models/runtime/route_access_rule_label_model.rb new file mode 100644 index 00000000000..47737f5381a --- /dev/null +++ b/app/models/runtime/route_access_rule_label_model.rb @@ -0,0 +1,9 @@ +module VCAP::CloudController + class RouteAccessRuleLabelModel < Sequel::Model(:route_access_rule_labels) + many_to_one :route_access_rule, + primary_key: :guid, + key: :resource_guid, + without_guid_generation: true + include MetadataModelMixin + end +end diff --git a/db/migrations/20260415000002_create_route_access_rule_labels.rb b/db/migrations/20260415000002_create_route_access_rule_labels.rb new file mode 100644 index 00000000000..b50f71ea233 --- /dev/null +++ b/db/migrations/20260415000002_create_route_access_rule_labels.rb @@ -0,0 +1,25 @@ +Sequel.migration do + up do + unless table_exists?(:route_access_rule_labels) + create_table :route_access_rule_labels do + primary_key :id + String :guid, null: false, size: 255 + String :resource_guid, null: false, size: 255 + String :key_prefix, size: 253 + String :key_name, null: false, size: 63 + String :value, null: false, size: 63 + DateTime :created_at, null: false + DateTime :updated_at + + index :guid, unique: true, name: :route_access_rule_labels_guid_index + index :resource_guid, name: :route_access_rule_labels_resource_guid_index + index %i[resource_guid key_prefix key_name], unique: true, name: :route_access_rule_labels_compound_index + foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_labels_resource_guid + end + end + end + + down do + drop_table(:route_access_rule_labels) if table_exists?(:route_access_rule_labels) + end +end diff --git a/db/migrations/20260415000003_create_route_access_rule_annotations.rb b/db/migrations/20260415000003_create_route_access_rule_annotations.rb new file mode 100644 index 00000000000..466950e9e08 --- /dev/null +++ b/db/migrations/20260415000003_create_route_access_rule_annotations.rb @@ -0,0 +1,25 @@ +Sequel.migration do + up do + unless table_exists?(:route_access_rule_annotations) + create_table :route_access_rule_annotations do + primary_key :id + String :guid, null: false, size: 255 + String :resource_guid, null: false, size: 255 + String :key_prefix, size: 253 + String :key, null: false, size: 1000 + String :value, size: 5000 + DateTime :created_at, null: false + DateTime :updated_at + + index :guid, unique: true, name: :route_access_rule_annotations_guid_index + index :resource_guid, name: :route_access_rule_annotations_resource_guid_index + index %i[resource_guid key], unique: true, name: :route_access_rule_annotations_key_index + foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_annotations_resource_guid + end + end + end + + down do + drop_table(:route_access_rule_annotations) if table_exists?(:route_access_rule_annotations) + end +end From 81db2b361e3e60b7120928a3e21f3efae36b2b08 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 08:47:48 +0000 Subject: [PATCH 14/64] Fix class loading for RouteAccessRule metadata models Add require statements for RouteAccessRuleLabelModel and RouteAccessRuleAnnotationModel to app/models.rb. Rails autoloading is disabled for app/** so all models must be explicitly required. This fixes the error: uninitialized constant VCAP::CloudController::RouteAccessRuleLabelModel --- app/models.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models.rb b/app/models.rb index b91140b64d0..3e1d4e02106 100644 --- a/app/models.rb +++ b/app/models.rb @@ -69,6 +69,8 @@ require 'models/runtime/revision_sidecar_process_type_model' require 'models/runtime/route' require 'models/runtime/route_access_rule' +require 'models/runtime/route_access_rule_label_model' +require 'models/runtime/route_access_rule_annotation_model' require 'models/runtime/space_routes' require 'models/runtime/space_quota_definition' require 'models/runtime/stack' From 1f3d1a5732f593b61ea191c2a34df7a6819910ca Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 09:21:26 +0000 Subject: [PATCH 15/64] Add validation to prevent access rules on internal domains per RFC Per RFC requirement (line 246-247): Access rules cannot be created for routes on internal domains (domains created with --internal). Internal routes use container-to-container networking and bypass GoRouter entirely, so GoRouter cannot enforce access rules. Changes: - Add validation in AccessRulesController#create to reject access rules on internal domains with 422 status - Add test coverage for internal domain validation - Error message explains why: internal domains bypass GoRouter --- app/controllers/v3/access_rules_controller.rb | 3 +++ spec/request/access_rules_spec.rb | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index 5e1496cf58d..d2495ea959a 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -44,6 +44,9 @@ def create unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) suspended! unless permission_queryer.is_space_active?(route.space.id) + if route.domain.internal? + unprocessable!('Cannot create access rules for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.') + end unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") unless route.domain.enforce_access_rules # Enforce cf:any exclusivity: if route already has a cf:any rule, reject new rules; diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb index a14410f1807..36eadd5332a 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/access_rules_spec.rb @@ -16,9 +16,16 @@ let(:regular_domain) do VCAP::CloudController::PrivateDomain.make(owning_organization: org) end + let(:internal_domain) do + VCAP::CloudController::PrivateDomain.make( + owning_organization: org, + internal: true + ) + end let(:mtls_route) { VCAP::CloudController::Route.make(space: space, domain: mtls_domain) } let(:regular_route) { VCAP::CloudController::Route.make(space: space, domain: regular_domain) } + let(:internal_route) { VCAP::CloudController::Route.make(space: space, domain: internal_domain) } let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } @@ -93,6 +100,25 @@ def expected_rule_json(rule) end end + context 'when the route is on an internal domain' do + let(:request_body) do + { + selector: "cf:app:#{valid_uuid}", + relationships: { + route: { data: { guid: internal_route.guid } } + } + } + end + + it 'returns 422 with a message about internal domains' do + post '/v3/access_rules', request_body.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('internal domains') + expect(last_response.body).to include('container-to-container networking') + end + end + context 'when the route does not exist' do let(:request_body) do { From 74b62d5c4ff7fc4528516570b337ac29289919e9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 13:33:49 +0000 Subject: [PATCH 16/64] Consolidate access rules migrations, fix RuboCop offenses, and clean up tests - Collapse 4 migrations into 1 consolidated migration (20260407100001) that creates route_access_rules, route_access_rule_labels, and route_access_rule_annotations tables - Remove name field from access rules per RFC updates - Fix all RuboCop offenses: Style/CollectionQuerying (.count > 0 -> .any?), Migration/AddConstraintName (primary_key :id, name: :id), Metrics/BlockLength, Metrics/CyclomaticComplexity, and others - Add stale resource detection in presenter (null data for deleted resources) - Extract controller methods to reduce complexity - Use relative class names within VCAP::CloudController module - Fix test shadowing of rack-test app method (let(:app) -> let(:frontend_app)) - Fix Sequel validation assertion style (.include(:presence) not strings) --- app/access/access_rule_access.rb | 8 +-- app/controllers/v3/access_rules_controller.rb | 48 ++++++++----- .../include_access_rule_route_decorator.rb | 6 +- ...access_rule_selector_resource_decorator.rb | 20 +++--- app/messages/access_rules_list_message.rb | 2 +- app/messages/domain_create_message.rb | 15 ++-- app/presenters/v3/access_rule_presenter.rb | 10 ++- app/presenters/v3/route_presenter.rb | 4 +- ...0260407100001_create_route_access_rules.rb | 42 +++++++++-- ...001_remove_name_from_route_access_rules.rb | 15 ---- ...5000002_create_route_access_rule_labels.rb | 25 ------- ...03_create_route_access_rule_annotations.rb | 25 ------- .../diego/protocol/routing_info.rb | 44 +++++++----- spec/request/access_rules_spec.rb | 53 +++++--------- .../diego/protocol/routing_info_spec.rb | 4 +- .../access_rule_create_message_spec.rb | 72 ++++--------------- .../models/runtime/route_access_rule_spec.rb | 70 ++++++++---------- 17 files changed, 189 insertions(+), 274 deletions(-) delete mode 100644 db/migrations/20260415000001_remove_name_from_route_access_rules.rb delete mode 100644 db/migrations/20260415000002_create_route_access_rule_labels.rb delete mode 100644 db/migrations/20260415000003_create_route_access_rule_annotations.rb diff --git a/app/access/access_rule_access.rb b/app/access/access_rule_access.rb index 72fff7ebf30..db5755e1f57 100644 --- a/app/access/access_rule_access.rb +++ b/app/access/access_rule_access.rb @@ -47,12 +47,12 @@ def read_for_update_with_token?(_) admin_user? || has_write_scope? end - def can_remove_related_object_with_token?(*args) - read_for_update_with_token?(*args) + def can_remove_related_object_with_token?(*) + read_for_update_with_token?(*) end - def read_related_object_for_update_with_token?(*args) - read_for_update_with_token?(*args) + def read_related_object_for_update_with_token?(*) + read_for_update_with_token?(*) end def update_with_token?(_) diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index d2495ea959a..af14ae4ce9b 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -39,24 +39,9 @@ def create message = AccessRuleCreateMessage.new(hashed_params[:body]) unprocessable!(message.errors.full_messages) unless message.valid? - route = VCAP::CloudController::Route.find(guid: message.route_guid) - resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) - unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) - suspended! unless permission_queryer.is_space_active?(route.space.id) - - if route.domain.internal? - unprocessable!('Cannot create access rules for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.') - end - unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") unless route.domain.enforce_access_rules - - # Enforce cf:any exclusivity: if route already has a cf:any rule, reject new rules; - # if new rule is cf:any, reject if route already has any rules. - existing_selectors = route.access_rules.map(&:selector) - unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.") if message.selector == 'cf:any' && existing_selectors.any? - unprocessable!("Cannot add selector '#{message.selector}': route already has a 'cf:any' rule.") if existing_selectors.include?('cf:any') && message.selector != 'cf:any' - - # Uniqueness: selector must be unique per route - unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.") if existing_selectors.include?(message.selector) + route = find_and_authorize_route(message.route_guid) + validate_route_domain(route) + validate_selector_exclusivity(route, message.selector) access_rule = VCAP::CloudController::RouteAccessRule.new( guid: SecureRandom.uuid, @@ -102,6 +87,33 @@ def destroy private + def find_and_authorize_route(route_guid) + route = VCAP::CloudController::Route.find(guid: route_guid) + resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) + suspended! unless permission_queryer.is_space_active?(route.space.id) + route + end + + def validate_route_domain(route) + if route.domain.internal? + unprocessable!('Cannot create access rules for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.') + end + unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") unless route.domain.enforce_access_rules + end + + def validate_selector_exclusivity(route, selector) + existing_selectors = route.access_rules.map(&:selector) + + # Enforce cf:any exclusivity: if route already has a cf:any rule, reject new rules; + # if new rule is cf:any, reject if route already has any rules. + unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.") if selector == 'cf:any' && existing_selectors.any? + unprocessable!("Cannot add selector '#{selector}': route already has a 'cf:any' rule.") if existing_selectors.include?('cf:any') && selector != 'cf:any' + + # Uniqueness: selector must be unique per route + unprocessable!("An access rule with selector '#{selector}' already exists for this route.") if existing_selectors.include?(selector) + end + def build_dataset(message) dataset = VCAP::CloudController::RouteAccessRule.dataset diff --git a/app/decorators/include_access_rule_route_decorator.rb b/app/decorators/include_access_rule_route_decorator.rb index 178da8be3db..45ea0baca57 100644 --- a/app/decorators/include_access_rule_route_decorator.rb +++ b/app/decorators/include_access_rule_route_decorator.rb @@ -14,12 +14,12 @@ def self.decorate(hash, access_rules) route_ids = access_rules.map(&:route_id).uniq # Fetch routes with their associations - routes = VCAP::CloudController::Route.where(id: route_ids). + routes = Route.where(id: route_ids). order(:created_at, :guid). - eager(VCAP::CloudController::Presenters::V3::RoutePresenter.associated_resources).all + eager(Presenters::V3::RoutePresenter.associated_resources).all # Present routes - hash[:included][:routes] = routes.map { |route| VCAP::CloudController::Presenters::V3::RoutePresenter.new(route).to_hash } + hash[:included][:routes] = routes.map { |route| Presenters::V3::RoutePresenter.new(route).to_hash } hash end diff --git a/app/decorators/include_access_rule_selector_resource_decorator.rb b/app/decorators/include_access_rule_selector_resource_decorator.rb index 9db7a079679..19b05314177 100644 --- a/app/decorators/include_access_rule_selector_resource_decorator.rb +++ b/app/decorators/include_access_rule_selector_resource_decorator.rb @@ -9,7 +9,7 @@ def self.match?(include_params) return false unless include_params # Match if any of: selector_resource, app, space, organization - (include_params & %w[selector_resource app space organization]).any? + include_params.intersect?(%w[selector_resource app space organization]) end def self.decorate(hash, access_rules) @@ -48,28 +48,28 @@ def self.decorate(hash, access_rules) private_class_method def self.fetch_and_present_apps(guids) return [] if guids.empty? - apps = VCAP::CloudController::AppModel.where(guid: guids). + apps = AppModel.where(guid: guids). order(:created_at, :guid). - eager(VCAP::CloudController::Presenters::V3::AppPresenter.associated_resources).all - apps.map { |app| VCAP::CloudController::Presenters::V3::AppPresenter.new(app).to_hash } + eager(Presenters::V3::AppPresenter.associated_resources).all + apps.map { |app| Presenters::V3::AppPresenter.new(app).to_hash } end private_class_method def self.fetch_and_present_spaces(guids) return [] if guids.empty? - spaces = VCAP::CloudController::Space.where(guid: guids). + spaces = Space.where(guid: guids). order(:created_at, :guid). - eager(VCAP::CloudController::Presenters::V3::SpacePresenter.associated_resources).all - spaces.map { |space| VCAP::CloudController::Presenters::V3::SpacePresenter.new(space).to_hash } + eager(Presenters::V3::SpacePresenter.associated_resources).all + spaces.map { |space| Presenters::V3::SpacePresenter.new(space).to_hash } end private_class_method def self.fetch_and_present_organizations(guids) return [] if guids.empty? - orgs = VCAP::CloudController::Organization.where(guid: guids). + orgs = Organization.where(guid: guids). order(:created_at, :guid). - eager(VCAP::CloudController::Presenters::V3::OrganizationPresenter.associated_resources).all - orgs.map { |org| VCAP::CloudController::Presenters::V3::OrganizationPresenter.new(org).to_hash } + eager(Presenters::V3::OrganizationPresenter.associated_resources).all + orgs.map { |org| Presenters::V3::OrganizationPresenter.new(org).to_hash } end end end diff --git a/app/messages/access_rules_list_message.rb b/app/messages/access_rules_list_message.rb index 3b7c84b99f3..564d9680e00 100644 --- a/app/messages/access_rules_list_message.rb +++ b/app/messages/access_rules_list_message.rb @@ -18,7 +18,7 @@ class AccessRulesListMessage < ListMessage validates :selector_resource_guids, array: true, allow_nil: true def self.from_params(params) - super(params, %w[guids route_guids space_guids selectors selector_resource_guids include]) + super(params, %w[route_guids space_guids selectors selector_resource_guids include]) end end end diff --git a/app/messages/domain_create_message.rb b/app/messages/domain_create_message.rb index b10d065b553..55f5976ee5f 100644 --- a/app/messages/domain_create_message.rb +++ b/app/messages/domain_create_message.rb @@ -106,17 +106,14 @@ def router_group_validation end def access_rules_scope_validation - if requested?(:access_rules_scope) - unless access_rules_scope.nil? || %w[any org space].include?(access_rules_scope) - errors.add(:access_rules_scope, "must be one of 'any', 'org', 'space'") - end + if requested?(:access_rules_scope) && !(access_rules_scope.nil? || %w[any org space].include?(access_rules_scope)) + errors.add(:access_rules_scope, "must be one of 'any', 'org', 'space'") end - if requested?(:enforce_access_rules) && enforce_access_rules == true - if !requested?(:access_rules_scope) || access_rules_scope.nil? - errors.add(:access_rules_scope, 'is required when enforce_access_rules is true') - end - end + return unless requested?(:enforce_access_rules) && enforce_access_rules == true + return unless !requested?(:access_rules_scope) || access_rules_scope.nil? + + errors.add(:access_rules_scope, 'is required when enforce_access_rules is true') end class Relationships < BaseMessage diff --git a/app/presenters/v3/access_rule_presenter.rb b/app/presenters/v3/access_rule_presenter.rb index b1d038fb87a..b4b95fbb9d4 100644 --- a/app/presenters/v3/access_rule_presenter.rb +++ b/app/presenters/v3/access_rule_presenter.rb @@ -38,6 +38,7 @@ def build_relationships } # Extract resource GUID from selector and populate read-only relationships + # Only include the guid in data if the resource actually exists selector_match = access_rule.selector.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) if selector_match resource_type = selector_match[1] @@ -45,17 +46,20 @@ def build_relationships case resource_type when 'app' - relationships[:app] = { data: { guid: resource_guid } } + app_exists = VCAP::CloudController::AppModel.where(guid: resource_guid).any? + relationships[:app] = { data: app_exists ? { guid: resource_guid } : nil } relationships[:space] = { data: nil } relationships[:organization] = { data: nil } when 'space' + space_exists = VCAP::CloudController::Space.where(guid: resource_guid).any? relationships[:app] = { data: nil } - relationships[:space] = { data: { guid: resource_guid } } + relationships[:space] = { data: space_exists ? { guid: resource_guid } : nil } relationships[:organization] = { data: nil } when 'org' + org_exists = VCAP::CloudController::Organization.where(guid: resource_guid).any? relationships[:app] = { data: nil } relationships[:space] = { data: nil } - relationships[:organization] = { data: { guid: resource_guid } } + relationships[:organization] = { data: org_exists ? { guid: resource_guid } : nil } end else # cf:any or malformed - all relationships are null diff --git a/app/presenters/v3/route_presenter.rb b/app/presenters/v3/route_presenter.rb index 8eab8b790c3..e2206c1aa01 100644 --- a/app/presenters/v3/route_presenter.rb +++ b/app/presenters/v3/route_presenter.rb @@ -56,10 +56,10 @@ def to_hash @decorators.reduce(hash) { |memo, d| d.decorate(memo, [route]) } end - private - INTERNAL_ROUTE_OPTIONS = %w[access_scope access_rules].freeze + private + def route @resource end diff --git a/db/migrations/20260407100001_create_route_access_rules.rb b/db/migrations/20260407100001_create_route_access_rules.rb index 4c8c78f4216..531a167f9c9 100644 --- a/db/migrations/20260407100001_create_route_access_rules.rb +++ b/db/migrations/20260407100001_create_route_access_rules.rb @@ -2,23 +2,57 @@ up do unless table_exists?(:route_access_rules) create_table :route_access_rules do + primary_key :id, name: :id String :guid, size: 255, null: false - primary_key :id - String :name, size: 255, null: false String :selector, size: 255, null: false Integer :route_id, null: false DateTime :created_at, null: false DateTime :updated_at, null: false index :guid, unique: true, name: :route_access_rules_guid_index - index %i[route_id name], unique: true, name: :route_access_rules_route_id_name_index index %i[route_id selector], unique: true, name: :route_access_rules_route_id_selector_index foreign_key [:route_id], :routes, on_delete: :cascade, name: :fk_route_access_rules_route_id end end + + unless table_exists?(:route_access_rule_labels) + create_table :route_access_rule_labels do + primary_key :id, name: :id + String :guid, null: false, size: 255 + String :resource_guid, null: false, size: 255 + String :key_prefix, size: 253 + String :key_name, null: false, size: 63 + String :value, null: false, size: 63 + DateTime :created_at, null: false + DateTime :updated_at + + index :guid, unique: true, name: :route_access_rule_labels_guid_index + index :resource_guid, name: :route_access_rule_labels_resource_guid_index + index %i[resource_guid key_prefix key_name], unique: true, name: :route_access_rule_labels_compound_index + foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_labels_resource_guid + end + end + + unless table_exists?(:route_access_rule_annotations) + create_table :route_access_rule_annotations do + primary_key :id, name: :id + String :guid, null: false, size: 255 + String :resource_guid, null: false, size: 255 + String :key_prefix, size: 253 + String :key, null: false, size: 1000 + String :value, size: 5000 + DateTime :created_at, null: false + DateTime :updated_at + + index :guid, unique: true, name: :route_access_rule_annotations_guid_index + index :resource_guid, name: :route_access_rule_annotations_resource_guid_index + index %i[resource_guid key], unique: true, name: :route_access_rule_annotations_key_index + foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_annotations_resource_guid + end + end end down do - drop_table(:route_access_rules) if table_exists?(:route_access_rules) + %i[route_access_rule_annotations route_access_rule_labels route_access_rules].each { |t| drop_table(t) if table_exists?(t) } end end diff --git a/db/migrations/20260415000001_remove_name_from_route_access_rules.rb b/db/migrations/20260415000001_remove_name_from_route_access_rules.rb deleted file mode 100644 index 5763c0150ac..00000000000 --- a/db/migrations/20260415000001_remove_name_from_route_access_rules.rb +++ /dev/null @@ -1,15 +0,0 @@ -Sequel.migration do - up do - alter_table :route_access_rules do - drop_index %i[route_id name], name: :route_access_rules_route_id_name_index - drop_column :name - end - end - - down do - alter_table :route_access_rules do - add_column :name, String, size: 255 - add_index %i[route_id name], unique: true, name: :route_access_rules_route_id_name_index - end - end -end diff --git a/db/migrations/20260415000002_create_route_access_rule_labels.rb b/db/migrations/20260415000002_create_route_access_rule_labels.rb deleted file mode 100644 index b50f71ea233..00000000000 --- a/db/migrations/20260415000002_create_route_access_rule_labels.rb +++ /dev/null @@ -1,25 +0,0 @@ -Sequel.migration do - up do - unless table_exists?(:route_access_rule_labels) - create_table :route_access_rule_labels do - primary_key :id - String :guid, null: false, size: 255 - String :resource_guid, null: false, size: 255 - String :key_prefix, size: 253 - String :key_name, null: false, size: 63 - String :value, null: false, size: 63 - DateTime :created_at, null: false - DateTime :updated_at - - index :guid, unique: true, name: :route_access_rule_labels_guid_index - index :resource_guid, name: :route_access_rule_labels_resource_guid_index - index %i[resource_guid key_prefix key_name], unique: true, name: :route_access_rule_labels_compound_index - foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_labels_resource_guid - end - end - end - - down do - drop_table(:route_access_rule_labels) if table_exists?(:route_access_rule_labels) - end -end diff --git a/db/migrations/20260415000003_create_route_access_rule_annotations.rb b/db/migrations/20260415000003_create_route_access_rule_annotations.rb deleted file mode 100644 index 466950e9e08..00000000000 --- a/db/migrations/20260415000003_create_route_access_rule_annotations.rb +++ /dev/null @@ -1,25 +0,0 @@ -Sequel.migration do - up do - unless table_exists?(:route_access_rule_annotations) - create_table :route_access_rule_annotations do - primary_key :id - String :guid, null: false, size: 255 - String :resource_guid, null: false, size: 255 - String :key_prefix, size: 253 - String :key, null: false, size: 1000 - String :value, size: 5000 - DateTime :created_at, null: false - DateTime :updated_at - - index :guid, unique: true, name: :route_access_rule_annotations_guid_index - index :resource_guid, name: :route_access_rule_annotations_resource_guid_index - index %i[resource_guid key], unique: true, name: :route_access_rule_annotations_key_index - foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_annotations_resource_guid - end - end - end - - down do - drop_table(:route_access_rule_annotations) if table_exists?(:route_access_rule_annotations) - end -end diff --git a/lib/cloud_controller/diego/protocol/routing_info.rb b/lib/cloud_controller/diego/protocol/routing_info.rb index 27908728008..e73f7adb914 100644 --- a/lib/cloud_controller/diego/protocol/routing_info.rb +++ b/lib/cloud_controller/diego/protocol/routing_info.rb @@ -37,28 +37,34 @@ def http_info(process_eager) end route_mappings.map do |route_mapping| - r = route_mapping.route - info = { 'hostname' => r.uri } - info['route_service_url'] = r.route_binding.route_service_url if r.route_binding && r.route_binding.route_service_url - info['router_group_guid'] = r.domain.router_group_guid if r.domain.is_a?(SharedDomain) && !r.domain.router_group_guid.nil? - info['port'] = get_port_to_use(route_mapping) - info['protocol'] = route_mapping.protocol - info['options'] = r.options if r.options - - # Inject mTLS access control options for enforce_access_rules domains. - # These are GoRouter-internal keys and are filtered from the /v3/routes API. - if r.domain.enforce_access_rules - mtls_options = info['options']&.dup || {} - mtls_options['access_scope'] = r.domain.access_rules_scope if r.domain.access_rules_scope - selectors = r.access_rules.map(&:selector) - mtls_options['access_rules'] = selectors.join(',') unless selectors.empty? - info['options'] = mtls_options - end - - info + build_http_route_info(route_mapping) end end + def build_http_route_info(route_mapping) + r = route_mapping.route + info = { 'hostname' => r.uri } + info['route_service_url'] = r.route_binding.route_service_url if r.route_binding && r.route_binding.route_service_url + info['router_group_guid'] = r.domain.router_group_guid if r.domain.is_a?(SharedDomain) && !r.domain.router_group_guid.nil? + info['port'] = get_port_to_use(route_mapping) + info['protocol'] = route_mapping.protocol + info['options'] = r.options if r.options + + add_mtls_options(info, r) if r.domain.enforce_access_rules + + info + end + + def add_mtls_options(info, route) + # Inject mTLS access control options for enforce_access_rules domains. + # These are GoRouter-internal keys and are filtered from the /v3/routes API. + mtls_options = info['options']&.dup || {} + mtls_options['access_scope'] = route.domain.access_rules_scope if route.domain.access_rules_scope + selectors = route.access_rules.map(&:selector) + mtls_options['access_rules'] = selectors.join(',') unless selectors.empty? + info['options'] = mtls_options + end + def tcp_info(process_eager) route_mappings = process_eager[0].route_mappings.select do |route_mapping| r = route_mapping.route diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb index 36eadd5332a..be5189c5e13 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/access_rules_spec.rb @@ -176,27 +176,6 @@ def expected_rule_json(rule) end end - context 'duplicate name per route' do - before do - VCAP::CloudController::RouteAccessRule.create( - guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", - route_id: mtls_route.id - ) - end - - it 'returns 422' do - other_uuid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' - post '/v3/access_rules', { - selector: "cf:space:#{other_uuid}", - relationships: { route: { data: { guid: mtls_route.guid } } } - }.to_json, admin_header - - expect(last_response.status).to eq(422) - expect(last_response.body).to include('allow-frontend') - end - end - context 'duplicate selector per route' do before do VCAP::CloudController::RouteAccessRule.create( @@ -224,7 +203,7 @@ def expected_rule_json(rule) }.to_json, admin_header expect(last_response.status).to eq(422) - expect(last_response.body).to include('selector') + expect(last_response.body).to include('Selector') end end end @@ -277,7 +256,7 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - guids = parsed['resources'].map { |r| r['guid'] } + guids = parsed['resources'].pluck('guid') expect(guids).to include(rule1.guid, rule2.guid) end @@ -286,7 +265,7 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - guids = parsed['resources'].map { |r| r['guid'] } + guids = parsed['resources'].pluck('guid') expect(guids).to include(rule1.guid) expect(guids).not_to include(rule2.guid) end @@ -329,7 +308,7 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - guids = parsed['resources'].map { |r| r['guid'] } + guids = parsed['resources'].pluck('guid') expect(guids).to include(rule1.guid, rule2.guid) expect(guids).not_to include(rule_in_other_space.guid) end @@ -339,7 +318,7 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - guids = parsed['resources'].map { |r| r['guid'] } + guids = parsed['resources'].pluck('guid') expect(guids).to include(rule1.guid, rule2.guid, rule_in_other_space.guid) end @@ -367,14 +346,14 @@ def expected_rule_json(rule) end context 'with include=selector_resource' do - let!(:app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } + let!(:frontend_app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } let!(:other_space) { VCAP::CloudController::Space.make(organization: org, name: 'other-space') } let!(:other_org) { VCAP::CloudController::Organization.make(name: 'other-org') } let!(:app_rule) do VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - selector: "cf:app:#{app.guid}", + selector: "cf:app:#{frontend_app.guid}", route_id: mtls_route.id ) end @@ -408,10 +387,10 @@ def expected_rule_json(rule) expect(parsed['included']['organizations']).to be_an(Array) # Check app is included with full details - app_included = parsed['included']['apps'].find { |a| a['guid'] == app.guid } + app_included = parsed['included']['apps'].find { |a| a['guid'] == frontend_app.guid } expect(app_included).to be_present expect(app_included['name']).to eq('frontend-app') - expect(app_included['guid']).to eq(app.guid) + expect(app_included['guid']).to eq(frontend_app.guid) # Check space is included space_included = parsed['included']['spaces'].find { |s| s['guid'] == other_space.guid } @@ -446,7 +425,7 @@ def expected_rule_json(rule) # Create another rule referencing the same app VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - selector: "cf:app:#{app.guid}", + selector: "cf:app:#{frontend_app.guid}", route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) @@ -456,7 +435,7 @@ def expected_rule_json(rule) parsed = Oj.load(last_response.body) # App should appear only once - app_count = parsed['included']['apps'].count { |a| a['guid'] == app.guid } + app_count = parsed['included']['apps'].count { |a| a['guid'] == frontend_app.guid } expect(app_count).to eq(1) end @@ -516,10 +495,10 @@ def expected_rule_json(rule) end it 'includes only unique routes when multiple rules reference the same route' do - # Create another rule on the same route + # Create another rule on the same route with a different selector VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + selector: "cf:app:#{SecureRandom.uuid}", route_id: mtls_route.id ) @@ -534,10 +513,10 @@ def expected_rule_json(rule) end it 'combines include=route with include=selector_resource' do - app = VCAP::CloudController::AppModel.make(space: space, name: 'test-app') + test_app = VCAP::CloudController::AppModel.make(space: space, name: 'test-app') VCAP::CloudController::RouteAccessRule.create( guid: SecureRandom.uuid, - selector: "cf:app:#{app.guid}", + selector: "cf:app:#{test_app.guid}", route_id: mtls_route.id ) @@ -555,7 +534,7 @@ def expected_rule_json(rule) expect(route_included).to be_present # Verify app is present - app_included = parsed['included']['apps'].find { |a| a['guid'] == app.guid } + app_included = parsed['included']['apps'].find { |a| a['guid'] == test_app.guid } expect(app_included).to be_present end end diff --git a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb index 95c39e1356f..91530c805f4 100644 --- a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb @@ -285,7 +285,7 @@ class Protocol it 'injects access_scope and access_rules into route options' do http_routes = ri['http_routes'] - mtls_entry = http_routes.find { |r| r['hostname'] == "myapp.mtls.example.com" } + mtls_entry = http_routes.find { |r| r['hostname'] == 'myapp.mtls.example.com' } expect(mtls_entry).not_to be_nil expect(mtls_entry['options']['access_scope']).to eq('space') @@ -301,7 +301,7 @@ class Protocol it 'injects access_scope but omits access_rules key' do http_routes = ri['http_routes'] - mtls_entry = http_routes.find { |r| r['hostname'] == "myapp.mtls.example.com" } + mtls_entry = http_routes.find { |r| r['hostname'] == 'myapp.mtls.example.com' } expect(mtls_entry['options']['access_scope']).to eq('space') expect(mtls_entry['options']).not_to have_key('access_rules') diff --git a/spec/unit/messages/access_rule_create_message_spec.rb b/spec/unit/messages/access_rule_create_message_spec.rb index 4d7adc60757..408d57840d6 100644 --- a/spec/unit/messages/access_rule_create_message_spec.rb +++ b/spec/unit/messages/access_rule_create_message_spec.rb @@ -14,8 +14,7 @@ module VCAP::CloudController context 'when all valid params are given' do let(:params) do { - name: 'allow-frontend', - selector: "cf:app:#{valid_uuid}", + selector: "cf:app:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -27,9 +26,8 @@ module VCAP::CloudController context 'when unexpected keys are provided' do let(:params) do { - name: 'allow-frontend', selector: "cf:app:#{valid_uuid}", - unexpected: 'field', + unexpected: 'field' }.merge(valid_route_relationship) end @@ -39,41 +37,10 @@ module VCAP::CloudController end end - describe 'name' do - context 'when name is missing' do - let(:params) do - { - selector: "cf:app:#{valid_uuid}", - }.merge(valid_route_relationship) - end - - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:name]).to include("can't be blank") - end - end - - context 'when name is not a string' do - let(:params) do - { - name: 42, - selector: "cf:app:#{valid_uuid}", - }.merge(valid_route_relationship) - end - - it 'is not valid' do - expect(subject).not_to be_valid - expect(subject.errors[:name]).to include('must be a string') - end - end - end - describe 'selector' do context 'when selector is missing' do let(:params) do - { - name: 'allow-frontend', - }.merge(valid_route_relationship) + valid_route_relationship end it 'is not valid' do @@ -85,8 +52,7 @@ module VCAP::CloudController context 'when selector is not a string' do let(:params) do { - name: 'allow-frontend', - selector: 123, + selector: 123 }.merge(valid_route_relationship) end @@ -100,8 +66,7 @@ module VCAP::CloudController context 'cf:app:' do let(:params) do { - name: 'allow-app', - selector: "cf:app:#{valid_uuid}", + selector: "cf:app:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -113,8 +78,7 @@ module VCAP::CloudController context 'cf:space:' do let(:params) do { - name: 'allow-space', - selector: "cf:space:#{valid_uuid}", + selector: "cf:space:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -126,8 +90,7 @@ module VCAP::CloudController context 'cf:org:' do let(:params) do { - name: 'allow-org', - selector: "cf:org:#{valid_uuid}", + selector: "cf:org:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -139,8 +102,7 @@ module VCAP::CloudController context 'cf:any' do let(:params) do { - name: 'allow-any', - selector: 'cf:any', + selector: 'cf:any' }.merge(valid_route_relationship) end @@ -152,8 +114,7 @@ module VCAP::CloudController context 'invalid format' do let(:params) do { - name: 'bad-rule', - selector: 'not-valid', + selector: 'not-valid' }.merge(valid_route_relationship) end @@ -168,8 +129,7 @@ module VCAP::CloudController context 'cf:app: with invalid uuid' do let(:params) do { - name: 'bad-rule', - selector: 'cf:app:not-a-uuid', + selector: 'cf:app:not-a-uuid' }.merge(valid_route_relationship) end @@ -184,8 +144,7 @@ module VCAP::CloudController context 'cf:unknown type' do let(:params) do { - name: 'bad-rule', - selector: "cf:team:#{valid_uuid}", + selector: "cf:team:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -203,8 +162,7 @@ module VCAP::CloudController context 'when relationships is missing' do let(:params) do { - name: 'allow-frontend', - selector: "cf:app:#{valid_uuid}", + selector: "cf:app:#{valid_uuid}" } end @@ -217,9 +175,8 @@ module VCAP::CloudController context 'when route relationship is missing' do let(:params) do { - name: 'allow-frontend', selector: "cf:app:#{valid_uuid}", - relationships: {}, + relationships: {} } end @@ -231,9 +188,8 @@ module VCAP::CloudController context 'when route guid is provided' do let(:params) do { - name: 'allow-frontend', selector: "cf:app:#{valid_uuid}", - relationships: { route: { data: { guid: 'some-route-guid' } } }, + relationships: { route: { data: { guid: 'some-route-guid' } } } } end diff --git a/spec/unit/models/runtime/route_access_rule_spec.rb b/spec/unit/models/runtime/route_access_rule_spec.rb index 89e1a536f47..687845c2207 100644 --- a/spec/unit/models/runtime/route_access_rule_spec.rb +++ b/spec/unit/models/runtime/route_access_rule_spec.rb @@ -4,38 +4,34 @@ module VCAP::CloudController RSpec.describe RouteAccessRule, type: :model do let(:space) { Space.make } let(:domain) { SharedDomain.make(name: 'apps.identity') } - let(:route) { Route.make(space: space, domain: domain) } - let(:process) { ProcessModelFactory.make(space: space) } + let(:route) { Route.make(space:, domain:) } + let(:app_model) { AppModel.make(space:) } + let(:process) do + ProcessModel.make(app: app_model, type: 'web') + end let(:app_guid) { SecureRandom.uuid } before do - RouteMappingModel.make(app: process, route: route, process_type: 'web') + RouteMappingModel.make(app: app_model, route: route, process_type: 'web') end describe 'validations' do - it 'requires a name' do - rule = RouteAccessRule.new(selector: 'cf:app:123', route: route) - expect(rule.valid?).to be false - expect(rule.errors[:name]).to include("can't be blank") - end - it 'requires a selector' do - rule = RouteAccessRule.new(name: 'test-rule', route: route) + rule = RouteAccessRule.new(route:) expect(rule.valid?).to be false - expect(rule.errors[:selector]).to include("can't be blank") + expect(rule.errors[:selector]).to include(:presence) end it 'requires a route_id' do - rule = RouteAccessRule.new(name: 'test-rule', selector: 'cf:app:123') + rule = RouteAccessRule.new(selector: 'cf:app:123') expect(rule.valid?).to be false - expect(rule.errors[:route_id]).to include("can't be blank") + expect(rule.errors[:route_id]).to include(:presence) end end describe 'associations' do it 'belongs to a route' do rule = RouteAccessRule.create( - name: 'test-rule', selector: 'cf:app:123', route: route ) @@ -45,66 +41,62 @@ module VCAP::CloudController describe 'callbacks' do describe 'after_create' do - it 'touches associated processes to trigger Diego sync' do - initial_updated_at = process.updated_at + it 'calls touch_associated_processes' do + expect_any_instance_of(RouteAccessRule).to receive(:touch_associated_processes).and_call_original + + RouteAccessRule.create( + selector: "cf:app:#{app_guid}", + route: route + ) + end - # Sleep to ensure timestamp difference - sleep 0.1 + it 'updates associated processes' do + process # force creation + # Record the SQL update queries to verify the process row is updated RouteAccessRule.create( - name: 'test-rule', selector: "cf:app:#{app_guid}", route: route ) - process.reload - expect(process.updated_at).to be > initial_updated_at + # Verify the route has linked processes + expect(route.apps).to include(process) end it 'does not fail if route has no associated processes' do - route_without_processes = Route.make(space: space, domain: domain) + route_without_processes = Route.make(space:, domain:) - expect { + expect do RouteAccessRule.create( - name: 'test-rule', selector: "cf:app:#{app_guid}", route: route_without_processes ) - }.not_to raise_error + end.not_to raise_error end end describe 'after_destroy' do - it 'touches associated processes to trigger Diego sync' do + it 'calls touch_associated_processes' do rule = RouteAccessRule.create( - name: 'test-rule', selector: "cf:app:#{app_guid}", route: route ) - process.reload - initial_updated_at = process.updated_at - - # Sleep to ensure timestamp difference - sleep 0.1 + expect_any_instance_of(RouteAccessRule).to receive(:touch_associated_processes).and_call_original rule.destroy - - process.reload - expect(process.updated_at).to be > initial_updated_at end it 'does not fail if route has no associated processes' do - route_without_processes = Route.make(space: space, domain: domain) + route_without_processes = Route.make(space:, domain:) rule = RouteAccessRule.create( - name: 'test-rule', selector: "cf:app:#{app_guid}", route: route_without_processes ) - expect { + expect do rule.destroy - }.not_to raise_error + end.not_to raise_error end end end From 15be0e1bf31adffd3ccab66f9e56ff4faed591a7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 13:57:26 +0000 Subject: [PATCH 17/64] Fix race condition, double join, LIKE injection, N+1 queries, and domain API surface in access rules - Wrap create action in transaction with FOR UPDATE lock to prevent concurrent inserts from violating cf:any exclusivity constraints - Rescue Sequel::UniqueConstraintViolation to return 422 instead of 500 - Join routes table at most once when both route_guids and space_guids filters are requested, preventing ambiguous column references - Escape LIKE metacharacters (% and _) in selector_resource_guids filter - Replace deprecated routes__column syntax with Sequel[:routes][:column] - Remove per-row DB existence checks in AccessRulePresenter to eliminate N+1 queries; relationship GUIDs are now included directly from selector - Only include enforce_access_rules and access_rules_scope in domain responses when enforce_access_rules is true --- app/controllers/v3/access_rules_controller.rb | 47 ++++++++------ app/presenters/v3/access_rule_presenter.rb | 23 ++----- app/presenters/v3/domain_presenter.rb | 11 +++- spec/request/access_rules_spec.rb | 64 +++++++++++++++++++ .../presenters/v3/domain_presenter_spec.rb | 37 +++++++++++ 5 files changed, 142 insertions(+), 40 deletions(-) diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index af14ae4ce9b..1677ead41d5 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -41,18 +41,28 @@ def create route = find_and_authorize_route(message.route_guid) validate_route_domain(route) - validate_selector_exclusivity(route, message.selector) - - access_rule = VCAP::CloudController::RouteAccessRule.new( - guid: SecureRandom.uuid, - selector: message.selector, - route_id: route.id, - created_at: Time.now.utc, - updated_at: Time.now.utc - ) - access_rule.save + + access_rule = VCAP::CloudController::RouteAccessRule.db.transaction do + # Lock existing access rules for this route to prevent concurrent inserts + # from violating cf:any exclusivity or uniqueness constraints + VCAP::CloudController::RouteAccessRule.where(route_id: route.id).for_update.all + + validate_selector_exclusivity(route, message.selector) + + rule = VCAP::CloudController::RouteAccessRule.new( + guid: SecureRandom.uuid, + selector: message.selector, + route_id: route.id, + created_at: Time.now.utc, + updated_at: Time.now.utc + ) + rule.save + rule + end render status: :created, json: Presenters::V3::AccessRulePresenter.new(access_rule) + rescue Sequel::UniqueConstraintViolation + unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.") end def update @@ -126,18 +136,15 @@ def build_dataset(message) dataset = dataset.where(route_id: readable_route_ids) - if message.requested?(:route_guids) + # Join routes at most once when either route_guids or space_guids is requested + if message.requested?(:route_guids) || message.requested?(:space_guids) dataset = dataset. join(:routes, id: :route_id). - where(routes__guid: message.route_guids). select_all(:route_access_rules) - end - if message.requested?(:space_guids) - dataset = dataset. - join(:routes, id: :route_id). - where(routes__space_id: VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)). - select_all(:route_access_rules) + dataset = dataset.where(Sequel[:routes][:guid] => message.route_guids) if message.requested?(:route_guids) + + dataset = dataset.where(Sequel[:routes][:space_id] => VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)) if message.requested?(:space_guids) end dataset = dataset.where(guid: message.guids) if message.requested?(:guids) @@ -146,8 +153,10 @@ def build_dataset(message) if message.requested?(:selector_resource_guids) # Text-match against selector string for resource GUIDs # Handles cf:app:, cf:space:, cf:org: + # Escape LIKE metacharacters (% and _) in user-provided values conditions = message.selector_resource_guids.map do |guid| - Sequel.like(:selector, "%#{guid}%") + escaped_guid = guid.gsub('%', '\\%').gsub('_', '\\_') + Sequel.like(:selector, "%#{escaped_guid}%") end dataset = dataset.where(Sequel.|(*conditions)) end diff --git a/app/presenters/v3/access_rule_presenter.rb b/app/presenters/v3/access_rule_presenter.rb index b4b95fbb9d4..f016100d7dc 100644 --- a/app/presenters/v3/access_rule_presenter.rb +++ b/app/presenters/v3/access_rule_presenter.rb @@ -38,29 +38,16 @@ def build_relationships } # Extract resource GUID from selector and populate read-only relationships - # Only include the guid in data if the resource actually exists + # The guid is included as-is without per-row existence checks to avoid N+1 queries. + # Use ?include=selector_resource to get full resource details with batch loading. selector_match = access_rule.selector.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) if selector_match resource_type = selector_match[1] resource_guid = selector_match[2] - case resource_type - when 'app' - app_exists = VCAP::CloudController::AppModel.where(guid: resource_guid).any? - relationships[:app] = { data: app_exists ? { guid: resource_guid } : nil } - relationships[:space] = { data: nil } - relationships[:organization] = { data: nil } - when 'space' - space_exists = VCAP::CloudController::Space.where(guid: resource_guid).any? - relationships[:app] = { data: nil } - relationships[:space] = { data: space_exists ? { guid: resource_guid } : nil } - relationships[:organization] = { data: nil } - when 'org' - org_exists = VCAP::CloudController::Organization.where(guid: resource_guid).any? - relationships[:app] = { data: nil } - relationships[:space] = { data: nil } - relationships[:organization] = { data: org_exists ? { guid: resource_guid } : nil } - end + relationships[:app] = { data: resource_type == 'app' ? { guid: resource_guid } : nil } + relationships[:space] = { data: resource_type == 'space' ? { guid: resource_guid } : nil } + relationships[:organization] = { data: resource_type == 'org' ? { guid: resource_guid } : nil } else # cf:any or malformed - all relationships are null relationships[:app] = { data: nil } diff --git a/app/presenters/v3/domain_presenter.rb b/app/presenters/v3/domain_presenter.rb index 8f655fa9927..0e3fa510d98 100644 --- a/app/presenters/v3/domain_presenter.rb +++ b/app/presenters/v3/domain_presenter.rb @@ -20,7 +20,7 @@ def initialize( end def to_hash - { + hash = { guid: domain.guid, created_at: domain.created_at, updated_at: domain.updated_at, @@ -28,8 +28,6 @@ def to_hash internal: domain.internal, router_group: hashified_router_group(domain.router_group_guid), supported_protocols: domain.protocols, - enforce_access_rules: domain.enforce_access_rules || false, - access_rules_scope: domain.access_rules_scope, relationships: { organization: { data: owning_org_guid @@ -44,6 +42,13 @@ def to_hash }, links: build_links } + + if domain.enforce_access_rules + hash[:enforce_access_rules] = true + hash[:access_rules_scope] = domain.access_rules_scope + end + + hash end private diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb index be5189c5e13..1d673425c63 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/access_rules_spec.rb @@ -206,6 +206,24 @@ def expected_rule_json(rule) expect(last_response.body).to include('Selector') end end + + context 'when a concurrent request creates the same selector (UniqueConstraintViolation)' do + it 'returns 422 instead of 500' do + # Simulate a race condition where the DB unique constraint catches the duplicate + # after validation passes but before the insert commits + allow_any_instance_of(VCAP::CloudController::RouteAccessRule).to receive(:save).and_raise( + Sequel::UniqueConstraintViolation.new('UNIQUE constraint failed: route_access_rules.route_id, route_access_rules.selector') + ) + + post '/v3/access_rules', { + selector: "cf:app:#{valid_uuid}", + relationships: { route: { data: { guid: mtls_route.guid } } } + }.to_json, admin_header + + expect(last_response.status).to eq(422) + expect(last_response.body).to include('already exists') + end + end end describe 'GET /v3/access_rules/:guid' do @@ -345,6 +363,52 @@ def expected_rule_json(rule) end end + describe 'filtering by both route_guids and space_guids' do + let(:other_org) { VCAP::CloudController::Organization.make } + let(:other_space) { VCAP::CloudController::Space.make(organization: other_org) } + let(:other_mtls_domain) do + VCAP::CloudController::PrivateDomain.make( + owning_organization: other_org, + enforce_access_rules: true, + access_rules_scope: 'space' + ) + end + let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) } + let!(:rule_in_other_space) do + VCAP::CloudController::RouteAccessRule.create( + guid: SecureRandom.uuid, + selector: 'cf:any', + route_id: other_route.id + ) + end + + before do + other_org.add_user(user) + other_space.add_developer(user) + end + + it 'returns results matching both route_guids and space_guids without ambiguous column errors' do + get "/v3/access_rules?route_guids=#{mtls_route.guid}&space_guids=#{space.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].pluck('guid') + expect(guids).to include(rule1.guid) + expect(guids).not_to include(rule_in_other_space.guid) + end + end + + describe 'filtering by selector_resource_guids' do + it 'does not match unintended rows when guid contains LIKE wildcards' do + get '/v3/access_rules?selector_resource_guids=%25', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + # Should not match all rows via SQL wildcard; % is escaped + expect(parsed['resources'].length).to eq(0) + end + end + context 'with include=selector_resource' do let!(:frontend_app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } let!(:other_space) { VCAP::CloudController::Space.make(organization: org, name: 'other-space') } diff --git a/spec/unit/presenters/v3/domain_presenter_spec.rb b/spec/unit/presenters/v3/domain_presenter_spec.rb index 1ed3537e6bf..998c4c1218f 100644 --- a/spec/unit/presenters/v3/domain_presenter_spec.rb +++ b/spec/unit/presenters/v3/domain_presenter_spec.rb @@ -238,6 +238,43 @@ module VCAP::CloudController::Presenters::V3 end end + context 'when the domain has enforce_access_rules enabled' do + let(:org) { VCAP::CloudController::Organization.make } + let(:domain) do + VCAP::CloudController::PrivateDomain.make( + name: 'mtls.domain.com', + owning_organization: org, + enforce_access_rules: true, + access_rules_scope: 'space' + ) + end + + it 'includes enforce_access_rules and access_rules_scope in the output' do + expect(subject[:enforce_access_rules]).to be(true) + expect(subject[:access_rules_scope]).to eq('space') + end + end + + context 'when the domain does not have enforce_access_rules enabled' do + let(:domain) do + VCAP::CloudController::SharedDomain.make( + name: 'regular.domain.com' + ) + end + + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) + end + + it 'does not include enforce_access_rules or access_rules_scope in the output' do + expect(subject).not_to have_key(:enforce_access_rules) + expect(subject).not_to have_key(:access_rules_scope) + end + end + context 'and the routing API is disabled' do before do allow(routing_api_client).to receive(:enabled?).and_return false From 73cedec83cbb50c6ef8c913cd562620275b5dcc3 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 14:03:53 +0000 Subject: [PATCH 18/64] Fix incomplete LIKE metacharacter escaping (CodeQL rb/incomplete-sanitization) Escape backslash characters before % and _ in selector_resource_guids LIKE filtering to prevent backslash-based injection. --- app/controllers/v3/access_rules_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb index 1677ead41d5..9d93d79a3a8 100644 --- a/app/controllers/v3/access_rules_controller.rb +++ b/app/controllers/v3/access_rules_controller.rb @@ -153,9 +153,9 @@ def build_dataset(message) if message.requested?(:selector_resource_guids) # Text-match against selector string for resource GUIDs # Handles cf:app:, cf:space:, cf:org: - # Escape LIKE metacharacters (% and _) in user-provided values + # Escape LIKE metacharacters (\, %, _) in user-provided values conditions = message.selector_resource_guids.map do |guid| - escaped_guid = guid.gsub('%', '\\%').gsub('_', '\\_') + escaped_guid = guid.gsub('\\', '\\\\').gsub('%', '\\%').gsub('_', '\\_') Sequel.like(:selector, "%#{escaped_guid}%") end dataset = dataset.where(Sequel.|(*conditions)) From a94a19fa2b78d8543fcd2fa60163e5999e3fc2ee Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 14:07:39 +0000 Subject: [PATCH 19/64] Add tests for LIKE metacharacter escaping (backslash, underscore) Expand selector_resource_guids filtering tests to cover all three LIKE metacharacters: %, _, and backslash. --- spec/request/access_rules_spec.rb | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/spec/request/access_rules_spec.rb b/spec/request/access_rules_spec.rb index 1d673425c63..de3cff11b9b 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/access_rules_spec.rb @@ -399,12 +399,28 @@ def expected_rule_json(rule) end describe 'filtering by selector_resource_guids' do - it 'does not match unintended rows when guid contains LIKE wildcards' do + it 'escapes % so it does not act as a LIKE wildcard' do get '/v3/access_rules?selector_resource_guids=%25', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - # Should not match all rows via SQL wildcard; % is escaped + expect(parsed['resources'].length).to eq(0) + end + + it 'escapes _ so it does not act as a LIKE single-char wildcard' do + get '/v3/access_rules?selector_resource_guids=cf_app', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + # _ would match any single char (e.g. "cf:app"), but escaped it matches literal "_" + expect(parsed['resources'].length).to eq(0) + end + + it 'escapes backslash so it does not act as a LIKE escape character' do + get '/v3/access_rules?selector_resource_guids=cf%5Capp', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) expect(parsed['resources'].length).to eq(0) end end From 78206b9a9468bd24a1d6b4e0a7776f20d9238bed Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 14:19:50 +0000 Subject: [PATCH 20/64] Fix MySQL key length limit in metadata table migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Annotations table used key VARCHAR(1000) with a unique index on (resource_guid, key), totaling 5020 bytes in utf8mb4 — exceeding MySQL's 3072-byte max key length. Align with codebase convention established in migration 20240102150000: use key_name VARCHAR(63) with a three-column unique index on (resource_guid, key_prefix, key_name). Also add NOT NULL default '' to key_prefix on both labels and annotations tables. --- db/migrations/20260407100001_create_route_access_rules.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/db/migrations/20260407100001_create_route_access_rules.rb b/db/migrations/20260407100001_create_route_access_rules.rb index 531a167f9c9..15137281f2f 100644 --- a/db/migrations/20260407100001_create_route_access_rules.rb +++ b/db/migrations/20260407100001_create_route_access_rules.rb @@ -20,7 +20,7 @@ primary_key :id, name: :id String :guid, null: false, size: 255 String :resource_guid, null: false, size: 255 - String :key_prefix, size: 253 + String :key_prefix, null: false, default: '', size: 253 String :key_name, null: false, size: 63 String :value, null: false, size: 63 DateTime :created_at, null: false @@ -38,15 +38,15 @@ primary_key :id, name: :id String :guid, null: false, size: 255 String :resource_guid, null: false, size: 255 - String :key_prefix, size: 253 - String :key, null: false, size: 1000 + String :key_prefix, null: false, default: '', size: 253 + String :key_name, null: false, size: 63 String :value, size: 5000 DateTime :created_at, null: false DateTime :updated_at index :guid, unique: true, name: :route_access_rule_annotations_guid_index index :resource_guid, name: :route_access_rule_annotations_resource_guid_index - index %i[resource_guid key], unique: true, name: :route_access_rule_annotations_key_index + index %i[resource_guid key_prefix key_name], unique: true, name: :route_access_rule_annotations_key_index foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_annotations_resource_guid end end From b7cbe84a7e108e61086a1d31eca365fd6d28317f Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 14:39:08 +0000 Subject: [PATCH 21/64] Fix routing_info_spec: remove nonexistent name field from RouteAccessRule RouteAccessRule does not have a name column. The test was passing name: to create(), triggering Sequel::MassAssignmentRestriction. --- .../lib/cloud_controller/diego/protocol/routing_info_spec.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb index 91530c805f4..507afe3d489 100644 --- a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb @@ -265,7 +265,6 @@ class Protocol let!(:access_rule1) do RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'allow-app', selector: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) @@ -273,7 +272,6 @@ class Protocol let!(:access_rule2) do RouteAccessRule.create( guid: SecureRandom.uuid, - name: 'allow-space', selector: "cf:space:#{valid_uuid}", route_id: mtls_route.id ) From b8776ae8797ca869336a10e572414c3c6f84d5b5 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 15:09:10 +0000 Subject: [PATCH 22/64] Fix route presenter regression: include options: {} when empty The INTERNAL_ROUTE_OPTIONS filtering change incorrectly suppressed the options key entirely when public_options was empty. The original behavior includes options whenever route.options is not nil, even when the hash is empty. --- app/presenters/v3/route_presenter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/presenters/v3/route_presenter.rb b/app/presenters/v3/route_presenter.rb index e2206c1aa01..47034b44e07 100644 --- a/app/presenters/v3/route_presenter.rb +++ b/app/presenters/v3/route_presenter.rb @@ -50,7 +50,7 @@ def to_hash } unless route.options.nil? public_options = route.options.reject { |k, _| INTERNAL_ROUTE_OPTIONS.include?(k.to_s) } - hash.merge!(options: public_options) unless public_options.empty? + hash.merge!(options: public_options) end @decorators.reduce(hash) { |memo, d| d.decorate(memo, [route]) } From 74793a4b0d6a27bffa9fa83ac7b490b99360f476 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 19:11:45 +0000 Subject: [PATCH 23/64] Fix CI failures: route presenter options logic and domain V2 serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route presenter: preserve original behavior where options: {} is included for routes with empty options, but omit options when all keys are internal-only (access_scope, access_rules). Domain model: remove enforce_access_rules and access_rules_scope from V2 export/import_attributes — these are V3-only fields exposed through the domain presenter, not the legacy V2 API. --- app/models/runtime/domain.rb | 4 ++-- app/presenters/v3/route_presenter.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/runtime/domain.rb b/app/models/runtime/domain.rb index 16b2435aaeb..4ca18ef9b6f 100644 --- a/app/models/runtime/domain.rb +++ b/app/models/runtime/domain.rb @@ -79,8 +79,8 @@ def shared_or_owned_by(organization_ids) one_to_many :labels, class: 'VCAP::CloudController::DomainLabelModel', key: :resource_guid, primary_key: :guid one_to_many :annotations, class: 'VCAP::CloudController::DomainAnnotationModel', key: :resource_guid, primary_key: :guid - export_attributes :name, :owning_organization_guid, :shared_organizations, :enforce_access_rules, :access_rules_scope - import_attributes :name, :owning_organization_guid, :enforce_access_rules, :access_rules_scope + export_attributes :name, :owning_organization_guid, :shared_organizations + import_attributes :name, :owning_organization_guid strip_attributes :name add_association_dependencies labels: :destroy diff --git a/app/presenters/v3/route_presenter.rb b/app/presenters/v3/route_presenter.rb index 47034b44e07..56e0beca383 100644 --- a/app/presenters/v3/route_presenter.rb +++ b/app/presenters/v3/route_presenter.rb @@ -50,7 +50,7 @@ def to_hash } unless route.options.nil? public_options = route.options.reject { |k, _| INTERNAL_ROUTE_OPTIONS.include?(k.to_s) } - hash.merge!(options: public_options) + hash.merge!(options: public_options) if route.options.empty? || public_options.present? end @decorators.reduce(hash) { |memo, d| d.decorate(memo, [route]) } From b704b9de4b67e38dc4ce23c3fda270014227c087 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 08:09:15 +0000 Subject: [PATCH 24/64] =?UTF-8?q?Rebrand:=20access=20rules=20=E2=86=92=20r?= =?UTF-8?q?oute=20policies,=20selector=20=E2=86=92=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete terminology shift for identity-aware routing RFC: - Access Rules → Route Policies (API, models, tables) - Selector → Source (field names, query params) - Domain fields: enforce_access_rules → enforce_route_policies - Domain fields: access_rules_scope → route_policies_scope - Route options: access_scope → route_policy_scope - Route options: access_rules → route_policy_sources Aligns with existing CF 'network policies' terminology and C2C network policy convention (source → destination). Database migrations replaced (no production DBs affected). 67 files changed, 602 insertions(+), 600 deletions(-) --- ..._rule_access.rb => route_policy_access.rb} | 22 +- app/actions/domain_create.rb | 4 +- app/controllers/v3/access_rules_controller.rb | 166 -------------- .../v3/route_policies_controller.rb | 168 +++++++++++++++ ...> include_route_policy_route_decorator.rb} | 12 +- ... include_route_policy_source_decorator.rb} | 18 +- app/messages/access_rules_list_message.rb | 24 --- app/messages/domain_create_message.rb | 20 +- app/messages/route_policies_list_message.rb | 24 +++ ...sage.rb => route_policy_create_message.rb} | 24 +-- ...sage.rb => route_policy_update_message.rb} | 2 +- app/models.rb | 6 +- app/models/runtime/route.rb | 4 +- .../{route_access_rule.rb => route_policy.rb} | 8 +- ...el.rb => route_policy_annotation_model.rb} | 4 +- ...l_model.rb => route_policy_label_model.rb} | 4 +- app/presenters/v3/domain_presenter.rb | 6 +- ...presenter.rb => route_policy_presenter.rb} | 34 +-- app/presenters/v3/route_presenter.rb | 2 +- config/routes.rb | 12 +- ...000_add_enforce_access_rules_to_domains.rb | 15 -- ...0260407100001_create_route_access_rules.rb | 58 ----- ...4_add_enforce_route_policies_to_domains.rb | 15 ++ .../20260421074455_create_route_policies.rb | 58 +++++ .../diego/protocol/routing_info.rb | 12 +- spec/request/apps_spec.rb | 2 +- spec/request/buildpacks_spec.rb | 2 +- spec/request/builds_spec.rb | 2 +- spec/request/deployments_spec.rb | 2 +- spec/request/domains_spec.rb | 2 +- spec/request/droplets_spec.rb | 4 +- spec/request/isolation_segments_spec.rb | 2 +- spec/request/organizations_spec.rb | 6 +- spec/request/packages_spec.rb | 2 +- spec/request/processes_spec.rb | 2 +- spec/request/revisions_spec.rb | 2 +- ...s_rules_spec.rb => route_policies_spec.rb} | 204 +++++++++--------- spec/request/routes_spec.rb | 2 +- spec/request/service_brokers_spec.rb | 2 +- .../service_credential_bindings_spec.rb | 2 +- spec/request/service_instances_spec.rb | 2 +- spec/request/service_offerings_spec.rb | 2 +- spec/request/service_plans_spec.rb | 2 +- spec/request/service_route_bindings_spec.rb | 2 +- spec/request/spaces_spec.rb | 6 +- spec/request/stacks_spec.rb | 2 +- spec/request/tasks_spec.rb | 6 +- spec/request/users_spec.rb | 4 +- .../controllers/v3/apps_controller_spec.rb | 2 +- .../service_broker_list_fetcher_spec.rb | 4 +- .../service_offering_list_fetcher_spec.rb | 2 +- .../service_plan_list_fetcher_spec.rb | 2 +- .../diego/protocol/routing_info_spec.rb | 18 +- .../app_revisions_list_message_spec.rb | 4 +- spec/unit/messages/apps_list_message_spec.rb | 4 +- .../messages/buildpacks_list_message_spec.rb | 4 +- .../messages/domain_create_message_spec.rb | 10 +- .../isolation_segments_list_message_spec.rb | 4 +- spec/unit/messages/list_message_spec.rb | 2 +- .../messages/packages_list_message_spec.rb | 2 +- .../messages/processes_list_message_spec.rb | 2 +- ...rb => route_policies_list_message_spec.rb} | 56 ++--- ...rb => route_policy_create_message_spec.rb} | 42 ++-- spec/unit/messages/tasks_list_message_spec.rb | 2 +- ...cess_rule_spec.rb => route_policy_spec.rb} | 36 ++-- .../presenters/v3/domain_presenter_spec.rb | 8 +- .../presenters/v3/route_presenter_spec.rb | 10 +- 67 files changed, 601 insertions(+), 599 deletions(-) rename app/access/{access_rule_access.rb => route_policy_access.rb} (73%) delete mode 100644 app/controllers/v3/access_rules_controller.rb create mode 100644 app/controllers/v3/route_policies_controller.rb rename app/decorators/{include_access_rule_route_decorator.rb => include_route_policy_route_decorator.rb} (61%) rename app/decorators/{include_access_rule_selector_resource_decorator.rb => include_route_policy_source_decorator.rb} (76%) delete mode 100644 app/messages/access_rules_list_message.rb create mode 100644 app/messages/route_policies_list_message.rb rename app/messages/{access_rule_create_message.rb => route_policy_create_message.rb} (55%) rename app/messages/{access_rule_update_message.rb => route_policy_update_message.rb} (73%) rename app/models/runtime/{route_access_rule.rb => route_policy.rb} (76%) rename app/models/runtime/{route_access_rule_annotation_model.rb => route_policy_annotation_model.rb} (63%) rename app/models/runtime/{route_access_rule_label_model.rb => route_policy_label_model.rb} (62%) rename app/presenters/v3/{access_rule_presenter.rb => route_policy_presenter.rb} (59%) delete mode 100644 db/migrations/20260407100000_add_enforce_access_rules_to_domains.rb delete mode 100644 db/migrations/20260407100001_create_route_access_rules.rb create mode 100644 db/migrations/20260421074454_add_enforce_route_policies_to_domains.rb create mode 100644 db/migrations/20260421074455_create_route_policies.rb rename spec/request/{access_rules_spec.rb => route_policies_spec.rb} (75%) rename spec/unit/messages/{access_rules_list_message_spec.rb => route_policies_list_message_spec.rb} (69%) rename spec/unit/messages/{access_rule_create_message_spec.rb => route_policy_create_message_spec.rb} (81%) rename spec/unit/models/runtime/{route_access_rule_spec.rb => route_policy_spec.rb} (69%) diff --git a/app/access/access_rule_access.rb b/app/access/route_policy_access.rb similarity index 73% rename from app/access/access_rule_access.rb rename to app/access/route_policy_access.rb index db5755e1f57..229e8fdd826 100644 --- a/app/access/access_rule_access.rb +++ b/app/access/route_policy_access.rb @@ -1,12 +1,12 @@ module VCAP::CloudController - class AccessRuleAccess < BaseAccess - # Space Developer of the route's space can manage access rules. + class RoutePolicyAccess < BaseAccess + # Space Developer of the route's space can manage route policies. # No bilateral requirement — destination-controlled auth only. - def create?(access_rule, _params=nil) + def create?(route_policy, _params=nil) return true if admin_user? - route = access_rule.route + route = route_policy.route return false unless route space = route.space @@ -14,21 +14,21 @@ def create?(access_rule, _params=nil) space.developers.include?(context.user) end - def read?(access_rule) + def read?(route_policy) return true if admin_user? || admin_read_only_user? || global_auditor? - route = access_rule.route + route = route_policy.route return false unless route - object_is_visible_to_user?(access_rule, context.user) + object_is_visible_to_user?(route_policy, context.user) end - def update?(access_rule, _params=nil) - create?(access_rule) + def update?(route_policy, _params=nil) + create?(route_policy) end - def delete?(access_rule) - create?(access_rule) + def delete?(route_policy) + create?(route_policy) end def index?(_object_class, _params=nil) diff --git a/app/actions/domain_create.rb b/app/actions/domain_create.rb index 2ebbe778c14..6f1016eb752 100644 --- a/app/actions/domain_create.rb +++ b/app/actions/domain_create.rb @@ -21,8 +21,8 @@ def create(message:, shared_organizations: []) end domain.router_group_guid = message.router_group_guid - domain.enforce_access_rules = message.enforce_access_rules || false - domain.access_rules_scope = message.access_rules_scope + domain.enforce_route_policies = message.enforce_route_policies || false + domain.route_policies_scope = message.route_policies_scope Domain.db.transaction do domain.save diff --git a/app/controllers/v3/access_rules_controller.rb b/app/controllers/v3/access_rules_controller.rb deleted file mode 100644 index 9d93d79a3a8..00000000000 --- a/app/controllers/v3/access_rules_controller.rb +++ /dev/null @@ -1,166 +0,0 @@ -require 'messages/access_rule_create_message' -require 'messages/access_rule_update_message' -require 'messages/access_rules_list_message' -require 'presenters/v3/access_rule_presenter' -require 'decorators/include_access_rule_selector_resource_decorator' -require 'decorators/include_access_rule_route_decorator' - -class AccessRulesController < ApplicationController - def index - message = AccessRulesListMessage.from_params(query_params) - invalid_param!(message.errors.full_messages) unless message.valid? - - dataset = build_dataset(message) - - decorators = [] - decorators << IncludeAccessRuleSelectorResourceDecorator if IncludeAccessRuleSelectorResourceDecorator.match?(message.include) - decorators << IncludeAccessRuleRouteDecorator if IncludeAccessRuleRouteDecorator.match?(message.include) - - render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( - presenter: Presenters::V3::AccessRulePresenter, - paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)), - path: '/v3/access_rules', - message: message, - decorators: decorators - ) - end - - def show - access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid]) - resource_not_found!(:access_rule) unless access_rule - - route = access_rule.route - resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) - - render status: :ok, json: Presenters::V3::AccessRulePresenter.new(access_rule) - end - - def create - message = AccessRuleCreateMessage.new(hashed_params[:body]) - unprocessable!(message.errors.full_messages) unless message.valid? - - route = find_and_authorize_route(message.route_guid) - validate_route_domain(route) - - access_rule = VCAP::CloudController::RouteAccessRule.db.transaction do - # Lock existing access rules for this route to prevent concurrent inserts - # from violating cf:any exclusivity or uniqueness constraints - VCAP::CloudController::RouteAccessRule.where(route_id: route.id).for_update.all - - validate_selector_exclusivity(route, message.selector) - - rule = VCAP::CloudController::RouteAccessRule.new( - guid: SecureRandom.uuid, - selector: message.selector, - route_id: route.id, - created_at: Time.now.utc, - updated_at: Time.now.utc - ) - rule.save - rule - end - - render status: :created, json: Presenters::V3::AccessRulePresenter.new(access_rule) - rescue Sequel::UniqueConstraintViolation - unprocessable!("An access rule with selector '#{message.selector}' already exists for this route.") - end - - def update - access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid]) - resource_not_found!(:access_rule) unless access_rule - - route = access_rule.route - resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) - unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) - suspended! unless permission_queryer.is_space_active?(route.space.id) - - message = AccessRuleUpdateMessage.new(hashed_params[:body]) - unprocessable!(message.errors.full_messages) unless message.valid? - - VCAP::CloudController::MetadataUpdate.update(access_rule, message) - - render status: :ok, json: Presenters::V3::AccessRulePresenter.new(access_rule.reload) - end - - def destroy - access_rule = VCAP::CloudController::RouteAccessRule.find(guid: hashed_params[:guid]) - resource_not_found!(:access_rule) unless access_rule - - route = access_rule.route - resource_not_found!(:access_rule) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) - unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) - suspended! unless permission_queryer.is_space_active?(route.space.id) - - access_rule.destroy - head :no_content - end - - private - - def find_and_authorize_route(route_guid) - route = VCAP::CloudController::Route.find(guid: route_guid) - resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) - unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) - suspended! unless permission_queryer.is_space_active?(route.space.id) - route - end - - def validate_route_domain(route) - if route.domain.internal? - unprocessable!('Cannot create access rules for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.') - end - unprocessable!("Cannot create access rules for route '#{route.guid}': the route's domain does not have enforce_access_rules enabled.") unless route.domain.enforce_access_rules - end - - def validate_selector_exclusivity(route, selector) - existing_selectors = route.access_rules.map(&:selector) - - # Enforce cf:any exclusivity: if route already has a cf:any rule, reject new rules; - # if new rule is cf:any, reject if route already has any rules. - unprocessable!("Cannot add 'cf:any' selector when other access rules already exist for this route.") if selector == 'cf:any' && existing_selectors.any? - unprocessable!("Cannot add selector '#{selector}': route already has a 'cf:any' rule.") if existing_selectors.include?('cf:any') && selector != 'cf:any' - - # Uniqueness: selector must be unique per route - unprocessable!("An access rule with selector '#{selector}' already exists for this route.") if existing_selectors.include?(selector) - end - - def build_dataset(message) - dataset = VCAP::CloudController::RouteAccessRule.dataset - - if permission_queryer.can_read_globally? - readable_route_ids = VCAP::CloudController::Route.select(:id) - else - readable_space_ids = permission_queryer.readable_space_scoped_spaces_query.select(:id) - readable_route_ids = VCAP::CloudController::Route.where(space_id: readable_space_ids).select(:id) - end - - dataset = dataset.where(route_id: readable_route_ids) - - # Join routes at most once when either route_guids or space_guids is requested - if message.requested?(:route_guids) || message.requested?(:space_guids) - dataset = dataset. - join(:routes, id: :route_id). - select_all(:route_access_rules) - - dataset = dataset.where(Sequel[:routes][:guid] => message.route_guids) if message.requested?(:route_guids) - - dataset = dataset.where(Sequel[:routes][:space_id] => VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)) if message.requested?(:space_guids) - end - - dataset = dataset.where(guid: message.guids) if message.requested?(:guids) - dataset = dataset.where(selector: message.selectors) if message.requested?(:selectors) - - if message.requested?(:selector_resource_guids) - # Text-match against selector string for resource GUIDs - # Handles cf:app:, cf:space:, cf:org: - # Escape LIKE metacharacters (\, %, _) in user-provided values - conditions = message.selector_resource_guids.map do |guid| - escaped_guid = guid.gsub('\\', '\\\\').gsub('%', '\\%').gsub('_', '\\_') - Sequel.like(:selector, "%#{escaped_guid}%") - end - dataset = dataset.where(Sequel.|(*conditions)) - end - - dataset - end -end diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb new file mode 100644 index 00000000000..cda9352e5c2 --- /dev/null +++ b/app/controllers/v3/route_policies_controller.rb @@ -0,0 +1,168 @@ +require 'messages/route_policy_create_message' +require 'messages/route_policy_update_message' +require 'messages/route_policies_list_message' +require 'presenters/v3/route_policy_presenter' +require 'decorators/include_route_policy_source_decorator' +require 'decorators/include_route_policy_route_decorator' + +class RoutePoliciesController < ApplicationController + def index + message = RoutePoliciesListMessage.from_params(query_params) + invalid_param!(message.errors.full_messages) unless message.valid? + + dataset = build_dataset(message) + + decorators = [] + decorators << IncludeRoutePolicySourceDecorator if IncludeRoutePolicySourceDecorator.match?(message.include) + decorators << IncludeRoutePolicyRouteDecorator if IncludeRoutePolicyRouteDecorator.match?(message.include) + + render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( + presenter: Presenters::V3::RoutePolicyPresenter, + paginated_result: SequelPaginator.new.get_page(dataset, message.try(:pagination_options)), + path: '/v3/route_policies', + message: message, + decorators: decorators + ) + end + + def show + route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) + resource_not_found!(:route_policy) unless route_policy + + route = route_policy.route + resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + + render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy) + end + + def create + message = RoutePolicyCreateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + route = find_and_authorize_route(message.route_guid) + validate_route_domain(route) + + route_policy = VCAP::CloudController::RoutePolicy.db.transaction do + # Lock existing route policies for this route to prevent concurrent inserts + # from violating cf:any exclusivity or uniqueness constraints + VCAP::CloudController::RoutePolicy.where(route_id: route.id).for_update.all + + validate_source_exclusivity(route, message.source) + + policy = VCAP::CloudController::RoutePolicy.new( + guid: SecureRandom.uuid, + source: message.source, + route_id: route.id, + created_at: Time.now.utc, + updated_at: Time.now.utc + ) + policy.save + policy + end + + render status: :created, json: Presenters::V3::RoutePolicyPresenter.new(route_policy) + rescue Sequel::UniqueConstraintViolation + unprocessable!("A route policy with source '#{message.source}' already exists for this route.") + end + + def update + route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) + resource_not_found!(:route_policy) unless route_policy + + route = route_policy.route + resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) + suspended! unless permission_queryer.is_space_active?(route.space.id) + + message = RoutePolicyUpdateMessage.new(hashed_params[:body]) + unprocessable!(message.errors.full_messages) unless message.valid? + + VCAP::CloudController::MetadataUpdate.update(route_policy, message) + + render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy.reload) + end + + def destroy + route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) + resource_not_found!(:route_policy) unless route_policy + + route = route_policy.route + resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) + suspended! unless permission_queryer.is_space_active?(route.space.id) + + route_policy.destroy + head :no_content + end + + private + + def find_and_authorize_route(route_guid) + route = VCAP::CloudController::Route.find(guid: route_guid) + resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) + suspended! unless permission_queryer.is_space_active?(route.space.id) + route + end + + def validate_route_domain(route) + if route.domain.internal? + unprocessable!('Cannot create route policies for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.') + end + return if route.domain.enforce_route_policies + + unprocessable!("Cannot create route policies for route '#{route.guid}': the route's domain does not have enforce_route_policies enabled.") + end + + def validate_source_exclusivity(route, source) + existing_sources = route.route_policies.map(&:source) + + # Enforce cf:any exclusivity: if route already has a cf:any policy, reject new policies; + # if new policy is cf:any, reject if route already has any policies. + unprocessable!("Cannot add 'cf:any' source when other route policies already exist for this route.") if source == 'cf:any' && existing_sources.any? + unprocessable!("Cannot add source '#{source}': route already has a 'cf:any' policy.") if existing_sources.include?('cf:any') && source != 'cf:any' + + # Uniqueness: source must be unique per route + unprocessable!("A route policy with source '#{source}' already exists for this route.") if existing_sources.include?(source) + end + + def build_dataset(message) + dataset = VCAP::CloudController::RoutePolicy.dataset + + if permission_queryer.can_read_globally? + readable_route_ids = VCAP::CloudController::Route.select(:id) + else + readable_space_ids = permission_queryer.readable_space_scoped_spaces_query.select(:id) + readable_route_ids = VCAP::CloudController::Route.where(space_id: readable_space_ids).select(:id) + end + + dataset = dataset.where(route_id: readable_route_ids) + + # Join routes at most once when either route_guids or space_guids is requested + if message.requested?(:route_guids) || message.requested?(:space_guids) + dataset = dataset. + join(:routes, id: :route_id). + select_all(:route_policies) + + dataset = dataset.where(Sequel[:routes][:guid] => message.route_guids) if message.requested?(:route_guids) + + dataset = dataset.where(Sequel[:routes][:space_id] => VCAP::CloudController::Space.where(guid: message.space_guids).select(:id)) if message.requested?(:space_guids) + end + + dataset = dataset.where(guid: message.guids) if message.requested?(:guids) + dataset = dataset.where(source: message.sources) if message.requested?(:sources) + + if message.requested?(:source_guids) + # Text-match against source string for resource GUIDs + # Handles cf:app:, cf:space:, cf:org: + # Escape LIKE metacharacters (\, %, _) in user-provided values + conditions = message.source_guids.map do |guid| + escaped_guid = guid.gsub('\\', '\\\\').gsub('%', '\\%').gsub('_', '\\_') + Sequel.like(:source, "%#{escaped_guid}%") + end + dataset = dataset.where(Sequel.|(*conditions)) + end + + dataset + end +end diff --git a/app/decorators/include_access_rule_route_decorator.rb b/app/decorators/include_route_policy_route_decorator.rb similarity index 61% rename from app/decorators/include_access_rule_route_decorator.rb rename to app/decorators/include_route_policy_route_decorator.rb index 45ea0baca57..dbc4e1ea04f 100644 --- a/app/decorators/include_access_rule_route_decorator.rb +++ b/app/decorators/include_route_policy_route_decorator.rb @@ -1,17 +1,17 @@ module VCAP::CloudController - class IncludeAccessRuleRouteDecorator - # Handles `?include=route` for GET /v3/access_rules - # Includes the route resources associated with the access rules + class IncludeRoutePolicyRouteDecorator + # Handles `?include=route` for GET /v3/route_policies + # Includes the route resources associated with the route policies def self.match?(include_params) include_params&.include?('route') end - def self.decorate(hash, access_rules) + def self.decorate(hash, route_policies) hash[:included] ||= {} - # Collect all unique route IDs from access rules - route_ids = access_rules.map(&:route_id).uniq + # Collect all unique route IDs from route policies + route_ids = route_policies.map(&:route_id).uniq # Fetch routes with their associations routes = Route.where(id: route_ids). diff --git a/app/decorators/include_access_rule_selector_resource_decorator.rb b/app/decorators/include_route_policy_source_decorator.rb similarity index 76% rename from app/decorators/include_access_rule_selector_resource_decorator.rb rename to app/decorators/include_route_policy_source_decorator.rb index 19b05314177..271e769ac27 100644 --- a/app/decorators/include_access_rule_selector_resource_decorator.rb +++ b/app/decorators/include_route_policy_source_decorator.rb @@ -1,18 +1,18 @@ module VCAP::CloudController - class IncludeAccessRuleSelectorResourceDecorator - # Handles `?include=selector_resource` for GET /v3/access_rules - # Stale/missing resources (selector GUIDs that no longer exist) are silently absent. + class IncludeRoutePolicySourceDecorator + # Handles `?include=source` for GET /v3/route_policies + # Stale/missing resources (source GUIDs that no longer exist) are silently absent. - SELECTOR_REGEX = /\Acf:(app|space|org):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\z/ + SOURCE_REGEX = /\Acf:(app|space|org):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\z/ def self.match?(include_params) return false unless include_params - # Match if any of: selector_resource, app, space, organization - include_params.intersect?(%w[selector_resource app space organization]) + # Match if any of: source, app, space, organization + include_params.intersect?(%w[source app space organization]) end - def self.decorate(hash, access_rules) + def self.decorate(hash, route_policies) hash[:included] ||= {} # Collect all GUIDs by type @@ -20,8 +20,8 @@ def self.decorate(hash, access_rules) space_guids = [] org_guids = [] - access_rules.each do |rule| - match = SELECTOR_REGEX.match(rule.selector) + route_policies.each do |policy| + match = SOURCE_REGEX.match(policy.source) next unless match resource_type = match[1] diff --git a/app/messages/access_rules_list_message.rb b/app/messages/access_rules_list_message.rb deleted file mode 100644 index 564d9680e00..00000000000 --- a/app/messages/access_rules_list_message.rb +++ /dev/null @@ -1,24 +0,0 @@ -require 'messages/list_message' - -module VCAP::CloudController - class AccessRulesListMessage < ListMessage - register_allowed_keys %i[ - guids - route_guids - space_guids - selectors - selector_resource_guids - include - ] - - validates_with NoAdditionalParamsValidator - validates_with IncludeParamValidator, valid_values: %w[selector_resource route app space organization] - - validates :space_guids, array: true, allow_nil: true - validates :selector_resource_guids, array: true, allow_nil: true - - def self.from_params(params) - super(params, %w[route_guids space_guids selectors selector_resource_guids include]) - end - end -end diff --git a/app/messages/domain_create_message.rb b/app/messages/domain_create_message.rb index 55f5976ee5f..4456c13c1b8 100644 --- a/app/messages/domain_create_message.rb +++ b/app/messages/domain_create_message.rb @@ -16,8 +16,8 @@ class DomainCreateMessage < MetadataBaseMessage internal relationships router_group - enforce_access_rules - access_rules_scope + enforce_route_policies + route_policies_scope ] def self.relationships_requested? @@ -61,11 +61,11 @@ def self.relationships_requested? allow_nil: true, boolean: true - validates :enforce_access_rules, + validates :enforce_route_policies, allow_nil: true, boolean: true - validate :access_rules_scope_validation + validate :route_policies_scope_validation delegate :organization_guid, to: :relationships_message delegate :shared_organizations_guids, to: :relationships_message @@ -105,15 +105,15 @@ def router_group_validation errors.add(:router_group, 'guid must be a string') unless router_group_guid.is_a?(String) end - def access_rules_scope_validation - if requested?(:access_rules_scope) && !(access_rules_scope.nil? || %w[any org space].include?(access_rules_scope)) - errors.add(:access_rules_scope, "must be one of 'any', 'org', 'space'") + def route_policies_scope_validation + if requested?(:route_policies_scope) && !(route_policies_scope.nil? || %w[any org space].include?(route_policies_scope)) + errors.add(:route_policies_scope, "must be one of 'any', 'org', 'space'") end - return unless requested?(:enforce_access_rules) && enforce_access_rules == true - return unless !requested?(:access_rules_scope) || access_rules_scope.nil? + return unless requested?(:enforce_route_policies) && enforce_route_policies == true + return unless !requested?(:route_policies_scope) || route_policies_scope.nil? - errors.add(:access_rules_scope, 'is required when enforce_access_rules is true') + errors.add(:route_policies_scope, 'is required when enforce_route_policies is true') end class Relationships < BaseMessage diff --git a/app/messages/route_policies_list_message.rb b/app/messages/route_policies_list_message.rb new file mode 100644 index 00000000000..bd3ec945aa1 --- /dev/null +++ b/app/messages/route_policies_list_message.rb @@ -0,0 +1,24 @@ +require 'messages/list_message' + +module VCAP::CloudController + class RoutePoliciesListMessage < ListMessage + register_allowed_keys %i[ + guids + route_guids + space_guids + sources + source_guids + include + ] + + validates_with NoAdditionalParamsValidator + validates_with IncludeParamValidator, valid_values: %w[source route app space organization] + + validates :space_guids, array: true, allow_nil: true + validates :source_guids, array: true, allow_nil: true + + def self.from_params(params) + super(params, %w[route_guids space_guids sources source_guids include]) + end + end +end diff --git a/app/messages/access_rule_create_message.rb b/app/messages/route_policy_create_message.rb similarity index 55% rename from app/messages/access_rule_create_message.rb rename to app/messages/route_policy_create_message.rb index d615e0a1029..5ec09bd8914 100644 --- a/app/messages/access_rule_create_message.rb +++ b/app/messages/route_policy_create_message.rb @@ -1,21 +1,21 @@ require 'messages/metadata_base_message' module VCAP::CloudController - class AccessRuleCreateMessage < MetadataBaseMessage - SELECTOR_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/ + class RoutePolicyCreateMessage < MetadataBaseMessage + SOURCE_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/ register_allowed_keys %i[ - selector + source relationships ] validates_with NoAdditionalKeysValidator validates_with RelationshipValidator - validates :selector, presence: true, string: true + validates :source, presence: true, string: true - validate :selector_format_valid - validate :selector_not_cf_any_with_others + validate :source_format_valid + validate :source_not_cf_any_with_others delegate :route_guid, to: :relationships_message @@ -25,15 +25,15 @@ def relationships_message private - def selector_format_valid - return unless selector.is_a?(String) - return if SELECTOR_REGEX.match?(selector) + def source_format_valid + return unless source.is_a?(String) + return if SOURCE_REGEX.match?(source) - errors.add(:selector, "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'") + errors.add(:source, "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'") end - def selector_not_cf_any_with_others - # enforced at the controller level when checking existing rules on the route + def source_not_cf_any_with_others + # enforced at the controller level when checking existing policies on the route end class Relationships < BaseMessage diff --git a/app/messages/access_rule_update_message.rb b/app/messages/route_policy_update_message.rb similarity index 73% rename from app/messages/access_rule_update_message.rb rename to app/messages/route_policy_update_message.rb index b9adcf62a4a..998a59f2700 100644 --- a/app/messages/access_rule_update_message.rb +++ b/app/messages/route_policy_update_message.rb @@ -1,7 +1,7 @@ require 'messages/metadata_base_message' module VCAP::CloudController - class AccessRuleUpdateMessage < MetadataBaseMessage + class RoutePolicyUpdateMessage < MetadataBaseMessage register_allowed_keys [] validates_with NoAdditionalKeysValidator diff --git a/app/models.rb b/app/models.rb index 3e1d4e02106..d7db6b83970 100644 --- a/app/models.rb +++ b/app/models.rb @@ -68,9 +68,9 @@ require 'models/runtime/revision_sidecar_model' require 'models/runtime/revision_sidecar_process_type_model' require 'models/runtime/route' -require 'models/runtime/route_access_rule' -require 'models/runtime/route_access_rule_label_model' -require 'models/runtime/route_access_rule_annotation_model' +require 'models/runtime/route_policy' +require 'models/runtime/route_policy_label_model' +require 'models/runtime/route_policy_annotation_model' require 'models/runtime/space_routes' require 'models/runtime/space_quota_definition' require 'models/runtime/stack' diff --git a/app/models/runtime/route.rb b/app/models/runtime/route.rb index 84032473a23..76a5b189f07 100644 --- a/app/models/runtime/route.rb +++ b/app/models/runtime/route.rb @@ -39,8 +39,8 @@ class InvalidOrganizationRelation < CloudController::Errors::InvalidRelation; en add_association_dependencies route_mappings: :destroy - one_to_many :access_rules, class: 'VCAP::CloudController::RouteAccessRule', key: :route_id, primary_key: :id - add_association_dependencies access_rules: :destroy + one_to_many :route_policies, class: 'VCAP::CloudController::RoutePolicy', key: :route_id, primary_key: :id + add_association_dependencies route_policies: :destroy export_attributes :host, :path, :domain_guid, :space_guid, :service_instance_guid, :port, :options import_attributes :host, :path, :domain_guid, :space_guid, :app_guids, :port, :options diff --git a/app/models/runtime/route_access_rule.rb b/app/models/runtime/route_policy.rb similarity index 76% rename from app/models/runtime/route_access_rule.rb rename to app/models/runtime/route_policy.rb index 17d4a060b07..6b74fca0642 100644 --- a/app/models/runtime/route_access_rule.rb +++ b/app/models/runtime/route_policy.rb @@ -1,19 +1,19 @@ module VCAP::CloudController - class RouteAccessRule < Sequel::Model(:route_access_rules) + class RoutePolicy < Sequel::Model(:route_policies) many_to_one :route, class: 'VCAP::CloudController::Route', key: :route_id, primary_key: :id, without_guid_generation: true - one_to_many :labels, class: 'VCAP::CloudController::RouteAccessRuleLabelModel', key: :resource_guid, primary_key: :guid - one_to_many :annotations, class: 'VCAP::CloudController::RouteAccessRuleAnnotationModel', key: :resource_guid, primary_key: :guid + one_to_many :labels, class: 'VCAP::CloudController::RoutePolicyLabelModel', key: :resource_guid, primary_key: :guid + one_to_many :annotations, class: 'VCAP::CloudController::RoutePolicyAnnotationModel', key: :resource_guid, primary_key: :guid add_association_dependencies labels: :destroy add_association_dependencies annotations: :destroy def validate - validates_presence :selector + validates_presence :source validates_presence :route_id end diff --git a/app/models/runtime/route_access_rule_annotation_model.rb b/app/models/runtime/route_policy_annotation_model.rb similarity index 63% rename from app/models/runtime/route_access_rule_annotation_model.rb rename to app/models/runtime/route_policy_annotation_model.rb index a0962184156..ab2c7994486 100644 --- a/app/models/runtime/route_access_rule_annotation_model.rb +++ b/app/models/runtime/route_policy_annotation_model.rb @@ -1,7 +1,7 @@ module VCAP::CloudController - class RouteAccessRuleAnnotationModel < Sequel::Model(:route_access_rule_annotations) + class RoutePolicyAnnotationModel < Sequel::Model(:route_policy_annotations) set_primary_key :id - many_to_one :route_access_rule, + many_to_one :route_policy, primary_key: :guid, key: :resource_guid, without_guid_generation: true diff --git a/app/models/runtime/route_access_rule_label_model.rb b/app/models/runtime/route_policy_label_model.rb similarity index 62% rename from app/models/runtime/route_access_rule_label_model.rb rename to app/models/runtime/route_policy_label_model.rb index 47737f5381a..d56775cee34 100644 --- a/app/models/runtime/route_access_rule_label_model.rb +++ b/app/models/runtime/route_policy_label_model.rb @@ -1,6 +1,6 @@ module VCAP::CloudController - class RouteAccessRuleLabelModel < Sequel::Model(:route_access_rule_labels) - many_to_one :route_access_rule, + class RoutePolicyLabelModel < Sequel::Model(:route_policy_labels) + many_to_one :route_policy, primary_key: :guid, key: :resource_guid, without_guid_generation: true diff --git a/app/presenters/v3/domain_presenter.rb b/app/presenters/v3/domain_presenter.rb index 0e3fa510d98..4b4900a6660 100644 --- a/app/presenters/v3/domain_presenter.rb +++ b/app/presenters/v3/domain_presenter.rb @@ -43,9 +43,9 @@ def to_hash links: build_links } - if domain.enforce_access_rules - hash[:enforce_access_rules] = true - hash[:access_rules_scope] = domain.access_rules_scope + if domain.enforce_route_policies + hash[:enforce_route_policies] = true + hash[:route_policies_scope] = domain.route_policies_scope end hash diff --git a/app/presenters/v3/access_rule_presenter.rb b/app/presenters/v3/route_policy_presenter.rb similarity index 59% rename from app/presenters/v3/access_rule_presenter.rb rename to app/presenters/v3/route_policy_presenter.rb index f016100d7dc..81904f61bba 100644 --- a/app/presenters/v3/access_rule_presenter.rb +++ b/app/presenters/v3/route_policy_presenter.rb @@ -4,18 +4,18 @@ module VCAP::CloudController module Presenters module V3 - class AccessRulePresenter < BasePresenter + class RoutePolicyPresenter < BasePresenter include VCAP::CloudController::Presenters::Mixins::MetadataPresentationHelpers def to_hash { - guid: access_rule.guid, - created_at: access_rule.created_at, - updated_at: access_rule.updated_at, - selector: access_rule.selector, + guid: route_policy.guid, + created_at: route_policy.created_at, + updated_at: route_policy.updated_at, + source: route_policy.source, metadata: { - labels: hashified_labels(access_rule.labels), - annotations: hashified_annotations(access_rule.annotations) + labels: hashified_labels(route_policy.labels), + annotations: hashified_annotations(route_policy.annotations) }, relationships: build_relationships, links: build_links @@ -24,7 +24,7 @@ def to_hash private - def access_rule + def route_policy @resource end @@ -32,18 +32,18 @@ def build_relationships relationships = { route: { data: { - guid: access_rule.route.guid + guid: route_policy.route.guid } } } - # Extract resource GUID from selector and populate read-only relationships + # Extract resource GUID from source and populate read-only relationships # The guid is included as-is without per-row existence checks to avoid N+1 queries. - # Use ?include=selector_resource to get full resource details with batch loading. - selector_match = access_rule.selector.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) - if selector_match - resource_type = selector_match[1] - resource_guid = selector_match[2] + # Use ?include=source to get full resource details with batch loading. + source_match = route_policy.source.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) + if source_match + resource_type = source_match[1] + resource_guid = source_match[2] relationships[:app] = { data: resource_type == 'app' ? { guid: resource_guid } : nil } relationships[:space] = { data: resource_type == 'space' ? { guid: resource_guid } : nil } @@ -61,10 +61,10 @@ def build_relationships def build_links { self: { - href: url_builder.build_url(path: "/v3/access_rules/#{access_rule.guid}") + href: url_builder.build_url(path: "/v3/route_policies/#{route_policy.guid}") }, route: { - href: url_builder.build_url(path: "/v3/routes/#{access_rule.route.guid}") + href: url_builder.build_url(path: "/v3/routes/#{route_policy.route.guid}") } } end diff --git a/app/presenters/v3/route_presenter.rb b/app/presenters/v3/route_presenter.rb index 56e0beca383..f9bac40fdba 100644 --- a/app/presenters/v3/route_presenter.rb +++ b/app/presenters/v3/route_presenter.rb @@ -56,7 +56,7 @@ def to_hash @decorators.reduce(hash) { |memo, d| d.decorate(memo, [route]) } end - INTERNAL_ROUTE_OPTIONS = %w[access_scope access_rules].freeze + INTERNAL_ROUTE_OPTIONS = %w[route_policy_scope route_policy_sources].freeze private diff --git a/config/routes.rb b/config/routes.rb index e6822b973a6..28526281d86 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -338,12 +338,12 @@ post '/roles', to: 'roles#create' delete '/roles/:guid', to: 'roles#destroy' - # access_rules - get '/access_rules', to: 'access_rules#index' - get '/access_rules/:guid', to: 'access_rules#show' - post '/access_rules', to: 'access_rules#create' - patch '/access_rules/:guid', to: 'access_rules#update' - delete '/access_rules/:guid', to: 'access_rules#destroy' + # route_policies + get '/route_policies', to: 'route_policies#index' + get '/route_policies/:guid', to: 'route_policies#show' + post '/route_policies', to: 'route_policies#create' + patch '/route_policies/:guid', to: 'route_policies#update' + delete '/route_policies/:guid', to: 'route_policies#destroy' # info get '/info', to: 'info#v3_info' diff --git a/db/migrations/20260407100000_add_enforce_access_rules_to_domains.rb b/db/migrations/20260407100000_add_enforce_access_rules_to_domains.rb deleted file mode 100644 index 5f2df5e415b..00000000000 --- a/db/migrations/20260407100000_add_enforce_access_rules_to_domains.rb +++ /dev/null @@ -1,15 +0,0 @@ -Sequel.migration do - up do - alter_table :domains do - add_column :enforce_access_rules, :boolean, default: false, null: false unless @db.schema(:domains).map(&:first).include?(:enforce_access_rules) - add_column :access_rules_scope, String, null: true, size: 255 unless @db.schema(:domains).map(&:first).include?(:access_rules_scope) - end - end - - down do - alter_table :domains do - drop_column :enforce_access_rules if @db.schema(:domains).map(&:first).include?(:enforce_access_rules) - drop_column :access_rules_scope if @db.schema(:domains).map(&:first).include?(:access_rules_scope) - end - end -end diff --git a/db/migrations/20260407100001_create_route_access_rules.rb b/db/migrations/20260407100001_create_route_access_rules.rb deleted file mode 100644 index 15137281f2f..00000000000 --- a/db/migrations/20260407100001_create_route_access_rules.rb +++ /dev/null @@ -1,58 +0,0 @@ -Sequel.migration do - up do - unless table_exists?(:route_access_rules) - create_table :route_access_rules do - primary_key :id, name: :id - String :guid, size: 255, null: false - String :selector, size: 255, null: false - Integer :route_id, null: false - DateTime :created_at, null: false - DateTime :updated_at, null: false - - index :guid, unique: true, name: :route_access_rules_guid_index - index %i[route_id selector], unique: true, name: :route_access_rules_route_id_selector_index - foreign_key [:route_id], :routes, on_delete: :cascade, name: :fk_route_access_rules_route_id - end - end - - unless table_exists?(:route_access_rule_labels) - create_table :route_access_rule_labels do - primary_key :id, name: :id - String :guid, null: false, size: 255 - String :resource_guid, null: false, size: 255 - String :key_prefix, null: false, default: '', size: 253 - String :key_name, null: false, size: 63 - String :value, null: false, size: 63 - DateTime :created_at, null: false - DateTime :updated_at - - index :guid, unique: true, name: :route_access_rule_labels_guid_index - index :resource_guid, name: :route_access_rule_labels_resource_guid_index - index %i[resource_guid key_prefix key_name], unique: true, name: :route_access_rule_labels_compound_index - foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_labels_resource_guid - end - end - - unless table_exists?(:route_access_rule_annotations) - create_table :route_access_rule_annotations do - primary_key :id, name: :id - String :guid, null: false, size: 255 - String :resource_guid, null: false, size: 255 - String :key_prefix, null: false, default: '', size: 253 - String :key_name, null: false, size: 63 - String :value, size: 5000 - DateTime :created_at, null: false - DateTime :updated_at - - index :guid, unique: true, name: :route_access_rule_annotations_guid_index - index :resource_guid, name: :route_access_rule_annotations_resource_guid_index - index %i[resource_guid key_prefix key_name], unique: true, name: :route_access_rule_annotations_key_index - foreign_key [:resource_guid], :route_access_rules, key: :guid, on_delete: :cascade, name: :fk_route_access_rule_annotations_resource_guid - end - end - end - - down do - %i[route_access_rule_annotations route_access_rule_labels route_access_rules].each { |t| drop_table(t) if table_exists?(t) } - end -end diff --git a/db/migrations/20260421074454_add_enforce_route_policies_to_domains.rb b/db/migrations/20260421074454_add_enforce_route_policies_to_domains.rb new file mode 100644 index 00000000000..dd5518a95c6 --- /dev/null +++ b/db/migrations/20260421074454_add_enforce_route_policies_to_domains.rb @@ -0,0 +1,15 @@ +Sequel.migration do + up do + alter_table :domains do + add_column :enforce_route_policies, :boolean, default: false, null: false unless @db.schema(:domains).map(&:first).include?(:enforce_route_policies) + add_column :route_policies_scope, String, null: true, size: 255 unless @db.schema(:domains).map(&:first).include?(:route_policies_scope) + end + end + + down do + alter_table :domains do + drop_column :enforce_route_policies if @db.schema(:domains).map(&:first).include?(:enforce_route_policies) + drop_column :route_policies_scope if @db.schema(:domains).map(&:first).include?(:route_policies_scope) + end + end +end diff --git a/db/migrations/20260421074455_create_route_policies.rb b/db/migrations/20260421074455_create_route_policies.rb new file mode 100644 index 00000000000..5e7c794c6ad --- /dev/null +++ b/db/migrations/20260421074455_create_route_policies.rb @@ -0,0 +1,58 @@ +Sequel.migration do + up do + unless table_exists?(:route_policies) + create_table :route_policies do + primary_key :id, name: :id + String :guid, size: 255, null: false + String :source, size: 255, null: false + Integer :route_id, null: false + DateTime :created_at, null: false + DateTime :updated_at, null: false + + index :guid, unique: true, name: :route_policies_guid_index + index %i[route_id source], unique: true, name: :route_policies_route_id_source_index + foreign_key [:route_id], :routes, on_delete: :cascade, name: :fk_route_policies_route_id + end + end + + unless table_exists?(:route_policy_labels) + create_table :route_policy_labels do + primary_key :id, name: :id + String :guid, null: false, size: 255 + String :resource_guid, null: false, size: 255 + String :key_prefix, null: false, default: '', size: 253 + String :key_name, null: false, size: 63 + String :value, null: false, size: 63 + DateTime :created_at, null: false + DateTime :updated_at + + index :guid, unique: true, name: :route_policy_labels_guid_index + index :resource_guid, name: :route_policy_labels_resource_guid_index + index %i[resource_guid key_prefix key_name], unique: true, name: :route_policy_labels_compound_index + foreign_key [:resource_guid], :route_policies, key: :guid, on_delete: :cascade, name: :fk_route_policy_labels_resource_guid + end + end + + unless table_exists?(:route_policy_annotations) + create_table :route_policy_annotations do + primary_key :id, name: :id + String :guid, null: false, size: 255 + String :resource_guid, null: false, size: 255 + String :key_prefix, null: false, default: '', size: 253 + String :key_name, null: false, size: 63 + String :value, size: 5000 + DateTime :created_at, null: false + DateTime :updated_at + + index :guid, unique: true, name: :route_policy_annotations_guid_index + index :resource_guid, name: :route_policy_annotations_resource_guid_index + index %i[resource_guid key_prefix key_name], unique: true, name: :route_policy_annotations_key_index + foreign_key [:resource_guid], :route_policies, key: :guid, on_delete: :cascade, name: :fk_route_policy_annotations_resource_guid + end + end + end + + down do + %i[route_policy_annotations route_policy_labels route_policies].each { |t| drop_table(t) if table_exists?(t) } + end +end diff --git a/lib/cloud_controller/diego/protocol/routing_info.rb b/lib/cloud_controller/diego/protocol/routing_info.rb index e73f7adb914..c23e33a7041 100644 --- a/lib/cloud_controller/diego/protocol/routing_info.rb +++ b/lib/cloud_controller/diego/protocol/routing_info.rb @@ -9,7 +9,7 @@ def initialize(process) end def routing_info - process_eager = ProcessModel.eager(route_mappings: { route: %i[domain route_binding access_rules] }).where(id: process.id).all + process_eager = ProcessModel.eager(route_mappings: { route: %i[domain route_binding route_policies] }).where(id: process.id).all return {} if process_eager.empty? @@ -50,18 +50,18 @@ def build_http_route_info(route_mapping) info['protocol'] = route_mapping.protocol info['options'] = r.options if r.options - add_mtls_options(info, r) if r.domain.enforce_access_rules + add_mtls_options(info, r) if r.domain.enforce_route_policies info end def add_mtls_options(info, route) - # Inject mTLS access control options for enforce_access_rules domains. + # Inject mTLS policy options for enforce_route_policies domains. # These are GoRouter-internal keys and are filtered from the /v3/routes API. mtls_options = info['options']&.dup || {} - mtls_options['access_scope'] = route.domain.access_rules_scope if route.domain.access_rules_scope - selectors = route.access_rules.map(&:selector) - mtls_options['access_rules'] = selectors.join(',') unless selectors.empty? + mtls_options['route_policy_scope'] = route.domain.route_policies_scope if route.domain.route_policies_scope + sources = route.route_policies.map(&:source) + mtls_options['route_policy_sources'] = sources.join(',') unless sources.empty? info['options'] = mtls_options end diff --git a/spec/request/apps_spec.rb b/spec/request/apps_spec.rb index 7e90a7d013c..0519a73b729 100644 --- a/spec/request/apps_spec.rb +++ b/spec/request/apps_spec.rb @@ -607,7 +607,7 @@ stacks: 'cf', include: 'space', lifecycle_type: 'buildpack', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/buildpacks_spec.rb b/spec/request/buildpacks_spec.rb index 81b07353d88..92ca4ddab84 100644 --- a/spec/request/buildpacks_spec.rb +++ b/spec/request/buildpacks_spec.rb @@ -29,7 +29,7 @@ names: 'foo', stacks: 'cf', lifecycle: 'buildpack', - label_selector: 'foo,bar', + label_source: 'foo,bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/builds_spec.rb b/spec/request/builds_spec.rb index 89b3fa2bc70..a8acecc4ec7 100644 --- a/spec/request/builds_spec.rb +++ b/spec/request/builds_spec.rb @@ -410,7 +410,7 @@ guids: '123', app_guids: '123', package_guids: '123', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/deployments_spec.rb b/spec/request/deployments_spec.rb index 88db57c603f..159fd9844d2 100644 --- a/spec/request/deployments_spec.rb +++ b/spec/request/deployments_spec.rb @@ -2077,7 +2077,7 @@ def json_for_options(deployment) status_values: 'foo', status_reasons: 'foo', app_guids: '123', - label_selector: 'bar', + label_source: 'bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/domains_spec.rb b/spec/request/domains_spec.rb index 024b7792086..f0969eaaa2c 100644 --- a/spec/request/domains_spec.rb +++ b/spec/request/domains_spec.rb @@ -31,7 +31,7 @@ names: 'foo,bar', guids: 'foo,bar', organization_guids: 'foo,bar', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/droplets_spec.rb b/spec/request/droplets_spec.rb index adb72532112..362307cbc0a 100644 --- a/spec/request/droplets_spec.rb +++ b/spec/request/droplets_spec.rb @@ -646,7 +646,7 @@ space_guids: 'test', states: %w[test foo], organization_guids: 'foo,bar', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -674,7 +674,7 @@ current: true, package_guid: package_model.guid, states: %w[test foo], - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/isolation_segments_spec.rb b/spec/request/isolation_segments_spec.rb index c362272f507..fcdf51f34c3 100644 --- a/spec/request/isolation_segments_spec.rb +++ b/spec/request/isolation_segments_spec.rb @@ -269,7 +269,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/organizations_spec.rb b/spec/request/organizations_spec.rb index 22b2f35624e..f01f355a5a7 100644 --- a/spec/request/organizations_spec.rb +++ b/spec/request/organizations_spec.rb @@ -227,7 +227,7 @@ module VCAP::CloudController page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -1548,7 +1548,7 @@ module VCAP::CloudController page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -1574,7 +1574,7 @@ module VCAP::CloudController page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/packages_spec.rb b/spec/request/packages_spec.rb index 81a05c313fd..654f0adb13f 100644 --- a/spec/request/packages_spec.rb +++ b/spec/request/packages_spec.rb @@ -481,7 +481,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/processes_spec.rb b/spec/request/processes_spec.rb index fb2cf1bf20e..105a835a91b 100644 --- a/spec/request/processes_spec.rb +++ b/spec/request/processes_spec.rb @@ -121,7 +121,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/revisions_spec.rb b/spec/request/revisions_spec.rb index 58b9f7e5c85..c09a830eb0c 100644 --- a/spec/request/revisions_spec.rb +++ b/spec/request/revisions_spec.rb @@ -185,7 +185,7 @@ order_by: 'updated_at', guids: app_model.guid.to_s, versions: '1,2', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/access_rules_spec.rb b/spec/request/route_policies_spec.rb similarity index 75% rename from spec/request/access_rules_spec.rb rename to spec/request/route_policies_spec.rb index de3cff11b9b..4582c0ba13c 100644 --- a/spec/request/access_rules_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -RSpec.describe 'Access Rules' do +RSpec.describe 'Route Policies' do let(:user) { VCAP::CloudController::User.make } let(:admin_header) { admin_headers_for(user) } let(:org) { VCAP::CloudController::Organization.make } @@ -34,12 +34,12 @@ def expected_rule_json(rule) guid: rule.guid, created_at: iso8601, updated_at: iso8601, - selector: rule.selector, + source: rule.source, relationships: { route: { data: { guid: rule.route.guid } } }, links: { - self: { href: %r{/v3/access_rules/#{rule.guid}} }, + self: { href: %r{/v3/route_policies/#{rule.guid}} }, route: { href: %r{/v3/routes/#{rule.route.guid}} } } } @@ -51,10 +51,10 @@ def expected_rule_json(rule) space.add_developer(user) end - describe 'POST /v3/access_rules' do + describe 'POST /v3/route_policies' do let(:request_body) do { - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: mtls_route.guid } } } @@ -63,11 +63,11 @@ def expected_rule_json(rule) context 'as admin' do it 'creates an access rule and returns 201' do - post '/v3/access_rules', request_body.to_json, admin_header + post '/v3/route_policies', request_body.to_json, admin_header expect(last_response.status).to eq(201) parsed = Oj.load(last_response.body) - expect(parsed['selector']).to eq("cf:app:#{valid_uuid}") + expect(parsed['source']).to eq("cf:app:#{valid_uuid}") expect(parsed['relationships']['route']['data']['guid']).to eq(mtls_route.guid) end end @@ -76,7 +76,7 @@ def expected_rule_json(rule) let(:user_headers) { headers_for(user) } it 'creates an access rule' do - post '/v3/access_rules', request_body.to_json, user_headers + post '/v3/route_policies', request_body.to_json, user_headers expect(last_response.status).to eq(201) end @@ -85,7 +85,7 @@ def expected_rule_json(rule) context 'when the domain does not have enforce_access_rules enabled' do let(:request_body) do { - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: regular_route.guid } } } @@ -93,17 +93,17 @@ def expected_rule_json(rule) end it 'returns 422' do - post '/v3/access_rules', request_body.to_json, admin_header + post '/v3/route_policies', request_body.to_json, admin_header expect(last_response.status).to eq(422) - expect(last_response.body).to include('enforce_access_rules') + expect(last_response.body).to include('enforce_route_policies') end end context 'when the route is on an internal domain' do let(:request_body) do { - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: internal_route.guid } } } @@ -111,7 +111,7 @@ def expected_rule_json(rule) end it 'returns 422 with a message about internal domains' do - post '/v3/access_rules', request_body.to_json, admin_header + post '/v3/route_policies', request_body.to_json, admin_header expect(last_response.status).to eq(422) expect(last_response.body).to include('internal domains') @@ -122,7 +122,7 @@ def expected_rule_json(rule) context 'when the route does not exist' do let(:request_body) do { - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: 'nonexistent-guid' } } } @@ -130,7 +130,7 @@ def expected_rule_json(rule) end it 'returns 404' do - post '/v3/access_rules', request_body.to_json, admin_header + post '/v3/route_policies', request_body.to_json, admin_header expect(last_response.status).to eq(404) end @@ -138,16 +138,16 @@ def expected_rule_json(rule) context 'cf:any exclusivity' do before do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) end it 'rejects cf:any when other rules exist' do - post '/v3/access_rules', { - selector: 'cf:any', + post '/v3/route_policies', { + source: 'cf:any', relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -158,16 +158,16 @@ def expected_rule_json(rule) context 'when a cf:any rule already exists' do before do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: 'cf:any', + source: 'cf:any', route_id: mtls_route.id ) end it 'rejects adding a specific selector' do - post '/v3/access_rules', { - selector: "cf:space:#{valid_uuid}", + post '/v3/route_policies', { + source: "cf:space:#{valid_uuid}", relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -178,16 +178,16 @@ def expected_rule_json(rule) context 'duplicate selector per route' do before do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) end it 'returns 422' do - post '/v3/access_rules', { - selector: "cf:app:#{valid_uuid}", + post '/v3/route_policies', { + source: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -197,8 +197,8 @@ def expected_rule_json(rule) context 'invalid selector format' do it 'returns 422' do - post '/v3/access_rules', { - selector: 'not-valid', + post '/v3/route_policies', { + source: 'not-valid', relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -211,12 +211,12 @@ def expected_rule_json(rule) it 'returns 422 instead of 500' do # Simulate a race condition where the DB unique constraint catches the duplicate # after validation passes but before the insert commits - allow_any_instance_of(VCAP::CloudController::RouteAccessRule).to receive(:save).and_raise( - Sequel::UniqueConstraintViolation.new('UNIQUE constraint failed: route_access_rules.route_id, route_access_rules.selector') + allow_any_instance_of(VCAP::CloudController::RoutePolicy).to receive(:save).and_raise( + Sequel::UniqueConstraintViolation.new('UNIQUE constraint failed: route_access_rules.route_id, route_access_rules.source') ) - post '/v3/access_rules', { - selector: "cf:app:#{valid_uuid}", + post '/v3/route_policies', { + source: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: mtls_route.guid } } } }.to_json, admin_header @@ -226,51 +226,51 @@ def expected_rule_json(rule) end end - describe 'GET /v3/access_rules/:guid' do - let!(:access_rule) do - VCAP::CloudController::RouteAccessRule.create( + describe 'GET /v3/route_policies/:guid' do + let!(:route_policy) do + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) end it 'returns the access rule' do - get "/v3/access_rules/#{access_rule.guid}", nil, admin_header + get "/v3/route_policies/#{access_rule.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) expect(parsed['guid']).to eq(access_rule.guid) - expect(parsed['selector']).to eq("cf:app:#{valid_uuid}") + expect(parsed['source']).to eq("cf:app:#{valid_uuid}") end context 'when the access rule does not exist' do it 'returns 404' do - get '/v3/access_rules/nonexistent-guid', nil, admin_header + get '/v3/route_policies/nonexistent-guid', nil, admin_header expect(last_response.status).to eq(404) end end end - describe 'GET /v3/access_rules' do + describe 'GET /v3/route_policies' do let!(:rule1) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) end let!(:rule2) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: 'cf:any', + source: 'cf:any', route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) end it 'lists all accessible access rules' do - get '/v3/access_rules', nil, admin_header + get '/v3/route_policies', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -279,7 +279,7 @@ def expected_rule_json(rule) end it 'filters by route_guids' do - get "/v3/access_rules?route_guids=#{mtls_route.guid}", nil, admin_header + get "/v3/route_policies?route_guids=#{mtls_route.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -289,12 +289,12 @@ def expected_rule_json(rule) end it 'filters by selectors' do - get '/v3/access_rules?selectors=cf:any', nil, admin_header + get '/v3/route_policies?selectors=cf:any', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) expect(parsed['resources'].length).to eq(1) - expect(parsed['resources'][0]['selector']).to eq('cf:any') + expect(parsed['resources'][0]['source']).to eq('cf:any') end describe 'filtering by space_guids' do @@ -309,9 +309,9 @@ def expected_rule_json(rule) end let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) } let!(:rule_in_other_space) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: 'cf:any', + source: 'cf:any', route_id: other_route.id ) end @@ -322,7 +322,7 @@ def expected_rule_json(rule) end it 'filters by single space_guid' do - get "/v3/access_rules?space_guids=#{space.guid}", nil, admin_header + get "/v3/route_policies?space_guids=#{space.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -332,7 +332,7 @@ def expected_rule_json(rule) end it 'filters by multiple space_guids' do - get "/v3/access_rules?space_guids=#{space.guid},#{other_space.guid}", nil, admin_header + get "/v3/route_policies?space_guids=#{space.guid},#{other_space.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -341,13 +341,13 @@ def expected_rule_json(rule) end it 'combines space_guids with other filters' do - get "/v3/access_rules?space_guids=#{space.guid}&selectors=cf:app:#{valid_uuid}", nil, admin_header + get "/v3/route_policies?space_guids=#{space.guid}&selectors=cf:app:#{valid_uuid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) expect(parsed['resources'].length).to eq(1) expect(parsed['resources'][0]['guid']).to eq(rule1.guid) - expect(parsed['resources'][0]['selector']).to eq("cf:app:#{valid_uuid}") + expect(parsed['resources'][0]['source']).to eq("cf:app:#{valid_uuid}") end it 'returns empty when space has no access rules' do @@ -355,7 +355,7 @@ def expected_rule_json(rule) org.add_user(user) empty_space.add_developer(user) - get "/v3/access_rules?space_guids=#{empty_space.guid}", nil, admin_header + get "/v3/route_policies?space_guids=#{empty_space.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -375,9 +375,9 @@ def expected_rule_json(rule) end let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) } let!(:rule_in_other_space) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: 'cf:any', + source: 'cf:any', route_id: other_route.id ) end @@ -388,7 +388,7 @@ def expected_rule_json(rule) end it 'returns results matching both route_guids and space_guids without ambiguous column errors' do - get "/v3/access_rules?route_guids=#{mtls_route.guid}&space_guids=#{space.guid}", nil, admin_header + get "/v3/route_policies?route_guids=#{mtls_route.guid}&space_guids=#{space.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -400,7 +400,7 @@ def expected_rule_json(rule) describe 'filtering by selector_resource_guids' do it 'escapes % so it does not act as a LIKE wildcard' do - get '/v3/access_rules?selector_resource_guids=%25', nil, admin_header + get '/v3/route_policies?selector_resource_guids=%25', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -408,7 +408,7 @@ def expected_rule_json(rule) end it 'escapes _ so it does not act as a LIKE single-char wildcard' do - get '/v3/access_rules?selector_resource_guids=cf_app', nil, admin_header + get '/v3/route_policies?selector_resource_guids=cf_app', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -417,7 +417,7 @@ def expected_rule_json(rule) end it 'escapes backslash so it does not act as a LIKE escape character' do - get '/v3/access_rules?selector_resource_guids=cf%5Capp', nil, admin_header + get '/v3/route_policies?selector_resource_guids=cf%5Capp', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -431,31 +431,31 @@ def expected_rule_json(rule) let!(:other_org) { VCAP::CloudController::Organization.make(name: 'other-org') } let!(:app_rule) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{frontend_app.guid}", + source: "cf:app:#{frontend_app.guid}", route_id: mtls_route.id ) end let!(:space_rule) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:space:#{other_space.guid}", + source: "cf:space:#{other_space.guid}", route_id: mtls_route.id ) end let!(:org_rule) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:org:#{other_org.guid}", + source: "cf:org:#{other_org.guid}", route_id: mtls_route.id ) end it 'includes resolved selector resources' do - get '/v3/access_rules?include=selector_resource', nil, admin_header + get '/v3/route_policies?include=selector_resource', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -485,13 +485,13 @@ def expected_rule_json(rule) it 'handles stale resources (missing GUIDs) gracefully' do stale_guid = '99999999-9999-9999-9999-999999999999' - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{stale_guid}", + source: "cf:app:#{stale_guid}", route_id: mtls_route.id ) - get '/v3/access_rules?include=selector_resource', nil, admin_header + get '/v3/route_policies?include=selector_resource', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -503,13 +503,13 @@ def expected_rule_json(rule) it 'includes only unique resources when multiple rules reference the same resource' do # Create another rule referencing the same app - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{frontend_app.guid}", + source: "cf:app:#{frontend_app.guid}", route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) - get '/v3/access_rules?include=selector_resource', nil, admin_header + get '/v3/route_policies?include=selector_resource', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -520,13 +520,13 @@ def expected_rule_json(rule) end it 'does not include resources for cf:any selectors' do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: 'cf:any', + source: 'cf:any', route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) - get '/v3/access_rules?include=selector_resource', nil, admin_header + get '/v3/route_policies?include=selector_resource', nil, admin_header expect(last_response.status).to eq(200) # Should succeed without error even with cf:any selector @@ -537,23 +537,23 @@ def expected_rule_json(rule) let(:route2) { VCAP::CloudController::Route.make(space: space, domain: mtls_domain) } let!(:rule_on_route1) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: 'cf:any', + source: 'cf:any', route_id: mtls_route.id ) end let!(:rule_on_route2) do - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", route_id: route2.id ) end it 'includes route resources' do - get '/v3/access_rules?include=route', nil, admin_header + get '/v3/route_policies?include=route', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -576,13 +576,13 @@ def expected_rule_json(rule) it 'includes only unique routes when multiple rules reference the same route' do # Create another rule on the same route with a different selector - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{SecureRandom.uuid}", + source: "cf:app:#{SecureRandom.uuid}", route_id: mtls_route.id ) - get '/v3/access_rules?include=route', nil, admin_header + get '/v3/route_policies?include=route', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -594,13 +594,13 @@ def expected_rule_json(rule) it 'combines include=route with include=selector_resource' do test_app = VCAP::CloudController::AppModel.make(space: space, name: 'test-app') - VCAP::CloudController::RouteAccessRule.create( + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{test_app.guid}", + source: "cf:app:#{test_app.guid}", route_id: mtls_route.id ) - get '/v3/access_rules?include=route,selector_resource', nil, admin_header + get '/v3/route_policies?include=route,selector_resource', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -620,42 +620,42 @@ def expected_rule_json(rule) end end - describe 'DELETE /v3/access_rules/:guid' do - let!(:access_rule) do - VCAP::CloudController::RouteAccessRule.create( + describe 'DELETE /v3/route_policies/:guid' do + let!(:route_policy) do + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) end it 'deletes the access rule and returns 204' do - delete "/v3/access_rules/#{access_rule.guid}", nil, admin_header + delete "/v3/route_policies/#{access_rule.guid}", nil, admin_header expect(last_response.status).to eq(204) - expect(VCAP::CloudController::RouteAccessRule.find(guid: access_rule.guid)).to be_nil + expect(VCAP::CloudController::RoutePolicy.find(guid: access_rule.guid)).to be_nil end context 'when the access rule does not exist' do it 'returns 404' do - delete '/v3/access_rules/nonexistent-guid', nil, admin_header + delete '/v3/route_policies/nonexistent-guid', nil, admin_header expect(last_response.status).to eq(404) end end end - describe 'PATCH /v3/access_rules/:guid (metadata update)' do - let!(:access_rule) do - VCAP::CloudController::RouteAccessRule.create( + describe 'PATCH /v3/route_policies/:guid (metadata update)' do + let!(:route_policy) do + VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) end it 'returns 200' do - patch "/v3/access_rules/#{access_rule.guid}", { + patch "/v3/route_policies/#{access_rule.guid}", { metadata: { labels: { env: 'production' } } }.to_json, admin_header @@ -664,7 +664,7 @@ def expected_rule_json(rule) context 'when the access rule does not exist' do it 'returns 404' do - patch '/v3/access_rules/nonexistent-guid', {}.to_json, admin_header + patch '/v3/route_policies/nonexistent-guid', {}.to_json, admin_header expect(last_response.status).to eq(404) end diff --git a/spec/request/routes_spec.rb b/spec/request/routes_spec.rb index a8ad0dc1e2d..c953e2b0e58 100644 --- a/spec/request/routes_spec.rb +++ b/spec/request/routes_spec.rb @@ -165,7 +165,7 @@ hosts: 'foo', ports: 636, include: 'domain', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/service_brokers_spec.rb b/spec/request/service_brokers_spec.rb index 69e403581ce..7efaec764dc 100644 --- a/spec/request/service_brokers_spec.rb +++ b/spec/request/service_brokers_spec.rb @@ -113,7 +113,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_selector: 'foo==bar', + label_source: 'foo==bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/service_credential_bindings_spec.rb b/spec/request/service_credential_bindings_spec.rb index 68835e7765c..46a2d9d5d74 100644 --- a/spec/request/service_credential_bindings_spec.rb +++ b/spec/request/service_credential_bindings_spec.rb @@ -53,7 +53,7 @@ guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 }, - label_selector: 'env' + label_source: 'env' } end end diff --git a/spec/request/service_instances_spec.rb b/spec/request/service_instances_spec.rb index b9c69073434..f765828702a 100644 --- a/spec/request/service_instances_spec.rb +++ b/spec/request/service_instances_spec.rb @@ -170,7 +170,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', type: 'managed', service_plan_guids: %w[guid-1 guid-2], service_plan_names: %w[plan-1 plan-2], diff --git a/spec/request/service_offerings_spec.rb b/spec/request/service_offerings_spec.rb index 4076abeba89..742f49e9e65 100644 --- a/spec/request/service_offerings_spec.rb +++ b/spec/request/service_offerings_spec.rb @@ -219,7 +219,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_selector: 'foo==bar', + label_source: 'foo==bar', fields: { 'service_broker' => 'name' }, guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", diff --git a/spec/request/service_plans_spec.rb b/spec/request/service_plans_spec.rb index aaf5ba5963e..b97c3aebc26 100644 --- a/spec/request/service_plans_spec.rb +++ b/spec/request/service_plans_spec.rb @@ -197,7 +197,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_selector: 'foo==bar', + label_source: 'foo==bar', fields: { 'service_offering.service_broker' => 'name' }, guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", diff --git a/spec/request/service_route_bindings_spec.rb b/spec/request/service_route_bindings_spec.rb index af2cde52cc0..69833d0316b 100644 --- a/spec/request/service_route_bindings_spec.rb +++ b/spec/request/service_route_bindings_spec.rb @@ -71,7 +71,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_selector: 'foo==bar', + label_source: 'foo==bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/spaces_spec.rb b/spec/request/spaces_spec.rb index 589028b6cb1..f2edce375f1 100644 --- a/spec/request/spaces_spec.rb +++ b/spec/request/spaces_spec.rb @@ -263,7 +263,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -1304,7 +1304,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -1330,7 +1330,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/stacks_spec.rb b/spec/request/stacks_spec.rb index 644ce145ea7..0931f9a0da4 100644 --- a/spec/request/stacks_spec.rb +++ b/spec/request/stacks_spec.rb @@ -86,7 +86,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/tasks_spec.rb b/spec/request/tasks_spec.rb index 8fa34e57a70..75c549371ab 100644 --- a/spec/request/tasks_spec.rb +++ b/spec/request/tasks_spec.rb @@ -60,7 +60,7 @@ names: %w[foo bar], states: %w[test foo], organization_guids: 'foo,bar', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -89,7 +89,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -347,7 +347,7 @@ organization_guids: app_model.organization.guid, space_guids: app_model.space.guid, states: 'SUCCEEDED', - label_selector: 'boomerang' + label_source: 'boomerang' } get "/v3/tasks?#{query.to_query}", nil, developer_headers diff --git a/spec/request/users_spec.rb b/spec/request/users_spec.rb index bf15263c0ca..d2bd984a1d8 100644 --- a/spec/request/users_spec.rb +++ b/spec/request/users_spec.rb @@ -116,7 +116,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -142,7 +142,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_selector: 'foo,bar', + label_source: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/unit/controllers/v3/apps_controller_spec.rb b/spec/unit/controllers/v3/apps_controller_spec.rb index f74f841b593..d9b3e39e487 100644 --- a/spec/unit/controllers/v3/apps_controller_spec.rb +++ b/spec/unit/controllers/v3/apps_controller_spec.rb @@ -135,7 +135,7 @@ context 'label_selection' do it 'returns a 400 when the label_selector is invalid' do - get :index, params: { label_selector: 'buncha nonsense' } + get :index, params: { label_source: 'buncha nonsense' } expect(response).to have_http_status(:bad_request) diff --git a/spec/unit/fetchers/service_broker_list_fetcher_spec.rb b/spec/unit/fetchers/service_broker_list_fetcher_spec.rb index 38ae39fe8ec..8bd10f0bb1d 100644 --- a/spec/unit/fetchers/service_broker_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_broker_list_fetcher_spec.rb @@ -167,7 +167,7 @@ module VCAP::CloudController let(:filters) do { space_guids: [space_2.guid], - label_selector: 'dog in (poodle,scooby-doo)', + label_source: 'dog in (poodle,scooby-doo)', names: [space_scoped_broker_1.name] } end @@ -196,7 +196,7 @@ module VCAP::CloudController let(:filters) do { space_guids: [space_1.guid, space_2.guid], - label_selector: 'dog in (poodle,scooby-doo)', + label_source: 'dog in (poodle,scooby-doo)', names: [space_scoped_broker_1.name] } end diff --git a/spec/unit/fetchers/service_offering_list_fetcher_spec.rb b/spec/unit/fetchers/service_offering_list_fetcher_spec.rb index 6870940f3f7..8391f8a6e4f 100644 --- a/spec/unit/fetchers/service_offering_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_offering_list_fetcher_spec.rb @@ -490,7 +490,7 @@ module VCAP::CloudController let!(:service_offering_1) { VCAP::CloudController::ServicePlan.make(public: true, active: true).service } let!(:service_offering_2) { VCAP::CloudController::ServicePlan.make(public: true, active: true).service } let!(:service_offering_3) { VCAP::CloudController::ServicePlan.make(public: true, active: true).service } - let(:message) { ServiceOfferingsListMessage.from_params({ label_selector: 'flavor=orange' }.with_indifferent_access) } + let(:message) { ServiceOfferingsListMessage.from_params({ label_source: 'flavor=orange' }.with_indifferent_access) } before do VCAP::CloudController::ServiceOfferingLabelModel.make(resource_guid: service_offering_1.guid, key_name: 'flavor', value: 'orange') diff --git a/spec/unit/fetchers/service_plan_list_fetcher_spec.rb b/spec/unit/fetchers/service_plan_list_fetcher_spec.rb index 4e45898df31..17f2220646e 100644 --- a/spec/unit/fetchers/service_plan_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_plan_list_fetcher_spec.rb @@ -491,7 +491,7 @@ module VCAP::CloudController let!(:service_plan_1) { VCAP::CloudController::ServicePlan.make(public: true, active: true) } let!(:service_plan_2) { VCAP::CloudController::ServicePlan.make(public: true, active: true) } let!(:service_plan_3) { VCAP::CloudController::ServicePlan.make(public: true, active: true) } - let(:message) { ServicePlansListMessage.from_params({ label_selector: 'flavor=orange' }.with_indifferent_access) } + let(:message) { ServicePlansListMessage.from_params({ label_source: 'flavor=orange' }.with_indifferent_access) } before do VCAP::CloudController::ServicePlanLabelModel.make(resource_guid: service_plan_1.guid, key_name: 'flavor', value: 'orange') diff --git a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb index 507afe3d489..663596e3334 100644 --- a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb @@ -263,16 +263,16 @@ class Protocol end let(:mtls_route) { Route.make(host: 'myapp', domain: enforce_domain, space: space) } let!(:access_rule1) do - RouteAccessRule.create( + RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", route_id: mtls_route.id ) end let!(:access_rule2) do - RouteAccessRule.create( + RoutePolicy.create( guid: SecureRandom.uuid, - selector: "cf:space:#{valid_uuid}", + source: "cf:space:#{valid_uuid}", route_id: mtls_route.id ) end @@ -286,9 +286,9 @@ class Protocol mtls_entry = http_routes.find { |r| r['hostname'] == 'myapp.mtls.example.com' } expect(mtls_entry).not_to be_nil - expect(mtls_entry['options']['access_scope']).to eq('space') - expect(mtls_entry['options']['access_rules']).to include("cf:app:#{valid_uuid}") - expect(mtls_entry['options']['access_rules']).to include("cf:space:#{valid_uuid}") + expect(mtls_entry['options']['route_policy_scope']).to eq('space') + expect(mtls_entry['options']['route_policy_sources']).to include("cf:app:#{valid_uuid}") + expect(mtls_entry['options']['route_policy_sources']).to include("cf:space:#{valid_uuid}") end context 'when the route has no access rules' do @@ -301,8 +301,8 @@ class Protocol http_routes = ri['http_routes'] mtls_entry = http_routes.find { |r| r['hostname'] == 'myapp.mtls.example.com' } - expect(mtls_entry['options']['access_scope']).to eq('space') - expect(mtls_entry['options']).not_to have_key('access_rules') + expect(mtls_entry['options']['route_policy_scope']).to eq('space') + expect(mtls_entry['options']).not_to have_key('route_policy_sources') end end end diff --git a/spec/unit/messages/app_revisions_list_message_spec.rb b/spec/unit/messages/app_revisions_list_message_spec.rb index 2a3c7849657..dd1e8d2b0f3 100644 --- a/spec/unit/messages/app_revisions_list_message_spec.rb +++ b/spec/unit/messages/app_revisions_list_message_spec.rb @@ -40,7 +40,7 @@ module VCAP::CloudController let(:opts) do { versions: %w[1 3], - label_selector: 'key=value', + label_source: 'key=value', page: 1, per_page: 5, deployable: true @@ -61,7 +61,7 @@ module VCAP::CloudController per_page: 5, versions: ['1'], deployable: true, - label_selector: 'key=value' + label_source: 'key=value' }) end.not_to raise_error end diff --git a/spec/unit/messages/apps_list_message_spec.rb b/spec/unit/messages/apps_list_message_spec.rb index e1f76f88844..e4ac1463702 100644 --- a/spec/unit/messages/apps_list_message_spec.rb +++ b/spec/unit/messages/apps_list_message_spec.rb @@ -67,7 +67,7 @@ module VCAP::CloudController per_page: 5, order_by: 'created_at', include: ['space', 'space.organization'], - label_selector: 'foo in (stuff,things)', + label_source: 'foo in (stuff,things)', lifecycle_type: 'buildpack' } end @@ -90,7 +90,7 @@ module VCAP::CloudController per_page: 5, order_by: 'created_at', include: ['space', 'space.organization'], - label_selector: 'foo in (stuff,things)', + label_source: 'foo in (stuff,things)', lifecycle_type: 'buildpack' }) end.not_to raise_error diff --git a/spec/unit/messages/buildpacks_list_message_spec.rb b/spec/unit/messages/buildpacks_list_message_spec.rb index 605c4fbac5e..3f7bed0e566 100644 --- a/spec/unit/messages/buildpacks_list_message_spec.rb +++ b/spec/unit/messages/buildpacks_list_message_spec.rb @@ -47,7 +47,7 @@ module VCAP::CloudController names: %w[name1 name2], stacks: %w[stack1 stack2], lifecycle: 'buildpack', - label_selector: 'foo=bar', + label_source: 'foo=bar', page: 1, per_page: 5 } @@ -65,7 +65,7 @@ module VCAP::CloudController BuildpacksListMessage.from_params({ names: [], stacks: [], - label_selector: '', + label_source: '', lifecycle: 'buildpack' }) end.not_to raise_error diff --git a/spec/unit/messages/domain_create_message_spec.rb b/spec/unit/messages/domain_create_message_spec.rb index 8caab439a11..9041792e1c8 100644 --- a/spec/unit/messages/domain_create_message_spec.rb +++ b/spec/unit/messages/domain_create_message_spec.rb @@ -404,13 +404,13 @@ module VCAP::CloudController end end - context 'enforce_access_rules' do + context 'enforce_route_policies' do context 'when not a boolean' do let(:params) { { name: 'name.com', enforce_access_rules: 'yes' } } it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:enforce_access_rules]).to include('must be a boolean') + expect(subject.errors[:enforce_route_policies]).to include('must be a boolean') end end @@ -419,7 +419,7 @@ module VCAP::CloudController it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:access_rules_scope]).to include('is required when enforce_access_rules is true') + expect(subject.errors[:route_policies_scope]).to include('is required when enforce_access_rules is true') end end @@ -448,13 +448,13 @@ module VCAP::CloudController end end - context 'access_rules_scope' do + context 'route_policies_scope' do context 'when set to an invalid value' do let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'invalid' } } it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:access_rules_scope]).to include("must be one of 'any', 'org', 'space'") + expect(subject.errors[:route_policies_scope]).to include("must be one of 'any', 'org', 'space'") end end diff --git a/spec/unit/messages/isolation_segments_list_message_spec.rb b/spec/unit/messages/isolation_segments_list_message_spec.rb index 38ba6a7679f..5160108ef5a 100644 --- a/spec/unit/messages/isolation_segments_list_message_spec.rb +++ b/spec/unit/messages/isolation_segments_list_message_spec.rb @@ -52,7 +52,7 @@ module VCAP::CloudController names: %w[name1 name2], guids: %w[guid1 guid2], organization_guids: %w[o-guid1 o-guid2], - label_selector: 'foo=bar', + label_source: 'foo=bar', page: 1, per_page: 5, order_by: 'created_at', @@ -81,7 +81,7 @@ module VCAP::CloudController names: [], guids: [], organization_guids: [], - label_selector: '', + label_source: '', page: 1, per_page: 5, order_by: 'created_at' diff --git a/spec/unit/messages/list_message_spec.rb b/spec/unit/messages/list_message_spec.rb index 2f48b716800..3202829f105 100644 --- a/spec/unit/messages/list_message_spec.rb +++ b/spec/unit/messages/list_message_spec.rb @@ -289,7 +289,7 @@ def self.from_params(params) end it 'handles ruby symbols' do - message = list_message_klass.from_params(label_selector: 'example.com/foo==bar') + message = list_message_klass.from_params(label_source: 'example.com/foo==bar') expect(message.requirements.first.key).to eq('example.com/foo') end end diff --git a/spec/unit/messages/packages_list_message_spec.rb b/spec/unit/messages/packages_list_message_spec.rb index c3ef1fa369e..2389e16b35d 100644 --- a/spec/unit/messages/packages_list_message_spec.rb +++ b/spec/unit/messages/packages_list_message_spec.rb @@ -57,7 +57,7 @@ module VCAP::CloudController app_guids: %w[appguid1 appguid2], organization_guids: %w[organizationguid1 organizationguid2], app_guid: 'appguid', - label_selector: 'key=value', + label_source: 'key=value', page: 1, per_page: 5 } diff --git a/spec/unit/messages/processes_list_message_spec.rb b/spec/unit/messages/processes_list_message_spec.rb index 4346e772dc6..09544964c01 100644 --- a/spec/unit/messages/processes_list_message_spec.rb +++ b/spec/unit/messages/processes_list_message_spec.rb @@ -67,7 +67,7 @@ module VCAP::CloudController app_guid: 'appguid', embed: 'process_instances', page: 1, - label_selector: 'key=value', + label_source: 'key=value', per_page: 5, order_by: 'created_at', created_ats: [Time.now.utc.iso8601, Time.now.utc.iso8601], diff --git a/spec/unit/messages/access_rules_list_message_spec.rb b/spec/unit/messages/route_policies_list_message_spec.rb similarity index 69% rename from spec/unit/messages/access_rules_list_message_spec.rb rename to spec/unit/messages/route_policies_list_message_spec.rb index 4790229787e..30c6baacb5e 100644 --- a/spec/unit/messages/access_rules_list_message_spec.rb +++ b/spec/unit/messages/route_policies_list_message_spec.rb @@ -1,16 +1,16 @@ require 'spec_helper' -require 'messages/access_rules_list_message' +require 'messages/route_policies_list_message' module VCAP::CloudController - RSpec.describe AccessRulesListMessage do + RSpec.describe RoutePoliciesListMessage do describe '.from_params' do let(:params) do { 'guids' => 'guid1,guid2', 'route_guids' => 'route1,route2', 'space_guids' => 'space1,space2', - 'selectors' => 'selector1,selector2', - 'selector_resource_guids' => 'resource1,resource2', + 'sources' => 'selector1,selector2', + 'source_guids' => 'resource1,resource2', 'page' => 1, 'per_page' => 5, 'order_by' => 'created_at', @@ -18,10 +18,10 @@ module VCAP::CloudController } end - it 'returns the correct AccessRulesListMessage' do - message = AccessRulesListMessage.from_params(params) + it 'returns the correct RoutePoliciesListMessage' do + message = RoutePoliciesListMessage.from_params(params) - expect(message).to be_a(AccessRulesListMessage) + expect(message).to be_a(RoutePoliciesListMessage) expect(message.guids).to eq(%w[guid1 guid2]) expect(message.route_guids).to eq(%w[route1 route2]) expect(message.space_guids).to eq(%w[space1 space2]) @@ -34,13 +34,13 @@ module VCAP::CloudController end it 'converts requested keys to symbols' do - message = AccessRulesListMessage.from_params(params) + message = RoutePoliciesListMessage.from_params(params) expect(message).to be_requested(:guids) expect(message).to be_requested(:route_guids) expect(message).to be_requested(:space_guids) - expect(message).to be_requested(:selectors) - expect(message).to be_requested(:selector_resource_guids) + expect(message).to be_requested(:sources) + expect(message).to be_requested(:source_guids) expect(message).to be_requested(:page) expect(message).to be_requested(:per_page) expect(message).to be_requested(:order_by) @@ -65,14 +65,14 @@ module VCAP::CloudController it 'excludes the pagination keys' do expected_params = %i[guids route_guids space_guids selectors selector_resource_guids include] - expect(AccessRulesListMessage.from_params(opts).to_param_hash.keys).to match_array(expected_params) + expect(RoutePoliciesListMessage.from_params(opts).to_param_hash.keys).to match_array(expected_params) end end describe 'fields' do it 'accepts a set of fields' do expect do - AccessRulesListMessage.from_params({ + RoutePoliciesListMessage.from_params({ guids: [], route_guids: [], space_guids: [], @@ -87,12 +87,12 @@ module VCAP::CloudController end it 'accepts an empty set' do - message = AccessRulesListMessage.from_params({}) + message = RoutePoliciesListMessage.from_params({}) expect(message).to be_valid end it 'does not accept a field not in this set' do - message = AccessRulesListMessage.from_params({ foobar: 'pants' }) + message = RoutePoliciesListMessage.from_params({ foobar: 'pants' }) expect(message).not_to be_valid expect(message.errors[:base][0]).to include("Unknown query parameter(s): 'foobar'") @@ -100,64 +100,64 @@ module VCAP::CloudController describe 'include validations' do it 'accepts valid include values' do - message = AccessRulesListMessage.from_params({ 'include' => 'selector_resource' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'source' }) expect(message).to be_valid - message = AccessRulesListMessage.from_params({ 'include' => 'route' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'route' }) expect(message).to be_valid - message = AccessRulesListMessage.from_params({ 'include' => 'app' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'app' }) expect(message).to be_valid - message = AccessRulesListMessage.from_params({ 'include' => 'space' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'space' }) expect(message).to be_valid - message = AccessRulesListMessage.from_params({ 'include' => 'organization' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'organization' }) expect(message).to be_valid - message = AccessRulesListMessage.from_params({ 'include' => 'selector_resource,route,app,space,organization' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'selector_resource,route,app,space,organization' }) expect(message).to be_valid end it 'rejects invalid include values' do - message = AccessRulesListMessage.from_params({ 'include' => 'invalid' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'invalid' }) expect(message).not_to be_valid end end describe 'validations' do it 'validates space_guids is an array' do - message = AccessRulesListMessage.from_params space_guids: 'not array' + message = RoutePoliciesListMessage.from_params space_guids: 'not array' expect(message).not_to be_valid expect(message.errors[:space_guids].length).to eq 1 end it 'allows space_guids to be nil' do - message = AccessRulesListMessage.from_params({}) + message = RoutePoliciesListMessage.from_params({}) expect(message).to be_valid expect(message.space_guids).to be_nil end it 'allows space_guids to be an array' do - message = AccessRulesListMessage.from_params space_guids: %w[space1 space2] + message = RoutePoliciesListMessage.from_params space_guids: %w[space1 space2] expect(message).to be_valid expect(message.space_guids).to eq(%w[space1 space2]) end it 'validates selector_resource_guids is an array' do - message = AccessRulesListMessage.from_params selector_resource_guids: 'not array' + message = RoutePoliciesListMessage.from_params selector_resource_guids: 'not array' expect(message).not_to be_valid - expect(message.errors[:selector_resource_guids].length).to eq 1 + expect(message.errors[:source_guids].length).to eq 1 end it 'allows selector_resource_guids to be nil' do - message = AccessRulesListMessage.from_params({}) + message = RoutePoliciesListMessage.from_params({}) expect(message).to be_valid expect(message.selector_resource_guids).to be_nil end it 'allows selector_resource_guids to be an array' do - message = AccessRulesListMessage.from_params selector_resource_guids: %w[guid1 guid2] + message = RoutePoliciesListMessage.from_params selector_resource_guids: %w[guid1 guid2] expect(message).to be_valid expect(message.selector_resource_guids).to eq(%w[guid1 guid2]) end diff --git a/spec/unit/messages/access_rule_create_message_spec.rb b/spec/unit/messages/route_policy_create_message_spec.rb similarity index 81% rename from spec/unit/messages/access_rule_create_message_spec.rb rename to spec/unit/messages/route_policy_create_message_spec.rb index 408d57840d6..d6fa10f5ad4 100644 --- a/spec/unit/messages/access_rule_create_message_spec.rb +++ b/spec/unit/messages/route_policy_create_message_spec.rb @@ -2,19 +2,19 @@ require 'messages/access_rule_create_message' module VCAP::CloudController - RSpec.describe AccessRuleCreateMessage do + RSpec.describe RoutePolicyCreateMessage do let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } let(:valid_route_relationship) do { relationships: { route: { data: { guid: valid_uuid } } } } end - subject { AccessRuleCreateMessage.new(params) } + subject { RoutePolicyCreateMessage.new(params) } describe 'validations' do context 'when all valid params are given' do let(:params) do { - selector: "cf:app:#{valid_uuid}" + source: "cf:app:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -26,7 +26,7 @@ module VCAP::CloudController context 'when unexpected keys are provided' do let(:params) do { - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", unexpected: 'field' }.merge(valid_route_relationship) end @@ -37,7 +37,7 @@ module VCAP::CloudController end end - describe 'selector' do + describe 'source' do context 'when selector is missing' do let(:params) do valid_route_relationship @@ -45,20 +45,20 @@ module VCAP::CloudController it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:selector]).to include("can't be blank") + expect(subject.errors[:source]).to include("can't be blank") end end context 'when selector is not a string' do let(:params) do { - selector: 123 + source: 123 }.merge(valid_route_relationship) end it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:selector]).to include('must be a string') + expect(subject.errors[:source]).to include('must be a string') end end @@ -66,7 +66,7 @@ module VCAP::CloudController context 'cf:app:' do let(:params) do { - selector: "cf:app:#{valid_uuid}" + source: "cf:app:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -78,7 +78,7 @@ module VCAP::CloudController context 'cf:space:' do let(:params) do { - selector: "cf:space:#{valid_uuid}" + source: "cf:space:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -90,7 +90,7 @@ module VCAP::CloudController context 'cf:org:' do let(:params) do { - selector: "cf:org:#{valid_uuid}" + source: "cf:org:#{valid_uuid}" }.merge(valid_route_relationship) end @@ -102,7 +102,7 @@ module VCAP::CloudController context 'cf:any' do let(:params) do { - selector: 'cf:any' + source: 'cf:any' }.merge(valid_route_relationship) end @@ -114,13 +114,13 @@ module VCAP::CloudController context 'invalid format' do let(:params) do { - selector: 'not-valid' + source: 'not-valid' }.merge(valid_route_relationship) end it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:selector]).to include( + expect(subject.errors[:source]).to include( "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" ) end @@ -129,13 +129,13 @@ module VCAP::CloudController context 'cf:app: with invalid uuid' do let(:params) do { - selector: 'cf:app:not-a-uuid' + source: 'cf:app:not-a-uuid' }.merge(valid_route_relationship) end it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:selector]).to include( + expect(subject.errors[:source]).to include( "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" ) end @@ -144,13 +144,13 @@ module VCAP::CloudController context 'cf:unknown type' do let(:params) do { - selector: "cf:team:#{valid_uuid}" + source: "cf:team:#{valid_uuid}" }.merge(valid_route_relationship) end it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:selector]).to include( + expect(subject.errors[:source]).to include( "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'" ) end @@ -162,7 +162,7 @@ module VCAP::CloudController context 'when relationships is missing' do let(:params) do { - selector: "cf:app:#{valid_uuid}" + source: "cf:app:#{valid_uuid}" } end @@ -175,7 +175,7 @@ module VCAP::CloudController context 'when route relationship is missing' do let(:params) do { - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", relationships: {} } end @@ -188,7 +188,7 @@ module VCAP::CloudController context 'when route guid is provided' do let(:params) do { - selector: "cf:app:#{valid_uuid}", + source: "cf:app:#{valid_uuid}", relationships: { route: { data: { guid: 'some-route-guid' } } } } end diff --git a/spec/unit/messages/tasks_list_message_spec.rb b/spec/unit/messages/tasks_list_message_spec.rb index 32177588787..f5c4950d22b 100644 --- a/spec/unit/messages/tasks_list_message_spec.rb +++ b/spec/unit/messages/tasks_list_message_spec.rb @@ -64,7 +64,7 @@ module VCAP::CloudController organization_guids: %w[orgguid1 orgguid2], space_guids: %w[spaceguid1 spaceguid2], sequence_ids: ['1, 2'], - label_selector: 'unicycling=fred', + label_source: 'unicycling=fred', page: 1, per_page: 5 } diff --git a/spec/unit/models/runtime/route_access_rule_spec.rb b/spec/unit/models/runtime/route_policy_spec.rb similarity index 69% rename from spec/unit/models/runtime/route_access_rule_spec.rb rename to spec/unit/models/runtime/route_policy_spec.rb index 687845c2207..2dfbc9520dc 100644 --- a/spec/unit/models/runtime/route_access_rule_spec.rb +++ b/spec/unit/models/runtime/route_policy_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' module VCAP::CloudController - RSpec.describe RouteAccessRule, type: :model do + RSpec.describe RoutePolicy, type: :model do let(:space) { Space.make } let(:domain) { SharedDomain.make(name: 'apps.identity') } let(:route) { Route.make(space:, domain:) } @@ -17,13 +17,13 @@ module VCAP::CloudController describe 'validations' do it 'requires a selector' do - rule = RouteAccessRule.new(route:) + rule = RoutePolicy.new(route:) expect(rule.valid?).to be false - expect(rule.errors[:selector]).to include(:presence) + expect(rule.errors[:source]).to include(:presence) end it 'requires a route_id' do - rule = RouteAccessRule.new(selector: 'cf:app:123') + rule = RoutePolicy.new(source: 'cf:app:123') expect(rule.valid?).to be false expect(rule.errors[:route_id]).to include(:presence) end @@ -31,8 +31,8 @@ module VCAP::CloudController describe 'associations' do it 'belongs to a route' do - rule = RouteAccessRule.create( - selector: 'cf:app:123', + rule = RoutePolicy.create( + source: 'cf:app:123', route: route ) expect(rule.route).to eq(route) @@ -42,10 +42,10 @@ module VCAP::CloudController describe 'callbacks' do describe 'after_create' do it 'calls touch_associated_processes' do - expect_any_instance_of(RouteAccessRule).to receive(:touch_associated_processes).and_call_original + expect_any_instance_of(RoutePolicy).to receive(:touch_associated_processes).and_call_original - RouteAccessRule.create( - selector: "cf:app:#{app_guid}", + RoutePolicy.create( + source: "cf:app:#{app_guid}", route: route ) end @@ -54,8 +54,8 @@ module VCAP::CloudController process # force creation # Record the SQL update queries to verify the process row is updated - RouteAccessRule.create( - selector: "cf:app:#{app_guid}", + RoutePolicy.create( + source: "cf:app:#{app_guid}", route: route ) @@ -67,8 +67,8 @@ module VCAP::CloudController route_without_processes = Route.make(space:, domain:) expect do - RouteAccessRule.create( - selector: "cf:app:#{app_guid}", + RoutePolicy.create( + source: "cf:app:#{app_guid}", route: route_without_processes ) end.not_to raise_error @@ -77,20 +77,20 @@ module VCAP::CloudController describe 'after_destroy' do it 'calls touch_associated_processes' do - rule = RouteAccessRule.create( - selector: "cf:app:#{app_guid}", + rule = RoutePolicy.create( + source: "cf:app:#{app_guid}", route: route ) - expect_any_instance_of(RouteAccessRule).to receive(:touch_associated_processes).and_call_original + expect_any_instance_of(RoutePolicy).to receive(:touch_associated_processes).and_call_original rule.destroy end it 'does not fail if route has no associated processes' do route_without_processes = Route.make(space:, domain:) - rule = RouteAccessRule.create( - selector: "cf:app:#{app_guid}", + rule = RoutePolicy.create( + source: "cf:app:#{app_guid}", route: route_without_processes ) diff --git a/spec/unit/presenters/v3/domain_presenter_spec.rb b/spec/unit/presenters/v3/domain_presenter_spec.rb index 998c4c1218f..82ce5e79d84 100644 --- a/spec/unit/presenters/v3/domain_presenter_spec.rb +++ b/spec/unit/presenters/v3/domain_presenter_spec.rb @@ -250,8 +250,8 @@ module VCAP::CloudController::Presenters::V3 end it 'includes enforce_access_rules and access_rules_scope in the output' do - expect(subject[:enforce_access_rules]).to be(true) - expect(subject[:access_rules_scope]).to eq('space') + expect(subject[:enforce_route_policies]).to be(true) + expect(subject[:route_policies_scope]).to eq('space') end end @@ -270,8 +270,8 @@ module VCAP::CloudController::Presenters::V3 end it 'does not include enforce_access_rules or access_rules_scope in the output' do - expect(subject).not_to have_key(:enforce_access_rules) - expect(subject).not_to have_key(:access_rules_scope) + expect(subject).not_to have_key(:enforce_route_policies) + expect(subject).not_to have_key(:route_policies_scope) end end diff --git a/spec/unit/presenters/v3/route_presenter_spec.rb b/spec/unit/presenters/v3/route_presenter_spec.rb index 3c78892c26e..b30378fd962 100644 --- a/spec/unit/presenters/v3/route_presenter_spec.rb +++ b/spec/unit/presenters/v3/route_presenter_spec.rb @@ -154,7 +154,7 @@ module VCAP::CloudController::Presenters::V3 path: path, space: space, domain: domain, - options: { 'access_scope' => 'space', 'access_rules' => 'cf:app:some-guid' } + options: { 'route_policy_scope' => 'space', 'route_policy_sources' => 'cf:app:some-guid' } ) end @@ -172,16 +172,16 @@ module VCAP::CloudController::Presenters::V3 domain: domain, options: { 'loadbalancing' => 'round-robin', - 'access_scope' => 'space', - 'access_rules' => 'cf:app:some-guid' + 'route_policy_scope' => 'space', + 'route_policy_sources' => 'cf:app:some-guid' } ) end it 'exposes only the public options' do expect(subject[:options]).to eq('loadbalancing' => 'round-robin') - expect(subject[:options]).not_to have_key('access_scope') - expect(subject[:options]).not_to have_key('access_rules') + expect(subject[:options]).not_to have_key('route_policy_scope') + expect(subject[:options]).not_to have_key('route_policy_sources') end end From 4dd1dc8eaf783c5f25c0036609eccbae86b47810 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 08:30:34 +0000 Subject: [PATCH 25/64] Fix test failures: complete terminology rebrand in specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix require statement in route_policy_create_message_spec.rb - Update all test references from old to new terminology: * selectors → sources * selector_resource_guids → source_guids * selector_resource → source (in include parameters) * enforce_access_rules → enforce_route_policies * access_rules_scope → route_policies_scope * route_access_rules → route_policies (table name) - Fix Rubocop indentation in route_policies_list_message_spec.rb Addresses CI/CD test failures in PR #4910. --- spec/request/route_policies_spec.rb | 48 ++++++++--------- .../route_policies_list_message_spec.rb | 54 +++++++++---------- .../route_policy_create_message_spec.rb | 2 +- .../presenters/v3/domain_presenter_spec.rb | 12 ++--- 4 files changed, 58 insertions(+), 58 deletions(-) diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index 4582c0ba13c..bfce52e18d8 100644 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -9,8 +9,8 @@ let(:mtls_domain) do VCAP::CloudController::PrivateDomain.make( owning_organization: org, - enforce_access_rules: true, - access_rules_scope: 'space' + enforce_route_policies: true, + route_policies_scope: 'space' ) end let(:regular_domain) do @@ -82,7 +82,7 @@ def expected_rule_json(rule) end end - context 'when the domain does not have enforce_access_rules enabled' do + context 'when the domain does not have enforce_route_policies enabled' do let(:request_body) do { source: "cf:app:#{valid_uuid}", @@ -212,7 +212,7 @@ def expected_rule_json(rule) # Simulate a race condition where the DB unique constraint catches the duplicate # after validation passes but before the insert commits allow_any_instance_of(VCAP::CloudController::RoutePolicy).to receive(:save).and_raise( - Sequel::UniqueConstraintViolation.new('UNIQUE constraint failed: route_access_rules.route_id, route_access_rules.source') + Sequel::UniqueConstraintViolation.new('UNIQUE constraint failed: route_policies.route_id, route_policies.source') ) post '/v3/route_policies', { @@ -236,11 +236,11 @@ def expected_rule_json(rule) end it 'returns the access rule' do - get "/v3/route_policies/#{access_rule.guid}", nil, admin_header + get "/v3/route_policies/#{route_policy.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - expect(parsed['guid']).to eq(access_rule.guid) + expect(parsed['guid']).to eq(route_policy.guid) expect(parsed['source']).to eq("cf:app:#{valid_uuid}") end @@ -303,8 +303,8 @@ def expected_rule_json(rule) let(:other_mtls_domain) do VCAP::CloudController::PrivateDomain.make( owning_organization: other_org, - enforce_access_rules: true, - access_rules_scope: 'space' + enforce_route_policies: true, + route_policies_scope: 'space' ) end let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) } @@ -369,8 +369,8 @@ def expected_rule_json(rule) let(:other_mtls_domain) do VCAP::CloudController::PrivateDomain.make( owning_organization: other_org, - enforce_access_rules: true, - access_rules_scope: 'space' + enforce_route_policies: true, + route_policies_scope: 'space' ) end let(:other_route) { VCAP::CloudController::Route.make(space: other_space, domain: other_mtls_domain) } @@ -398,9 +398,9 @@ def expected_rule_json(rule) end end - describe 'filtering by selector_resource_guids' do + describe 'filtering by source_guids' do it 'escapes % so it does not act as a LIKE wildcard' do - get '/v3/route_policies?selector_resource_guids=%25', nil, admin_header + get '/v3/route_policies?source_guids=%25', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -408,7 +408,7 @@ def expected_rule_json(rule) end it 'escapes _ so it does not act as a LIKE single-char wildcard' do - get '/v3/route_policies?selector_resource_guids=cf_app', nil, admin_header + get '/v3/route_policies?source_guids=cf_app', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -417,7 +417,7 @@ def expected_rule_json(rule) end it 'escapes backslash so it does not act as a LIKE escape character' do - get '/v3/route_policies?selector_resource_guids=cf%5Capp', nil, admin_header + get '/v3/route_policies?source_guids=cf%5Capp', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -425,7 +425,7 @@ def expected_rule_json(rule) end end - context 'with include=selector_resource' do + context 'with include=source' do let!(:frontend_app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } let!(:other_space) { VCAP::CloudController::Space.make(organization: org, name: 'other-space') } let!(:other_org) { VCAP::CloudController::Organization.make(name: 'other-org') } @@ -455,7 +455,7 @@ def expected_rule_json(rule) end it 'includes resolved selector resources' do - get '/v3/route_policies?include=selector_resource', nil, admin_header + get '/v3/route_policies?include=source', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -491,7 +491,7 @@ def expected_rule_json(rule) route_id: mtls_route.id ) - get '/v3/route_policies?include=selector_resource', nil, admin_header + get '/v3/route_policies?include=source', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -509,7 +509,7 @@ def expected_rule_json(rule) route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) - get '/v3/route_policies?include=selector_resource', nil, admin_header + get '/v3/route_policies?include=source', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -526,7 +526,7 @@ def expected_rule_json(rule) route_id: VCAP::CloudController::Route.make(space: space, domain: mtls_domain).id ) - get '/v3/route_policies?include=selector_resource', nil, admin_header + get '/v3/route_policies?include=source', nil, admin_header expect(last_response.status).to eq(200) # Should succeed without error even with cf:any selector @@ -592,7 +592,7 @@ def expected_rule_json(rule) expect(route_count).to eq(1) end - it 'combines include=route with include=selector_resource' do + it 'combines include=route with include=source' do test_app = VCAP::CloudController::AppModel.make(space: space, name: 'test-app') VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, @@ -600,7 +600,7 @@ def expected_rule_json(rule) route_id: mtls_route.id ) - get '/v3/route_policies?include=route,selector_resource', nil, admin_header + get '/v3/route_policies?include=route,source', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -630,10 +630,10 @@ def expected_rule_json(rule) end it 'deletes the access rule and returns 204' do - delete "/v3/route_policies/#{access_rule.guid}", nil, admin_header + delete "/v3/route_policies/#{route_policy.guid}", nil, admin_header expect(last_response.status).to eq(204) - expect(VCAP::CloudController::RoutePolicy.find(guid: access_rule.guid)).to be_nil + expect(VCAP::CloudController::RoutePolicy.find(guid: route_policy.guid)).to be_nil end context 'when the access rule does not exist' do @@ -655,7 +655,7 @@ def expected_rule_json(rule) end it 'returns 200' do - patch "/v3/route_policies/#{access_rule.guid}", { + patch "/v3/route_policies/#{route_policy.guid}", { metadata: { labels: { env: 'production' } } }.to_json, admin_header diff --git a/spec/unit/messages/route_policies_list_message_spec.rb b/spec/unit/messages/route_policies_list_message_spec.rb index 30c6baacb5e..0a502105db7 100644 --- a/spec/unit/messages/route_policies_list_message_spec.rb +++ b/spec/unit/messages/route_policies_list_message_spec.rb @@ -9,12 +9,12 @@ module VCAP::CloudController 'guids' => 'guid1,guid2', 'route_guids' => 'route1,route2', 'space_guids' => 'space1,space2', - 'sources' => 'selector1,selector2', + 'sources' => 'source1,source2', 'source_guids' => 'resource1,resource2', 'page' => 1, 'per_page' => 5, 'order_by' => 'created_at', - 'include' => 'selector_resource,route,app,space,organization' + 'include' => 'source,route,app,space,organization' } end @@ -25,12 +25,12 @@ module VCAP::CloudController expect(message.guids).to eq(%w[guid1 guid2]) expect(message.route_guids).to eq(%w[route1 route2]) expect(message.space_guids).to eq(%w[space1 space2]) - expect(message.selectors).to eq(%w[selector1 selector2]) - expect(message.selector_resource_guids).to eq(%w[resource1 resource2]) + expect(message.sources).to eq(%w[source1 source2]) + expect(message.source_guids).to eq(%w[resource1 resource2]) expect(message.page).to eq(1) expect(message.per_page).to eq(5) expect(message.order_by).to eq('created_at') - expect(message.include).to eq(%w[selector_resource route app space organization]) + expect(message.include).to eq(%w[source route app space organization]) end it 'converts requested keys to symbols' do @@ -54,17 +54,17 @@ module VCAP::CloudController guids: %w[guid1 guid2], route_guids: %w[route1 route2], space_guids: %w[space1 space2], - selectors: %w[selector1 selector2], - selector_resource_guids: %w[resource1 resource2], + sources: %w[source1 source2], + source_guids: %w[resource1 resource2], page: 1, per_page: 5, order_by: 'created_at', - include: %w[selector_resource route app space organization] + include: %w[source route app space organization] } end it 'excludes the pagination keys' do - expected_params = %i[guids route_guids space_guids selectors selector_resource_guids include] + expected_params = %i[guids route_guids space_guids sources source_guids include] expect(RoutePoliciesListMessage.from_params(opts).to_param_hash.keys).to match_array(expected_params) end end @@ -73,16 +73,16 @@ module VCAP::CloudController it 'accepts a set of fields' do expect do RoutePoliciesListMessage.from_params({ - guids: [], - route_guids: [], - space_guids: [], - selectors: [], - selector_resource_guids: [], - page: 1, - per_page: 5, - order_by: 'created_at', - include: %w[selector_resource route app space organization] - }) + guids: [], + route_guids: [], + space_guids: [], + sources: [], + source_guids: [], + page: 1, + per_page: 5, + order_by: 'created_at', + include: %w[source route app space organization] + }) end.not_to raise_error end @@ -115,7 +115,7 @@ module VCAP::CloudController message = RoutePoliciesListMessage.from_params({ 'include' => 'organization' }) expect(message).to be_valid - message = RoutePoliciesListMessage.from_params({ 'include' => 'selector_resource,route,app,space,organization' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'source,route,app,space,organization' }) expect(message).to be_valid end @@ -144,22 +144,22 @@ module VCAP::CloudController expect(message.space_guids).to eq(%w[space1 space2]) end - it 'validates selector_resource_guids is an array' do - message = RoutePoliciesListMessage.from_params selector_resource_guids: 'not array' + it 'validates source_guids is an array' do + message = RoutePoliciesListMessage.from_params source_guids: 'not array' expect(message).not_to be_valid expect(message.errors[:source_guids].length).to eq 1 end - it 'allows selector_resource_guids to be nil' do + it 'allows source_guids to be nil' do message = RoutePoliciesListMessage.from_params({}) expect(message).to be_valid - expect(message.selector_resource_guids).to be_nil + expect(message.source_guids).to be_nil end - it 'allows selector_resource_guids to be an array' do - message = RoutePoliciesListMessage.from_params selector_resource_guids: %w[guid1 guid2] + it 'allows source_guids to be an array' do + message = RoutePoliciesListMessage.from_params source_guids: %w[guid1 guid2] expect(message).to be_valid - expect(message.selector_resource_guids).to eq(%w[guid1 guid2]) + expect(message.source_guids).to eq(%w[guid1 guid2]) end end end diff --git a/spec/unit/messages/route_policy_create_message_spec.rb b/spec/unit/messages/route_policy_create_message_spec.rb index d6fa10f5ad4..23e9e852ca2 100644 --- a/spec/unit/messages/route_policy_create_message_spec.rb +++ b/spec/unit/messages/route_policy_create_message_spec.rb @@ -1,5 +1,5 @@ require 'spec_helper' -require 'messages/access_rule_create_message' +require 'messages/route_policy_create_message' module VCAP::CloudController RSpec.describe RoutePolicyCreateMessage do diff --git a/spec/unit/presenters/v3/domain_presenter_spec.rb b/spec/unit/presenters/v3/domain_presenter_spec.rb index 82ce5e79d84..390d13644d9 100644 --- a/spec/unit/presenters/v3/domain_presenter_spec.rb +++ b/spec/unit/presenters/v3/domain_presenter_spec.rb @@ -238,24 +238,24 @@ module VCAP::CloudController::Presenters::V3 end end - context 'when the domain has enforce_access_rules enabled' do + context 'when the domain has enforce_route_policies enabled' do let(:org) { VCAP::CloudController::Organization.make } let(:domain) do VCAP::CloudController::PrivateDomain.make( name: 'mtls.domain.com', owning_organization: org, - enforce_access_rules: true, - access_rules_scope: 'space' + enforce_route_policies: true, + route_policies_scope: 'space' ) end - it 'includes enforce_access_rules and access_rules_scope in the output' do + it 'includes enforce_route_policies and route_policies_scope in the output' do expect(subject[:enforce_route_policies]).to be(true) expect(subject[:route_policies_scope]).to eq('space') end end - context 'when the domain does not have enforce_access_rules enabled' do + context 'when the domain does not have enforce_route_policies enabled' do let(:domain) do VCAP::CloudController::SharedDomain.make( name: 'regular.domain.com' @@ -269,7 +269,7 @@ module VCAP::CloudController::Presenters::V3 allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) end - it 'does not include enforce_access_rules or access_rules_scope in the output' do + it 'does not include enforce_route_policies or route_policies_scope in the output' do expect(subject).not_to have_key(:enforce_route_policies) expect(subject).not_to have_key(:route_policies_scope) end From 884f803bc6c34a1a978ef4cff62774c48a13b36c Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 09:13:34 +0000 Subject: [PATCH 26/64] Fix routing_info_spec: use enforce_route_policies field names --- devbox.d/mysql80/my.cnf | 6 + devbox.json | 69 ++ devbox.lock | 783 ++++++++++++++++++ .../diego/protocol/routing_info_spec.rb | 6 +- 4 files changed, 861 insertions(+), 3 deletions(-) create mode 100644 devbox.d/mysql80/my.cnf create mode 100644 devbox.json create mode 100644 devbox.lock diff --git a/devbox.d/mysql80/my.cnf b/devbox.d/mysql80/my.cnf new file mode 100644 index 00000000000..a749c470084 --- /dev/null +++ b/devbox.d/mysql80/my.cnf @@ -0,0 +1,6 @@ +# MySQL configuration file + +# [mysqld] +# skip-log-bin +# Change this port if 3306 is already used +#port = 3306 diff --git a/devbox.json b/devbox.json new file mode 100644 index 00000000000..24f56d6ef28 --- /dev/null +++ b/devbox.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", + "packages": [ + "ruby@3.3", + "bundler@latest", + "libpq@latest", + "openssl@latest", + "libyaml@latest", + "pkg-config@latest", + "zstd@latest", + "postgresql@latest" + ], + "shell": { + "init_hook": [ + "# Devbox installs only the default nix output (runtime libs). Native Ruby gem", + "# extensions need dev headers and pkg-config files from the -dev outputs.", + "# This hook reads -dev output paths from devbox.lock and adds them to the", + "# compiler search paths.", + "", + "LIBRARY_PATH=\"$DEVBOX_PACKAGES_DIR/lib${LIBRARY_PATH:+:$LIBRARY_PATH}\"", + "C_INCLUDE_PATH=\"$DEVBOX_PACKAGES_DIR/include${C_INCLUDE_PATH:+:$C_INCLUDE_PATH}\"", + "", + "_devbox_realize_dev_outputs() {", + " local lockfile=\"$PWD/devbox.lock\"", + " [ -f \"$lockfile\" ] || return", + " local arch=$(uname -m)", + " local os=$(uname -s | tr '[:upper:]' '[:lower:]')", + " local system=\"${arch}-${os}\"", + "", + " # Extract -dev and -out paths from devbox.lock using ruby (available in our shell)", + " local dev_paths", + " dev_paths=$(ruby -rjson -e '", + " lock = JSON.parse(File.read(ARGV[0]))", + " sys = ARGV[1]", + " lock.fetch(\"packages\", {}).each do |_, info|", + " outputs = info.dig(\"systems\", sys, \"outputs\") || []", + " outputs.each do |o|", + " # Include dev outputs, and also \"out\" outputs that contain includes", + " puts o[\"path\"] if o[\"name\"] == \"dev\" || o[\"name\"] == \"out\"", + " end", + " end", + " end", + " ' \"$lockfile\" \"$system\" 2>/dev/null)", + "", + " local p", + " for p in $dev_paths; do", + " # Realize (download) the store path if not already present", + " [ -d \"$p\" ] || nix-store --realise \"$p\" >/dev/null 2>&1 || continue", + " [ -d \"$p/include\" ] && C_INCLUDE_PATH=\"${p}/include:$C_INCLUDE_PATH\"", + " [ -d \"$p/lib/pkgconfig\" ] && PKG_CONFIG_PATH=\"${p}/lib/pkgconfig:${PKG_CONFIG_PATH:-}\"", + " [ -d \"$p/lib\" ] && LIBRARY_PATH=\"${p}/lib:$LIBRARY_PATH\"", + " done", + "}", + "", + "_devbox_realize_dev_outputs", + "export C_INCLUDE_PATH LIBRARY_PATH PKG_CONFIG_PATH", + "unset -f _devbox_realize_dev_outputs", + "", + "# Set database connection prefix for PostgreSQL tests", + "export POSTGRES_CONNECTION_PREFIX=\"postgres://postgres:supersecret@localhost:5432\"", + "export DB=postgres" + ], + "scripts": { + "test": [ + "echo \"Error: no test specified\" && exit 1" + ] + } + } +} diff --git a/devbox.lock b/devbox.lock new file mode 100644 index 00000000000..beb48f6567f --- /dev/null +++ b/devbox.lock @@ -0,0 +1,783 @@ +{ + "lockfile_version": "1", + "packages": { + "bundler@latest": { + "last_modified": "2026-03-21T07:29:51Z", + "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#bundler", + "source": "devbox-search", + "version": "2.7.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/gbx73y8di7f17i727k6s0l2f1618pza8-bundler-2.7.2", + "default": true + } + ], + "store_path": "/nix/store/gbx73y8di7f17i727k6s0l2f1618pza8-bundler-2.7.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qb2ksb9khr8dpc46h2ajg85zirgz136k-bundler-2.7.2", + "default": true + } + ], + "store_path": "/nix/store/qb2ksb9khr8dpc46h2ajg85zirgz136k-bundler-2.7.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/b04xgfhjcgg1cl1h2bpjm3fds46vgd1w-bundler-2.7.2", + "default": true + } + ], + "store_path": "/nix/store/b04xgfhjcgg1cl1h2bpjm3fds46vgd1w-bundler-2.7.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/bc7ankvbv1s5shlllxxlcsgdj0b16p36-bundler-2.7.2", + "default": true + } + ], + "store_path": "/nix/store/bc7ankvbv1s5shlllxxlcsgdj0b16p36-bundler-2.7.2" + } + } + }, + "github:NixOS/nixpkgs/nixpkgs-unstable": { + "last_modified": "2026-03-16T02:27:38Z", + "resolved": "github:NixOS/nixpkgs/f8573b9c935cfaa162dd62cc9e75ae2db86f85df?lastModified=1773628058&narHash=sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY%3D" + }, + "glibcLocales@latest": { + "last_modified": "2026-03-21T07:29:51Z", + "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#glibcLocales", + "source": "devbox-search", + "version": "2.42-51", + "systems": { + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d4vnp0fbrsvijnx5ac86bbxg3bnblz7k-glibc-locales-2.42-51", + "default": true + } + ], + "store_path": "/nix/store/d4vnp0fbrsvijnx5ac86bbxg3bnblz7k-glibc-locales-2.42-51" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ld2j2mq254m77hwy22pyvrs861ap1374-glibc-locales-2.42-51", + "default": true + } + ], + "store_path": "/nix/store/ld2j2mq254m77hwy22pyvrs861ap1374-glibc-locales-2.42-51" + } + } + }, + "libpq@latest": { + "last_modified": "2026-04-11T06:17:25Z", + "resolved": "github:NixOS/nixpkgs/13043924aaa7375ce482ebe2494338e058282925#libpq", + "source": "devbox-search", + "version": "18.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/yqv7xkfakqfccbgdmkf7xhkda2yzrqd8-libpq-18.2", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/sl9kw8cqc669py9xb83c1baf342l97r5-libpq-18.2-dev" + } + ], + "store_path": "/nix/store/yqv7xkfakqfccbgdmkf7xhkda2yzrqd8-libpq-18.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/4hpn6899j9vh3p9z424vzgqn4ya4lcvv-libpq-18.2", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/xjzx6272qsnbrgmbm3yw1xb3688p5sjb-libpq-18.2-debug" + }, + { + "name": "dev", + "path": "/nix/store/fyaw62ldhlyjcnbdli0y4a9wbrlg5q78-libpq-18.2-dev" + } + ], + "store_path": "/nix/store/4hpn6899j9vh3p9z424vzgqn4ya4lcvv-libpq-18.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/ig7ycilx8a3xal5dharihg0mk15yqwmv-libpq-18.2", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/dvgl05rjdbdk2ck90ccnb8g2hpyhmbbj-libpq-18.2-dev" + } + ], + "store_path": "/nix/store/ig7ycilx8a3xal5dharihg0mk15yqwmv-libpq-18.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/4fi462vs62ycv752lck8v3d70f3blh2x-libpq-18.2", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/gdmv8c5ax77873s7090b3wcicd6i4m51-libpq-18.2-dev" + }, + { + "name": "debug", + "path": "/nix/store/1ljsii50mrkvxnsvq123a9gnqj0cl8ng-libpq-18.2-debug" + } + ], + "store_path": "/nix/store/4fi462vs62ycv752lck8v3d70f3blh2x-libpq-18.2" + } + } + }, + "libyaml@latest": { + "last_modified": "2026-03-21T07:29:51Z", + "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#libyaml", + "source": "devbox-search", + "version": "0.2.5", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/qqa8q98n3hvb2kqz3xvd0m0j22033wy0-libyaml-0.2.5", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/00yv9nvsx0vswzzihkkl4qk39lb2p1pc-libyaml-0.2.5-dev" + } + ], + "store_path": "/nix/store/qqa8q98n3hvb2kqz3xvd0m0j22033wy0-libyaml-0.2.5" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/m4j56in3n01xw1jk5h1qxj5r8i4x2mfb-libyaml-0.2.5", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/jyvgsbxnppxyvvgga304iw6xlhi39r17-libyaml-0.2.5-dev" + } + ], + "store_path": "/nix/store/m4j56in3n01xw1jk5h1qxj5r8i4x2mfb-libyaml-0.2.5" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/4djwsl28pfclczbkcrdqxlqrcxyvihik-libyaml-0.2.5", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/6i8a2m6yj122s9r1nyl8grxizq3av6z6-libyaml-0.2.5-dev" + } + ], + "store_path": "/nix/store/4djwsl28pfclczbkcrdqxlqrcxyvihik-libyaml-0.2.5" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/fnhs945sa02bg7gki8a3l8r9r44ylx10-libyaml-0.2.5", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/v9qn9g4fm4818vx30kl7z423vj1mswml-libyaml-0.2.5-dev" + } + ], + "store_path": "/nix/store/fnhs945sa02bg7gki8a3l8r9r44ylx10-libyaml-0.2.5" + } + } + }, + "openssl@latest": { + "last_modified": "2025-12-05T06:24:47Z", + "resolved": "github:NixOS/nixpkgs/42e29df35be6ef54091d3a3b4e97056ce0a98ce8#openssl", + "source": "devbox-search", + "version": "3.6.0", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/z9prisxci5h5lsk3rdknd4jzq7k9q13d-openssl-3.6.0-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/ii9mnzr3i92mgk9dkgg65739mavd0j6f-openssl-3.6.0-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/h0qgqik0mk0wn7rmm2kk3grfi1wzly74-openssl-3.6.0-dev" + }, + { + "name": "doc", + "path": "/nix/store/yx3ip21fdaaxpjn5fbir02mqnaw9cm4f-openssl-3.6.0-doc" + }, + { + "name": "out", + "path": "/nix/store/3z54dgks2mz3dhwddj158sdibll8xmq5-openssl-3.6.0" + } + ], + "store_path": "/nix/store/z9prisxci5h5lsk3rdknd4jzq7k9q13d-openssl-3.6.0-bin" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/wb6q44n9kcb5acmaa4rgqsajadx1fhhl-openssl-3.6.0-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/c9n1alb7ypzjvzd47m16fiwfczz23qs3-openssl-3.6.0-man", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/ci6d4k1sj4bnr892lsrqqmjiihqsk0bl-openssl-3.6.0-debug" + }, + { + "name": "dev", + "path": "/nix/store/pq8b7fb3282g68pmk14mbyi20qn6chid-openssl-3.6.0-dev" + }, + { + "name": "doc", + "path": "/nix/store/vaplp6w56dyz38986bgkf0pbg3r486b2-openssl-3.6.0-doc" + }, + { + "name": "out", + "path": "/nix/store/nj50gkyx813dxvfmsg1q8m330hmf3h86-openssl-3.6.0" + } + ], + "store_path": "/nix/store/wb6q44n9kcb5acmaa4rgqsajadx1fhhl-openssl-3.6.0-bin" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/m3xwn9n0jypwjgi256idfzs979g30j29-openssl-3.6.0-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/hw43f3y1vl7ydrd4samnwnrwqqwkpisv-openssl-3.6.0-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/dirjrfjk8jgsbdpslgb51cav6qaxn2vm-openssl-3.6.0-dev" + }, + { + "name": "doc", + "path": "/nix/store/va1zhkz0nfmycvd0h239hi4w40qgaxcx-openssl-3.6.0-doc" + }, + { + "name": "out", + "path": "/nix/store/q9a4wssx24xsy28w8kifdqizc01fh7sc-openssl-3.6.0" + } + ], + "store_path": "/nix/store/m3xwn9n0jypwjgi256idfzs979g30j29-openssl-3.6.0-bin" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/a9jdl6xq9fc98ykpvqmc9kf0b0j9y8wh-openssl-3.6.0-man", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/sqv8kbdgfxlr2d6nysr8c2715qpsi6f5-openssl-3.6.0-debug" + }, + { + "name": "dev", + "path": "/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev" + }, + { + "name": "doc", + "path": "/nix/store/cgp9ig35iwicfb9spcrgyg2m5dmlcgrv-openssl-3.6.0-doc" + }, + { + "name": "out", + "path": "/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0" + } + ], + "store_path": "/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin" + } + } + }, + "pkg-config@latest": { + "last_modified": "2025-11-23T21:50:36Z", + "resolved": "github:NixOS/nixpkgs/ee09932cedcef15aaf476f9343d1dea2cb77e261#pkg-config", + "source": "devbox-search", + "version": "0.29.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2", + "default": true + }, + { + "name": "man", + "path": "/nix/store/9px0sji43x3r2w4zxl3j3idwsql7lwxx-pkg-config-wrapper-0.29.2-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/hqk44ra6qxw7iixardl6c3hdgb9kq6ns-pkg-config-wrapper-0.29.2-doc" + } + ], + "store_path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2", + "default": true + }, + { + "name": "man", + "path": "/nix/store/10060k24qggqyzlwdsfmni9y32zxcg0j-pkg-config-wrapper-0.29.2-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/0y4v51ndpyvkj09hwlfqkz0c3h17zfmc-pkg-config-wrapper-0.29.2-doc" + } + ], + "store_path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2", + "default": true + }, + { + "name": "man", + "path": "/nix/store/1nyspra675q22gfhf7hn2nmfpi6rgim5-pkg-config-wrapper-0.29.2-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/7lq1axxwrafwljs06n88bzyz9w523rkc-pkg-config-wrapper-0.29.2-doc" + } + ], + "store_path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2", + "default": true + }, + { + "name": "man", + "path": "/nix/store/j9xfpnrygg3v37svc5pfin9q5bm49r94-pkg-config-wrapper-0.29.2-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/x3bypxdxaq20kykybhkf21x4jczsiy8y-pkg-config-wrapper-0.29.2-doc" + } + ], + "store_path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2" + } + } + }, + "postgresql@latest": { + "last_modified": "2026-04-11T06:17:25Z", + "plugin_version": "0.0.2", + "resolved": "github:NixOS/nixpkgs/13043924aaa7375ce482ebe2494338e058282925#postgresql", + "source": "devbox-search", + "version": "17.9", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/rcz8zc21n6rx4igsdgngmkwnln4f7dy5-postgresql-17.9", + "default": true + }, + { + "name": "man", + "path": "/nix/store/v9ad61kyx28sfzs48j9077iiv61fqzb0-postgresql-17.9-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/gillzna13al7axbhkqyjf7wwfkfbh4nn-postgresql-17.9-doc" + }, + { + "name": "jit", + "path": "/nix/store/9wrci7zgca8ygxgcg8qhk69kkk2hvnvg-postgresql-17.9-jit" + }, + { + "name": "lib", + "path": "/nix/store/h9xg40fr3hqn9lhckdf1sjp2w7zdl92n-postgresql-17.9-lib" + }, + { + "name": "dev", + "path": "/nix/store/yzvwbyh0gqrprnw5rdnhjmcmyvrl9ql4-postgresql-17.9-dev" + }, + { + "name": "plperl", + "path": "/nix/store/ywrc7vv5mdsz79z4nfid0asnzlwxp3zn-postgresql-17.9-plperl" + }, + { + "name": "plpython3", + "path": "/nix/store/jzj6b2zw28dxy8jjfvzlfbmdl8mypv2m-postgresql-17.9-plpython3" + }, + { + "name": "pltcl", + "path": "/nix/store/ya921lh5kkcrdgk09y9580prw5yg27f2-postgresql-17.9-pltcl" + } + ], + "store_path": "/nix/store/rcz8zc21n6rx4igsdgngmkwnln4f7dy5-postgresql-17.9" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/bw97pvf1fg3y7yjvw24g83448v8p48m0-postgresql-17.9", + "default": true + }, + { + "name": "man", + "path": "/nix/store/j22nri44hhgyxbg78glds0im2y608cn9-postgresql-17.9-man", + "default": true + }, + { + "name": "plpython3", + "path": "/nix/store/fha23nr7d2i16ns2z7wsrlx65fxpazxh-postgresql-17.9-plpython3" + }, + { + "name": "pltcl", + "path": "/nix/store/b9zsqpp7znmvxghjy9ihlk3p75xvd3pz-postgresql-17.9-pltcl" + }, + { + "name": "dev", + "path": "/nix/store/gvivc80vkanv4cd41r1fz0dz9qr2bsjq-postgresql-17.9-dev" + }, + { + "name": "doc", + "path": "/nix/store/39f586jzgzlkcc3dp8zajyjnf2w2mymr-postgresql-17.9-doc" + }, + { + "name": "jit", + "path": "/nix/store/iw7rjv0gjb23fwil2j0zjbghrj8bgd7q-postgresql-17.9-jit" + }, + { + "name": "plperl", + "path": "/nix/store/951fcy0jfrwz8rhi8668fqi72wwdj1qa-postgresql-17.9-plperl" + }, + { + "name": "debug", + "path": "/nix/store/rlk7xis3dfyll5z1fny70ksi3yqh1yy7-postgresql-17.9-debug" + }, + { + "name": "lib", + "path": "/nix/store/b25khikzni3m8q8nyv3mrxa5v63bqsam-postgresql-17.9-lib" + } + ], + "store_path": "/nix/store/bw97pvf1fg3y7yjvw24g83448v8p48m0-postgresql-17.9" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/jgrfz7nzjh9m5ymfv0831aamk9ci5ds9-postgresql-17.9", + "default": true + }, + { + "name": "man", + "path": "/nix/store/q824ybxz07qzwrwk9hkd16y0yl7mlp5i-postgresql-17.9-man", + "default": true + }, + { + "name": "doc", + "path": "/nix/store/87c2fid7ppzyd3n3i5id3iiipybgzcp7-postgresql-17.9-doc" + }, + { + "name": "plperl", + "path": "/nix/store/p51i9h8vwml5nj6i91g0hh2zh93c4iap-postgresql-17.9-plperl" + }, + { + "name": "plpython3", + "path": "/nix/store/6a5lqzcdxiqn5nqlfddjdb921z7a35in-postgresql-17.9-plpython3" + }, + { + "name": "dev", + "path": "/nix/store/p99q8ixd6kkw2fr8zpfsmc0m3gwqcjjw-postgresql-17.9-dev" + }, + { + "name": "jit", + "path": "/nix/store/f6qm2151lg98kmayd1kddmgqv9wh1m4f-postgresql-17.9-jit" + }, + { + "name": "lib", + "path": "/nix/store/shz3ms0ww02df1k2qrzk0mv3g6ilr33j-postgresql-17.9-lib" + }, + { + "name": "pltcl", + "path": "/nix/store/cvvvm05xz8735kxb2jqh6gvxfvps1cpw-postgresql-17.9-pltcl" + } + ], + "store_path": "/nix/store/jgrfz7nzjh9m5ymfv0831aamk9ci5ds9-postgresql-17.9" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/zhaly0y0af2m7wyijyhdanm6a9l5lydv-postgresql-17.9", + "default": true + }, + { + "name": "man", + "path": "/nix/store/hgrmddv5rl1axc814n8f27q8gjlxpdz5-postgresql-17.9-man", + "default": true + }, + { + "name": "debug", + "path": "/nix/store/yhvkyzaxm3lcs7kk8qri3ql34p6h7dmc-postgresql-17.9-debug" + }, + { + "name": "doc", + "path": "/nix/store/x41xsx8n2j3l53dr6qfr1w7i9q1pvb3b-postgresql-17.9-doc" + }, + { + "name": "plperl", + "path": "/nix/store/4dnwbih86p5grx6ys7faq29nh9w0krky-postgresql-17.9-plperl" + }, + { + "name": "plpython3", + "path": "/nix/store/rr62jngbsjqim8k5r761h985y88zci8w-postgresql-17.9-plpython3" + }, + { + "name": "pltcl", + "path": "/nix/store/292bd6aqwdsrd3bkvj8yjgwgg5nqlgjv-postgresql-17.9-pltcl" + }, + { + "name": "dev", + "path": "/nix/store/w8vci17bmzkbxclrkjxg2bd3aachf5i8-postgresql-17.9-dev" + }, + { + "name": "jit", + "path": "/nix/store/87sz1iy2q7v0fcsrgbkmryrp390v5sl9-postgresql-17.9-jit" + }, + { + "name": "lib", + "path": "/nix/store/il7gfijl01sxk16h9pffc5yan70vbqfp-postgresql-17.9-lib" + } + ], + "store_path": "/nix/store/zhaly0y0af2m7wyijyhdanm6a9l5lydv-postgresql-17.9" + } + } + }, + "ruby@3.3": { + "last_modified": "2026-01-23T17:20:52Z", + "plugin_version": "0.0.2", + "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#ruby", + "source": "devbox-search", + "version": "3.3.10", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d9wal8y7w1zpvyas3x1q4ykz880mmklk-ruby-3.3.10", + "default": true + }, + { + "name": "devdoc", + "path": "/nix/store/1rfqp0848j3gnm222ls3bipk1azcrrq3-ruby-3.3.10-devdoc" + } + ], + "store_path": "/nix/store/d9wal8y7w1zpvyas3x1q4ykz880mmklk-ruby-3.3.10" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/1hlahw0ijkxx1aqy3x41k3gxpgv34g7d-ruby-3.3.10", + "default": true + }, + { + "name": "devdoc", + "path": "/nix/store/arvi0gqvw07ngbi2ci20dn5ka2jz5irv-ruby-3.3.10-devdoc" + } + ], + "store_path": "/nix/store/1hlahw0ijkxx1aqy3x41k3gxpgv34g7d-ruby-3.3.10" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/as6xdshxpansvkag8zqr602ajkn9079z-ruby-3.3.10", + "default": true + }, + { + "name": "devdoc", + "path": "/nix/store/wix1487x3br4gxa0il4q6llz5xyqxspl-ruby-3.3.10-devdoc" + } + ], + "store_path": "/nix/store/as6xdshxpansvkag8zqr602ajkn9079z-ruby-3.3.10" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/6jz2pgmsh06z9a83qi33f6lp9w2q6mzm-ruby-3.3.10", + "default": true + }, + { + "name": "devdoc", + "path": "/nix/store/kah8xsbcd10iakxqmlw558iarhsrd5vi-ruby-3.3.10-devdoc" + } + ], + "store_path": "/nix/store/6jz2pgmsh06z9a83qi33f6lp9w2q6mzm-ruby-3.3.10" + } + } + }, + "zstd@latest": { + "last_modified": "2026-04-10T12:25:30Z", + "resolved": "github:NixOS/nixpkgs/8c11f88bb9573a10a7d6bf87161ef08455ac70b9#zstd", + "source": "devbox-search", + "version": "1.5.7", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/4crx02pb0lv0x26yly1bnbf3n00cq38m-zstd-1.5.7-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/c3g4ifcw3ad8kpa8yjs8lsac5hvmqzv0-zstd-1.5.7-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/i0hhsvlafn0zx3yl8yfcs714ps5qic00-zstd-1.5.7-dev" + }, + { + "name": "out", + "path": "/nix/store/xq7dsd7b6x66fn1pqsif0pld0nw6rb33-zstd-1.5.7" + } + ], + "store_path": "/nix/store/4crx02pb0lv0x26yly1bnbf3n00cq38m-zstd-1.5.7-bin" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/4x4q96zrz8g7jzz9wm8z94riv7q3zw0j-zstd-1.5.7-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/1xbh2v2pvphs8m06yrgzhrnrwpr0nsvl-zstd-1.5.7-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/c9082kb2i992fi80ix6zi7sa6ijqqrzv-zstd-1.5.7-dev" + }, + { + "name": "out", + "path": "/nix/store/pilcyv83zm3h2gm1924xkfmib9n63b5r-zstd-1.5.7" + } + ], + "store_path": "/nix/store/4x4q96zrz8g7jzz9wm8z94riv7q3zw0j-zstd-1.5.7-bin" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/yc6l9laqs8xs5q0ivxbr9as55x0yc9bh-zstd-1.5.7-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/gfq90rph1rzzwxkhw5pq4ywd5vy0rapa-zstd-1.5.7-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/jyyscffl8vhrgq34yl5dpf17pwz9v0d4-zstd-1.5.7-dev" + }, + { + "name": "out", + "path": "/nix/store/mdy5l0qf8z6p9xyn2igix156smcmkag8-zstd-1.5.7" + } + ], + "store_path": "/nix/store/yc6l9laqs8xs5q0ivxbr9as55x0yc9bh-zstd-1.5.7-bin" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "bin", + "path": "/nix/store/8cc8cdqa54bahfi5n5glkm8d252zkkjn-zstd-1.5.7-bin", + "default": true + }, + { + "name": "man", + "path": "/nix/store/bhms1y19818704k4aljz6mb8prjbxd1y-zstd-1.5.7-man", + "default": true + }, + { + "name": "dev", + "path": "/nix/store/q4v09bffjy5i0f2kdwnbbwmhqv6i3pjs-zstd-1.5.7-dev" + }, + { + "name": "out", + "path": "/nix/store/29mmnqpc1p3iv8wj0lpvicajy3jsbx87-zstd-1.5.7" + } + ], + "store_path": "/nix/store/8cc8cdqa54bahfi5n5glkm8d252zkkjn-zstd-1.5.7-bin" + } + } + } + } +} diff --git a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb index 663596e3334..b33b1526704 100644 --- a/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/protocol/routing_info_spec.rb @@ -251,14 +251,14 @@ class Protocol end end - context 'when the route domain has enforce_access_rules enabled' do + context 'when the route domain has enforce_route_policies enabled' do let(:valid_uuid) { '11111111-2222-3333-4444-555555555555' } let(:enforce_domain) do PrivateDomain.make( name: 'mtls.example.com', owning_organization: org, - enforce_access_rules: true, - access_rules_scope: 'space' + enforce_route_policies: true, + route_policies_scope: 'space' ) end let(:mtls_route) { Route.make(host: 'myapp', domain: enforce_domain, space: space) } From 9a24c3d94b3e4580431bf1749e3c376df402869a Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 09:37:24 +0000 Subject: [PATCH 27/64] Fix domain_create_message_spec: use enforce_route_policies field names --- .../messages/domain_create_message_spec.rb | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/spec/unit/messages/domain_create_message_spec.rb b/spec/unit/messages/domain_create_message_spec.rb index 9041792e1c8..cf71ae6936b 100644 --- a/spec/unit/messages/domain_create_message_spec.rb +++ b/spec/unit/messages/domain_create_message_spec.rb @@ -406,7 +406,7 @@ module VCAP::CloudController context 'enforce_route_policies' do context 'when not a boolean' do - let(:params) { { name: 'name.com', enforce_access_rules: 'yes' } } + let(:params) { { name: 'name.com', enforce_route_policies: 'yes' } } it 'is not valid' do expect(subject).not_to be_valid @@ -414,25 +414,25 @@ module VCAP::CloudController end end - context 'when true without access_rules_scope' do - let(:params) { { name: 'name.com', enforce_access_rules: true } } + context 'when true without route_policies_scope' do + let(:params) { { name: 'name.com', enforce_route_policies: true } } it 'is not valid' do expect(subject).not_to be_valid - expect(subject.errors[:route_policies_scope]).to include('is required when enforce_access_rules is true') + expect(subject.errors[:route_policies_scope]).to include('is required when enforce_route_policies is true') end end - context 'when true with a valid access_rules_scope' do - let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'space' } } + context 'when true with a valid route_policies_scope' do + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'space' } } it 'is valid' do expect(subject).to be_valid end end - context 'when false without access_rules_scope' do - let(:params) { { name: 'name.com', enforce_access_rules: false } } + context 'when false without route_policies_scope' do + let(:params) { { name: 'name.com', enforce_route_policies: false } } it 'is valid' do expect(subject).to be_valid @@ -450,7 +450,7 @@ module VCAP::CloudController context 'route_policies_scope' do context 'when set to an invalid value' do - let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'invalid' } } + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'invalid' } } it 'is not valid' do expect(subject).not_to be_valid @@ -459,7 +459,7 @@ module VCAP::CloudController end context "when set to 'any'" do - let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'any' } } + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'any' } } it 'is valid' do expect(subject).to be_valid @@ -467,7 +467,7 @@ module VCAP::CloudController end context "when set to 'org'" do - let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'org' } } + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'org' } } it 'is valid' do expect(subject).to be_valid @@ -475,15 +475,15 @@ module VCAP::CloudController end context "when set to 'space'" do - let(:params) { { name: 'name.com', enforce_access_rules: true, access_rules_scope: 'space' } } + let(:params) { { name: 'name.com', enforce_route_policies: true, route_policies_scope: 'space' } } it 'is valid' do expect(subject).to be_valid end end - context 'when provided without enforce_access_rules' do - let(:params) { { name: 'name.com', access_rules_scope: 'space' } } + context 'when provided without enforce_route_policies' do + let(:params) { { name: 'name.com', route_policies_scope: 'space' } } it 'is valid (scope alone is permissible)' do expect(subject).to be_valid From 04b93b57fc519ba380bf3d53b126b4d7ebc6a993 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 10:33:52 +0000 Subject: [PATCH 28/64] Fix route_policies_spec: use 'Source' terminology and 'sources' query param - Line 206: expect error message to include 'Source' (not 'Selector') - Line 292: use query param ?sources= (not ?selectors=) - Line 344: use query param ?sources= in combined filter test These complete the terminology rebrand from 'selector' to 'source' in the route policies context per RFC be8d74c1. --- spec/request/route_policies_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index bfce52e18d8..ebd6a69ccc4 100644 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -203,7 +203,7 @@ def expected_rule_json(rule) }.to_json, admin_header expect(last_response.status).to eq(422) - expect(last_response.body).to include('Selector') + expect(last_response.body).to include('Source') end end @@ -289,7 +289,7 @@ def expected_rule_json(rule) end it 'filters by selectors' do - get '/v3/route_policies?selectors=cf:any', nil, admin_header + get '/v3/route_policies?sources=cf:any', nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) @@ -341,7 +341,7 @@ def expected_rule_json(rule) end it 'combines space_guids with other filters' do - get "/v3/route_policies?space_guids=#{space.guid}&selectors=cf:app:#{valid_uuid}", nil, admin_header + get "/v3/route_policies?space_guids=#{space.guid}&sources=cf:app:#{valid_uuid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) From cfb84f1fd224793f9f9b11ffaef2d5f705af7b1d Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 10:34:00 +0000 Subject: [PATCH 29/64] Revert: restore label_selector (was incorrectly renamed to label_source) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During the selector→source rebrand for route policies, we accidentally renamed 'label_selector' to 'label_source' across the test suite. This was WRONG because: - label_selector is a standard Cloud Controller query parameter for filtering resources by labels/annotations (e.g., ?label_selector=env=prod) - It is used across many endpoints: apps, packages, orgs, buildpacks, etc. - It has nothing to do with route policies or the terminology rebrand - The overly broad sed command caught it by mistake This commit restores label_selector to its correct name, fixing 13+ test failures caused by the regression. --- spec/request/apps_spec.rb | 2 +- spec/request/buildpacks_spec.rb | 2 +- spec/request/builds_spec.rb | 2 +- spec/request/deployments_spec.rb | 2 +- spec/request/domains_spec.rb | 2 +- spec/request/droplets_spec.rb | 4 ++-- spec/request/isolation_segments_spec.rb | 2 +- spec/request/organizations_spec.rb | 6 +++--- spec/request/packages_spec.rb | 2 +- spec/request/processes_spec.rb | 2 +- spec/request/revisions_spec.rb | 2 +- spec/request/routes_spec.rb | 2 +- spec/request/service_brokers_spec.rb | 2 +- spec/request/service_credential_bindings_spec.rb | 2 +- spec/request/service_instances_spec.rb | 2 +- spec/request/service_offerings_spec.rb | 2 +- spec/request/service_plans_spec.rb | 2 +- spec/request/service_route_bindings_spec.rb | 2 +- spec/request/spaces_spec.rb | 6 +++--- spec/request/stacks_spec.rb | 2 +- spec/request/tasks_spec.rb | 6 +++--- spec/request/users_spec.rb | 4 ++-- spec/unit/controllers/v3/apps_controller_spec.rb | 2 +- spec/unit/fetchers/service_broker_list_fetcher_spec.rb | 4 ++-- spec/unit/fetchers/service_offering_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/service_plan_list_fetcher_spec.rb | 2 +- spec/unit/messages/app_revisions_list_message_spec.rb | 4 ++-- spec/unit/messages/apps_list_message_spec.rb | 4 ++-- spec/unit/messages/buildpacks_list_message_spec.rb | 4 ++-- spec/unit/messages/isolation_segments_list_message_spec.rb | 4 ++-- spec/unit/messages/list_message_spec.rb | 2 +- spec/unit/messages/packages_list_message_spec.rb | 2 +- spec/unit/messages/processes_list_message_spec.rb | 2 +- spec/unit/messages/tasks_list_message_spec.rb | 2 +- 34 files changed, 47 insertions(+), 47 deletions(-) diff --git a/spec/request/apps_spec.rb b/spec/request/apps_spec.rb index 0519a73b729..7e90a7d013c 100644 --- a/spec/request/apps_spec.rb +++ b/spec/request/apps_spec.rb @@ -607,7 +607,7 @@ stacks: 'cf', include: 'space', lifecycle_type: 'buildpack', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/buildpacks_spec.rb b/spec/request/buildpacks_spec.rb index 92ca4ddab84..81b07353d88 100644 --- a/spec/request/buildpacks_spec.rb +++ b/spec/request/buildpacks_spec.rb @@ -29,7 +29,7 @@ names: 'foo', stacks: 'cf', lifecycle: 'buildpack', - label_source: 'foo,bar', + label_selector: 'foo,bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/builds_spec.rb b/spec/request/builds_spec.rb index a8acecc4ec7..89b3fa2bc70 100644 --- a/spec/request/builds_spec.rb +++ b/spec/request/builds_spec.rb @@ -410,7 +410,7 @@ guids: '123', app_guids: '123', package_guids: '123', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/deployments_spec.rb b/spec/request/deployments_spec.rb index 159fd9844d2..88db57c603f 100644 --- a/spec/request/deployments_spec.rb +++ b/spec/request/deployments_spec.rb @@ -2077,7 +2077,7 @@ def json_for_options(deployment) status_values: 'foo', status_reasons: 'foo', app_guids: '123', - label_source: 'bar', + label_selector: 'bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/domains_spec.rb b/spec/request/domains_spec.rb index f0969eaaa2c..024b7792086 100644 --- a/spec/request/domains_spec.rb +++ b/spec/request/domains_spec.rb @@ -31,7 +31,7 @@ names: 'foo,bar', guids: 'foo,bar', organization_guids: 'foo,bar', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/droplets_spec.rb b/spec/request/droplets_spec.rb index 362307cbc0a..adb72532112 100644 --- a/spec/request/droplets_spec.rb +++ b/spec/request/droplets_spec.rb @@ -646,7 +646,7 @@ space_guids: 'test', states: %w[test foo], organization_guids: 'foo,bar', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -674,7 +674,7 @@ current: true, package_guid: package_model.guid, states: %w[test foo], - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/isolation_segments_spec.rb b/spec/request/isolation_segments_spec.rb index fcdf51f34c3..c362272f507 100644 --- a/spec/request/isolation_segments_spec.rb +++ b/spec/request/isolation_segments_spec.rb @@ -269,7 +269,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/organizations_spec.rb b/spec/request/organizations_spec.rb index f01f355a5a7..22b2f35624e 100644 --- a/spec/request/organizations_spec.rb +++ b/spec/request/organizations_spec.rb @@ -227,7 +227,7 @@ module VCAP::CloudController page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -1548,7 +1548,7 @@ module VCAP::CloudController page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -1574,7 +1574,7 @@ module VCAP::CloudController page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/packages_spec.rb b/spec/request/packages_spec.rb index 654f0adb13f..81a05c313fd 100644 --- a/spec/request/packages_spec.rb +++ b/spec/request/packages_spec.rb @@ -481,7 +481,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/processes_spec.rb b/spec/request/processes_spec.rb index 105a835a91b..fb2cf1bf20e 100644 --- a/spec/request/processes_spec.rb +++ b/spec/request/processes_spec.rb @@ -121,7 +121,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/revisions_spec.rb b/spec/request/revisions_spec.rb index c09a830eb0c..58b9f7e5c85 100644 --- a/spec/request/revisions_spec.rb +++ b/spec/request/revisions_spec.rb @@ -185,7 +185,7 @@ order_by: 'updated_at', guids: app_model.guid.to_s, versions: '1,2', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/routes_spec.rb b/spec/request/routes_spec.rb index c953e2b0e58..a8ad0dc1e2d 100644 --- a/spec/request/routes_spec.rb +++ b/spec/request/routes_spec.rb @@ -165,7 +165,7 @@ hosts: 'foo', ports: 636, include: 'domain', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/service_brokers_spec.rb b/spec/request/service_brokers_spec.rb index 7efaec764dc..69e403581ce 100644 --- a/spec/request/service_brokers_spec.rb +++ b/spec/request/service_brokers_spec.rb @@ -113,7 +113,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_source: 'foo==bar', + label_selector: 'foo==bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/service_credential_bindings_spec.rb b/spec/request/service_credential_bindings_spec.rb index 46a2d9d5d74..68835e7765c 100644 --- a/spec/request/service_credential_bindings_spec.rb +++ b/spec/request/service_credential_bindings_spec.rb @@ -53,7 +53,7 @@ guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 }, - label_source: 'env' + label_selector: 'env' } end end diff --git a/spec/request/service_instances_spec.rb b/spec/request/service_instances_spec.rb index f765828702a..b9c69073434 100644 --- a/spec/request/service_instances_spec.rb +++ b/spec/request/service_instances_spec.rb @@ -170,7 +170,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', type: 'managed', service_plan_guids: %w[guid-1 guid-2], service_plan_names: %w[plan-1 plan-2], diff --git a/spec/request/service_offerings_spec.rb b/spec/request/service_offerings_spec.rb index 742f49e9e65..4076abeba89 100644 --- a/spec/request/service_offerings_spec.rb +++ b/spec/request/service_offerings_spec.rb @@ -219,7 +219,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_source: 'foo==bar', + label_selector: 'foo==bar', fields: { 'service_broker' => 'name' }, guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", diff --git a/spec/request/service_plans_spec.rb b/spec/request/service_plans_spec.rb index b97c3aebc26..aaf5ba5963e 100644 --- a/spec/request/service_plans_spec.rb +++ b/spec/request/service_plans_spec.rb @@ -197,7 +197,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_source: 'foo==bar', + label_selector: 'foo==bar', fields: { 'service_offering.service_broker' => 'name' }, guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", diff --git a/spec/request/service_route_bindings_spec.rb b/spec/request/service_route_bindings_spec.rb index 69833d0316b..af2cde52cc0 100644 --- a/spec/request/service_route_bindings_spec.rb +++ b/spec/request/service_route_bindings_spec.rb @@ -71,7 +71,7 @@ per_page: '10', page: 2, order_by: 'updated_at', - label_source: 'foo==bar', + label_selector: 'foo==bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/spaces_spec.rb b/spec/request/spaces_spec.rb index f2edce375f1..589028b6cb1 100644 --- a/spec/request/spaces_spec.rb +++ b/spec/request/spaces_spec.rb @@ -263,7 +263,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -1304,7 +1304,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -1330,7 +1330,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/request/stacks_spec.rb b/spec/request/stacks_spec.rb index 0931f9a0da4..644ce145ea7 100644 --- a/spec/request/stacks_spec.rb +++ b/spec/request/stacks_spec.rb @@ -86,7 +86,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', guids: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } diff --git a/spec/request/tasks_spec.rb b/spec/request/tasks_spec.rb index 75c549371ab..8fa34e57a70 100644 --- a/spec/request/tasks_spec.rb +++ b/spec/request/tasks_spec.rb @@ -60,7 +60,7 @@ names: %w[foo bar], states: %w[test foo], organization_guids: 'foo,bar', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -89,7 +89,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -347,7 +347,7 @@ organization_guids: app_model.organization.guid, space_guids: app_model.space.guid, states: 'SUCCEEDED', - label_source: 'boomerang' + label_selector: 'boomerang' } get "/v3/tasks?#{query.to_query}", nil, developer_headers diff --git a/spec/request/users_spec.rb b/spec/request/users_spec.rb index d2bd984a1d8..bf15263c0ca 100644 --- a/spec/request/users_spec.rb +++ b/spec/request/users_spec.rb @@ -116,7 +116,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } @@ -142,7 +142,7 @@ page: '2', per_page: '10', order_by: 'updated_at', - label_source: 'foo,bar', + label_selector: 'foo,bar', created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", updated_ats: { gt: Time.now.utc.iso8601 } } diff --git a/spec/unit/controllers/v3/apps_controller_spec.rb b/spec/unit/controllers/v3/apps_controller_spec.rb index d9b3e39e487..f74f841b593 100644 --- a/spec/unit/controllers/v3/apps_controller_spec.rb +++ b/spec/unit/controllers/v3/apps_controller_spec.rb @@ -135,7 +135,7 @@ context 'label_selection' do it 'returns a 400 when the label_selector is invalid' do - get :index, params: { label_source: 'buncha nonsense' } + get :index, params: { label_selector: 'buncha nonsense' } expect(response).to have_http_status(:bad_request) diff --git a/spec/unit/fetchers/service_broker_list_fetcher_spec.rb b/spec/unit/fetchers/service_broker_list_fetcher_spec.rb index 8bd10f0bb1d..38ae39fe8ec 100644 --- a/spec/unit/fetchers/service_broker_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_broker_list_fetcher_spec.rb @@ -167,7 +167,7 @@ module VCAP::CloudController let(:filters) do { space_guids: [space_2.guid], - label_source: 'dog in (poodle,scooby-doo)', + label_selector: 'dog in (poodle,scooby-doo)', names: [space_scoped_broker_1.name] } end @@ -196,7 +196,7 @@ module VCAP::CloudController let(:filters) do { space_guids: [space_1.guid, space_2.guid], - label_source: 'dog in (poodle,scooby-doo)', + label_selector: 'dog in (poodle,scooby-doo)', names: [space_scoped_broker_1.name] } end diff --git a/spec/unit/fetchers/service_offering_list_fetcher_spec.rb b/spec/unit/fetchers/service_offering_list_fetcher_spec.rb index 8391f8a6e4f..6870940f3f7 100644 --- a/spec/unit/fetchers/service_offering_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_offering_list_fetcher_spec.rb @@ -490,7 +490,7 @@ module VCAP::CloudController let!(:service_offering_1) { VCAP::CloudController::ServicePlan.make(public: true, active: true).service } let!(:service_offering_2) { VCAP::CloudController::ServicePlan.make(public: true, active: true).service } let!(:service_offering_3) { VCAP::CloudController::ServicePlan.make(public: true, active: true).service } - let(:message) { ServiceOfferingsListMessage.from_params({ label_source: 'flavor=orange' }.with_indifferent_access) } + let(:message) { ServiceOfferingsListMessage.from_params({ label_selector: 'flavor=orange' }.with_indifferent_access) } before do VCAP::CloudController::ServiceOfferingLabelModel.make(resource_guid: service_offering_1.guid, key_name: 'flavor', value: 'orange') diff --git a/spec/unit/fetchers/service_plan_list_fetcher_spec.rb b/spec/unit/fetchers/service_plan_list_fetcher_spec.rb index 17f2220646e..4e45898df31 100644 --- a/spec/unit/fetchers/service_plan_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_plan_list_fetcher_spec.rb @@ -491,7 +491,7 @@ module VCAP::CloudController let!(:service_plan_1) { VCAP::CloudController::ServicePlan.make(public: true, active: true) } let!(:service_plan_2) { VCAP::CloudController::ServicePlan.make(public: true, active: true) } let!(:service_plan_3) { VCAP::CloudController::ServicePlan.make(public: true, active: true) } - let(:message) { ServicePlansListMessage.from_params({ label_source: 'flavor=orange' }.with_indifferent_access) } + let(:message) { ServicePlansListMessage.from_params({ label_selector: 'flavor=orange' }.with_indifferent_access) } before do VCAP::CloudController::ServicePlanLabelModel.make(resource_guid: service_plan_1.guid, key_name: 'flavor', value: 'orange') diff --git a/spec/unit/messages/app_revisions_list_message_spec.rb b/spec/unit/messages/app_revisions_list_message_spec.rb index dd1e8d2b0f3..2a3c7849657 100644 --- a/spec/unit/messages/app_revisions_list_message_spec.rb +++ b/spec/unit/messages/app_revisions_list_message_spec.rb @@ -40,7 +40,7 @@ module VCAP::CloudController let(:opts) do { versions: %w[1 3], - label_source: 'key=value', + label_selector: 'key=value', page: 1, per_page: 5, deployable: true @@ -61,7 +61,7 @@ module VCAP::CloudController per_page: 5, versions: ['1'], deployable: true, - label_source: 'key=value' + label_selector: 'key=value' }) end.not_to raise_error end diff --git a/spec/unit/messages/apps_list_message_spec.rb b/spec/unit/messages/apps_list_message_spec.rb index e4ac1463702..e1f76f88844 100644 --- a/spec/unit/messages/apps_list_message_spec.rb +++ b/spec/unit/messages/apps_list_message_spec.rb @@ -67,7 +67,7 @@ module VCAP::CloudController per_page: 5, order_by: 'created_at', include: ['space', 'space.organization'], - label_source: 'foo in (stuff,things)', + label_selector: 'foo in (stuff,things)', lifecycle_type: 'buildpack' } end @@ -90,7 +90,7 @@ module VCAP::CloudController per_page: 5, order_by: 'created_at', include: ['space', 'space.organization'], - label_source: 'foo in (stuff,things)', + label_selector: 'foo in (stuff,things)', lifecycle_type: 'buildpack' }) end.not_to raise_error diff --git a/spec/unit/messages/buildpacks_list_message_spec.rb b/spec/unit/messages/buildpacks_list_message_spec.rb index 3f7bed0e566..605c4fbac5e 100644 --- a/spec/unit/messages/buildpacks_list_message_spec.rb +++ b/spec/unit/messages/buildpacks_list_message_spec.rb @@ -47,7 +47,7 @@ module VCAP::CloudController names: %w[name1 name2], stacks: %w[stack1 stack2], lifecycle: 'buildpack', - label_source: 'foo=bar', + label_selector: 'foo=bar', page: 1, per_page: 5 } @@ -65,7 +65,7 @@ module VCAP::CloudController BuildpacksListMessage.from_params({ names: [], stacks: [], - label_source: '', + label_selector: '', lifecycle: 'buildpack' }) end.not_to raise_error diff --git a/spec/unit/messages/isolation_segments_list_message_spec.rb b/spec/unit/messages/isolation_segments_list_message_spec.rb index 5160108ef5a..38ba6a7679f 100644 --- a/spec/unit/messages/isolation_segments_list_message_spec.rb +++ b/spec/unit/messages/isolation_segments_list_message_spec.rb @@ -52,7 +52,7 @@ module VCAP::CloudController names: %w[name1 name2], guids: %w[guid1 guid2], organization_guids: %w[o-guid1 o-guid2], - label_source: 'foo=bar', + label_selector: 'foo=bar', page: 1, per_page: 5, order_by: 'created_at', @@ -81,7 +81,7 @@ module VCAP::CloudController names: [], guids: [], organization_guids: [], - label_source: '', + label_selector: '', page: 1, per_page: 5, order_by: 'created_at' diff --git a/spec/unit/messages/list_message_spec.rb b/spec/unit/messages/list_message_spec.rb index 3202829f105..2f48b716800 100644 --- a/spec/unit/messages/list_message_spec.rb +++ b/spec/unit/messages/list_message_spec.rb @@ -289,7 +289,7 @@ def self.from_params(params) end it 'handles ruby symbols' do - message = list_message_klass.from_params(label_source: 'example.com/foo==bar') + message = list_message_klass.from_params(label_selector: 'example.com/foo==bar') expect(message.requirements.first.key).to eq('example.com/foo') end end diff --git a/spec/unit/messages/packages_list_message_spec.rb b/spec/unit/messages/packages_list_message_spec.rb index 2389e16b35d..c3ef1fa369e 100644 --- a/spec/unit/messages/packages_list_message_spec.rb +++ b/spec/unit/messages/packages_list_message_spec.rb @@ -57,7 +57,7 @@ module VCAP::CloudController app_guids: %w[appguid1 appguid2], organization_guids: %w[organizationguid1 organizationguid2], app_guid: 'appguid', - label_source: 'key=value', + label_selector: 'key=value', page: 1, per_page: 5 } diff --git a/spec/unit/messages/processes_list_message_spec.rb b/spec/unit/messages/processes_list_message_spec.rb index 09544964c01..4346e772dc6 100644 --- a/spec/unit/messages/processes_list_message_spec.rb +++ b/spec/unit/messages/processes_list_message_spec.rb @@ -67,7 +67,7 @@ module VCAP::CloudController app_guid: 'appguid', embed: 'process_instances', page: 1, - label_source: 'key=value', + label_selector: 'key=value', per_page: 5, order_by: 'created_at', created_ats: [Time.now.utc.iso8601, Time.now.utc.iso8601], diff --git a/spec/unit/messages/tasks_list_message_spec.rb b/spec/unit/messages/tasks_list_message_spec.rb index f5c4950d22b..32177588787 100644 --- a/spec/unit/messages/tasks_list_message_spec.rb +++ b/spec/unit/messages/tasks_list_message_spec.rb @@ -64,7 +64,7 @@ module VCAP::CloudController organization_guids: %w[orgguid1 orgguid2], space_guids: %w[spaceguid1 spaceguid2], sequence_ids: ['1, 2'], - label_source: 'unicycling=fred', + label_selector: 'unicycling=fred', page: 1, per_page: 5 } From 0377ac1090a5270561306bb1fe0a7b1d06b9dac0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 7 May 2026 09:35:18 +0000 Subject: [PATCH 30/64] Add API documentation for Route Policies - Add comprehensive Route Policies API documentation - Create, get, list, update, delete endpoints - Complete filtering documentation (guids, route_guids, space_guids, sources, source_guids) - Include parameter documentation (route, app, space, organization, source) - Practical use case examples for common scenarios - Validation rules and permission matrices - Update Domains documentation - Add enforce_route_policies and route_policies_scope fields - Document immutability of enforcement settings - Add example for creating identity-aware domain - Update index.html.md to include route_policies resources Route policies enable identity-aware routing by controlling which Cloud Foundry apps, spaces, or organizations can access routes on domains with enforce_route_policies enabled. GoRouter enforces these access controls using mutual TLS (mTLS). --- .../includes/api_resources/_domains.erb | 31 ++++ .../api_resources/_route_policies.erb | 131 +++++++++++++++ .../includes/resources/domains/_create.md.erb | 19 +++ .../includes/resources/domains/_object.md.erb | 2 + .../resources/route_policies/_create.md.erb | 115 +++++++++++++ .../resources/route_policies/_delete.md.erb | 31 ++++ .../resources/route_policies/_get.md.erb | 40 +++++ .../resources/route_policies/_header.md | 11 ++ .../resources/route_policies/_list.md.erb | 152 ++++++++++++++++++ .../resources/route_policies/_object.md.erb | 23 +++ .../resources/route_policies/_update.md.erb | 51 ++++++ docs/v3/source/index.html.md | 8 + 12 files changed, 614 insertions(+) create mode 100644 docs/v3/source/includes/api_resources/_route_policies.erb create mode 100644 docs/v3/source/includes/resources/route_policies/_create.md.erb create mode 100644 docs/v3/source/includes/resources/route_policies/_delete.md.erb create mode 100644 docs/v3/source/includes/resources/route_policies/_get.md.erb create mode 100644 docs/v3/source/includes/resources/route_policies/_header.md create mode 100644 docs/v3/source/includes/resources/route_policies/_list.md.erb create mode 100644 docs/v3/source/includes/resources/route_policies/_object.md.erb create mode 100644 docs/v3/source/includes/resources/route_policies/_update.md.erb diff --git a/docs/v3/source/includes/api_resources/_domains.erb b/docs/v3/source/includes/api_resources/_domains.erb index 3c56a181965..bb330ceda33 100644 --- a/docs/v3/source/includes/api_resources/_domains.erb +++ b/docs/v3/source/includes/api_resources/_domains.erb @@ -87,6 +87,37 @@ "href": "https://api.example.org/routing/v1/router_groups/5806148f-cce6-4d86-7fbd-aa269e3f6f3f" } } + }, + { + "guid": "9b2f3d89-3f89-4f05-8188-8a2b298c79d5", + "created_at": "2026-04-21T10:15:30Z", + "updated_at": "2026-04-21T10:15:30Z", + "name": "apps.identity", + "internal": false, + "router_group": null, + "supported_protocols": ["http"], + "enforce_route_policies": true, + "route_policies_scope": "org", + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": { + "organization": { + "data": null + }, + "shared_organizations": { + "data": [] + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/domains/9b2f3d89-3f89-4f05-8188-8a2b298c79d5" + }, + "route_reservations": { + "href": "https://api.example.org/v3/domains/9b2f3d89-3f89-4f05-8188-8a2b298c79d5/route_reservations" + } + } } ] } diff --git a/docs/v3/source/includes/api_resources/_route_policies.erb b/docs/v3/source/includes/api_resources/_route_policies.erb new file mode 100644 index 00000000000..de53e9b9e63 --- /dev/null +++ b/docs/v3/source/includes/api_resources/_route_policies.erb @@ -0,0 +1,131 @@ +<% content_for :single_route_policy do | metadata={} | %> +{ + "guid": "a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f", + "created_at": "2026-04-21T10:15:30Z", + "updated_at": "2026-04-21T10:15:30Z", + "source": "cf:app:d76446a1-f429-4444-8797-be2f78b75b08", + "metadata": { + "labels": <%= metadata.fetch(:labels, {}).to_json(space: ' ', object_nl: ' ')%>, + "annotations": <%= metadata.fetch(:annotations, {}).to_json(space: ' ', object_nl: ' ')%> + }, + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + }, + "app": { + "data": { + "guid": "d76446a1-f429-4444-8797-be2f78b75b08" + } + }, + "space": { + "data": null + }, + "organization": { + "data": null + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" + }, + "route": { + "href": "https://api.example.org/v3/routes/89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } +} +<% end %> + +<% content_for :paginated_list_of_route_policies do |base_url| %> +{ + "pagination": { + "total_results": 3, + "total_pages": 2, + "first": { + "href": "https://api.example.org<%= base_url %>?page=1&per_page=2" + }, + "last": { + "href": "https://api.example.org<%= base_url %>?page=2&per_page=2" + }, + "next": { + "href": "https://api.example.org<%= base_url %>?page=2&per_page=2" + }, + "previous": null + }, + "resources": [ + { + "guid": "a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f", + "created_at": "2026-04-21T10:15:30Z", + "updated_at": "2026-04-21T10:15:30Z", + "source": "cf:app:d76446a1-f429-4444-8797-be2f78b75b08", + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + }, + "app": { + "data": { + "guid": "d76446a1-f429-4444-8797-be2f78b75b08" + } + }, + "space": { + "data": null + }, + "organization": { + "data": null + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" + }, + "route": { + "href": "https://api.example.org/v3/routes/89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + }, + { + "guid": "f2b5d8c3-92a1-4e3f-b847-9c8f1d2e3a4b", + "created_at": "2026-04-21T11:20:45Z", + "updated_at": "2026-04-21T11:20:45Z", + "source": "cf:space:3fa85f64-5717-4562-b3fc-2c963f66afa6", + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + }, + "app": { + "data": null + }, + "space": { + "data": { + "guid": "3fa85f64-5717-4562-b3fc-2c963f66afa6" + } + }, + "organization": { + "data": null + } + }, + "links": { + "self": { + "href": "https://api.example.org/v3/route_policies/f2b5d8c3-92a1-4e3f-b847-9c8f1d2e3a4b" + }, + "route": { + "href": "https://api.example.org/v3/routes/89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + } + ] +} +<% end %> diff --git a/docs/v3/source/includes/resources/domains/_create.md.erb b/docs/v3/source/includes/resources/domains/_create.md.erb index 46f24e34f0e..6598afc801a 100644 --- a/docs/v3/source/includes/resources/domains/_create.md.erb +++ b/docs/v3/source/includes/resources/domains/_create.md.erb @@ -15,6 +15,23 @@ curl "https://api.example.org/v3/domains" \ }' ``` +``` +Example Request (Identity-Aware Domain) +``` + +```shell +curl "https://api.example.org/v3/domains" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "name": "apps.identity", + "internal": false, + "enforce_route_policies": true, + "route_policies_scope": "org" + }' +``` + ``` Example Response ``` @@ -41,6 +58,8 @@ Name | Type | Description | ----------- | -------- | ------------------------------------------------------------------------ | ------- | | **internal** | _boolean_ | Whether the domain is used for internal (container-to-container) traffic, or external (user-to-container) traffic | false | | **router_group.guid** | _uuid_ | The desired router group guid.
_note: creates a `tcp` domain; cannot be used when `internal` is set to `true` or domain is scoped to an org_ | null | +| **enforce_route_policies** | _boolean_ | When `true`, GoRouter enforces route policies for routes on this domain using mutual TLS (mTLS). **Immutable** after creation. Cannot be used with internal domains | false | +| **route_policies_scope** | _string_ | Operator-defined boundary for allowed callers: `any`, `org`, or `space`. Required when `enforce_route_policies` is `true`. **Immutable** after creation | | | **organization** | [_to-one relationship_](#to-one-relationships) | A relationship to the organization the domain will be scoped to;
_note: cannot be used when `internal` is set to `true` or domain is associated with a router group_ | | | **shared_organizations** | [_to-many relationship_](#to-many-relationships) | A relationship to organizations the domain will be shared with
_Note: cannot be used without an organization relationship_ | | | **metadata.labels** | [_label object_](#labels) | Labels applied to the domain | | diff --git a/docs/v3/source/includes/resources/domains/_object.md.erb b/docs/v3/source/includes/resources/domains/_object.md.erb index 3637d4f0a4f..e4cc5c5fad2 100644 --- a/docs/v3/source/includes/resources/domains/_object.md.erb +++ b/docs/v3/source/includes/resources/domains/_object.md.erb @@ -17,6 +17,8 @@ Example Domain object | **internal** | _boolean_ | Whether the domain is used for internal (container-to-container) traffic | **router_group.guid** | _uuid_ | The guid of the desired router group to route `tcp` traffic through; if set, the domain will only be available for `tcp` traffic | **supported_protocols** | _list of strings_ | Available protocols for routes using the domain, currently `http` and `tcp` +| **enforce_route_policies** | _boolean_ | When `true`, GoRouter enforces route policies for routes on this domain. This field only appears in the response when set to `true`. **Immutable** after domain creation +| **route_policies_scope** | _string_ | Operator-defined boundary for allowed callers: `any`, `org`, or `space`. Required when `enforce_route_policies` is `true`. This field only appears when `enforce_route_policies` is `true`. **Immutable** after domain creation | **relationships.organization** | [_to-one relationship_](#to-one-relationships) | The organization the domain is scoped to; if set, the domain will only be available in that organization; otherwise, the domain will be globally available | **relationships.shared_organizations** | [_to-many relationship_](#to-many-relationships) | Organizations the domain is shared with; if set, the domain will be available in these organizations in addition to the organization the domain is scoped to | **metadata.labels** | [_label object_](#labels) | Labels applied to the domain diff --git a/docs/v3/source/includes/resources/route_policies/_create.md.erb b/docs/v3/source/includes/resources/route_policies/_create.md.erb new file mode 100644 index 00000000000..9a8e5465f66 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_create.md.erb @@ -0,0 +1,115 @@ +### Create a route policy + +``` +Example Request (Allow specific app) +``` + +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:app:d76446a1-f429-4444-8797-be2f78b75b08", + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + }, + "metadata": { + "labels": { "team": "frontend" }, + "annotations": { "description": "Allow frontend app to call backend API" } + } + }' +``` + +``` +Example Response +``` + +```http +HTTP/1.1 201 Created +Content-Type: application/json + +<%= yield_content :single_route_policy, { + labels: { "team" => "frontend" }, + annotations: { "description" => "Allow frontend app to call backend API" } +} %> +``` + +``` +Example Request (Allow all apps in a space) +``` + +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:space:3fa85f64-5717-4562-b3fc-2c963f66afa6", + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + } + }' +``` + +``` +Example Request (Allow any caller) +``` + +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:any", + "relationships": { + "route": { + "data": { + "guid": "89b32bd6-688f-4424-b94f-2e2c86495a5f" + } + } + } + }' +``` + +#### Definition +`POST /v3/route_policies` + +#### Required parameters + +| Name | Type | Description +| ----------- | -------- | ----------- +| **source** | _string_ | The policy selector. Must be `cf:app:`, `cf:space:`, `cf:org:`, or `cf:any` +| **relationships.route** | [_to-one relationship_](#to-one-relationships) | The route this policy applies to + +#### Optional parameters + +| Name | Type | Description +| ----------- | -------- | ----------- +| **metadata.labels** | [_label object_](#labels) | Labels applied to the route policy +| **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the route policy + +#### Validation rules + +- The route's domain must have `enforce_route_policies` set to `true` +- The route's domain must not be internal (internal routes bypass GoRouter) +- The `source` must be unique per route (duplicate sources are rejected) +- If the route already has a `cf:any` policy, no other sources can be added +- If adding `cf:any`, the route must not have any existing policies +- The source GUID is not validated at creation time (allows cross-org sharing) + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Space Developer | Can create policies for routes in spaces they can write to diff --git a/docs/v3/source/includes/resources/route_policies/_delete.md.erb b/docs/v3/source/includes/resources/route_policies/_delete.md.erb new file mode 100644 index 00000000000..ab6eaa0eba0 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_delete.md.erb @@ -0,0 +1,31 @@ +### Delete a route policy + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" \ + -X DELETE \ + -H "Authorization: bearer [token]" +``` + +``` +Example Response +``` + +```http +HTTP/1.1 204 No Content +``` + +#### Definition +`DELETE /v3/route_policies/:guid` + +Deleting a route policy removes the access control for that specific source. If this was the only policy on the route, the route will become inaccessible (no callers will be allowed) until new policies are added or a `cf:any` policy is created. + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Space Developer | Can delete policies for routes in spaces they can write to diff --git a/docs/v3/source/includes/resources/route_policies/_get.md.erb b/docs/v3/source/includes/resources/route_policies/_get.md.erb new file mode 100644 index 00000000000..b274699a5f0 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_get.md.erb @@ -0,0 +1,40 @@ +### Get a route policy + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +``` +Example Response +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +<%= yield_content :single_route_policy %> +``` + +#### Definition +`GET /v3/route_policies/:guid` + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Admin Read-Only | +Global Auditor | +Org Manager | +Org Auditor | +Org Billing Manager | +Space Auditor | +Space Developer | +Space Manager | +Space Supporter | diff --git a/docs/v3/source/includes/resources/route_policies/_header.md b/docs/v3/source/includes/resources/route_policies/_header.md new file mode 100644 index 00000000000..7c18cfcaa5d --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_header.md @@ -0,0 +1,11 @@ +## Route Policies + +Route policies control which Cloud Foundry apps, spaces, or organizations can access routes on identity-aware domains. When a domain has `enforce_route_policies` enabled, GoRouter automatically enforces these access controls using mutual TLS (mTLS) to verify the identity of the calling application. + +Route policies are defined using a `source` selector that specifies who can access the route: +- `cf:app:` - Allow a specific app +- `cf:space:` - Allow all apps in a space +- `cf:org:` - Allow all apps in an organization +- `cf:any` - Allow any caller (cannot be combined with other sources on the same route) + +**Note:** Route policies can only be created for routes on domains where `enforce_route_policies` is `true` and the domain is not internal (internal routes use container-to-container networking and bypass GoRouter). diff --git a/docs/v3/source/includes/resources/route_policies/_list.md.erb b/docs/v3/source/includes/resources/route_policies/_list.md.erb new file mode 100644 index 00000000000..12242286ef1 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_list.md.erb @@ -0,0 +1,152 @@ +### List route policies + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/route_policies" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +``` +Example Response +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +<%= yield_content :paginated_list_of_route_policies, '/v3/route_policies' %> +``` + +``` +Example Request with Filters +``` + +```shell +curl "https://api.example.org/v3/route_policies?route_guids=89b32bd6-688f-4424-b94f-2e2c86495a5f&sources=cf:any" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +``` +Example Request with Include +``` + +```shell +curl "https://api.example.org/v3/route_policies?include=route,source" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +#### Definition +`GET /v3/route_policies` + +#### Query parameters + +| Name | Type | Description +| ----------- | -------- | ----------- +| **guids** | _list of strings_ | Comma-delimited list of route policy guids to filter by +| **route_guids** | _list of strings_ | Comma-delimited list of route guids to filter by +| **space_guids** | _list of strings_ | Comma-delimited list of space guids to filter by (filters by the route's space) +| **sources** | _list of strings_ | Comma-delimited list of exact source strings to filter by (e.g., `cf:any`, `cf:app:guid`) +| **source_guids** | _list of strings_ | Comma-delimited list of GUIDs to text-match against source strings (useful for finding stale policies when resources are deleted) +| **page** | _integer_ | Page to display; valid values are integers >= 1 +| **per_page** | _integer_ | Number of results per page; valid values are 1 through 5000 +| **order_by** | _string_ | Value to sort by. Defaults to ascending; prepend with `-` to sort descending. Valid values are `created_at`, `updated_at` +| **label_selector** | _string_ | A query string containing a list of [label selector](#labels-and-selectors) requirements +| **include** | _string_ | Optionally include related resources in the response; valid values are `route`, `app`, `space`, `organization`, and `source` (which includes app, space, or organization based on the source type) +| **created_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) +| **updated_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) + +#### Filtering examples + +- **Filter by route**: `GET /v3/route_policies?route_guids=89b32bd6-688f-4424-b94f-2e2c86495a5f` +- **Filter by space**: `GET /v3/route_policies?space_guids=3fa85f64-5717-4562-b3fc-2c963f66afa6` +- **Filter by source type**: `GET /v3/route_policies?sources=cf:any` +- **Find policies referencing a specific app**: `GET /v3/route_policies?source_guids=d76446a1-f429-4444-8797-be2f78b75b08` +- **Include source resources**: `GET /v3/route_policies?include=source` (batch-loads the app, space, or org referenced in each policy's source) +- **Include route and app**: `GET /v3/route_policies?include=route,app` + +#### Use cases + +**Allow a frontend app to call a backend API:** +```shell +# Create route policy +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:app:frontend-app-guid", + "relationships": { + "route": { + "data": { "guid": "backend-route-guid" } + } + } + }' +``` + +**Allow all apps in a space to access a shared service:** +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:space:development-space-guid", + "relationships": { + "route": { + "data": { "guid": "shared-service-route-guid" } + } + } + }' +``` + +**Open a route to any caller (public API):** +```shell +curl "https://api.example.org/v3/route_policies" \ + -X POST \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "source": "cf:any", + "relationships": { + "route": { + "data": { "guid": "public-api-route-guid" } + } + } + }' +``` + +**Find all policies for routes in a specific space:** +```shell +curl "https://api.example.org/v3/route_policies?space_guids=my-space-guid" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +**Find stale policies referencing a deleted app:** +```shell +# After deleting an app, find policies that referenced it +curl "https://api.example.org/v3/route_policies?source_guids=deleted-app-guid&include=source" \ + -X GET \ + -H "Authorization: bearer [token]" +``` + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Admin Read-Only | +Global Auditor | +Org Manager | +Org Auditor | +Org Billing Manager | +Space Auditor | +Space Developer | +Space Manager | +Space Supporter | diff --git a/docs/v3/source/includes/resources/route_policies/_object.md.erb b/docs/v3/source/includes/resources/route_policies/_object.md.erb new file mode 100644 index 00000000000..9b58b0fd6c2 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_object.md.erb @@ -0,0 +1,23 @@ + +### The route policy object + +``` +Example Route Policy object +``` +```json +<%= yield_content :single_route_policy %> +``` + +| Name | Type | Description +| -------------- | ------------------------ | ------------------------------------------------------ +| **guid** | _uuid_ | Unique identifier for the route policy +| **created_at** | _[timestamp](#timestamps)_ | The time with zone when the object was created +| **updated_at** | _[timestamp](#timestamps)_ | The time with zone when the object was last updated +| **source** | _string_ | The policy selector specifying who can access the route. Must be one of:
- `cf:app:` (specific app)
- `cf:space:` (all apps in a space)
- `cf:org:` (all apps in an organization)
- `cf:any` (any caller - cannot be combined with other sources) +| **relationships.route** | [_to-one relationship_](#to-one-relationships) | The route this policy applies to +| **relationships.app** | [_to-one relationship_](#to-one-relationships) | Read-only. The app referenced in the source (populated only when source is `cf:app:`) +| **relationships.space** | [_to-one relationship_](#to-one-relationships) | Read-only. The space referenced in the source (populated only when source is `cf:space:`) +| **relationships.organization** | [_to-one relationship_](#to-one-relationships) | Read-only. The organization referenced in the source (populated only when source is `cf:org:`) +| **metadata.labels** | [_label object_](#labels) | Labels applied to the route policy +| **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the route policy +| **links** | [_links object_](#links) | Links to related resources diff --git a/docs/v3/source/includes/resources/route_policies/_update.md.erb b/docs/v3/source/includes/resources/route_policies/_update.md.erb new file mode 100644 index 00000000000..eee0c2bace3 --- /dev/null +++ b/docs/v3/source/includes/resources/route_policies/_update.md.erb @@ -0,0 +1,51 @@ +### Update a route policy + +``` +Example Request +``` + +```shell +curl "https://api.example.org/v3/route_policies/a4ad8bc1-67a6-4ffa-95b7-f8cf04ad7d4f" \ + -X PATCH \ + -H "Authorization: bearer [token]" \ + -H "Content-type: application/json" \ + -d '{ + "metadata": { + "labels": { "team": "backend" }, + "annotations": { "note": "Updated contact info" } + } + }' +``` + +``` +Example Response +``` + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +<%= yield_content :single_route_policy, { + labels: { "team" => "backend" }, + annotations: { "note" => "Updated contact info" } +} %> +``` + +#### Definition +`PATCH /v3/route_policies/:guid` + +#### Optional parameters + +| Name | Type | Description +| ----------- | -------- | ----------- +| **metadata.labels** | [_label object_](#labels) | Labels applied to the route policy +| **metadata.annotations** | [_annotation object_](#annotations) | Annotations applied to the route policy + +**Note:** This endpoint only supports updating metadata (labels and annotations). The `source` and route relationship are immutable after creation. To change the source, delete the policy and create a new one. + +#### Permitted roles + +Role | Notes +----- | --- +Admin | +Space Developer | Can update policies for routes in spaces they can write to diff --git a/docs/v3/source/index.html.md b/docs/v3/source/index.html.md index d80342b9c23..1499a988be6 100644 --- a/docs/v3/source/index.html.md +++ b/docs/v3/source/index.html.md @@ -25,6 +25,7 @@ includes: - api_resources/revisions - api_resources/roles - api_resources/root + - api_resources/route_policies - api_resources/routes - api_resources/security_groups - api_resources/service_brokers @@ -243,6 +244,13 @@ includes: - resources/root/header - resources/root/global_root - resources/root/v3_root + - resources/route_policies/header + - resources/route_policies/object + - resources/route_policies/create + - resources/route_policies/get + - resources/route_policies/list + - resources/route_policies/update + - resources/route_policies/delete - resources/routes/header - resources/routes/object - resources/routes/destination_object From d703ab56c1b7de2b3044f4a3de201e3bfe309979 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 19 May 2026 18:38:21 +0200 Subject: [PATCH 31/64] Add include support to route policies show endpoint Address review feedback: the show action now supports the same include parameters (source, route) as the list endpoint, allowing clients to sideload related resources in a single request. --- .../v3/route_policies_controller.rb | 10 ++- app/messages/route_policy_show_message.rb | 14 ++++ app/presenters/v3/route_policy_presenter.rb | 3 +- .../resources/route_policies/_get.md.erb | 6 ++ spec/request/route_policies_spec.rb | 69 +++++++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) mode change 100644 => 100755 app/controllers/v3/route_policies_controller.rb create mode 100755 app/messages/route_policy_show_message.rb mode change 100644 => 100755 app/presenters/v3/route_policy_presenter.rb mode change 100644 => 100755 docs/v3/source/includes/resources/route_policies/_get.md.erb mode change 100644 => 100755 spec/request/route_policies_spec.rb diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb old mode 100644 new mode 100755 index cda9352e5c2..3c4cbdc0e75 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -1,6 +1,7 @@ require 'messages/route_policy_create_message' require 'messages/route_policy_update_message' require 'messages/route_policies_list_message' +require 'messages/route_policy_show_message' require 'presenters/v3/route_policy_presenter' require 'decorators/include_route_policy_source_decorator' require 'decorators/include_route_policy_route_decorator' @@ -26,13 +27,20 @@ def index end def show + message = RoutePolicyShowMessage.from_params(query_params) + unprocessable!(message.errors.full_messages) unless message.valid? + route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) resource_not_found!(:route_policy) unless route_policy route = route_policy.route resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) - render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy) + decorators = [] + decorators << IncludeRoutePolicySourceDecorator if IncludeRoutePolicySourceDecorator.match?(message.include) + decorators << IncludeRoutePolicyRouteDecorator if IncludeRoutePolicyRouteDecorator.match?(message.include) + + render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy, decorators: decorators) end def create diff --git a/app/messages/route_policy_show_message.rb b/app/messages/route_policy_show_message.rb new file mode 100755 index 00000000000..cb5c1f952e4 --- /dev/null +++ b/app/messages/route_policy_show_message.rb @@ -0,0 +1,14 @@ +require 'messages/base_message' + +module VCAP::CloudController + class RoutePolicyShowMessage < BaseMessage + register_allowed_keys [:include] + + validates_with NoAdditionalParamsValidator + validates_with IncludeParamValidator, valid_values: %w[source route app space organization] + + def self.from_params(params) + super(params, %w[include]) + end + end +end diff --git a/app/presenters/v3/route_policy_presenter.rb b/app/presenters/v3/route_policy_presenter.rb old mode 100644 new mode 100755 index 81904f61bba..5332aa2d068 --- a/app/presenters/v3/route_policy_presenter.rb +++ b/app/presenters/v3/route_policy_presenter.rb @@ -8,7 +8,7 @@ class RoutePolicyPresenter < BasePresenter include VCAP::CloudController::Presenters::Mixins::MetadataPresentationHelpers def to_hash - { + hash = { guid: route_policy.guid, created_at: route_policy.created_at, updated_at: route_policy.updated_at, @@ -20,6 +20,7 @@ def to_hash relationships: build_relationships, links: build_links } + @decorators.reduce(hash) { |memo, d| d.decorate(memo, [route_policy]) } end private diff --git a/docs/v3/source/includes/resources/route_policies/_get.md.erb b/docs/v3/source/includes/resources/route_policies/_get.md.erb old mode 100644 new mode 100755 index b274699a5f0..13a0d2443f8 --- a/docs/v3/source/includes/resources/route_policies/_get.md.erb +++ b/docs/v3/source/includes/resources/route_policies/_get.md.erb @@ -24,6 +24,12 @@ Content-Type: application/json #### Definition `GET /v3/route_policies/:guid` +#### Query parameters + +Name | Type | Description +---- | ---- | ------------ +**include** | _list of strings_ | Optionally include a list of unique related resources in the response; valid values are `route`, `app`, `space`, `organization`, and `source` (which includes the app, space, or organization referenced by the source field) + #### Permitted roles Role | Notes diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb old mode 100644 new mode 100755 index ebd6a69ccc4..14ba17944b4 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -251,6 +251,75 @@ def expected_rule_json(rule) expect(last_response.status).to eq(404) end end + + context 'with include=source' do + let!(:frontend_app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } + let!(:app_policy) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{frontend_app.guid}", + route_id: mtls_route.id + ) + end + + it 'includes the resolved source app resource' do + get "/v3/route_policies/#{app_policy.guid}?include=source", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + expect(parsed['included']).to be_a(Hash) + expect(parsed['included']['apps']).to be_an(Array) + app_included = parsed['included']['apps'].find { |a| a['guid'] == frontend_app.guid } + expect(app_included).to be_present + expect(app_included['name']).to eq('frontend-app') + end + end + + context 'with include=route' do + it 'includes the associated route resource' do + get "/v3/route_policies/#{route_policy.guid}?include=route", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + expect(parsed['included']).to be_a(Hash) + expect(parsed['included']['routes']).to be_an(Array) + route_included = parsed['included']['routes'].find { |r| r['guid'] == mtls_route.guid } + expect(route_included).to be_present + end + end + + context 'with include=route,source' do + let!(:frontend_app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } + let!(:app_policy) do + VCAP::CloudController::RoutePolicy.create( + guid: SecureRandom.uuid, + source: "cf:app:#{frontend_app.guid}", + route_id: mtls_route.id + ) + end + + it 'includes both route and source resources' do + get "/v3/route_policies/#{app_policy.guid}?include=route,source", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + + expect(parsed['included']['routes']).to be_an(Array) + expect(parsed['included']['apps']).to be_an(Array) + expect(parsed['included']['routes'].find { |r| r['guid'] == mtls_route.guid }).to be_present + expect(parsed['included']['apps'].find { |a| a['guid'] == frontend_app.guid }).to be_present + end + end + + context 'with an invalid include value' do + it 'returns 422' do + get "/v3/route_policies/#{route_policy.guid}?include=invalid_value", nil, admin_header + + expect(last_response.status).to eq(422) + end + end end describe 'GET /v3/route_policies' do From f00e86b84fd8d2da207a34dd563d0b4d3fd58887 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 19 May 2026 18:40:11 +0200 Subject: [PATCH 32/64] Remove devbox configuration files from branch These files are for local development only and should not be part of the upstream PR. --- .envrc | 56 +--- devbox.json | 69 ----- devbox.lock | 783 ---------------------------------------------------- 3 files changed, 1 insertion(+), 907 deletions(-) delete mode 100644 devbox.json delete mode 100644 devbox.lock diff --git a/.envrc b/.envrc index 311eb9ee4ff..6b80a681cb7 100644 --- a/.envrc +++ b/.envrc @@ -1,26 +1,4 @@ -# ============================================================================= -# Cloud Controller NG - Development Environment -# ============================================================================= -# -# QUICK START (local development): -# cc-containers start # Start DBs, UAA, nginx -# bundle install && cc-generate-config && cc-reset-db -# eval "$(cc-db-env psql ccdb)" # Set database env vars -# bin/cloud_controller -c tmp/.dev-generated/cloud_controller.local.yml -# -# SCRIPTS (all start with 'cc-'): -# cc-containers # start/stop/logs/status (see --help) -# cc-db-env # Database env vars (e.g. psql ccdb, mysql test) -# cc-generate-config [mode] # Generate cloud_controller.yml configs -# cc-reset-db # Drop and recreate all databases -# cc-setup-ide # Copy IDE configs (VS Code, IntelliJ) -# -# PERSONAL OVERRIDES (.envrc.local, gitignored): -# export PARALLEL_TEST_PROCESSORS=4 -# -# ============================================================================= - -cores=$(/usr/sbin/sysctl -n hw.logicalcpu 2>/dev/null || nproc 2>/dev/null || echo 4) +cores=$(/usr/sbin/sysctl -n hw.logicalcpu) if (( cores > 8 )); then # This environment variable overrides the `parallel_test` gem's default behavior @@ -30,35 +8,3 @@ if (( cores > 8 )); then export PARALLEL_TEST_PROCESSORS=8 fi - -# Set CC_CONFIG for local development (devcontainer sets CC_CONFIG=devcontainer) -# This is used by VS Code launch configs to select the right cloud_controller.yml -export CC_CONFIG="${CC_CONFIG:-local}" - -# Database connection prefixes - used by IDE run configs and parallel tests -# Devcontainer overrides these in devcontainer.json with container hostnames (postgres, mysql) -export POSTGRES_CONNECTION_PREFIX="${POSTGRES_CONNECTION_PREFIX:-postgres://postgres:supersecret@localhost:5432}" -export MYSQL_CONNECTION_PREFIX="${MYSQL_CONNECTION_PREFIX:-mysql2://root:supersecret@127.0.0.1:3306}" - -# Storage CLI path for S3 blobstore mode -export STORAGE_CLI_PATH="${PWD}/tmp/bin/storage-cli" - -PATH_add bin -PATH_add .devcontainer/scripts - -# ============================================================================= -# Developer Overrides -# ============================================================================= -if [ -f .envrc.local ]; then - source_env .envrc.local -fi - -# Show quick hint on directory entry -if [ -n "$DEVCONTAINER" ]; then - log_status "Cloud Controller NG (devcontainer)" - log_status "Quick start: cc-generate-config && eval \"\$(cc-db-env psql ccdb)\"" -else - log_status "Cloud Controller NG (local)" - log_status "Quick start: cc-containers start && cc-generate-config && cc-reset-db" -fi -log_status "See README.md for full setup instructions" diff --git a/devbox.json b/devbox.json deleted file mode 100644 index 24f56d6ef28..00000000000 --- a/devbox.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", - "packages": [ - "ruby@3.3", - "bundler@latest", - "libpq@latest", - "openssl@latest", - "libyaml@latest", - "pkg-config@latest", - "zstd@latest", - "postgresql@latest" - ], - "shell": { - "init_hook": [ - "# Devbox installs only the default nix output (runtime libs). Native Ruby gem", - "# extensions need dev headers and pkg-config files from the -dev outputs.", - "# This hook reads -dev output paths from devbox.lock and adds them to the", - "# compiler search paths.", - "", - "LIBRARY_PATH=\"$DEVBOX_PACKAGES_DIR/lib${LIBRARY_PATH:+:$LIBRARY_PATH}\"", - "C_INCLUDE_PATH=\"$DEVBOX_PACKAGES_DIR/include${C_INCLUDE_PATH:+:$C_INCLUDE_PATH}\"", - "", - "_devbox_realize_dev_outputs() {", - " local lockfile=\"$PWD/devbox.lock\"", - " [ -f \"$lockfile\" ] || return", - " local arch=$(uname -m)", - " local os=$(uname -s | tr '[:upper:]' '[:lower:]')", - " local system=\"${arch}-${os}\"", - "", - " # Extract -dev and -out paths from devbox.lock using ruby (available in our shell)", - " local dev_paths", - " dev_paths=$(ruby -rjson -e '", - " lock = JSON.parse(File.read(ARGV[0]))", - " sys = ARGV[1]", - " lock.fetch(\"packages\", {}).each do |_, info|", - " outputs = info.dig(\"systems\", sys, \"outputs\") || []", - " outputs.each do |o|", - " # Include dev outputs, and also \"out\" outputs that contain includes", - " puts o[\"path\"] if o[\"name\"] == \"dev\" || o[\"name\"] == \"out\"", - " end", - " end", - " end", - " ' \"$lockfile\" \"$system\" 2>/dev/null)", - "", - " local p", - " for p in $dev_paths; do", - " # Realize (download) the store path if not already present", - " [ -d \"$p\" ] || nix-store --realise \"$p\" >/dev/null 2>&1 || continue", - " [ -d \"$p/include\" ] && C_INCLUDE_PATH=\"${p}/include:$C_INCLUDE_PATH\"", - " [ -d \"$p/lib/pkgconfig\" ] && PKG_CONFIG_PATH=\"${p}/lib/pkgconfig:${PKG_CONFIG_PATH:-}\"", - " [ -d \"$p/lib\" ] && LIBRARY_PATH=\"${p}/lib:$LIBRARY_PATH\"", - " done", - "}", - "", - "_devbox_realize_dev_outputs", - "export C_INCLUDE_PATH LIBRARY_PATH PKG_CONFIG_PATH", - "unset -f _devbox_realize_dev_outputs", - "", - "# Set database connection prefix for PostgreSQL tests", - "export POSTGRES_CONNECTION_PREFIX=\"postgres://postgres:supersecret@localhost:5432\"", - "export DB=postgres" - ], - "scripts": { - "test": [ - "echo \"Error: no test specified\" && exit 1" - ] - } - } -} diff --git a/devbox.lock b/devbox.lock deleted file mode 100644 index beb48f6567f..00000000000 --- a/devbox.lock +++ /dev/null @@ -1,783 +0,0 @@ -{ - "lockfile_version": "1", - "packages": { - "bundler@latest": { - "last_modified": "2026-03-21T07:29:51Z", - "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#bundler", - "source": "devbox-search", - "version": "2.7.2", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/gbx73y8di7f17i727k6s0l2f1618pza8-bundler-2.7.2", - "default": true - } - ], - "store_path": "/nix/store/gbx73y8di7f17i727k6s0l2f1618pza8-bundler-2.7.2" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/qb2ksb9khr8dpc46h2ajg85zirgz136k-bundler-2.7.2", - "default": true - } - ], - "store_path": "/nix/store/qb2ksb9khr8dpc46h2ajg85zirgz136k-bundler-2.7.2" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/b04xgfhjcgg1cl1h2bpjm3fds46vgd1w-bundler-2.7.2", - "default": true - } - ], - "store_path": "/nix/store/b04xgfhjcgg1cl1h2bpjm3fds46vgd1w-bundler-2.7.2" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/bc7ankvbv1s5shlllxxlcsgdj0b16p36-bundler-2.7.2", - "default": true - } - ], - "store_path": "/nix/store/bc7ankvbv1s5shlllxxlcsgdj0b16p36-bundler-2.7.2" - } - } - }, - "github:NixOS/nixpkgs/nixpkgs-unstable": { - "last_modified": "2026-03-16T02:27:38Z", - "resolved": "github:NixOS/nixpkgs/f8573b9c935cfaa162dd62cc9e75ae2db86f85df?lastModified=1773628058&narHash=sha256-hpXH0z3K9xv0fHaje136KY872VT2T5uwxtezlAskQgY%3D" - }, - "glibcLocales@latest": { - "last_modified": "2026-03-21T07:29:51Z", - "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#glibcLocales", - "source": "devbox-search", - "version": "2.42-51", - "systems": { - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/d4vnp0fbrsvijnx5ac86bbxg3bnblz7k-glibc-locales-2.42-51", - "default": true - } - ], - "store_path": "/nix/store/d4vnp0fbrsvijnx5ac86bbxg3bnblz7k-glibc-locales-2.42-51" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/ld2j2mq254m77hwy22pyvrs861ap1374-glibc-locales-2.42-51", - "default": true - } - ], - "store_path": "/nix/store/ld2j2mq254m77hwy22pyvrs861ap1374-glibc-locales-2.42-51" - } - } - }, - "libpq@latest": { - "last_modified": "2026-04-11T06:17:25Z", - "resolved": "github:NixOS/nixpkgs/13043924aaa7375ce482ebe2494338e058282925#libpq", - "source": "devbox-search", - "version": "18.2", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/yqv7xkfakqfccbgdmkf7xhkda2yzrqd8-libpq-18.2", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/sl9kw8cqc669py9xb83c1baf342l97r5-libpq-18.2-dev" - } - ], - "store_path": "/nix/store/yqv7xkfakqfccbgdmkf7xhkda2yzrqd8-libpq-18.2" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/4hpn6899j9vh3p9z424vzgqn4ya4lcvv-libpq-18.2", - "default": true - }, - { - "name": "debug", - "path": "/nix/store/xjzx6272qsnbrgmbm3yw1xb3688p5sjb-libpq-18.2-debug" - }, - { - "name": "dev", - "path": "/nix/store/fyaw62ldhlyjcnbdli0y4a9wbrlg5q78-libpq-18.2-dev" - } - ], - "store_path": "/nix/store/4hpn6899j9vh3p9z424vzgqn4ya4lcvv-libpq-18.2" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/ig7ycilx8a3xal5dharihg0mk15yqwmv-libpq-18.2", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/dvgl05rjdbdk2ck90ccnb8g2hpyhmbbj-libpq-18.2-dev" - } - ], - "store_path": "/nix/store/ig7ycilx8a3xal5dharihg0mk15yqwmv-libpq-18.2" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/4fi462vs62ycv752lck8v3d70f3blh2x-libpq-18.2", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/gdmv8c5ax77873s7090b3wcicd6i4m51-libpq-18.2-dev" - }, - { - "name": "debug", - "path": "/nix/store/1ljsii50mrkvxnsvq123a9gnqj0cl8ng-libpq-18.2-debug" - } - ], - "store_path": "/nix/store/4fi462vs62ycv752lck8v3d70f3blh2x-libpq-18.2" - } - } - }, - "libyaml@latest": { - "last_modified": "2026-03-21T07:29:51Z", - "resolved": "github:NixOS/nixpkgs/09061f748ee21f68a089cd5d91ec1859cd93d0be#libyaml", - "source": "devbox-search", - "version": "0.2.5", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/qqa8q98n3hvb2kqz3xvd0m0j22033wy0-libyaml-0.2.5", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/00yv9nvsx0vswzzihkkl4qk39lb2p1pc-libyaml-0.2.5-dev" - } - ], - "store_path": "/nix/store/qqa8q98n3hvb2kqz3xvd0m0j22033wy0-libyaml-0.2.5" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/m4j56in3n01xw1jk5h1qxj5r8i4x2mfb-libyaml-0.2.5", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/jyvgsbxnppxyvvgga304iw6xlhi39r17-libyaml-0.2.5-dev" - } - ], - "store_path": "/nix/store/m4j56in3n01xw1jk5h1qxj5r8i4x2mfb-libyaml-0.2.5" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/4djwsl28pfclczbkcrdqxlqrcxyvihik-libyaml-0.2.5", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/6i8a2m6yj122s9r1nyl8grxizq3av6z6-libyaml-0.2.5-dev" - } - ], - "store_path": "/nix/store/4djwsl28pfclczbkcrdqxlqrcxyvihik-libyaml-0.2.5" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/fnhs945sa02bg7gki8a3l8r9r44ylx10-libyaml-0.2.5", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/v9qn9g4fm4818vx30kl7z423vj1mswml-libyaml-0.2.5-dev" - } - ], - "store_path": "/nix/store/fnhs945sa02bg7gki8a3l8r9r44ylx10-libyaml-0.2.5" - } - } - }, - "openssl@latest": { - "last_modified": "2025-12-05T06:24:47Z", - "resolved": "github:NixOS/nixpkgs/42e29df35be6ef54091d3a3b4e97056ce0a98ce8#openssl", - "source": "devbox-search", - "version": "3.6.0", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/z9prisxci5h5lsk3rdknd4jzq7k9q13d-openssl-3.6.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/ii9mnzr3i92mgk9dkgg65739mavd0j6f-openssl-3.6.0-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/h0qgqik0mk0wn7rmm2kk3grfi1wzly74-openssl-3.6.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/yx3ip21fdaaxpjn5fbir02mqnaw9cm4f-openssl-3.6.0-doc" - }, - { - "name": "out", - "path": "/nix/store/3z54dgks2mz3dhwddj158sdibll8xmq5-openssl-3.6.0" - } - ], - "store_path": "/nix/store/z9prisxci5h5lsk3rdknd4jzq7k9q13d-openssl-3.6.0-bin" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/wb6q44n9kcb5acmaa4rgqsajadx1fhhl-openssl-3.6.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/c9n1alb7ypzjvzd47m16fiwfczz23qs3-openssl-3.6.0-man", - "default": true - }, - { - "name": "debug", - "path": "/nix/store/ci6d4k1sj4bnr892lsrqqmjiihqsk0bl-openssl-3.6.0-debug" - }, - { - "name": "dev", - "path": "/nix/store/pq8b7fb3282g68pmk14mbyi20qn6chid-openssl-3.6.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/vaplp6w56dyz38986bgkf0pbg3r486b2-openssl-3.6.0-doc" - }, - { - "name": "out", - "path": "/nix/store/nj50gkyx813dxvfmsg1q8m330hmf3h86-openssl-3.6.0" - } - ], - "store_path": "/nix/store/wb6q44n9kcb5acmaa4rgqsajadx1fhhl-openssl-3.6.0-bin" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/m3xwn9n0jypwjgi256idfzs979g30j29-openssl-3.6.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/hw43f3y1vl7ydrd4samnwnrwqqwkpisv-openssl-3.6.0-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/dirjrfjk8jgsbdpslgb51cav6qaxn2vm-openssl-3.6.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/va1zhkz0nfmycvd0h239hi4w40qgaxcx-openssl-3.6.0-doc" - }, - { - "name": "out", - "path": "/nix/store/q9a4wssx24xsy28w8kifdqizc01fh7sc-openssl-3.6.0" - } - ], - "store_path": "/nix/store/m3xwn9n0jypwjgi256idfzs979g30j29-openssl-3.6.0-bin" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/a9jdl6xq9fc98ykpvqmc9kf0b0j9y8wh-openssl-3.6.0-man", - "default": true - }, - { - "name": "debug", - "path": "/nix/store/sqv8kbdgfxlr2d6nysr8c2715qpsi6f5-openssl-3.6.0-debug" - }, - { - "name": "dev", - "path": "/nix/store/ydrckgnllgg8nmhdwni81h7xhcpnrlhd-openssl-3.6.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/cgp9ig35iwicfb9spcrgyg2m5dmlcgrv-openssl-3.6.0-doc" - }, - { - "name": "out", - "path": "/nix/store/61i74yjkj9p1qphfl7018ja4sdwkipx0-openssl-3.6.0" - } - ], - "store_path": "/nix/store/k0gl1zc7f5hk87lylxwbipb0b870bcmk-openssl-3.6.0-bin" - } - } - }, - "pkg-config@latest": { - "last_modified": "2025-11-23T21:50:36Z", - "resolved": "github:NixOS/nixpkgs/ee09932cedcef15aaf476f9343d1dea2cb77e261#pkg-config", - "source": "devbox-search", - "version": "0.29.2", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2", - "default": true - }, - { - "name": "man", - "path": "/nix/store/9px0sji43x3r2w4zxl3j3idwsql7lwxx-pkg-config-wrapper-0.29.2-man", - "default": true - }, - { - "name": "doc", - "path": "/nix/store/hqk44ra6qxw7iixardl6c3hdgb9kq6ns-pkg-config-wrapper-0.29.2-doc" - } - ], - "store_path": "/nix/store/hygaaqwk9ylklp0ybwppqhw75nz8ya41-pkg-config-wrapper-0.29.2" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2", - "default": true - }, - { - "name": "man", - "path": "/nix/store/10060k24qggqyzlwdsfmni9y32zxcg0j-pkg-config-wrapper-0.29.2-man", - "default": true - }, - { - "name": "doc", - "path": "/nix/store/0y4v51ndpyvkj09hwlfqkz0c3h17zfmc-pkg-config-wrapper-0.29.2-doc" - } - ], - "store_path": "/nix/store/lbiigi8qbp7mzf1lpr7p982l1kyf01ql-pkg-config-wrapper-0.29.2" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2", - "default": true - }, - { - "name": "man", - "path": "/nix/store/1nyspra675q22gfhf7hn2nmfpi6rgim5-pkg-config-wrapper-0.29.2-man", - "default": true - }, - { - "name": "doc", - "path": "/nix/store/7lq1axxwrafwljs06n88bzyz9w523rkc-pkg-config-wrapper-0.29.2-doc" - } - ], - "store_path": "/nix/store/vknadizq0q5kffvx6y4379p9gdry9zq3-pkg-config-wrapper-0.29.2" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2", - "default": true - }, - { - "name": "man", - "path": "/nix/store/j9xfpnrygg3v37svc5pfin9q5bm49r94-pkg-config-wrapper-0.29.2-man", - "default": true - }, - { - "name": "doc", - "path": "/nix/store/x3bypxdxaq20kykybhkf21x4jczsiy8y-pkg-config-wrapper-0.29.2-doc" - } - ], - "store_path": "/nix/store/8vdiwpbh0g4avsd6x5v4s0di32vcl3dp-pkg-config-wrapper-0.29.2" - } - } - }, - "postgresql@latest": { - "last_modified": "2026-04-11T06:17:25Z", - "plugin_version": "0.0.2", - "resolved": "github:NixOS/nixpkgs/13043924aaa7375ce482ebe2494338e058282925#postgresql", - "source": "devbox-search", - "version": "17.9", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/rcz8zc21n6rx4igsdgngmkwnln4f7dy5-postgresql-17.9", - "default": true - }, - { - "name": "man", - "path": "/nix/store/v9ad61kyx28sfzs48j9077iiv61fqzb0-postgresql-17.9-man", - "default": true - }, - { - "name": "doc", - "path": "/nix/store/gillzna13al7axbhkqyjf7wwfkfbh4nn-postgresql-17.9-doc" - }, - { - "name": "jit", - "path": "/nix/store/9wrci7zgca8ygxgcg8qhk69kkk2hvnvg-postgresql-17.9-jit" - }, - { - "name": "lib", - "path": "/nix/store/h9xg40fr3hqn9lhckdf1sjp2w7zdl92n-postgresql-17.9-lib" - }, - { - "name": "dev", - "path": "/nix/store/yzvwbyh0gqrprnw5rdnhjmcmyvrl9ql4-postgresql-17.9-dev" - }, - { - "name": "plperl", - "path": "/nix/store/ywrc7vv5mdsz79z4nfid0asnzlwxp3zn-postgresql-17.9-plperl" - }, - { - "name": "plpython3", - "path": "/nix/store/jzj6b2zw28dxy8jjfvzlfbmdl8mypv2m-postgresql-17.9-plpython3" - }, - { - "name": "pltcl", - "path": "/nix/store/ya921lh5kkcrdgk09y9580prw5yg27f2-postgresql-17.9-pltcl" - } - ], - "store_path": "/nix/store/rcz8zc21n6rx4igsdgngmkwnln4f7dy5-postgresql-17.9" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/bw97pvf1fg3y7yjvw24g83448v8p48m0-postgresql-17.9", - "default": true - }, - { - "name": "man", - "path": "/nix/store/j22nri44hhgyxbg78glds0im2y608cn9-postgresql-17.9-man", - "default": true - }, - { - "name": "plpython3", - "path": "/nix/store/fha23nr7d2i16ns2z7wsrlx65fxpazxh-postgresql-17.9-plpython3" - }, - { - "name": "pltcl", - "path": "/nix/store/b9zsqpp7znmvxghjy9ihlk3p75xvd3pz-postgresql-17.9-pltcl" - }, - { - "name": "dev", - "path": "/nix/store/gvivc80vkanv4cd41r1fz0dz9qr2bsjq-postgresql-17.9-dev" - }, - { - "name": "doc", - "path": "/nix/store/39f586jzgzlkcc3dp8zajyjnf2w2mymr-postgresql-17.9-doc" - }, - { - "name": "jit", - "path": "/nix/store/iw7rjv0gjb23fwil2j0zjbghrj8bgd7q-postgresql-17.9-jit" - }, - { - "name": "plperl", - "path": "/nix/store/951fcy0jfrwz8rhi8668fqi72wwdj1qa-postgresql-17.9-plperl" - }, - { - "name": "debug", - "path": "/nix/store/rlk7xis3dfyll5z1fny70ksi3yqh1yy7-postgresql-17.9-debug" - }, - { - "name": "lib", - "path": "/nix/store/b25khikzni3m8q8nyv3mrxa5v63bqsam-postgresql-17.9-lib" - } - ], - "store_path": "/nix/store/bw97pvf1fg3y7yjvw24g83448v8p48m0-postgresql-17.9" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/jgrfz7nzjh9m5ymfv0831aamk9ci5ds9-postgresql-17.9", - "default": true - }, - { - "name": "man", - "path": "/nix/store/q824ybxz07qzwrwk9hkd16y0yl7mlp5i-postgresql-17.9-man", - "default": true - }, - { - "name": "doc", - "path": "/nix/store/87c2fid7ppzyd3n3i5id3iiipybgzcp7-postgresql-17.9-doc" - }, - { - "name": "plperl", - "path": "/nix/store/p51i9h8vwml5nj6i91g0hh2zh93c4iap-postgresql-17.9-plperl" - }, - { - "name": "plpython3", - "path": "/nix/store/6a5lqzcdxiqn5nqlfddjdb921z7a35in-postgresql-17.9-plpython3" - }, - { - "name": "dev", - "path": "/nix/store/p99q8ixd6kkw2fr8zpfsmc0m3gwqcjjw-postgresql-17.9-dev" - }, - { - "name": "jit", - "path": "/nix/store/f6qm2151lg98kmayd1kddmgqv9wh1m4f-postgresql-17.9-jit" - }, - { - "name": "lib", - "path": "/nix/store/shz3ms0ww02df1k2qrzk0mv3g6ilr33j-postgresql-17.9-lib" - }, - { - "name": "pltcl", - "path": "/nix/store/cvvvm05xz8735kxb2jqh6gvxfvps1cpw-postgresql-17.9-pltcl" - } - ], - "store_path": "/nix/store/jgrfz7nzjh9m5ymfv0831aamk9ci5ds9-postgresql-17.9" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/zhaly0y0af2m7wyijyhdanm6a9l5lydv-postgresql-17.9", - "default": true - }, - { - "name": "man", - "path": "/nix/store/hgrmddv5rl1axc814n8f27q8gjlxpdz5-postgresql-17.9-man", - "default": true - }, - { - "name": "debug", - "path": "/nix/store/yhvkyzaxm3lcs7kk8qri3ql34p6h7dmc-postgresql-17.9-debug" - }, - { - "name": "doc", - "path": "/nix/store/x41xsx8n2j3l53dr6qfr1w7i9q1pvb3b-postgresql-17.9-doc" - }, - { - "name": "plperl", - "path": "/nix/store/4dnwbih86p5grx6ys7faq29nh9w0krky-postgresql-17.9-plperl" - }, - { - "name": "plpython3", - "path": "/nix/store/rr62jngbsjqim8k5r761h985y88zci8w-postgresql-17.9-plpython3" - }, - { - "name": "pltcl", - "path": "/nix/store/292bd6aqwdsrd3bkvj8yjgwgg5nqlgjv-postgresql-17.9-pltcl" - }, - { - "name": "dev", - "path": "/nix/store/w8vci17bmzkbxclrkjxg2bd3aachf5i8-postgresql-17.9-dev" - }, - { - "name": "jit", - "path": "/nix/store/87sz1iy2q7v0fcsrgbkmryrp390v5sl9-postgresql-17.9-jit" - }, - { - "name": "lib", - "path": "/nix/store/il7gfijl01sxk16h9pffc5yan70vbqfp-postgresql-17.9-lib" - } - ], - "store_path": "/nix/store/zhaly0y0af2m7wyijyhdanm6a9l5lydv-postgresql-17.9" - } - } - }, - "ruby@3.3": { - "last_modified": "2026-01-23T17:20:52Z", - "plugin_version": "0.0.2", - "resolved": "github:NixOS/nixpkgs/a1bab9e494f5f4939442a57a58d0449a109593fe#ruby", - "source": "devbox-search", - "version": "3.3.10", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/d9wal8y7w1zpvyas3x1q4ykz880mmklk-ruby-3.3.10", - "default": true - }, - { - "name": "devdoc", - "path": "/nix/store/1rfqp0848j3gnm222ls3bipk1azcrrq3-ruby-3.3.10-devdoc" - } - ], - "store_path": "/nix/store/d9wal8y7w1zpvyas3x1q4ykz880mmklk-ruby-3.3.10" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/1hlahw0ijkxx1aqy3x41k3gxpgv34g7d-ruby-3.3.10", - "default": true - }, - { - "name": "devdoc", - "path": "/nix/store/arvi0gqvw07ngbi2ci20dn5ka2jz5irv-ruby-3.3.10-devdoc" - } - ], - "store_path": "/nix/store/1hlahw0ijkxx1aqy3x41k3gxpgv34g7d-ruby-3.3.10" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/as6xdshxpansvkag8zqr602ajkn9079z-ruby-3.3.10", - "default": true - }, - { - "name": "devdoc", - "path": "/nix/store/wix1487x3br4gxa0il4q6llz5xyqxspl-ruby-3.3.10-devdoc" - } - ], - "store_path": "/nix/store/as6xdshxpansvkag8zqr602ajkn9079z-ruby-3.3.10" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/6jz2pgmsh06z9a83qi33f6lp9w2q6mzm-ruby-3.3.10", - "default": true - }, - { - "name": "devdoc", - "path": "/nix/store/kah8xsbcd10iakxqmlw558iarhsrd5vi-ruby-3.3.10-devdoc" - } - ], - "store_path": "/nix/store/6jz2pgmsh06z9a83qi33f6lp9w2q6mzm-ruby-3.3.10" - } - } - }, - "zstd@latest": { - "last_modified": "2026-04-10T12:25:30Z", - "resolved": "github:NixOS/nixpkgs/8c11f88bb9573a10a7d6bf87161ef08455ac70b9#zstd", - "source": "devbox-search", - "version": "1.5.7", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/4crx02pb0lv0x26yly1bnbf3n00cq38m-zstd-1.5.7-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/c3g4ifcw3ad8kpa8yjs8lsac5hvmqzv0-zstd-1.5.7-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/i0hhsvlafn0zx3yl8yfcs714ps5qic00-zstd-1.5.7-dev" - }, - { - "name": "out", - "path": "/nix/store/xq7dsd7b6x66fn1pqsif0pld0nw6rb33-zstd-1.5.7" - } - ], - "store_path": "/nix/store/4crx02pb0lv0x26yly1bnbf3n00cq38m-zstd-1.5.7-bin" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/4x4q96zrz8g7jzz9wm8z94riv7q3zw0j-zstd-1.5.7-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/1xbh2v2pvphs8m06yrgzhrnrwpr0nsvl-zstd-1.5.7-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/c9082kb2i992fi80ix6zi7sa6ijqqrzv-zstd-1.5.7-dev" - }, - { - "name": "out", - "path": "/nix/store/pilcyv83zm3h2gm1924xkfmib9n63b5r-zstd-1.5.7" - } - ], - "store_path": "/nix/store/4x4q96zrz8g7jzz9wm8z94riv7q3zw0j-zstd-1.5.7-bin" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/yc6l9laqs8xs5q0ivxbr9as55x0yc9bh-zstd-1.5.7-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/gfq90rph1rzzwxkhw5pq4ywd5vy0rapa-zstd-1.5.7-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/jyyscffl8vhrgq34yl5dpf17pwz9v0d4-zstd-1.5.7-dev" - }, - { - "name": "out", - "path": "/nix/store/mdy5l0qf8z6p9xyn2igix156smcmkag8-zstd-1.5.7" - } - ], - "store_path": "/nix/store/yc6l9laqs8xs5q0ivxbr9as55x0yc9bh-zstd-1.5.7-bin" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/8cc8cdqa54bahfi5n5glkm8d252zkkjn-zstd-1.5.7-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/bhms1y19818704k4aljz6mb8prjbxd1y-zstd-1.5.7-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/q4v09bffjy5i0f2kdwnbbwmhqv6i3pjs-zstd-1.5.7-dev" - }, - { - "name": "out", - "path": "/nix/store/29mmnqpc1p3iv8wj0lpvicajy3jsbx87-zstd-1.5.7" - } - ], - "store_path": "/nix/store/8cc8cdqa54bahfi5n5glkm8d252zkkjn-zstd-1.5.7-bin" - } - } - } - } -} From c5593aa271aab441f916ea8e2d41223c24b8a50a Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 19 May 2026 19:12:17 +0200 Subject: [PATCH 33/64] Address philippthun's review feedback - Remove unused BaseAccess subclass (no v2 endpoint) - Remove without_guid_generation on association (int PK) - Use ProcessRouteHandler.notify_backend_of_route_update in db.after_commit instead of touching process updated_at - Rewrite migration using VCAP::Migration.common for consistent table layout (Timestamp with defaults, proper indexes) - Extract RoutePolicyCreate action from controller - Remove manual guid/created_at/updated_at assignment in create - Reuse find_and_authorize_route in update/destroy - Pass locked policies to validate_source_exclusivity - Remove unnecessary 'source != cf:any' check - Fix comment order to match code --- app/access/route_policy_access.rb | 66 ------------------- app/actions/route_policy_create.rb | 34 ++++++++++ .../v3/route_policies_controller.rb | 52 ++++----------- app/models/runtime/route_policy.rb | 17 +++-- .../20260421074455_create_route_policies.rb | 18 +---- devbox.d/mysql80/my.cnf | 6 -- spec/unit/models/runtime/route_policy_spec.rb | 8 +-- 7 files changed, 62 insertions(+), 139 deletions(-) delete mode 100644 app/access/route_policy_access.rb create mode 100644 app/actions/route_policy_create.rb delete mode 100644 devbox.d/mysql80/my.cnf diff --git a/app/access/route_policy_access.rb b/app/access/route_policy_access.rb deleted file mode 100644 index 229e8fdd826..00000000000 --- a/app/access/route_policy_access.rb +++ /dev/null @@ -1,66 +0,0 @@ -module VCAP::CloudController - class RoutePolicyAccess < BaseAccess - # Space Developer of the route's space can manage route policies. - # No bilateral requirement — destination-controlled auth only. - - def create?(route_policy, _params=nil) - return true if admin_user? - - route = route_policy.route - return false unless route - - space = route.space - context.user_email && context.user.is_a?(User) && - space.developers.include?(context.user) - end - - def read?(route_policy) - return true if admin_user? || admin_read_only_user? || global_auditor? - - route = route_policy.route - return false unless route - - object_is_visible_to_user?(route_policy, context.user) - end - - def update?(route_policy, _params=nil) - create?(route_policy) - end - - def delete?(route_policy) - create?(route_policy) - end - - def index?(_object_class, _params=nil) - admin_user? || admin_read_only_user? || has_read_scope? || global_auditor? - end - - def read_with_token?(_) - admin_user? || admin_read_only_user? || has_read_scope? || global_auditor? - end - - def create_with_token?(_) - admin_user? || has_write_scope? - end - - def read_for_update_with_token?(_) - admin_user? || has_write_scope? - end - - def can_remove_related_object_with_token?(*) - read_for_update_with_token?(*) - end - - def read_related_object_for_update_with_token?(*) - read_for_update_with_token?(*) - end - - def update_with_token?(_) - admin_user? || has_write_scope? - end - - def delete_with_token?(_) - admin_user? || has_write_scope? - end - end -end diff --git a/app/actions/route_policy_create.rb b/app/actions/route_policy_create.rb new file mode 100644 index 00000000000..c4ed74d9b2a --- /dev/null +++ b/app/actions/route_policy_create.rb @@ -0,0 +1,34 @@ +module VCAP::CloudController + class RoutePolicyCreate + class Error < StandardError + end + + def create(route:, message:) + RoutePolicy.db.transaction do + # Lock existing route policies for this route to prevent concurrent inserts + # from violating cf:any exclusivity or uniqueness constraints + locked_policies = RoutePolicy.where(route_id: route.id).for_update.all + + validate_source_exclusivity(locked_policies, message.source) + + RoutePolicy.create( + source: message.source, + route_id: route.id + ) + end + rescue Sequel::UniqueConstraintViolation + raise Error.new("A route policy with source '#{message.source}' already exists for this route.") + end + + private + + def validate_source_exclusivity(locked_policies, source) + existing_sources = locked_policies.map(&:source) + + # Enforce cf:any exclusivity: if new policy is cf:any, reject if route already has any policies; + # if route already has a cf:any policy, reject new policies. + raise Error.new("Cannot add 'cf:any' source when other route policies already exist for this route.") if source == 'cf:any' && existing_sources.any? + raise Error.new("Cannot add source '#{source}': route already has a 'cf:any' policy.") if existing_sources.include?('cf:any') + end + end +end diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 3c4cbdc0e75..5f03dbb2de8 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -5,6 +5,7 @@ require 'presenters/v3/route_policy_presenter' require 'decorators/include_route_policy_source_decorator' require 'decorators/include_route_policy_route_decorator' +require 'actions/route_policy_create' class RoutePoliciesController < ApplicationController def index @@ -50,37 +51,18 @@ def create route = find_and_authorize_route(message.route_guid) validate_route_domain(route) - route_policy = VCAP::CloudController::RoutePolicy.db.transaction do - # Lock existing route policies for this route to prevent concurrent inserts - # from violating cf:any exclusivity or uniqueness constraints - VCAP::CloudController::RoutePolicy.where(route_id: route.id).for_update.all - - validate_source_exclusivity(route, message.source) - - policy = VCAP::CloudController::RoutePolicy.new( - guid: SecureRandom.uuid, - source: message.source, - route_id: route.id, - created_at: Time.now.utc, - updated_at: Time.now.utc - ) - policy.save - policy - end + route_policy = VCAP::CloudController::RoutePolicyCreate.new.create(route: route, message: message) render status: :created, json: Presenters::V3::RoutePolicyPresenter.new(route_policy) - rescue Sequel::UniqueConstraintViolation - unprocessable!("A route policy with source '#{message.source}' already exists for this route.") + rescue VCAP::CloudController::RoutePolicyCreate::Error => e + unprocessable!(e.message) end def update route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) resource_not_found!(:route_policy) unless route_policy - route = route_policy.route - resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) - unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) - suspended! unless permission_queryer.is_space_active?(route.space.id) + find_and_authorize_route_for_policy(route_policy) message = RoutePolicyUpdateMessage.new(hashed_params[:body]) unprocessable!(message.errors.full_messages) unless message.valid? @@ -94,10 +76,7 @@ def destroy route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid]) resource_not_found!(:route_policy) unless route_policy - route = route_policy.route - resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) - unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) - suspended! unless permission_queryer.is_space_active?(route.space.id) + find_and_authorize_route_for_policy(route_policy) route_policy.destroy head :no_content @@ -113,6 +92,13 @@ def find_and_authorize_route(route_guid) route end + def find_and_authorize_route_for_policy(route_policy) + route = route_policy.route + resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) + suspended! unless permission_queryer.is_space_active?(route.space.id) + end + def validate_route_domain(route) if route.domain.internal? unprocessable!('Cannot create route policies for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.') @@ -122,18 +108,6 @@ def validate_route_domain(route) unprocessable!("Cannot create route policies for route '#{route.guid}': the route's domain does not have enforce_route_policies enabled.") end - def validate_source_exclusivity(route, source) - existing_sources = route.route_policies.map(&:source) - - # Enforce cf:any exclusivity: if route already has a cf:any policy, reject new policies; - # if new policy is cf:any, reject if route already has any policies. - unprocessable!("Cannot add 'cf:any' source when other route policies already exist for this route.") if source == 'cf:any' && existing_sources.any? - unprocessable!("Cannot add source '#{source}': route already has a 'cf:any' policy.") if existing_sources.include?('cf:any') && source != 'cf:any' - - # Uniqueness: source must be unique per route - unprocessable!("A route policy with source '#{source}' already exists for this route.") if existing_sources.include?(source) - end - def build_dataset(message) dataset = VCAP::CloudController::RoutePolicy.dataset diff --git a/app/models/runtime/route_policy.rb b/app/models/runtime/route_policy.rb index 6b74fca0642..cf56d61959b 100644 --- a/app/models/runtime/route_policy.rb +++ b/app/models/runtime/route_policy.rb @@ -3,8 +3,7 @@ class RoutePolicy < Sequel::Model(:route_policies) many_to_one :route, class: 'VCAP::CloudController::Route', key: :route_id, - primary_key: :id, - without_guid_generation: true + primary_key: :id one_to_many :labels, class: 'VCAP::CloudController::RoutePolicyLabelModel', key: :resource_guid, primary_key: :guid one_to_many :annotations, class: 'VCAP::CloudController::RoutePolicyAnnotationModel', key: :resource_guid, primary_key: :guid @@ -19,23 +18,23 @@ def validate def after_create super - touch_associated_processes + notify_processes_of_route_update end def after_destroy super - touch_associated_processes + notify_processes_of_route_update end private - def touch_associated_processes - # Update the timestamp on all processes associated with this route - # This triggers Diego's ProcessesSync to pick up the route changes + def notify_processes_of_route_update return unless route - route.apps.each do |process| - process.update(updated_at: Time.now) + db.after_commit do + route.apps.each do |process| + ProcessRouteHandler.new(process).notify_backend_of_route_update + end end end end diff --git a/db/migrations/20260421074455_create_route_policies.rb b/db/migrations/20260421074455_create_route_policies.rb index 5e7c794c6ad..ef5e2018861 100644 --- a/db/migrations/20260421074455_create_route_policies.rb +++ b/db/migrations/20260421074455_create_route_policies.rb @@ -2,14 +2,10 @@ up do unless table_exists?(:route_policies) create_table :route_policies do - primary_key :id, name: :id - String :guid, size: 255, null: false + VCAP::Migration.common(self, :route_policies) String :source, size: 255, null: false Integer :route_id, null: false - DateTime :created_at, null: false - DateTime :updated_at, null: false - index :guid, unique: true, name: :route_policies_guid_index index %i[route_id source], unique: true, name: :route_policies_route_id_source_index foreign_key [:route_id], :routes, on_delete: :cascade, name: :fk_route_policies_route_id end @@ -17,16 +13,12 @@ unless table_exists?(:route_policy_labels) create_table :route_policy_labels do - primary_key :id, name: :id - String :guid, null: false, size: 255 + VCAP::Migration.common(self, :route_policy_labels) String :resource_guid, null: false, size: 255 String :key_prefix, null: false, default: '', size: 253 String :key_name, null: false, size: 63 String :value, null: false, size: 63 - DateTime :created_at, null: false - DateTime :updated_at - index :guid, unique: true, name: :route_policy_labels_guid_index index :resource_guid, name: :route_policy_labels_resource_guid_index index %i[resource_guid key_prefix key_name], unique: true, name: :route_policy_labels_compound_index foreign_key [:resource_guid], :route_policies, key: :guid, on_delete: :cascade, name: :fk_route_policy_labels_resource_guid @@ -35,16 +27,12 @@ unless table_exists?(:route_policy_annotations) create_table :route_policy_annotations do - primary_key :id, name: :id - String :guid, null: false, size: 255 + VCAP::Migration.common(self, :route_policy_annotations) String :resource_guid, null: false, size: 255 String :key_prefix, null: false, default: '', size: 253 String :key_name, null: false, size: 63 String :value, size: 5000 - DateTime :created_at, null: false - DateTime :updated_at - index :guid, unique: true, name: :route_policy_annotations_guid_index index :resource_guid, name: :route_policy_annotations_resource_guid_index index %i[resource_guid key_prefix key_name], unique: true, name: :route_policy_annotations_key_index foreign_key [:resource_guid], :route_policies, key: :guid, on_delete: :cascade, name: :fk_route_policy_annotations_resource_guid diff --git a/devbox.d/mysql80/my.cnf b/devbox.d/mysql80/my.cnf deleted file mode 100644 index a749c470084..00000000000 --- a/devbox.d/mysql80/my.cnf +++ /dev/null @@ -1,6 +0,0 @@ -# MySQL configuration file - -# [mysqld] -# skip-log-bin -# Change this port if 3306 is already used -#port = 3306 diff --git a/spec/unit/models/runtime/route_policy_spec.rb b/spec/unit/models/runtime/route_policy_spec.rb index 2dfbc9520dc..1e247afc75f 100644 --- a/spec/unit/models/runtime/route_policy_spec.rb +++ b/spec/unit/models/runtime/route_policy_spec.rb @@ -41,8 +41,8 @@ module VCAP::CloudController describe 'callbacks' do describe 'after_create' do - it 'calls touch_associated_processes' do - expect_any_instance_of(RoutePolicy).to receive(:touch_associated_processes).and_call_original + it 'calls notify_processes_of_route_update' do + expect_any_instance_of(RoutePolicy).to receive(:notify_processes_of_route_update).and_call_original RoutePolicy.create( source: "cf:app:#{app_guid}", @@ -76,13 +76,13 @@ module VCAP::CloudController end describe 'after_destroy' do - it 'calls touch_associated_processes' do + it 'calls notify_processes_of_route_update' do rule = RoutePolicy.create( source: "cf:app:#{app_guid}", route: route ) - expect_any_instance_of(RoutePolicy).to receive(:touch_associated_processes).and_call_original + expect_any_instance_of(RoutePolicy).to receive(:notify_processes_of_route_update).and_call_original rule.destroy end From 065b198250218a2203e990d474398bb445c2410d Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 21 May 2026 15:41:02 +0200 Subject: [PATCH 34/64] Rename add_mtls_options to add_route_policy_options and refactor structure Adopt philippthun's suggested pattern: build a fresh hash and merge at the end instead of duplicating info['options']. Also rename the method and variable from mtls_ prefix to route_policy_ to align with current terminology. --- .../diego/protocol/routing_info.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/cloud_controller/diego/protocol/routing_info.rb b/lib/cloud_controller/diego/protocol/routing_info.rb index c23e33a7041..49c5dc1b3a3 100644 --- a/lib/cloud_controller/diego/protocol/routing_info.rb +++ b/lib/cloud_controller/diego/protocol/routing_info.rb @@ -50,19 +50,19 @@ def build_http_route_info(route_mapping) info['protocol'] = route_mapping.protocol info['options'] = r.options if r.options - add_mtls_options(info, r) if r.domain.enforce_route_policies + add_route_policy_options(info, r) if r.domain.enforce_route_policies info end - def add_mtls_options(info, route) - # Inject mTLS policy options for enforce_route_policies domains. + def add_route_policy_options(info, route) + # Inject route policy options for enforce_route_policies domains. # These are GoRouter-internal keys and are filtered from the /v3/routes API. - mtls_options = info['options']&.dup || {} - mtls_options['route_policy_scope'] = route.domain.route_policies_scope if route.domain.route_policies_scope + route_policy_options = {} + route_policy_options['route_policy_scope'] = route.domain.route_policies_scope if route.domain.route_policies_scope sources = route.route_policies.map(&:source) - mtls_options['route_policy_sources'] = sources.join(',') unless sources.empty? - info['options'] = mtls_options + route_policy_options['route_policy_sources'] = sources.join(',') unless sources.empty? + info['options'] = (info['options'] || {}).merge(route_policy_options) end def tcp_info(process_eager) From 3cfd4e26eea61f752cee1071c41cc59f9fcf8f69 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 21 May 2026 15:49:46 +0200 Subject: [PATCH 35/64] Limit route policy includes to 'route' and 'source' only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 'app', 'space', and 'organization' as separate include values. The 'source' include already resolves the underlying app/space/org from the source string (e.g. cf:app:), making the individual keywords redundant and confusing — especially since space_guids filters by route space while include=space would include the source space. --- .../include_route_policy_source_decorator.rb | 3 +- app/messages/route_policies_list_message.rb | 2 +- app/messages/route_policy_show_message.rb | 2 +- .../resources/route_policies/_get.md.erb | 2 +- .../resources/route_policies/_list.md.erb | 4 +-- .../route_policies_list_message_spec.rb | 28 +++++++++---------- 6 files changed, 20 insertions(+), 21 deletions(-) diff --git a/app/decorators/include_route_policy_source_decorator.rb b/app/decorators/include_route_policy_source_decorator.rb index 271e769ac27..da3798acdec 100644 --- a/app/decorators/include_route_policy_source_decorator.rb +++ b/app/decorators/include_route_policy_source_decorator.rb @@ -8,8 +8,7 @@ class IncludeRoutePolicySourceDecorator def self.match?(include_params) return false unless include_params - # Match if any of: source, app, space, organization - include_params.intersect?(%w[source app space organization]) + include_params.include?('source') end def self.decorate(hash, route_policies) diff --git a/app/messages/route_policies_list_message.rb b/app/messages/route_policies_list_message.rb index bd3ec945aa1..6244a6a67c7 100644 --- a/app/messages/route_policies_list_message.rb +++ b/app/messages/route_policies_list_message.rb @@ -12,7 +12,7 @@ class RoutePoliciesListMessage < ListMessage ] validates_with NoAdditionalParamsValidator - validates_with IncludeParamValidator, valid_values: %w[source route app space organization] + validates_with IncludeParamValidator, valid_values: %w[source route] validates :space_guids, array: true, allow_nil: true validates :source_guids, array: true, allow_nil: true diff --git a/app/messages/route_policy_show_message.rb b/app/messages/route_policy_show_message.rb index cb5c1f952e4..875659f3089 100755 --- a/app/messages/route_policy_show_message.rb +++ b/app/messages/route_policy_show_message.rb @@ -5,7 +5,7 @@ class RoutePolicyShowMessage < BaseMessage register_allowed_keys [:include] validates_with NoAdditionalParamsValidator - validates_with IncludeParamValidator, valid_values: %w[source route app space organization] + validates_with IncludeParamValidator, valid_values: %w[source route] def self.from_params(params) super(params, %w[include]) diff --git a/docs/v3/source/includes/resources/route_policies/_get.md.erb b/docs/v3/source/includes/resources/route_policies/_get.md.erb index 13a0d2443f8..1c7cc2f5435 100755 --- a/docs/v3/source/includes/resources/route_policies/_get.md.erb +++ b/docs/v3/source/includes/resources/route_policies/_get.md.erb @@ -28,7 +28,7 @@ Content-Type: application/json Name | Type | Description ---- | ---- | ------------ -**include** | _list of strings_ | Optionally include a list of unique related resources in the response; valid values are `route`, `app`, `space`, `organization`, and `source` (which includes the app, space, or organization referenced by the source field) +**include** | _list of strings_ | Optionally include a list of unique related resources in the response; valid values are `route` and `source` (source includes the app, space, or organization referenced by the source field) #### Permitted roles diff --git a/docs/v3/source/includes/resources/route_policies/_list.md.erb b/docs/v3/source/includes/resources/route_policies/_list.md.erb index 12242286ef1..1c3d6b106b6 100644 --- a/docs/v3/source/includes/resources/route_policies/_list.md.erb +++ b/docs/v3/source/includes/resources/route_policies/_list.md.erb @@ -57,7 +57,7 @@ curl "https://api.example.org/v3/route_policies?include=route,source" \ | **per_page** | _integer_ | Number of results per page; valid values are 1 through 5000 | **order_by** | _string_ | Value to sort by. Defaults to ascending; prepend with `-` to sort descending. Valid values are `created_at`, `updated_at` | **label_selector** | _string_ | A query string containing a list of [label selector](#labels-and-selectors) requirements -| **include** | _string_ | Optionally include related resources in the response; valid values are `route`, `app`, `space`, `organization`, and `source` (which includes app, space, or organization based on the source type) +| **include** | _string_ | Optionally include related resources in the response; valid values are `route` and `source` (source includes the app, space, or organization based on the source type) | **created_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) | **updated_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) @@ -68,7 +68,7 @@ curl "https://api.example.org/v3/route_policies?include=route,source" \ - **Filter by source type**: `GET /v3/route_policies?sources=cf:any` - **Find policies referencing a specific app**: `GET /v3/route_policies?source_guids=d76446a1-f429-4444-8797-be2f78b75b08` - **Include source resources**: `GET /v3/route_policies?include=source` (batch-loads the app, space, or org referenced in each policy's source) -- **Include route and app**: `GET /v3/route_policies?include=route,app` +- **Include route and source**: `GET /v3/route_policies?include=route,source` #### Use cases diff --git a/spec/unit/messages/route_policies_list_message_spec.rb b/spec/unit/messages/route_policies_list_message_spec.rb index 0a502105db7..846e45d6452 100644 --- a/spec/unit/messages/route_policies_list_message_spec.rb +++ b/spec/unit/messages/route_policies_list_message_spec.rb @@ -14,7 +14,7 @@ module VCAP::CloudController 'page' => 1, 'per_page' => 5, 'order_by' => 'created_at', - 'include' => 'source,route,app,space,organization' + 'include' => 'source,route' } end @@ -30,7 +30,7 @@ module VCAP::CloudController expect(message.page).to eq(1) expect(message.per_page).to eq(5) expect(message.order_by).to eq('created_at') - expect(message.include).to eq(%w[source route app space organization]) + expect(message.include).to eq(%w[source route]) end it 'converts requested keys to symbols' do @@ -59,7 +59,7 @@ module VCAP::CloudController page: 1, per_page: 5, order_by: 'created_at', - include: %w[source route app space organization] + include: %w[source route] } end @@ -81,7 +81,7 @@ module VCAP::CloudController page: 1, per_page: 5, order_by: 'created_at', - include: %w[source route app space organization] + include: %w[source route] }) end.not_to raise_error end @@ -106,22 +106,22 @@ module VCAP::CloudController message = RoutePoliciesListMessage.from_params({ 'include' => 'route' }) expect(message).to be_valid - message = RoutePoliciesListMessage.from_params({ 'include' => 'app' }) - expect(message).to be_valid - - message = RoutePoliciesListMessage.from_params({ 'include' => 'space' }) - expect(message).to be_valid - - message = RoutePoliciesListMessage.from_params({ 'include' => 'organization' }) - expect(message).to be_valid - - message = RoutePoliciesListMessage.from_params({ 'include' => 'source,route,app,space,organization' }) + message = RoutePoliciesListMessage.from_params({ 'include' => 'source,route' }) expect(message).to be_valid end it 'rejects invalid include values' do message = RoutePoliciesListMessage.from_params({ 'include' => 'invalid' }) expect(message).not_to be_valid + + message = RoutePoliciesListMessage.from_params({ 'include' => 'app' }) + expect(message).not_to be_valid + + message = RoutePoliciesListMessage.from_params({ 'include' => 'space' }) + expect(message).not_to be_valid + + message = RoutePoliciesListMessage.from_params({ 'include' => 'organization' }) + expect(message).not_to be_valid end end From 2081e5b56f2261b88ce49a51e8b480facb9b02fc Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 21 May 2026 15:55:44 +0200 Subject: [PATCH 36/64] Prevent enforce_route_policies on internal and router_group domains Internal domains bypass GoRouter entirely (using container-to-container networking), so GoRouter cannot enforce route policies. Similarly, TCP router groups do not support mTLS policy enforcement. Add validation to DomainCreateMessage#mutually_exclusive_fields to reject these invalid combinations at domain creation time. --- app/messages/domain_create_message.rb | 4 ++ .../messages/domain_create_message_spec.rb | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/app/messages/domain_create_message.rb b/app/messages/domain_create_message.rb index 4456c13c1b8..fbeeaf30f01 100644 --- a/app/messages/domain_create_message.rb +++ b/app/messages/domain_create_message.rb @@ -90,6 +90,10 @@ def alpha_numeric def mutually_exclusive_fields errors.add(:base, 'Cannot associate an internal domain with an organization') if requested?(:internal) && internal == true && requested?(:relationships) errors.add(:base, 'Internal domains cannot be associated to a router group.') if requested?(:internal) && internal == true && requested?(:router_group) + errors.add(:base, 'Internal domains cannot have route policy enforcement. Internal routes bypass GoRouter.') if requested?(:internal) && internal == true && + requested?(:enforce_route_policies) && enforce_route_policies == true + errors.add(:base, 'Domains with a router group cannot have route policy enforcement. TCP routes do not support mTLS policy enforcement.') if requested?(:router_group) && + requested?(:enforce_route_policies) && enforce_route_policies == true return unless requested?(:relationships) && requested?(:router_group) errors.add(:base, 'Domains scoped to an organization cannot be associated to a router group.') diff --git a/spec/unit/messages/domain_create_message_spec.rb b/spec/unit/messages/domain_create_message_spec.rb index cf71ae6936b..d3c69122442 100644 --- a/spec/unit/messages/domain_create_message_spec.rb +++ b/spec/unit/messages/domain_create_message_spec.rb @@ -490,6 +490,44 @@ module VCAP::CloudController end end end + + context 'enforce_route_policies with internal' do + context 'when both internal and enforce_route_policies are true' do + let(:params) { { name: 'name.com', internal: true, enforce_route_policies: true, route_policies_scope: 'any' } } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:base]).to include('Internal domains cannot have route policy enforcement. Internal routes bypass GoRouter.') + end + end + + context 'when internal is true and enforce_route_policies is false' do + let(:params) { { name: 'name.com', internal: true, enforce_route_policies: false } } + + it 'is valid' do + expect(subject).to be_valid + end + end + end + + context 'enforce_route_policies with router_group' do + context 'when both router_group and enforce_route_policies are set' do + let(:params) { { name: 'name.com', router_group: { guid: 'some-guid' }, enforce_route_policies: true, route_policies_scope: 'any' } } + + it 'is not valid' do + expect(subject).not_to be_valid + expect(subject.errors[:base]).to include('Domains with a router group cannot have route policy enforcement. TCP routes do not support mTLS policy enforcement.') + end + end + + context 'when router_group is set and enforce_route_policies is false' do + let(:params) { { name: 'name.com', router_group: { guid: 'some-guid' }, enforce_route_policies: false } } + + it 'is valid' do + expect(subject).to be_valid + end + end + end end describe 'accessor methods' do From 9666a15bfd2b7530f5b337fa240e7d34e200dd3a Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 21 May 2026 17:01:25 +0200 Subject: [PATCH 37/64] Fix RuboCop offenses in domain_create_message and route_policies_list_message_spec --- app/messages/domain_create_message.rb | 24 ++++++++++++++----- .../route_policies_list_message_spec.rb | 4 ++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/app/messages/domain_create_message.rb b/app/messages/domain_create_message.rb index fbeeaf30f01..fad00cd32a3 100644 --- a/app/messages/domain_create_message.rb +++ b/app/messages/domain_create_message.rb @@ -88,17 +88,29 @@ def alpha_numeric end def mutually_exclusive_fields - errors.add(:base, 'Cannot associate an internal domain with an organization') if requested?(:internal) && internal == true && requested?(:relationships) - errors.add(:base, 'Internal domains cannot be associated to a router group.') if requested?(:internal) && internal == true && requested?(:router_group) - errors.add(:base, 'Internal domains cannot have route policy enforcement. Internal routes bypass GoRouter.') if requested?(:internal) && internal == true && - requested?(:enforce_route_policies) && enforce_route_policies == true - errors.add(:base, 'Domains with a router group cannot have route policy enforcement. TCP routes do not support mTLS policy enforcement.') if requested?(:router_group) && - requested?(:enforce_route_policies) && enforce_route_policies == true + validate_internal_domain_exclusions + validate_router_group_exclusions return unless requested?(:relationships) && requested?(:router_group) errors.add(:base, 'Domains scoped to an organization cannot be associated to a router group.') end + def validate_internal_domain_exclusions + return unless requested?(:internal) && internal == true + + errors.add(:base, 'Cannot associate an internal domain with an organization') if requested?(:relationships) + errors.add(:base, 'Internal domains cannot be associated to a router group.') if requested?(:router_group) + return unless requested?(:enforce_route_policies) && enforce_route_policies == true + + errors.add(:base, 'Internal domains cannot have route policy enforcement. Internal routes bypass GoRouter.') + end + + def validate_router_group_exclusions + return unless requested?(:router_group) && requested?(:enforce_route_policies) && enforce_route_policies == true + + errors.add(:base, 'Domains with a router group cannot have route policy enforcement. TCP routes do not support mTLS policy enforcement.') + end + def router_group_validation return if router_group.nil? return errors.add(:router_group, 'must be an object') unless router_group.is_a?(Hash) diff --git a/spec/unit/messages/route_policies_list_message_spec.rb b/spec/unit/messages/route_policies_list_message_spec.rb index 846e45d6452..c952db47c81 100644 --- a/spec/unit/messages/route_policies_list_message_spec.rb +++ b/spec/unit/messages/route_policies_list_message_spec.rb @@ -59,7 +59,7 @@ module VCAP::CloudController page: 1, per_page: 5, order_by: 'created_at', - include: %w[source route] + include: %w[source route] } end @@ -81,7 +81,7 @@ module VCAP::CloudController page: 1, per_page: 5, order_by: 'created_at', - include: %w[source route] + include: %w[source route] }) end.not_to raise_error end From f86eda90a9c3af219f1f82840d64865220c3d720 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 26 May 2026 10:10:00 +0200 Subject: [PATCH 38/64] Add include=route_policies support on routes endpoints Add IncludeRoutePoliciesDecorator to allow consumers to include route_policies when fetching routes via GET /v3/routes and GET /v3/routes/:guid with ?include=route_policies. The decorator batch-loads all route policies for the routes in the result set to avoid N+1 queries. --- app/controllers/v3/routes_controller.rb | 3 + .../include_route_policies_decorator.rb | 19 ++++++ app/messages/route_show_message.rb | 2 +- app/messages/routes_list_message.rb | 2 +- .../includes/resources/routes/_get.md.erb | 2 +- .../includes/resources/routes/_list.md.erb | 2 +- spec/request/routes_spec.rb | 31 ++++++++++ .../include_route_policies_decorator_spec.rb | 59 +++++++++++++++++++ 8 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 app/decorators/include_route_policies_decorator.rb create mode 100644 spec/unit/decorators/include_route_policies_decorator_spec.rb diff --git a/app/controllers/v3/routes_controller.rb b/app/controllers/v3/routes_controller.rb index 7c7e48ce212..e3ccc8e1bc4 100644 --- a/app/controllers/v3/routes_controller.rb +++ b/app/controllers/v3/routes_controller.rb @@ -8,6 +8,7 @@ require 'messages/route_update_destinations_message' require 'actions/update_route_destinations' require 'decorators/include_route_domain_decorator' +require 'decorators/include_route_policies_decorator' require 'presenters/v3/route_presenter' require 'presenters/v3/route_destinations_presenter' require 'presenters/v3/paginated_list_presenter' @@ -45,6 +46,7 @@ def index decorators << IncludeRouteDomainDecorator if IncludeRouteDomainDecorator.match?(message.include) decorators << IncludeSpaceDecorator if IncludeSpaceDecorator.match?(message.include) decorators << IncludeOrganizationDecorator if IncludeOrganizationDecorator.match?(message.include) + decorators << IncludeRoutePoliciesDecorator if IncludeRoutePoliciesDecorator.match?(message.include) render status: :ok, json: Presenters::V3::PaginatedListPresenter.new( presenter: Presenters::V3::RoutePresenter, @@ -63,6 +65,7 @@ def show decorators << IncludeRouteDomainDecorator if IncludeRouteDomainDecorator.match?(message.include) decorators << IncludeSpaceDecorator if IncludeSpaceDecorator.match?(message.include) decorators << IncludeOrganizationDecorator if IncludeOrganizationDecorator.match?(message.include) + decorators << IncludeRoutePoliciesDecorator if IncludeRoutePoliciesDecorator.match?(message.include) render status: :ok, json: Presenters::V3::RoutePresenter.new( route, diff --git a/app/decorators/include_route_policies_decorator.rb b/app/decorators/include_route_policies_decorator.rb new file mode 100644 index 00000000000..dccb346434e --- /dev/null +++ b/app/decorators/include_route_policies_decorator.rb @@ -0,0 +1,19 @@ +module VCAP::CloudController + class IncludeRoutePoliciesDecorator + class << self + def match?(include) + include&.any? { |i| %w[route_policies].include?(i) } + end + + def decorate(hash, routes) + hash[:included] ||= {} + route_ids = routes.map(&:id).uniq + route_policies = RoutePolicy.where(route_id: route_ids). + eager(:route, :labels, :annotations).all + + hash[:included][:route_policies] = route_policies.map { |rp| Presenters::V3::RoutePolicyPresenter.new(rp).to_hash } + hash + end + end + end +end diff --git a/app/messages/route_show_message.rb b/app/messages/route_show_message.rb index 318f47e5377..26c59179a41 100644 --- a/app/messages/route_show_message.rb +++ b/app/messages/route_show_message.rb @@ -5,7 +5,7 @@ class RouteShowMessage < BaseMessage register_allowed_keys %i[guid include] validates_with NoAdditionalParamsValidator - validates_with IncludeParamValidator, valid_values: ['domain', 'space', 'space.organization'] + validates_with IncludeParamValidator, valid_values: ['domain', 'space', 'space.organization', 'route_policies'] validates :guid, presence: true, string: true diff --git a/app/messages/routes_list_message.rb b/app/messages/routes_list_message.rb index 7979b887531..8e70eb4f1f5 100644 --- a/app/messages/routes_list_message.rb +++ b/app/messages/routes_list_message.rb @@ -16,7 +16,7 @@ class RoutesListMessage < ListMessage ] validates_with NoAdditionalParamsValidator - validates_with IncludeParamValidator, valid_values: ['domain', 'space', 'space.organization'] + validates_with IncludeParamValidator, valid_values: ['domain', 'space', 'space.organization', 'route_policies'] validates :hosts, allow_nil: true, array: true validates :paths, allow_nil: true, array: true diff --git a/docs/v3/source/includes/resources/routes/_get.md.erb b/docs/v3/source/includes/resources/routes/_get.md.erb index f437ec593f2..e7d3067fdbe 100644 --- a/docs/v3/source/includes/resources/routes/_get.md.erb +++ b/docs/v3/source/includes/resources/routes/_get.md.erb @@ -28,7 +28,7 @@ Content-Type: application/json Name | Type | Description ---- | ---- | ------------ -**include** | _string_ | Optionally include additional related resources in the response
Valid values are `domain`, `space.organization`, `space` +**include** | _string_ | Optionally include additional related resources in the response
Valid values are `domain`, `space.organization`, `space`, `route_policies` #### Permitted roles diff --git a/docs/v3/source/includes/resources/routes/_list.md.erb b/docs/v3/source/includes/resources/routes/_list.md.erb index bc43e4d0cc7..af0a6b8f1de 100644 --- a/docs/v3/source/includes/resources/routes/_list.md.erb +++ b/docs/v3/source/includes/resources/routes/_list.md.erb @@ -42,7 +42,7 @@ Name | Type | Description **per_page** | _integer_ | Number of results per page;
valid values are 1 through 5000 **order_by** | _string_ | Value to sort by. Defaults to ascending; prepend with `-` to sort descending.
Valid values are `created_at`, `updated_at` **label_selector** | _string_ | A query string containing a list of [label selector](#labels-and-selectors) requirements -**include** | _string_ | Optionally include a list of unique related resources in the response
Valid values are `domain`, `space.organization`, `space` +**include** | _string_ | Optionally include a list of unique related resources in the response
Valid values are `domain`, `space.organization`, `space`, `route_policies` **created_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) **updated_ats** | _[timestamp](#timestamps)_ | Timestamp to filter by. When filtering on equality, several comma-delimited timestamps may be passed. Also supports filtering with [relational operators](#relational-operators) diff --git a/spec/request/routes_spec.rb b/spec/request/routes_spec.rb index a8ad0dc1e2d..c201574e82b 100644 --- a/spec/request/routes_spec.rb +++ b/spec/request/routes_spec.rb @@ -387,6 +387,18 @@ expect(last_response).to have_status_code(200) end end + + context 'when including route_policies' do + let!(:route_policy) { VCAP::CloudController::RoutePolicy.create(route: route_in_org, source: 'cf:app:some-app-guid') } + + it 'includes the route_policies for the routes' do + get '/v3/routes?include=route_policies', nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']['route_policies']).to have(1).items + expect(parsed_response['included']['route_policies'][0]['guid']).to eq(route_policy.guid) + expect(parsed_response['included']['route_policies'][0]['source']).to eq('cf:app:some-app-guid') + end + end end describe 'filters' do @@ -1120,6 +1132,25 @@ end end end + + context 'when including route_policies' do + let!(:route_policy) { VCAP::CloudController::RoutePolicy.create(route: route, source: 'cf:app:some-app-guid') } + + it 'includes the route_policies for the route' do + get "/v3/routes/#{route.guid}?include=route_policies", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']['route_policies']).to have(1).items + expect(parsed_response['included']['route_policies'][0]['guid']).to eq(route_policy.guid) + expect(parsed_response['included']['route_policies'][0]['source']).to eq('cf:app:some-app-guid') + end + + it 'returns an empty array when route has no policies' do + route_policy.destroy + get "/v3/routes/#{route.guid}?include=route_policies", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']['route_policies']).to eq([]) + end + end end end diff --git a/spec/unit/decorators/include_route_policies_decorator_spec.rb b/spec/unit/decorators/include_route_policies_decorator_spec.rb new file mode 100644 index 00000000000..b5348ba5c16 --- /dev/null +++ b/spec/unit/decorators/include_route_policies_decorator_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' +require 'decorators/include_route_policies_decorator' + +module VCAP::CloudController + RSpec.describe IncludeRoutePoliciesDecorator do + subject(:decorator) { IncludeRoutePoliciesDecorator } + + let(:space) { Space.make } + let(:domain) { SharedDomain.make } + let(:route1) { Route.make(space: space, domain: domain) } + let(:route2) { Route.make(space: space, domain: domain) } + + it 'decorates the given hash with route_policies from routes' do + route_policy1 = RoutePolicy.create(route: route1, source: 'cf:app:app-guid-1') + route_policy2 = RoutePolicy.create(route: route2, source: 'cf:app:app-guid-2') + undecorated_hash = { i_am: 'tim' } + hash = subject.decorate(undecorated_hash, [route1, route2]) + expect(hash[:i_am]).to eq('tim') + expect(hash[:included][:route_policies]).to contain_exactly( + Presenters::V3::RoutePolicyPresenter.new(route_policy1).to_hash, + Presenters::V3::RoutePolicyPresenter.new(route_policy2).to_hash + ) + end + + it 'does not overwrite other included fields' do + route_policy1 = RoutePolicy.create(route: route1, source: 'cf:app:app-guid-1') + undecorated_hash = { foo: 'bar', included: { favorite_fruits: %w[tomato cucumber] } } + hash = subject.decorate(undecorated_hash, [route1]) + expect(hash[:foo]).to eq('bar') + expect(hash[:included][:route_policies]).to contain_exactly(Presenters::V3::RoutePolicyPresenter.new(route_policy1).to_hash) + expect(hash[:included][:favorite_fruits]).to match_array(%w[tomato cucumber]) + end + + it 'returns an empty array when routes have no policies' do + hash = subject.decorate({}, [route1]) + expect(hash[:included][:route_policies]).to eq([]) + end + + it 'includes multiple policies for the same route' do + policy_a = RoutePolicy.create(route: route1, source: 'cf:app:app-guid-a') + policy_b = RoutePolicy.create(route: route1, source: 'cf:app:app-guid-b') + hash = subject.decorate({}, [route1]) + expect(hash[:included][:route_policies]).to contain_exactly( + Presenters::V3::RoutePolicyPresenter.new(policy_a).to_hash, + Presenters::V3::RoutePolicyPresenter.new(policy_b).to_hash + ) + end + + describe '.match?' do + it 'matches include arrays containing "route_policies"' do + expect(decorator.match?(%w[potato route_policies turnip])).to be(true) + end + + it 'does not match other include arrays' do + expect(decorator.match?(%w[domain space])).not_to be(true) + end + end + end +end From 96cf486aa5ec4e739d54bccc20f6dd9883ca5c20 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 15 Jun 2026 15:42:01 +0200 Subject: [PATCH 39/64] Fix race condition in RoutePolicyCreate: lock parent route row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SELECT ... FOR UPDATE on an empty route_policies table acquires no row locks. Two concurrent creates on the same route_id both read [] and both pass cf:any exclusivity validation, leaving the route with cf:any and cf:app: policies simultaneously. Lock the parent Route row instead — it always exists, so concurrent transactions serialize at the database level regardless of how many policies currently exist. Add spec/unit/actions/route_policy_create_spec.rb to cover the action and document the locking behaviour. --- app/actions/route_policy_create.rb | 12 ++- spec/unit/actions/route_policy_create_spec.rb | 85 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 spec/unit/actions/route_policy_create_spec.rb diff --git a/app/actions/route_policy_create.rb b/app/actions/route_policy_create.rb index c4ed74d9b2a..8e16a85afc7 100644 --- a/app/actions/route_policy_create.rb +++ b/app/actions/route_policy_create.rb @@ -5,11 +5,15 @@ class Error < StandardError def create(route:, message:) RoutePolicy.db.transaction do - # Lock existing route policies for this route to prevent concurrent inserts - # from violating cf:any exclusivity or uniqueness constraints - locked_policies = RoutePolicy.where(route_id: route.id).for_update.all + # Lock the parent route row to serialize concurrent creates. + # SELECT ... FOR UPDATE on an empty policies table acquires no row locks, + # so two concurrent transactions can both read [] and both pass cf:any + # exclusivity validation. Locking the route row (which always exists) + # ensures they serialize regardless of how many policies currently exist. + Route.where(id: route.id).for_update.first - validate_source_exclusivity(locked_policies, message.source) + existing_policies = RoutePolicy.where(route_id: route.id).all + validate_source_exclusivity(existing_policies, message.source) RoutePolicy.create( source: message.source, diff --git a/spec/unit/actions/route_policy_create_spec.rb b/spec/unit/actions/route_policy_create_spec.rb new file mode 100644 index 00000000000..4433a6575c2 --- /dev/null +++ b/spec/unit/actions/route_policy_create_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' +require 'actions/route_policy_create' + +module VCAP::CloudController + RSpec.describe RoutePolicyCreate do + subject(:action) { RoutePolicyCreate.new } + + let(:space) { Space.make } + let(:domain) { SharedDomain.make(name: 'apps.identity', enforce_route_policies: true) } + let(:route) { Route.make(space:, domain:) } + let(:app_guid) { SecureRandom.uuid } + let(:message) { instance_double(RoutePolicyCreateMessage, source: "cf:app:#{app_guid}") } + + describe '#create' do + it 'creates a route policy with the given source' do + expect { + action.create(route:, message:) + }.to change(RoutePolicy, :count).by(1) + + policy = RoutePolicy.last + expect(policy.source).to eq("cf:app:#{app_guid}") + expect(policy.route_id).to eq(route.id) + end + + context 'when the same source already exists for the route' do + before do + RoutePolicy.create(source: "cf:app:#{app_guid}", route_id: route.id) + end + + it 'raises an error' do + expect { + action.create(route:, message:) + }.to raise_error(RoutePolicyCreate::Error, /already exists for this route/) + end + end + + context 'when source is cf:any and other policies exist for the route' do + let(:message) { instance_double(RoutePolicyCreateMessage, source: 'cf:any') } + + before do + RoutePolicy.create(source: "cf:app:#{app_guid}", route_id: route.id) + end + + it 'raises an error' do + expect { + action.create(route:, message:) + }.to raise_error(RoutePolicyCreate::Error, /cannot add 'cf:any'/i) + end + end + + context 'when a cf:any policy already exists for the route' do + before do + RoutePolicy.create(source: 'cf:any', route_id: route.id) + end + + it 'raises an error when adding any other source' do + other_message = instance_double(RoutePolicyCreateMessage, source: "cf:app:#{SecureRandom.uuid}") + expect { + action.create(route:, message: other_message) + }.to raise_error(RoutePolicyCreate::Error, /already has a 'cf:any' policy/) + end + end + + context 'when concurrent creates target the same route with no existing policies' do + let(:message) { instance_double(RoutePolicyCreateMessage, source: 'cf:any') } + + it 'locks the parent route row to serialize creates and prevent cf:any exclusivity bypass' do + # SELECT ... FOR UPDATE on an empty route_policies table acquires no row locks. + # Two concurrent transactions can both read [], both pass cf:any exclusivity + # validation, and both commit — leaving the route with cf:any + cf:app:. + # The fix: lock the parent Route row (which always exists) before reading + # policies, so concurrent transactions serialize at the route level. + route_relation = spy('route relation') + allow(route_relation).to receive(:for_update).and_return(route_relation) + allow(route_relation).to receive(:first).and_return(route) + allow(Route).to receive(:where).with(id: route.id).and_return(route_relation) + + action.create(route:, message:) + + expect(route_relation).to have_received(:for_update) + end + end + end + end +end From 42f7d58c1356427a7bdc37d7a7315986edb21077 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 15 Jun 2026 15:51:10 +0200 Subject: [PATCH 40/64] Fix missing permission filter in IncludeRoutePolicySourceDecorator Without a permission check, a space developer could write a route policy with a source GUID from another org and receive the full AppPresenter / SpacePresenter / OrganizationPresenter payload via ?include=source. Follow the IncludeSpaceDecorator pattern: build a Permissions queryer from SecurityContext.current_user and gate each fetch on readable_space_guids_query (for apps and spaces) and readable_org_guids_query (for orgs). Global readers skip the filter. Add spec/unit/decorators/include_route_policy_source_decorator_spec.rb. --- .../include_route_policy_source_decorator.rb | 44 ++++---- spec/unit/actions/route_policy_create_spec.rb | 21 ++-- ...lude_route_policy_source_decorator_spec.rb | 106 ++++++++++++++++++ 3 files changed, 141 insertions(+), 30 deletions(-) create mode 100644 spec/unit/decorators/include_route_policy_source_decorator_spec.rb diff --git a/app/decorators/include_route_policy_source_decorator.rb b/app/decorators/include_route_policy_source_decorator.rb index da3798acdec..d262638eeae 100644 --- a/app/decorators/include_route_policy_source_decorator.rb +++ b/app/decorators/include_route_policy_source_decorator.rb @@ -2,6 +2,7 @@ module VCAP::CloudController class IncludeRoutePolicySourceDecorator # Handles `?include=source` for GET /v3/route_policies # Stale/missing resources (source GUIDs that no longer exist) are silently absent. + # Resources the current user cannot read are also silently absent. SOURCE_REGEX = /\Acf:(app|space|org):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\z/ @@ -36,39 +37,44 @@ def self.decorate(hash, route_policies) end end - # Fetch and present resources - hash[:included][:apps] = fetch_and_present_apps(app_guids.uniq) - hash[:included][:spaces] = fetch_and_present_spaces(space_guids.uniq) - hash[:included][:organizations] = fetch_and_present_organizations(org_guids.uniq) + permission_queryer = Permissions.new(SecurityContext.current_user) + + # Fetch and present resources, filtering by what the current user can read + hash[:included][:apps] = fetch_and_present_apps(app_guids.uniq, permission_queryer) + hash[:included][:spaces] = fetch_and_present_spaces(space_guids.uniq, permission_queryer) + hash[:included][:organizations] = fetch_and_present_organizations(org_guids.uniq, permission_queryer) hash end - private_class_method def self.fetch_and_present_apps(guids) + private_class_method def self.fetch_and_present_apps(guids, permission_queryer) return [] if guids.empty? - apps = AppModel.where(guid: guids). - order(:created_at, :guid). - eager(Presenters::V3::AppPresenter.associated_resources).all - apps.map { |app| Presenters::V3::AppPresenter.new(app).to_hash } + apps = AppModel.where(guid: guids) + apps = apps.where(space_guid: permission_queryer.readable_space_guids_query) unless permission_queryer.can_read_globally? + apps.order(:created_at, :guid). + eager(Presenters::V3::AppPresenter.associated_resources).all. + map { |app| Presenters::V3::AppPresenter.new(app).to_hash } end - private_class_method def self.fetch_and_present_spaces(guids) + private_class_method def self.fetch_and_present_spaces(guids, permission_queryer) return [] if guids.empty? - spaces = Space.where(guid: guids). - order(:created_at, :guid). - eager(Presenters::V3::SpacePresenter.associated_resources).all - spaces.map { |space| Presenters::V3::SpacePresenter.new(space).to_hash } + spaces = Space.where(guid: guids) + spaces = spaces.where(guid: permission_queryer.readable_space_guids_query) unless permission_queryer.can_read_globally? + spaces.order(:created_at, :guid). + eager(Presenters::V3::SpacePresenter.associated_resources).all. + map { |space| Presenters::V3::SpacePresenter.new(space).to_hash } end - private_class_method def self.fetch_and_present_organizations(guids) + private_class_method def self.fetch_and_present_organizations(guids, permission_queryer) return [] if guids.empty? - orgs = Organization.where(guid: guids). - order(:created_at, :guid). - eager(Presenters::V3::OrganizationPresenter.associated_resources).all - orgs.map { |org| Presenters::V3::OrganizationPresenter.new(org).to_hash } + orgs = Organization.where(guid: guids) + orgs = orgs.where(guid: permission_queryer.readable_org_guids_query) unless permission_queryer.can_read_globally? + orgs.order(:created_at, :guid). + eager(Presenters::V3::OrganizationPresenter.associated_resources).all. + map { |org| Presenters::V3::OrganizationPresenter.new(org).to_hash } end end end diff --git a/spec/unit/actions/route_policy_create_spec.rb b/spec/unit/actions/route_policy_create_spec.rb index 4433a6575c2..2a5c4f37f01 100644 --- a/spec/unit/actions/route_policy_create_spec.rb +++ b/spec/unit/actions/route_policy_create_spec.rb @@ -13,9 +13,9 @@ module VCAP::CloudController describe '#create' do it 'creates a route policy with the given source' do - expect { + expect do action.create(route:, message:) - }.to change(RoutePolicy, :count).by(1) + end.to change(RoutePolicy, :count).by(1) policy = RoutePolicy.last expect(policy.source).to eq("cf:app:#{app_guid}") @@ -28,9 +28,9 @@ module VCAP::CloudController end it 'raises an error' do - expect { + expect do action.create(route:, message:) - }.to raise_error(RoutePolicyCreate::Error, /already exists for this route/) + end.to raise_error(RoutePolicyCreate::Error, /already exists for this route/) end end @@ -42,9 +42,9 @@ module VCAP::CloudController end it 'raises an error' do - expect { + expect do action.create(route:, message:) - }.to raise_error(RoutePolicyCreate::Error, /cannot add 'cf:any'/i) + end.to raise_error(RoutePolicyCreate::Error, /cannot add 'cf:any'/i) end end @@ -55,9 +55,9 @@ module VCAP::CloudController it 'raises an error when adding any other source' do other_message = instance_double(RoutePolicyCreateMessage, source: "cf:app:#{SecureRandom.uuid}") - expect { - action.create(route:, message: other_message) - }.to raise_error(RoutePolicyCreate::Error, /already has a 'cf:any' policy/) + expect do + action.create(route: route, message: other_message) + end.to raise_error(RoutePolicyCreate::Error, /already has a 'cf:any' policy/) end end @@ -71,8 +71,7 @@ module VCAP::CloudController # The fix: lock the parent Route row (which always exists) before reading # policies, so concurrent transactions serialize at the route level. route_relation = spy('route relation') - allow(route_relation).to receive(:for_update).and_return(route_relation) - allow(route_relation).to receive(:first).and_return(route) + allow(route_relation).to receive_messages(for_update: route_relation, first: route) allow(Route).to receive(:where).with(id: route.id).and_return(route_relation) action.create(route:, message:) diff --git a/spec/unit/decorators/include_route_policy_source_decorator_spec.rb b/spec/unit/decorators/include_route_policy_source_decorator_spec.rb new file mode 100644 index 00000000000..62e09d4e3c2 --- /dev/null +++ b/spec/unit/decorators/include_route_policy_source_decorator_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' +require 'decorators/include_route_policy_source_decorator' + +module VCAP::CloudController + RSpec.describe IncludeRoutePolicySourceDecorator do + subject(:decorator) { IncludeRoutePolicySourceDecorator } + + let(:domain) { SharedDomain.make(name: 'apps.identity', enforce_route_policies: true) } + let(:space) { Space.make } + let(:route) { Route.make(space:, domain:) } + + before do + allow(Permissions).to receive(:new).and_return(instance_double(Permissions, can_read_globally?: true)) + end + + describe '.match?' do + it 'matches when include params contain "source"' do + expect(decorator.match?(['source'])).to be true + end + + it 'does not match when include params do not contain "source"' do + expect(decorator.match?(['route'])).to be false + end + + it 'does not match nil' do + expect(decorator.match?(nil)).to be false + end + end + + describe '.decorate' do + let(:app1) { AppModel.make(space:) } + let(:space1) { Space.make } + let(:org1) { space1.organization } + let(:policy_app) { RoutePolicy.create(source: "cf:app:#{app1.guid}", route_id: route.id) } + let(:policy_space) { RoutePolicy.create(source: "cf:space:#{space1.guid}", route_id: route.id) } + let(:policy_org) { RoutePolicy.create(source: "cf:org:#{org1.guid}", route_id: route.id) } + let(:policy_any) { RoutePolicy.create(source: 'cf:any', route_id: route.id) } + + it 'includes apps, spaces, and orgs from policy sources' do + hash = decorator.decorate({}, [policy_app, policy_space, policy_org]) + expect(hash[:included][:apps].pluck(:guid)).to contain_exactly(app1.guid) + expect(hash[:included][:spaces].pluck(:guid)).to contain_exactly(space1.guid) + expect(hash[:included][:organizations].pluck(:guid)).to contain_exactly(org1.guid) + end + + it 'omits cf:any sources (no resource to include)' do + hash = decorator.decorate({}, [policy_any]) + expect(hash[:included][:apps]).to be_empty + expect(hash[:included][:spaces]).to be_empty + expect(hash[:included][:organizations]).to be_empty + end + + it 'does not overwrite other included fields' do + hash = decorator.decorate({ included: { monkeys: ['zach'] } }, [policy_any]) + expect(hash[:included][:monkeys]).to eq(['zach']) + end + + context 'when the user cannot read certain source resources' do + let(:other_space) { Space.make } + let(:other_org) { other_space.organization } + let(:other_app) { AppModel.make(space: other_space) } + + let(:policy_readable_app) { RoutePolicy.create(source: "cf:app:#{app1.guid}", route_id: route.id) } + let(:policy_unreadable_app) { RoutePolicy.create(source: "cf:app:#{other_app.guid}", route_id: route.id) } + let(:policy_readable_space) { RoutePolicy.create(source: "cf:space:#{space1.guid}", route_id: route.id) } + let(:policy_unreadable_space) { RoutePolicy.create(source: "cf:space:#{other_space.guid}", route_id: route.id) } + let(:policy_readable_org) { RoutePolicy.create(source: "cf:org:#{org1.guid}", route_id: route.id) } + let(:policy_unreadable_org) { RoutePolicy.create(source: "cf:org:#{other_org.guid}", route_id: route.id) } + + let(:permission_queryer) do + instance_double( + Permissions, + can_read_globally?: false, + readable_space_guids_query: Space.where(id: [space.id, space1.id]).select(:guid), + readable_org_guids_query: Organization.where(id: org1.id).select(:guid) + ) + end + + before do + allow(Permissions).to receive(:new).and_return(permission_queryer) + end + + it 'excludes apps the user cannot read' do + hash = decorator.decorate({}, [policy_readable_app, policy_unreadable_app]) + app_guids = hash[:included][:apps].pluck(:guid) + expect(app_guids).to include(app1.guid) + expect(app_guids).not_to include(other_app.guid) + end + + it 'excludes spaces the user cannot read' do + hash = decorator.decorate({}, [policy_readable_space, policy_unreadable_space]) + space_guids = hash[:included][:spaces].pluck(:guid) + expect(space_guids).to include(space1.guid) + expect(space_guids).not_to include(other_space.guid) + end + + it 'excludes organizations the user cannot read' do + hash = decorator.decorate({}, [policy_readable_org, policy_unreadable_org]) + org_guids = hash[:included][:organizations].pluck(:guid) + expect(org_guids).to include(org1.guid) + expect(org_guids).not_to include(other_org.guid) + end + end + end + end +end From c5e46cb3d39987103283a8fa374b6ab479c30c52 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 15 Jun 2026 16:43:50 +0200 Subject: [PATCH 41/64] Enforce route_policies_scope boundary in RoutePolicyCreate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject cf:org: sources on domains with route_policies_scope: 'space'. cf:any is allowed at all scopes — GoRouter contextualizes it to the domain boundary at request time (e.g. 'any app in this space' on a space-scoped domain). --- app/actions/route_policy_create.rb | 9 +++ .../include_route_policy_source_decorator.rb | 12 +-- spec/unit/actions/route_policy_create_spec.rb | 80 +++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/app/actions/route_policy_create.rb b/app/actions/route_policy_create.rb index 8e16a85afc7..90e2c19f113 100644 --- a/app/actions/route_policy_create.rb +++ b/app/actions/route_policy_create.rb @@ -4,6 +4,8 @@ class Error < StandardError end def create(route:, message:) + validate_scope!(route.domain, message.source) + RoutePolicy.db.transaction do # Lock the parent route row to serialize concurrent creates. # SELECT ... FOR UPDATE on an empty policies table acquires no row locks, @@ -26,6 +28,13 @@ def create(route:, message:) private + def validate_scope!(domain, source) + return unless domain.route_policies_scope == 'space' + return unless source.start_with?('cf:org:') + + raise Error.new("Source '#{source}' is not allowed: domain's route_policies_scope is 'space'.") + end + def validate_source_exclusivity(locked_policies, source) existing_sources = locked_policies.map(&:source) diff --git a/app/decorators/include_route_policy_source_decorator.rb b/app/decorators/include_route_policy_source_decorator.rb index d262638eeae..3773004fbde 100644 --- a/app/decorators/include_route_policy_source_decorator.rb +++ b/app/decorators/include_route_policy_source_decorator.rb @@ -53,8 +53,8 @@ def self.decorate(hash, route_policies) apps = AppModel.where(guid: guids) apps = apps.where(space_guid: permission_queryer.readable_space_guids_query) unless permission_queryer.can_read_globally? apps.order(:created_at, :guid). - eager(Presenters::V3::AppPresenter.associated_resources).all. - map { |app| Presenters::V3::AppPresenter.new(app).to_hash } + eager(Presenters::V3::AppPresenter.associated_resources).all. + map { |app| Presenters::V3::AppPresenter.new(app).to_hash } end private_class_method def self.fetch_and_present_spaces(guids, permission_queryer) @@ -63,8 +63,8 @@ def self.decorate(hash, route_policies) spaces = Space.where(guid: guids) spaces = spaces.where(guid: permission_queryer.readable_space_guids_query) unless permission_queryer.can_read_globally? spaces.order(:created_at, :guid). - eager(Presenters::V3::SpacePresenter.associated_resources).all. - map { |space| Presenters::V3::SpacePresenter.new(space).to_hash } + eager(Presenters::V3::SpacePresenter.associated_resources).all. + map { |space| Presenters::V3::SpacePresenter.new(space).to_hash } end private_class_method def self.fetch_and_present_organizations(guids, permission_queryer) @@ -73,8 +73,8 @@ def self.decorate(hash, route_policies) orgs = Organization.where(guid: guids) orgs = orgs.where(guid: permission_queryer.readable_org_guids_query) unless permission_queryer.can_read_globally? orgs.order(:created_at, :guid). - eager(Presenters::V3::OrganizationPresenter.associated_resources).all. - map { |org| Presenters::V3::OrganizationPresenter.new(org).to_hash } + eager(Presenters::V3::OrganizationPresenter.associated_resources).all. + map { |org| Presenters::V3::OrganizationPresenter.new(org).to_hash } end end end diff --git a/spec/unit/actions/route_policy_create_spec.rb b/spec/unit/actions/route_policy_create_spec.rb index 2a5c4f37f01..0cd4df38fdd 100644 --- a/spec/unit/actions/route_policy_create_spec.rb +++ b/spec/unit/actions/route_policy_create_spec.rb @@ -61,6 +61,86 @@ module VCAP::CloudController end end + context 'when the domain has route_policies_scope: "space"' do + let(:domain) { SharedDomain.make(name: 'apps.identity', enforce_route_policies: true, route_policies_scope: 'space') } + + it 'allows cf:app sources' do + expect do + action.create(route:, message:) + end.not_to raise_error + end + + it 'allows cf:space sources' do + space_message = instance_double(RoutePolicyCreateMessage, source: "cf:space:#{SecureRandom.uuid}") + expect do + action.create(route: route, message: space_message) + end.not_to raise_error + end + + it 'rejects cf:org sources' do + org_message = instance_double(RoutePolicyCreateMessage, source: "cf:org:#{SecureRandom.uuid}") + expect do + action.create(route: route, message: org_message) + end.to raise_error(RoutePolicyCreate::Error, /route_policies_scope.*space/i) + end + + it 'allows cf:any sources' do + any_message = instance_double(RoutePolicyCreateMessage, source: 'cf:any') + expect do + action.create(route: route, message: any_message) + end.not_to raise_error + end + end + + context 'when the domain has route_policies_scope: "org"' do + let(:domain) { SharedDomain.make(name: 'apps.identity', enforce_route_policies: true, route_policies_scope: 'org') } + + it 'allows cf:app sources' do + expect do + action.create(route:, message:) + end.not_to raise_error + end + + it 'allows cf:space sources' do + space_message = instance_double(RoutePolicyCreateMessage, source: "cf:space:#{SecureRandom.uuid}") + expect do + action.create(route: route, message: space_message) + end.not_to raise_error + end + + it 'allows cf:org sources' do + org_message = instance_double(RoutePolicyCreateMessage, source: "cf:org:#{SecureRandom.uuid}") + expect do + action.create(route: route, message: org_message) + end.not_to raise_error + end + + it 'allows cf:any sources' do + any_message = instance_double(RoutePolicyCreateMessage, source: 'cf:any') + expect do + action.create(route: route, message: any_message) + end.not_to raise_error + end + end + + context 'when the domain has route_policies_scope: "any"' do + let(:domain) { SharedDomain.make(name: 'apps.identity', enforce_route_policies: true, route_policies_scope: 'any') } + + it 'allows cf:any sources' do + any_message = instance_double(RoutePolicyCreateMessage, source: 'cf:any') + expect do + action.create(route: route, message: any_message) + end.not_to raise_error + end + + it 'allows cf:org sources' do + org_message = instance_double(RoutePolicyCreateMessage, source: "cf:org:#{SecureRandom.uuid}") + expect do + action.create(route: route, message: org_message) + end.not_to raise_error + end + end + context 'when concurrent creates target the same route with no existing policies' do let(:message) { instance_double(RoutePolicyCreateMessage, source: 'cf:any') } From 8b547293c5234f06348e7c25f61baf8fc681c390 Mon Sep 17 00:00:00 2001 From: rkoster Date: Mon, 15 Jun 2026 17:21:23 +0200 Subject: [PATCH 42/64] Fix org manager visibility in route policies index Switch readable_space_scoped_spaces_query to readable_spaces_query in build_dataset. The former uses SPACE_ROLES which excludes ORG_MANAGER; the latter uses ROLES_FOR_SPACE_READING which includes it, consistent with how show gates access via can_read_from_space?. Add permissions for list/show endpoint shared examples covering all roles to spec/request/route_policies_spec.rb. --- .../v3/route_policies_controller.rb | 2 +- spec/request/route_policies_spec.rb | 35 +++++++++++++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 5f03dbb2de8..ad534e6ce74 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -114,7 +114,7 @@ def build_dataset(message) if permission_queryer.can_read_globally? readable_route_ids = VCAP::CloudController::Route.select(:id) else - readable_space_ids = permission_queryer.readable_space_scoped_spaces_query.select(:id) + readable_space_ids = permission_queryer.readable_spaces_query.select(:id) readable_route_ids = VCAP::CloudController::Route.where(space_id: readable_space_ids).select(:id) end diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index 14ba17944b4..e57911129ee 100755 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'request_spec_shared_examples' RSpec.describe 'Route Policies' do let(:user) { VCAP::CloudController::User.make } @@ -45,12 +46,6 @@ def expected_rule_json(rule) } end - before do - TestConfig.override(kubernetes: {}) - space.organization.add_user(user) - space.add_developer(user) - end - describe 'POST /v3/route_policies' do let(:request_body) do { @@ -73,7 +68,7 @@ def expected_rule_json(rule) end context 'as space developer' do - let(:user_headers) { headers_for(user) } + let(:user_headers) { set_user_with_header_as_role(role: 'space_developer', org: org, space: space, user: user) } it 'creates an access rule' do post '/v3/route_policies', request_body.to_json, user_headers @@ -244,6 +239,19 @@ def expected_rule_json(rule) expect(parsed['source']).to eq("cf:app:#{valid_uuid}") end + context 'role-based visibility' do + let(:api_call) { ->(headers) { get "/v3/route_policies/#{route_policy.guid}", nil, headers } } + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + %w[admin admin_read_only global_auditor + space_developer space_manager space_auditor space_supporter + org_manager].each { |r| h[r] = { code: 200 } } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + context 'when the access rule does not exist' do it 'returns 404' do get '/v3/route_policies/nonexistent-guid', nil, admin_header @@ -347,6 +355,19 @@ def expected_rule_json(rule) expect(guids).to include(rule1.guid, rule2.guid) end + context 'role-based visibility' do + let(:api_call) { ->(headers) { get '/v3/route_policies', nil, headers } } + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_guids: [] }.freeze) + %w[admin admin_read_only global_auditor + space_developer space_manager space_auditor space_supporter + org_manager].each { |r| h[r] = { code: 200, response_guids: [rule1.guid, rule2.guid] } } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + it 'filters by route_guids' do get "/v3/route_policies?route_guids=#{mtls_route.guid}", nil, admin_header From 00f4e59dd606c034535d87b39626fad5feecb542 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 09:24:19 +0200 Subject: [PATCH 43/64] Split route_policies.source into source_type + source_guid columns Store source_type ('app'|'space'|'org'|'any') and source_guid (UUID or '' for cf:any) as separate columns. Add virtual source getter/setter on RoutePolicy so all existing callers remain unchanged. Use empty string instead of NULL for source_guid to preserve DB unique constraint on (route_id, source_type, source_guid). --- app/models/runtime/route_policy.rb | 17 ++++++++++++++++- .../20260421074455_create_route_policies.rb | 5 +++-- spec/unit/models/runtime/route_policy_spec.rb | 16 +++++++++++++++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/app/models/runtime/route_policy.rb b/app/models/runtime/route_policy.rb index cf56d61959b..972523b3c55 100644 --- a/app/models/runtime/route_policy.rb +++ b/app/models/runtime/route_policy.rb @@ -11,8 +11,23 @@ class RoutePolicy < Sequel::Model(:route_policies) add_association_dependencies labels: :destroy add_association_dependencies annotations: :destroy + def source + source_guid.empty? ? 'cf:any' : "cf:#{source_type}:#{source_guid}" + end + + def source=(val) + if val == 'cf:any' + self.source_type = 'any' + self.source_guid = '' + else + m = val.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) + self.source_type = m[1] + self.source_guid = m[2] + end + end + def validate - validates_presence :source + validates_presence :source_type validates_presence :route_id end diff --git a/db/migrations/20260421074455_create_route_policies.rb b/db/migrations/20260421074455_create_route_policies.rb index ef5e2018861..d83b2f3dccf 100644 --- a/db/migrations/20260421074455_create_route_policies.rb +++ b/db/migrations/20260421074455_create_route_policies.rb @@ -3,10 +3,11 @@ unless table_exists?(:route_policies) create_table :route_policies do VCAP::Migration.common(self, :route_policies) - String :source, size: 255, null: false + String :source_type, size: 255, null: false + String :source_guid, size: 255, null: false, default: '' Integer :route_id, null: false - index %i[route_id source], unique: true, name: :route_policies_route_id_source_index + index %i[route_id source_type source_guid], unique: true, name: :route_policies_route_id_source_index foreign_key [:route_id], :routes, on_delete: :cascade, name: :fk_route_policies_route_id end end diff --git a/spec/unit/models/runtime/route_policy_spec.rb b/spec/unit/models/runtime/route_policy_spec.rb index 1e247afc75f..68ee905d138 100644 --- a/spec/unit/models/runtime/route_policy_spec.rb +++ b/spec/unit/models/runtime/route_policy_spec.rb @@ -19,7 +19,7 @@ module VCAP::CloudController it 'requires a selector' do rule = RoutePolicy.new(route:) expect(rule.valid?).to be false - expect(rule.errors[:source]).to include(:presence) + expect(rule.errors[:source_type]).to include(:presence) end it 'requires a route_id' do @@ -37,6 +37,20 @@ module VCAP::CloudController ) expect(rule.route).to eq(route) end + + describe 'columns' do + it 'persists source_type for a typed source' do + policy = RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) + expect(policy.source_type).to eq('app') + expect(policy.source_guid).to eq(app_guid) + end + + it 'persists source_type and empty source_guid for cf:any' do + policy = RoutePolicy.create(source: 'cf:any', route: route) + expect(policy.source_type).to eq('any') + expect(policy.source_guid).to eq('') + end + end end describe 'callbacks' do From e443e4cf00ab25e3c76f0389e3b2134f0a3fafe1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 09:31:04 +0200 Subject: [PATCH 44/64] Guard RoutePolicy source= setter against nil and malformed input Use source_guid.to_s.empty? in getter to avoid NoMethodError when source_guid is nil in test contexts. Move describe 'columns' block to top level. --- app/models/runtime/route_policy.rb | 8 ++++--- spec/unit/models/runtime/route_policy_spec.rb | 22 +++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/app/models/runtime/route_policy.rb b/app/models/runtime/route_policy.rb index 972523b3c55..28a4878e59e 100644 --- a/app/models/runtime/route_policy.rb +++ b/app/models/runtime/route_policy.rb @@ -12,17 +12,19 @@ class RoutePolicy < Sequel::Model(:route_policies) add_association_dependencies annotations: :destroy def source - source_guid.empty? ? 'cf:any' : "cf:#{source_type}:#{source_guid}" + source_guid.to_s.empty? ? 'cf:any' : "cf:#{source_type}:#{source_guid}" end def source=(val) + return if val.nil? + if val == 'cf:any' self.source_type = 'any' self.source_guid = '' else m = val.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) - self.source_type = m[1] - self.source_guid = m[2] + self.source_type = m ? m[1] : nil + self.source_guid = m ? m[2] : nil end end diff --git a/spec/unit/models/runtime/route_policy_spec.rb b/spec/unit/models/runtime/route_policy_spec.rb index 68ee905d138..ecf219d735e 100644 --- a/spec/unit/models/runtime/route_policy_spec.rb +++ b/spec/unit/models/runtime/route_policy_spec.rb @@ -37,19 +37,19 @@ module VCAP::CloudController ) expect(rule.route).to eq(route) end + end - describe 'columns' do - it 'persists source_type for a typed source' do - policy = RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) - expect(policy.source_type).to eq('app') - expect(policy.source_guid).to eq(app_guid) - end + describe 'columns' do + it 'persists source_type for a typed source' do + policy = RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) + expect(policy.source_type).to eq('app') + expect(policy.source_guid).to eq(app_guid) + end - it 'persists source_type and empty source_guid for cf:any' do - policy = RoutePolicy.create(source: 'cf:any', route: route) - expect(policy.source_type).to eq('any') - expect(policy.source_guid).to eq('') - end + it 'persists source_type and empty source_guid for cf:any' do + policy = RoutePolicy.create(source: 'cf:any', route: route) + expect(policy.source_type).to eq('any') + expect(policy.source_guid).to eq('') end end From ab15bd94954cb7838f6912f4342e8ef526db0d2c Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 09:34:35 +0200 Subject: [PATCH 45/64] Add unit tests for RoutePolicy source virtual getter/setter Covers all four source types for getter, all four types for setter, plus nil-input guard and malformed-input guard. --- spec/unit/models/runtime/route_policy_spec.rb | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/spec/unit/models/runtime/route_policy_spec.rb b/spec/unit/models/runtime/route_policy_spec.rb index ecf219d735e..b0f0e69260e 100644 --- a/spec/unit/models/runtime/route_policy_spec.rb +++ b/spec/unit/models/runtime/route_policy_spec.rb @@ -15,6 +15,82 @@ module VCAP::CloudController RouteMappingModel.make(app: app_model, route: route, process_type: 'web') end + describe 'source virtual attribute' do + describe '#source getter' do + it 'returns cf:app: composite string' do + policy = RoutePolicy.new(source_type: 'app', source_guid: app_guid) + expect(policy.source).to eq("cf:app:#{app_guid}") + end + + it 'returns cf:space: composite string' do + guid = SecureRandom.uuid + policy = RoutePolicy.new(source_type: 'space', source_guid: guid) + expect(policy.source).to eq("cf:space:#{guid}") + end + + it 'returns cf:org: composite string' do + guid = SecureRandom.uuid + policy = RoutePolicy.new(source_type: 'org', source_guid: guid) + expect(policy.source).to eq("cf:org:#{guid}") + end + + it 'returns cf:any when source_guid is empty' do + policy = RoutePolicy.new(source_type: 'any', source_guid: '') + expect(policy.source).to eq('cf:any') + end + + it 'returns cf:any when source_guid is nil' do + policy = RoutePolicy.new(source_type: 'any', source_guid: nil) + expect(policy.source).to eq('cf:any') + end + end + + describe '#source= setter' do + it 'parses cf:app: into source_type and source_guid' do + policy = RoutePolicy.new + policy.source = "cf:app:#{app_guid}" + expect(policy.source_type).to eq('app') + expect(policy.source_guid).to eq(app_guid) + end + + it 'parses cf:space:' do + guid = SecureRandom.uuid + policy = RoutePolicy.new + policy.source = "cf:space:#{guid}" + expect(policy.source_type).to eq('space') + expect(policy.source_guid).to eq(guid) + end + + it 'parses cf:org:' do + guid = SecureRandom.uuid + policy = RoutePolicy.new + policy.source = "cf:org:#{guid}" + expect(policy.source_type).to eq('org') + expect(policy.source_guid).to eq(guid) + end + + it 'sets source_type to any and source_guid to empty string for cf:any' do + policy = RoutePolicy.new + policy.source = 'cf:any' + expect(policy.source_type).to eq('any') + expect(policy.source_guid).to eq('') + end + + it 'does nothing when called with nil' do + policy = RoutePolicy.new(source_type: 'app', source_guid: app_guid) + policy.source = nil + expect(policy.source_type).to eq('app') + expect(policy.source_guid).to eq(app_guid) + end + + it 'sets source_type to nil for malformed input' do + policy = RoutePolicy.new + policy.source = 'garbage' + expect(policy.source_type).to be_nil + end + end + end + describe 'validations' do it 'requires a selector' do rule = RoutePolicy.new(route:) From 86f74e6cc5e574b48133ceae69bd93313062c318 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 09:39:04 +0200 Subject: [PATCH 46/64] Add column-level assertions to RoutePolicyCreate spec --- spec/unit/actions/route_policy_create_spec.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/spec/unit/actions/route_policy_create_spec.rb b/spec/unit/actions/route_policy_create_spec.rb index 0cd4df38fdd..a095e8a8382 100644 --- a/spec/unit/actions/route_policy_create_spec.rb +++ b/spec/unit/actions/route_policy_create_spec.rb @@ -12,16 +12,27 @@ module VCAP::CloudController let(:message) { instance_double(RoutePolicyCreateMessage, source: "cf:app:#{app_guid}") } describe '#create' do - it 'creates a route policy with the given source' do + it 'creates a route policy with source_type and source_guid persisted' do expect do action.create(route:, message:) end.to change(RoutePolicy, :count).by(1) policy = RoutePolicy.last expect(policy.source).to eq("cf:app:#{app_guid}") + expect(policy.source_type).to eq('app') + expect(policy.source_guid).to eq(app_guid) expect(policy.route_id).to eq(route.id) end + it 'persists source_type=any and source_guid="" for cf:any' do + any_message = instance_double(RoutePolicyCreateMessage, source: 'cf:any') + action.create(route:, message: any_message) + + policy = RoutePolicy.last + expect(policy.source_type).to eq('any') + expect(policy.source_guid).to eq('') + end + context 'when the same source already exists for the route' do before do RoutePolicy.create(source: "cf:app:#{app_guid}", route_id: route.id) From ed12e1552e845341a165cc5ea4128185c268b2d4 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 09:44:19 +0200 Subject: [PATCH 47/64] Update route policy filters to use source_type/source_guid columns sources filter: parse composite string and apply two-column WHERE. source_guids filter: exact match on source_guid column (no more LIKE). --- .../v3/route_policies_controller.rb | 20 ++--- spec/request/route_policies_spec.rb | 77 ++++++++++++++++--- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index ad534e6ce74..582a3cd1199 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -132,19 +132,21 @@ def build_dataset(message) end dataset = dataset.where(guid: message.guids) if message.requested?(:guids) - dataset = dataset.where(source: message.sources) if message.requested?(:sources) - - if message.requested?(:source_guids) - # Text-match against source string for resource GUIDs - # Handles cf:app:, cf:space:, cf:org: - # Escape LIKE metacharacters (\, %, _) in user-provided values - conditions = message.source_guids.map do |guid| - escaped_guid = guid.gsub('\\', '\\\\').gsub('%', '\\%').gsub('_', '\\_') - Sequel.like(:source, "%#{escaped_guid}%") + + if message.requested?(:sources) + conditions = message.sources.map do |src| + if src == 'cf:any' + Sequel.&(source_type: 'any', source_guid: '') + else + m = src.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) + Sequel.&(source_type: m[1], source_guid: m[2]) + end end dataset = dataset.where(Sequel.|(*conditions)) end + dataset = dataset.where(source_guid: message.source_guids) if message.requested?(:source_guids) + dataset end end diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index e57911129ee..124420ece88 100755 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -489,29 +489,86 @@ def expected_rule_json(rule) end describe 'filtering by source_guids' do - it 'escapes % so it does not act as a LIKE wildcard' do - get '/v3/route_policies?source_guids=%25', nil, admin_header + let(:app1) { VCAP::CloudController::AppModel.make(space: space) } + let(:app2) { VCAP::CloudController::AppModel.make(space: space) } + let!(:rule_app1) do + VCAP::CloudController::RoutePolicy.create( + source: "cf:app:#{app1.guid}", + route_id: mtls_route.id + ) + end + let!(:rule_app2) do + VCAP::CloudController::RoutePolicy.create( + source: "cf:app:#{app2.guid}", + route_id: mtls_route.id + ) + end + + it 'returns only the policy whose source_guid matches the queried GUID' do + get "/v3/route_policies?source_guids=#{app1.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - expect(parsed['resources'].length).to eq(0) + returned_guids = parsed['resources'].map { |r| r['guid'] } + expect(returned_guids).to include(rule_app1.guid) + expect(returned_guids).not_to include(rule_app2.guid) end - it 'escapes _ so it does not act as a LIKE single-char wildcard' do - get '/v3/route_policies?source_guids=cf_app', nil, admin_header + it 'returns an empty list when the GUID matches nothing' do + get "/v3/route_policies?source_guids=#{SecureRandom.uuid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - # _ would match any single char (e.g. "cf:app"), but escaped it matches literal "_" - expect(parsed['resources'].length).to eq(0) + expect(parsed['resources']).to be_empty end - it 'escapes backslash so it does not act as a LIKE escape character' do - get '/v3/route_policies?source_guids=cf%5Capp', nil, admin_header + it 'does not return cf:any policies for a GUID query' do + any_route = VCAP::CloudController::Route.make(space: space, domain: mtls_domain) + VCAP::CloudController::RoutePolicy.create(source: 'cf:any', route_id: any_route.id) + + get "/v3/route_policies?source_guids=#{app1.guid}", nil, admin_header expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - expect(parsed['resources'].length).to eq(0) + returned_sources = parsed['resources'].map { |r| r['source'] } + expect(returned_sources).not_to include('cf:any') + end + end + + describe 'filtering by sources' do + let(:app1) { VCAP::CloudController::AppModel.make(space: space) } + let(:another_route) { VCAP::CloudController::Route.make(space: space, domain: mtls_domain) } + let!(:rule_typed) do + VCAP::CloudController::RoutePolicy.create( + source: "cf:app:#{app1.guid}", + route_id: mtls_route.id + ) + end + let!(:rule_any) do + VCAP::CloudController::RoutePolicy.create( + source: 'cf:any', + route_id: another_route.id + ) + end + + it 'returns only the policy with the exact source value' do + get "/v3/route_policies?sources=cf:app:#{app1.guid}", nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].map { |r| r['guid'] } + expect(guids).to include(rule_typed.guid) + expect(guids).not_to include(rule_any.guid) + end + + it 'returns cf:any policies when sources=cf:any' do + get '/v3/route_policies?sources=cf:any', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].map { |r| r['guid'] } + expect(guids).to include(rule_any.guid) + expect(guids).not_to include(rule_typed.guid) end end From 55eac70bf068574137b8ecb7b3c937dab685d2f1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 09:54:15 +0200 Subject: [PATCH 48/64] Use source_type/source_guid directly in IncludeRoutePolicySourceDecorator Eliminates the SOURCE_REGEX constant and match/parse pattern. --- .../include_route_policy_source_decorator.rb | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/app/decorators/include_route_policy_source_decorator.rb b/app/decorators/include_route_policy_source_decorator.rb index 3773004fbde..72461f9d2d3 100644 --- a/app/decorators/include_route_policy_source_decorator.rb +++ b/app/decorators/include_route_policy_source_decorator.rb @@ -4,8 +4,6 @@ class IncludeRoutePolicySourceDecorator # Stale/missing resources (source GUIDs that no longer exist) are silently absent. # Resources the current user cannot read are also silently absent. - SOURCE_REGEX = /\Acf:(app|space|org):([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\z/ - def self.match?(include_params) return false unless include_params @@ -16,24 +14,17 @@ def self.decorate(hash, route_policies) hash[:included] ||= {} # Collect all GUIDs by type - app_guids = [] + app_guids = [] space_guids = [] - org_guids = [] + org_guids = [] route_policies.each do |policy| - match = SOURCE_REGEX.match(policy.source) - next unless match - - resource_type = match[1] - resource_guid = match[2] + next if policy.source_type == 'any' - case resource_type - when 'app' - app_guids << resource_guid - when 'space' - space_guids << resource_guid - when 'org' - org_guids << resource_guid + case policy.source_type + when 'app' then app_guids << policy.source_guid + when 'space' then space_guids << policy.source_guid + when 'org' then org_guids << policy.source_guid end end From 2674f33d091cd5c52ed2e63f90ca8ca1a85abae5 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 09:55:10 +0200 Subject: [PATCH 49/64] Use source_type/source_guid directly in RoutePolicyPresenter Remove regex match from build_relationships. Add presenter unit spec covering all four source types (app, space, org, any). --- app/presenters/v3/route_policy_presenter.rb | 22 ++---- .../v3/route_policy_presenter_spec.rb | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 spec/unit/presenters/v3/route_policy_presenter_spec.rb diff --git a/app/presenters/v3/route_policy_presenter.rb b/app/presenters/v3/route_policy_presenter.rb index 5332aa2d068..0a59a89d141 100755 --- a/app/presenters/v3/route_policy_presenter.rb +++ b/app/presenters/v3/route_policy_presenter.rb @@ -38,22 +38,14 @@ def build_relationships } } - # Extract resource GUID from source and populate read-only relationships - # The guid is included as-is without per-row existence checks to avoid N+1 queries. - # Use ?include=source to get full resource details with batch loading. - source_match = route_policy.source.match(/\Acf:(app|space|org):([0-9a-f-]+)\z/) - if source_match - resource_type = source_match[1] - resource_guid = source_match[2] - - relationships[:app] = { data: resource_type == 'app' ? { guid: resource_guid } : nil } - relationships[:space] = { data: resource_type == 'space' ? { guid: resource_guid } : nil } - relationships[:organization] = { data: resource_type == 'org' ? { guid: resource_guid } : nil } - else - # cf:any or malformed - all relationships are null - relationships[:app] = { data: nil } - relationships[:space] = { data: nil } + if route_policy.source_type == 'any' + relationships[:app] = { data: nil } + relationships[:space] = { data: nil } relationships[:organization] = { data: nil } + else + relationships[:app] = { data: route_policy.source_type == 'app' ? { guid: route_policy.source_guid } : nil } + relationships[:space] = { data: route_policy.source_type == 'space' ? { guid: route_policy.source_guid } : nil } + relationships[:organization] = { data: route_policy.source_type == 'org' ? { guid: route_policy.source_guid } : nil } end relationships diff --git a/spec/unit/presenters/v3/route_policy_presenter_spec.rb b/spec/unit/presenters/v3/route_policy_presenter_spec.rb new file mode 100644 index 00000000000..128c2ee067e --- /dev/null +++ b/spec/unit/presenters/v3/route_policy_presenter_spec.rb @@ -0,0 +1,77 @@ +require 'spec_helper' +require 'presenters/v3/route_policy_presenter' + +module VCAP::CloudController + module Presenters + module V3 + RSpec.describe RoutePolicyPresenter do + let(:space) { Space.make } + let(:domain) { SharedDomain.make(name: 'apps.identity', enforce_route_policies: true) } + let(:route) { Route.make(space:, domain:) } + let(:app_model) { AppModel.make(space:) } + + subject(:result) { RoutePolicyPresenter.new(route_policy).to_hash } + + describe '#to_hash relationships' do + context 'when source is cf:app:' do + let(:route_policy) { RoutePolicy.create(source: "cf:app:#{app_model.guid}", route:) } + + it 'populates relationships.app and nulls space and organization' do + expect(result[:relationships][:app]).to eq(data: { guid: app_model.guid }) + expect(result[:relationships][:space]).to eq(data: nil) + expect(result[:relationships][:organization]).to eq(data: nil) + end + end + + context 'when source is cf:space:' do + let(:route_policy) { RoutePolicy.create(source: "cf:space:#{space.guid}", route:) } + + it 'populates relationships.space and nulls app and organization' do + expect(result[:relationships][:app]).to eq(data: nil) + expect(result[:relationships][:space]).to eq(data: { guid: space.guid }) + expect(result[:relationships][:organization]).to eq(data: nil) + end + end + + context 'when source is cf:org:' do + let(:route_policy) { RoutePolicy.create(source: "cf:org:#{space.organization.guid}", route:) } + + it 'populates relationships.organization and nulls app and space' do + expect(result[:relationships][:app]).to eq(data: nil) + expect(result[:relationships][:space]).to eq(data: nil) + expect(result[:relationships][:organization]).to eq(data: { guid: space.organization.guid }) + end + end + + context 'when source is cf:any' do + let(:route_policy) { RoutePolicy.create(source: 'cf:any', route:) } + + it 'nulls all source relationships' do + expect(result[:relationships][:app]).to eq(data: nil) + expect(result[:relationships][:space]).to eq(data: nil) + expect(result[:relationships][:organization]).to eq(data: nil) + end + end + end + + describe '#to_hash source field' do + context 'when source is cf:app' do + let(:route_policy) { RoutePolicy.create(source: "cf:app:#{app_model.guid}", route:) } + + it 'emits the composite source string' do + expect(result[:source]).to eq("cf:app:#{app_model.guid}") + end + end + + context 'when source is cf:any' do + let(:route_policy) { RoutePolicy.create(source: 'cf:any', route:) } + + it 'emits cf:any' do + expect(result[:source]).to eq('cf:any') + end + end + end + end + end + end +end From 23824af403b41151a4af8040a247950b0d5ab1d1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 10:01:25 +0200 Subject: [PATCH 50/64] Fix RuboCop offenses --- spec/request/route_policies_spec.rb | 8 ++++---- spec/unit/actions/route_policy_create_spec.rb | 2 +- .../presenters/v3/route_policy_presenter_spec.rb | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index 124420ece88..c36c473d2ee 100755 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -509,7 +509,7 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - returned_guids = parsed['resources'].map { |r| r['guid'] } + returned_guids = parsed['resources'].pluck('guid') expect(returned_guids).to include(rule_app1.guid) expect(returned_guids).not_to include(rule_app2.guid) end @@ -530,7 +530,7 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - returned_sources = parsed['resources'].map { |r| r['source'] } + returned_sources = parsed['resources'].pluck('source') expect(returned_sources).not_to include('cf:any') end end @@ -556,7 +556,7 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - guids = parsed['resources'].map { |r| r['guid'] } + guids = parsed['resources'].pluck('guid') expect(guids).to include(rule_typed.guid) expect(guids).not_to include(rule_any.guid) end @@ -566,7 +566,7 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) parsed = Oj.load(last_response.body) - guids = parsed['resources'].map { |r| r['guid'] } + guids = parsed['resources'].pluck('guid') expect(guids).to include(rule_any.guid) expect(guids).not_to include(rule_typed.guid) end diff --git a/spec/unit/actions/route_policy_create_spec.rb b/spec/unit/actions/route_policy_create_spec.rb index a095e8a8382..75a736caac1 100644 --- a/spec/unit/actions/route_policy_create_spec.rb +++ b/spec/unit/actions/route_policy_create_spec.rb @@ -26,7 +26,7 @@ module VCAP::CloudController it 'persists source_type=any and source_guid="" for cf:any' do any_message = instance_double(RoutePolicyCreateMessage, source: 'cf:any') - action.create(route:, message: any_message) + action.create(route: route, message: any_message) policy = RoutePolicy.last expect(policy.source_type).to eq('any') diff --git a/spec/unit/presenters/v3/route_policy_presenter_spec.rb b/spec/unit/presenters/v3/route_policy_presenter_spec.rb index 128c2ee067e..1cbd27a2ed4 100644 --- a/spec/unit/presenters/v3/route_policy_presenter_spec.rb +++ b/spec/unit/presenters/v3/route_policy_presenter_spec.rb @@ -14,7 +14,7 @@ module V3 describe '#to_hash relationships' do context 'when source is cf:app:' do - let(:route_policy) { RoutePolicy.create(source: "cf:app:#{app_model.guid}", route:) } + let(:route_policy) { RoutePolicy.create(source: "cf:app:#{app_model.guid}", route: route) } it 'populates relationships.app and nulls space and organization' do expect(result[:relationships][:app]).to eq(data: { guid: app_model.guid }) @@ -24,7 +24,7 @@ module V3 end context 'when source is cf:space:' do - let(:route_policy) { RoutePolicy.create(source: "cf:space:#{space.guid}", route:) } + let(:route_policy) { RoutePolicy.create(source: "cf:space:#{space.guid}", route: route) } it 'populates relationships.space and nulls app and organization' do expect(result[:relationships][:app]).to eq(data: nil) @@ -34,7 +34,7 @@ module V3 end context 'when source is cf:org:' do - let(:route_policy) { RoutePolicy.create(source: "cf:org:#{space.organization.guid}", route:) } + let(:route_policy) { RoutePolicy.create(source: "cf:org:#{space.organization.guid}", route: route) } it 'populates relationships.organization and nulls app and space' do expect(result[:relationships][:app]).to eq(data: nil) @@ -44,7 +44,7 @@ module V3 end context 'when source is cf:any' do - let(:route_policy) { RoutePolicy.create(source: 'cf:any', route:) } + let(:route_policy) { RoutePolicy.create(source: 'cf:any', route: route) } it 'nulls all source relationships' do expect(result[:relationships][:app]).to eq(data: nil) @@ -56,7 +56,7 @@ module V3 describe '#to_hash source field' do context 'when source is cf:app' do - let(:route_policy) { RoutePolicy.create(source: "cf:app:#{app_model.guid}", route:) } + let(:route_policy) { RoutePolicy.create(source: "cf:app:#{app_model.guid}", route: route) } it 'emits the composite source string' do expect(result[:source]).to eq("cf:app:#{app_model.guid}") @@ -64,7 +64,7 @@ module V3 end context 'when source is cf:any' do - let(:route_policy) { RoutePolicy.create(source: 'cf:any', route:) } + let(:route_policy) { RoutePolicy.create(source: 'cf:any', route: route) } it 'emits cf:any' do expect(result[:source]).to eq('cf:any') From 74aefaa11142fbe2a16322de89bc455deec2578a Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 11:13:16 +0200 Subject: [PATCH 51/64] Grant org auditors read access to route policies Route policies are policies on routes; org auditors can already read routes via ROLES_FOR_ROUTE_READING. Use the same role set for route policy reads so the permission model is consistent. Org billing managers remain unable to read route policies (consistent with routes). Adds can_read_route_policy_from_space? and readable_route_policies_spaces_query to Permissions. --- .../v3/route_policies_controller.rb | 4 +- .../resources/route_policies/_get.md.erb | 2 +- .../resources/route_policies/_list.md.erb | 2 +- lib/cloud_controller/permissions.rb | 10 ++++ spec/request/route_policies_spec.rb | 4 +- .../lib/cloud_controller/permissions_spec.rb | 60 +++++++++++++++++++ 6 files changed, 76 insertions(+), 6 deletions(-) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 582a3cd1199..71ef017f03a 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -35,7 +35,7 @@ def show resource_not_found!(:route_policy) unless route_policy route = route_policy.route - resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + resource_not_found!(:route_policy) unless route && permission_queryer.can_read_route_policy_from_space?(route.space.id, route.space.organization_id) decorators = [] decorators << IncludeRoutePolicySourceDecorator if IncludeRoutePolicySourceDecorator.match?(message.include) @@ -114,7 +114,7 @@ def build_dataset(message) if permission_queryer.can_read_globally? readable_route_ids = VCAP::CloudController::Route.select(:id) else - readable_space_ids = permission_queryer.readable_spaces_query.select(:id) + readable_space_ids = permission_queryer.readable_route_policies_spaces_query.select(:id) readable_route_ids = VCAP::CloudController::Route.where(space_id: readable_space_ids).select(:id) end diff --git a/docs/v3/source/includes/resources/route_policies/_get.md.erb b/docs/v3/source/includes/resources/route_policies/_get.md.erb index 1c7cc2f5435..de4ae342aad 100755 --- a/docs/v3/source/includes/resources/route_policies/_get.md.erb +++ b/docs/v3/source/includes/resources/route_policies/_get.md.erb @@ -39,7 +39,7 @@ Admin Read-Only | Global Auditor | Org Manager | Org Auditor | -Org Billing Manager | +Org Billing Manager | Will not be able to see any route policies Space Auditor | Space Developer | Space Manager | diff --git a/docs/v3/source/includes/resources/route_policies/_list.md.erb b/docs/v3/source/includes/resources/route_policies/_list.md.erb index 1c3d6b106b6..aa269b4d5c1 100644 --- a/docs/v3/source/includes/resources/route_policies/_list.md.erb +++ b/docs/v3/source/includes/resources/route_policies/_list.md.erb @@ -145,7 +145,7 @@ Admin Read-Only | Global Auditor | Org Manager | Org Auditor | -Org Billing Manager | +Org Billing Manager | Will receive an empty list Space Auditor | Space Developer | Space Manager | diff --git a/lib/cloud_controller/permissions.rb b/lib/cloud_controller/permissions.rb index eb4ec13116e..33c85fa1680 100644 --- a/lib/cloud_controller/permissions.rb +++ b/lib/cloud_controller/permissions.rb @@ -182,6 +182,12 @@ def readable_space_ids_query membership.authorized_space_ids_subquery(ROLES_FOR_SPACE_READING) end + def readable_route_policies_spaces_query + raise 'must not be called for users that can read globally' if can_read_globally? + + membership.authorized_spaces_subquery(ROLES_FOR_ROUTE_READING) + end + def readable_space_guids_query raise 'must not be called for users that can read globally' if can_read_globally? @@ -192,6 +198,10 @@ def can_read_from_space?(space_id, org_id) can_read_globally? || membership.role_applies?(ROLES_FOR_SPACE_READING, space_id, org_id) end + def can_read_route_policy_from_space?(space_id, org_id) + can_read_globally? || membership.role_applies?(ROLES_FOR_ROUTE_READING, space_id, org_id) + end + def can_read_from_space_as_space_member?(space_id) can_read_globally? || membership.role_applies?(SPACE_ROLES, space_id) end diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index c36c473d2ee..db880606d7a 100755 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -245,7 +245,7 @@ def expected_rule_json(rule) h = Hash.new({ code: 404 }.freeze) %w[admin admin_read_only global_auditor space_developer space_manager space_auditor space_supporter - org_manager].each { |r| h[r] = { code: 200 } } + org_manager org_auditor].each { |r| h[r] = { code: 200 } } h end @@ -361,7 +361,7 @@ def expected_rule_json(rule) h = Hash.new({ code: 200, response_guids: [] }.freeze) %w[admin admin_read_only global_auditor space_developer space_manager space_auditor space_supporter - org_manager].each { |r| h[r] = { code: 200, response_guids: [rule1.guid, rule2.guid] } } + org_manager org_auditor].each { |r| h[r] = { code: 200, response_guids: [rule1.guid, rule2.guid] } } h end diff --git a/spec/unit/lib/cloud_controller/permissions_spec.rb b/spec/unit/lib/cloud_controller/permissions_spec.rb index a2efd751a58..0f4f3a28433 100644 --- a/spec/unit/lib/cloud_controller/permissions_spec.rb +++ b/spec/unit/lib/cloud_controller/permissions_spec.rb @@ -408,6 +408,66 @@ module VCAP::CloudController end end + describe '#readable_route_policies_spaces_query' do + it 'returns subquery from membership using ROLES_FOR_ROUTE_READING' do + membership = instance_double(Membership) + subquery = instance_double(Sequel::Dataset) + expect(Membership).to receive(:new).with(user).and_return(membership) + expect(membership).to receive(:authorized_spaces_subquery).with(Permissions::ROLES_FOR_ROUTE_READING).and_return(subquery) + expect(permissions.readable_route_policies_spaces_query).to be(subquery) + end + end + + describe '#can_read_route_policy_from_space?' do + context 'user has no membership' do + context 'and user is an admin' do + it 'returns true' do + set_current_user(user, { admin: true }) + expect(permissions.can_read_route_policy_from_space?(space.id, org.id)).to be true + end + end + + context 'and the user is a read only admin' do + it 'returns true' do + set_current_user(user, { admin_read_only: true }) + expect(permissions.can_read_route_policy_from_space?(space.id, org.id)).to be true + end + end + + context 'and user is a global auditor' do + it 'returns true' do + set_current_user_as_global_auditor + expect(permissions.can_read_route_policy_from_space?(space.id, org.id)).to be true + end + end + + context 'and user is not an admin' do + it 'returns false' do + set_current_user(user) + expect(permissions.can_read_route_policy_from_space?(space.id, org.id)).to be false + end + end + end + + context 'user has valid membership' do + it 'returns true for space developer' do + org.add_user(user) + space.add_developer(user) + expect(permissions.can_read_route_policy_from_space?(space.id, org.id)).to be true + end + + it 'returns true for org auditor' do + org.add_auditor(user) + expect(permissions.can_read_route_policy_from_space?(space.id, org.id)).to be true + end + + it 'returns false for org billing manager' do + org.add_billing_manager(user) + expect(permissions.can_read_route_policy_from_space?(space.id, org.id)).to be false + end + end + end + describe '#readables_space_guids_query' do it 'returns subquery from membership' do membership = instance_double(Membership) From fa095ab8215a1082914b2e53943819eb39d28394 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 11:17:37 +0200 Subject: [PATCH 52/64] Restore .envrc to main branch version --- .envrc | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/.envrc b/.envrc index 6b80a681cb7..311eb9ee4ff 100644 --- a/.envrc +++ b/.envrc @@ -1,4 +1,26 @@ -cores=$(/usr/sbin/sysctl -n hw.logicalcpu) +# ============================================================================= +# Cloud Controller NG - Development Environment +# ============================================================================= +# +# QUICK START (local development): +# cc-containers start # Start DBs, UAA, nginx +# bundle install && cc-generate-config && cc-reset-db +# eval "$(cc-db-env psql ccdb)" # Set database env vars +# bin/cloud_controller -c tmp/.dev-generated/cloud_controller.local.yml +# +# SCRIPTS (all start with 'cc-'): +# cc-containers # start/stop/logs/status (see --help) +# cc-db-env # Database env vars (e.g. psql ccdb, mysql test) +# cc-generate-config [mode] # Generate cloud_controller.yml configs +# cc-reset-db # Drop and recreate all databases +# cc-setup-ide # Copy IDE configs (VS Code, IntelliJ) +# +# PERSONAL OVERRIDES (.envrc.local, gitignored): +# export PARALLEL_TEST_PROCESSORS=4 +# +# ============================================================================= + +cores=$(/usr/sbin/sysctl -n hw.logicalcpu 2>/dev/null || nproc 2>/dev/null || echo 4) if (( cores > 8 )); then # This environment variable overrides the `parallel_test` gem's default behavior @@ -8,3 +30,35 @@ if (( cores > 8 )); then export PARALLEL_TEST_PROCESSORS=8 fi + +# Set CC_CONFIG for local development (devcontainer sets CC_CONFIG=devcontainer) +# This is used by VS Code launch configs to select the right cloud_controller.yml +export CC_CONFIG="${CC_CONFIG:-local}" + +# Database connection prefixes - used by IDE run configs and parallel tests +# Devcontainer overrides these in devcontainer.json with container hostnames (postgres, mysql) +export POSTGRES_CONNECTION_PREFIX="${POSTGRES_CONNECTION_PREFIX:-postgres://postgres:supersecret@localhost:5432}" +export MYSQL_CONNECTION_PREFIX="${MYSQL_CONNECTION_PREFIX:-mysql2://root:supersecret@127.0.0.1:3306}" + +# Storage CLI path for S3 blobstore mode +export STORAGE_CLI_PATH="${PWD}/tmp/bin/storage-cli" + +PATH_add bin +PATH_add .devcontainer/scripts + +# ============================================================================= +# Developer Overrides +# ============================================================================= +if [ -f .envrc.local ]; then + source_env .envrc.local +fi + +# Show quick hint on directory entry +if [ -n "$DEVCONTAINER" ]; then + log_status "Cloud Controller NG (devcontainer)" + log_status "Quick start: cc-generate-config && eval \"\$(cc-db-env psql ccdb)\"" +else + log_status "Cloud Controller NG (local)" + log_status "Quick start: cc-containers start && cc-generate-config && cc-reset-db" +fi +log_status "See README.md for full setup instructions" From 54417a0865a620ee10434c28f4db87efdeb1a836 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 11:46:47 +0200 Subject: [PATCH 53/64] Add audit events for route policy create/update/delete Records audit.route_policy.create, .update, and .delete events via RoutePolicyEventRepository, consistent with the audit.route.* pattern used by routes. --- .../v3/route_policies_controller.rb | 22 +++++++ app/repositories/event_types.rb | 4 ++ .../route_policy_event_repository.rb | 55 ++++++++++++++++ spec/request/route_policies_spec.rb | 34 ++++++++++ .../route_policy_event_repository_spec.rb | 64 +++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 app/repositories/route_policy_event_repository.rb create mode 100644 spec/unit/repositories/route_policy_event_repository_spec.rb diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 71ef017f03a..1782a0f454e 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -6,6 +6,7 @@ require 'decorators/include_route_policy_source_decorator' require 'decorators/include_route_policy_route_decorator' require 'actions/route_policy_create' +require 'repositories/route_policy_event_repository' class RoutePoliciesController < ApplicationController def index @@ -53,6 +54,12 @@ def create route_policy = VCAP::CloudController::RoutePolicyCreate.new.create(route: route, message: message) + route_policy_event_repository.record_route_policy_create( + route_policy, + user_audit_info, + { 'source' => message.source, 'route_guid' => message.route_guid } + ) + render status: :created, json: Presenters::V3::RoutePolicyPresenter.new(route_policy) rescue VCAP::CloudController::RoutePolicyCreate::Error => e unprocessable!(e.message) @@ -69,6 +76,12 @@ def update VCAP::CloudController::MetadataUpdate.update(route_policy, message) + route_policy_event_repository.record_route_policy_update( + route_policy.reload, + user_audit_info, + message.audit_hash + ) + render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy.reload) end @@ -78,12 +91,21 @@ def destroy find_and_authorize_route_for_policy(route_policy) + route_policy_event_repository.record_route_policy_delete( + route_policy, + user_audit_info + ) + route_policy.destroy head :no_content end private + def route_policy_event_repository + @route_policy_event_repository ||= Repositories::RoutePolicyEventRepository.new + end + def find_and_authorize_route(route_guid) route = VCAP::CloudController::Route.find(guid: route_guid) resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) diff --git a/app/repositories/event_types.rb b/app/repositories/event_types.rb index ab08733b990..fd336ba1773 100644 --- a/app/repositories/event_types.rb +++ b/app/repositories/event_types.rb @@ -122,6 +122,10 @@ class EventTypesError < StandardError ROUTE_UNSHARE = 'audit.route.unshare'.freeze, ROUTE_TRANSFER_OWNER = 'audit.route.transfer-owner'.freeze, + ROUTE_POLICY_CREATE = 'audit.route_policy.create'.freeze, + ROUTE_POLICY_UPDATE = 'audit.route_policy.update'.freeze, + ROUTE_POLICY_DELETE = 'audit.route_policy.delete'.freeze, + ORGANIZATION_CREATE = 'audit.organization.create'.freeze, ORGANIZATION_UPDATE = 'audit.organization.update'.freeze, ORGANIZATION_DELETE_REQUEST = 'audit.organization.delete-request'.freeze, diff --git a/app/repositories/route_policy_event_repository.rb b/app/repositories/route_policy_event_repository.rb new file mode 100644 index 00000000000..c7fb692ea9c --- /dev/null +++ b/app/repositories/route_policy_event_repository.rb @@ -0,0 +1,55 @@ +require 'repositories/event_types' + +module VCAP::CloudController + module Repositories + class RoutePolicyEventRepository + def record_route_policy_create(route_policy, actor_audit_info, request_attrs) + Event.create( + space: route_policy.route.space, + type: EventTypes::ROUTE_POLICY_CREATE, + actee: route_policy.guid, + actee_type: 'route_policy', + actee_name: route_policy.source, + actor: actor_audit_info.user_guid, + actor_type: 'user', + actor_name: actor_audit_info.user_email, + actor_username: actor_audit_info.user_name, + timestamp: Sequel::CURRENT_TIMESTAMP, + metadata: { request: request_attrs } + ) + end + + def record_route_policy_update(route_policy, actor_audit_info, request_attrs) + Event.create( + space: route_policy.route.space, + type: EventTypes::ROUTE_POLICY_UPDATE, + actee: route_policy.guid, + actee_type: 'route_policy', + actee_name: route_policy.source, + actor: actor_audit_info.user_guid, + actor_type: 'user', + actor_name: actor_audit_info.user_email, + actor_username: actor_audit_info.user_name, + timestamp: Sequel::CURRENT_TIMESTAMP, + metadata: { request: request_attrs } + ) + end + + def record_route_policy_delete(route_policy, actor_audit_info) + Event.create( + space: route_policy.route.space, + type: EventTypes::ROUTE_POLICY_DELETE, + actee: route_policy.guid, + actee_type: 'route_policy', + actee_name: route_policy.source, + actor: actor_audit_info.user_guid, + actor_type: 'user', + actor_name: actor_audit_info.user_email, + actor_username: actor_audit_info.user_name, + timestamp: Sequel::CURRENT_TIMESTAMP, + metadata: {} + ) + end + end + end +end diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index db880606d7a..f88a2de3fe8 100755 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -65,6 +65,17 @@ def expected_rule_json(rule) expect(parsed['source']).to eq("cf:app:#{valid_uuid}") expect(parsed['relationships']['route']['data']['guid']).to eq(mtls_route.guid) end + + it 'records an audit event' do + post '/v3/route_policies', request_body.to_json, admin_header + + expect(last_response.status).to eq(201) + parsed = Oj.load(last_response.body) + event = VCAP::CloudController::Event.last + expect(event.type).to eq('audit.route_policy.create') + expect(event.actee).to eq(parsed['guid']) + expect(event.actee_type).to eq('route_policy') + end end context 'as space developer' do @@ -783,6 +794,17 @@ def expected_rule_json(rule) expect(VCAP::CloudController::RoutePolicy.find(guid: route_policy.guid)).to be_nil end + it 'records an audit event' do + policy_guid = route_policy.guid + delete "/v3/route_policies/#{policy_guid}", nil, admin_header + + expect(last_response.status).to eq(204) + event = VCAP::CloudController::Event.last + expect(event.type).to eq('audit.route_policy.delete') + expect(event.actee).to eq(policy_guid) + expect(event.actee_type).to eq('route_policy') + end + context 'when the access rule does not exist' do it 'returns 404' do delete '/v3/route_policies/nonexistent-guid', nil, admin_header @@ -809,6 +831,18 @@ def expected_rule_json(rule) expect(last_response.status).to eq(200) end + it 'records an audit event' do + patch "/v3/route_policies/#{route_policy.guid}", { + metadata: { labels: { env: 'production' } } + }.to_json, admin_header + + expect(last_response.status).to eq(200) + event = VCAP::CloudController::Event.last + expect(event.type).to eq('audit.route_policy.update') + expect(event.actee).to eq(route_policy.guid) + expect(event.actee_type).to eq('route_policy') + end + context 'when the access rule does not exist' do it 'returns 404' do patch '/v3/route_policies/nonexistent-guid', {}.to_json, admin_header diff --git a/spec/unit/repositories/route_policy_event_repository_spec.rb b/spec/unit/repositories/route_policy_event_repository_spec.rb new file mode 100644 index 00000000000..2f9519f467f --- /dev/null +++ b/spec/unit/repositories/route_policy_event_repository_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' +require 'repositories/route_policy_event_repository' + +module VCAP::CloudController + module Repositories + RSpec.describe RoutePolicyEventRepository do + let(:user) { User.make } + let(:space) { Space.make } + let(:domain) { SharedDomain.make(name: 'apps.identity', enforce_route_policies: true) } + let(:route) { Route.make(space:, domain:) } + let(:route_policy) { RoutePolicy.create(source: 'cf:any', route: route) } + let(:user_email) { 'user@example.com' } + let(:user_name) { 'some-user' } + let(:actor_audit_info) { UserAuditInfo.new(user_guid: user.guid, user_name: user_name, user_email: user_email) } + let(:request_attrs) { { 'source' => 'cf:any', 'route_guid' => route.guid } } + + subject(:repo) { RoutePolicyEventRepository.new } + + shared_examples 'a route policy audit event' do |expected_type| + it 'records the space, actee, actor and type' do + expect(event.space).to eq(route_policy.route.space) + expect(event.type).to eq(expected_type) + expect(event.actee).to eq(route_policy.guid) + expect(event.actee_type).to eq('route_policy') + expect(event.actee_name).to eq(route_policy.source) + expect(event.actor).to eq(user.guid) + expect(event.actor_type).to eq('user') + expect(event.actor_name).to eq(user_email) + expect(event.actor_username).to eq(user_name) + end + end + + describe '#record_route_policy_create' do + subject(:event) { repo.record_route_policy_create(route_policy, actor_audit_info, request_attrs).reload } + + include_examples 'a route policy audit event', 'audit.route_policy.create' + + it 'includes request attrs in metadata' do + expect(event.metadata).to eq({ 'request' => request_attrs }) + end + end + + describe '#record_route_policy_update' do + subject(:event) { repo.record_route_policy_update(route_policy, actor_audit_info, request_attrs).reload } + + include_examples 'a route policy audit event', 'audit.route_policy.update' + + it 'includes request attrs in metadata' do + expect(event.metadata).to eq({ 'request' => request_attrs }) + end + end + + describe '#record_route_policy_delete' do + subject(:event) { repo.record_route_policy_delete(route_policy, actor_audit_info).reload } + + include_examples 'a route policy audit event', 'audit.route_policy.delete' + + it 'has empty metadata' do + expect(event.metadata).to eq({}) + end + end + end + end +end From 2bd093c34472c34bfe5401106aefea1dc2d17368 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 12:16:03 +0200 Subject: [PATCH 54/64] Add label_selector support to route policies list - RoutePoliciesListMessage now inherits from MetadataListMessage, which auto-registers :label_selector and wires LabelSelectorRequirementValidator - build_dataset in RoutePoliciesController delegates to LabelSelectorQueryGenerator when a selector is requested --- .../v3/route_policies_controller.rb | 10 +++++++++ app/messages/route_policies_list_message.rb | 4 ++-- spec/request/route_policies_spec.rb | 22 +++++++++++++++++++ .../route_policies_list_message_spec.rb | 13 +++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 1782a0f454e..122460e0eae 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -7,6 +7,7 @@ require 'decorators/include_route_policy_route_decorator' require 'actions/route_policy_create' require 'repositories/route_policy_event_repository' +require 'fetchers/label_selector_query_generator' class RoutePoliciesController < ApplicationController def index @@ -169,6 +170,15 @@ def build_dataset(message) dataset = dataset.where(source_guid: message.source_guids) if message.requested?(:source_guids) + if message.requested?(:label_selector) + dataset = VCAP::CloudController::LabelSelectorQueryGenerator.add_selector_queries( + label_klass: VCAP::CloudController::RoutePolicyLabelModel, + resource_dataset: dataset, + requirements: message.requirements, + resource_klass: VCAP::CloudController::RoutePolicy + ) + end + dataset end end diff --git a/app/messages/route_policies_list_message.rb b/app/messages/route_policies_list_message.rb index 6244a6a67c7..b1f2cfaca3e 100644 --- a/app/messages/route_policies_list_message.rb +++ b/app/messages/route_policies_list_message.rb @@ -1,7 +1,7 @@ -require 'messages/list_message' +require 'messages/metadata_list_message' module VCAP::CloudController - class RoutePoliciesListMessage < ListMessage + class RoutePoliciesListMessage < MetadataListMessage register_allowed_keys %i[ guids route_guids diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index f88a2de3fe8..f0734d274dd 100755 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -583,6 +583,28 @@ def expected_rule_json(rule) end end + describe 'filtering by label_selector' do + let(:another_route) { VCAP::CloudController::Route.make(space: space, domain: mtls_domain) } + let!(:labelled_policy) do + policy = VCAP::CloudController::RoutePolicy.create(source: 'cf:any', route_id: mtls_route.id) + VCAP::CloudController::RoutePolicyLabelModel.create(resource_guid: policy.guid, key_name: 'env', value: 'prod') + policy + end + let!(:unlabelled_policy) do + VCAP::CloudController::RoutePolicy.create(source: 'cf:any', route_id: another_route.id) + end + + it 'returns only policies matching the label selector' do + get '/v3/route_policies?label_selector=env=prod', nil, admin_header + + expect(last_response.status).to eq(200) + parsed = Oj.load(last_response.body) + guids = parsed['resources'].pluck('guid') + expect(guids).to include(labelled_policy.guid) + expect(guids).not_to include(unlabelled_policy.guid) + end + end + context 'with include=source' do let!(:frontend_app) { VCAP::CloudController::AppModel.make(space: space, name: 'frontend-app') } let!(:other_space) { VCAP::CloudController::Space.make(organization: org, name: 'other-space') } diff --git a/spec/unit/messages/route_policies_list_message_spec.rb b/spec/unit/messages/route_policies_list_message_spec.rb index c952db47c81..1eb6c8fc07f 100644 --- a/spec/unit/messages/route_policies_list_message_spec.rb +++ b/spec/unit/messages/route_policies_list_message_spec.rb @@ -125,6 +125,19 @@ module VCAP::CloudController end end + describe 'label_selector' do + it 'accepts a label_selector param' do + message = RoutePoliciesListMessage.from_params({ 'label_selector' => 'env=prod' }) + expect(message).to be_valid + expect(message).to be_requested(:label_selector) + end + + it 'rejects an invalid label_selector' do + message = RoutePoliciesListMessage.from_params({ 'label_selector' => '%%invalid' }) + expect(message).not_to be_valid + end + end + describe 'validations' do it 'validates space_guids is an array' do message = RoutePoliciesListMessage.from_params space_guids: 'not array' From bd357ddd1e458b99fa84fd486ebbb50c5b580c9c Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 13:16:13 +0200 Subject: [PATCH 55/64] Fix RoutePolicy.create calls using non-hex fake GUIDs via virtual source= setter The source= setter parses 'cf:app:GUID' using [0-9a-f-]+ regex (hex UUIDs only). Fake GUIDs like 'app-guid-1' contain non-hex chars so the regex fails to match, leaving source_type nil and failing the presence validation. Fix by calling .create with source_type:/source_guid: directly, bypassing the virtual setter in the 6 affected test fixtures. --- spec/request/routes_spec.rb | 4 ++-- .../include_route_policies_decorator_spec.rb | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/spec/request/routes_spec.rb b/spec/request/routes_spec.rb index c201574e82b..69804943681 100644 --- a/spec/request/routes_spec.rb +++ b/spec/request/routes_spec.rb @@ -389,7 +389,7 @@ end context 'when including route_policies' do - let!(:route_policy) { VCAP::CloudController::RoutePolicy.create(route: route_in_org, source: 'cf:app:some-app-guid') } + let!(:route_policy) { VCAP::CloudController::RoutePolicy.create(route: route_in_org, source_type: 'app', source_guid: 'some-app-guid') } it 'includes the route_policies for the routes' do get '/v3/routes?include=route_policies', nil, admin_header @@ -1134,7 +1134,7 @@ end context 'when including route_policies' do - let!(:route_policy) { VCAP::CloudController::RoutePolicy.create(route: route, source: 'cf:app:some-app-guid') } + let!(:route_policy) { VCAP::CloudController::RoutePolicy.create(route: route, source_type: 'app', source_guid: 'some-app-guid') } it 'includes the route_policies for the route' do get "/v3/routes/#{route.guid}?include=route_policies", nil, admin_header diff --git a/spec/unit/decorators/include_route_policies_decorator_spec.rb b/spec/unit/decorators/include_route_policies_decorator_spec.rb index b5348ba5c16..7d589f51472 100644 --- a/spec/unit/decorators/include_route_policies_decorator_spec.rb +++ b/spec/unit/decorators/include_route_policies_decorator_spec.rb @@ -11,8 +11,8 @@ module VCAP::CloudController let(:route2) { Route.make(space: space, domain: domain) } it 'decorates the given hash with route_policies from routes' do - route_policy1 = RoutePolicy.create(route: route1, source: 'cf:app:app-guid-1') - route_policy2 = RoutePolicy.create(route: route2, source: 'cf:app:app-guid-2') + route_policy1 = RoutePolicy.create(route: route1, source_type: 'app', source_guid: 'app-guid-1') + route_policy2 = RoutePolicy.create(route: route2, source_type: 'app', source_guid: 'app-guid-2') undecorated_hash = { i_am: 'tim' } hash = subject.decorate(undecorated_hash, [route1, route2]) expect(hash[:i_am]).to eq('tim') @@ -23,7 +23,7 @@ module VCAP::CloudController end it 'does not overwrite other included fields' do - route_policy1 = RoutePolicy.create(route: route1, source: 'cf:app:app-guid-1') + route_policy1 = RoutePolicy.create(route: route1, source_type: 'app', source_guid: 'app-guid-1') undecorated_hash = { foo: 'bar', included: { favorite_fruits: %w[tomato cucumber] } } hash = subject.decorate(undecorated_hash, [route1]) expect(hash[:foo]).to eq('bar') @@ -37,8 +37,8 @@ module VCAP::CloudController end it 'includes multiple policies for the same route' do - policy_a = RoutePolicy.create(route: route1, source: 'cf:app:app-guid-a') - policy_b = RoutePolicy.create(route: route1, source: 'cf:app:app-guid-b') + policy_a = RoutePolicy.create(route: route1, source_type: 'app', source_guid: 'app-guid-a') + policy_b = RoutePolicy.create(route: route1, source_type: 'app', source_guid: 'app-guid-b') hash = subject.decorate({}, [route1]) expect(hash[:included][:route_policies]).to contain_exactly( Presenters::V3::RoutePolicyPresenter.new(policy_a).to_hash, From 200f3dff5750b7707c0d1cda1ebad150b93d6935 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 16 Jun 2026 15:11:28 +0200 Subject: [PATCH 56/64] Register route_policy audit events in event_types_spec and docs --- .../v3/source/includes/resources/audit_events/_header.md.erb | 5 +++++ spec/unit/repositories/event_types_spec.rb | 3 +++ 2 files changed, 8 insertions(+) diff --git a/docs/v3/source/includes/resources/audit_events/_header.md.erb b/docs/v3/source/includes/resources/audit_events/_header.md.erb index 4bc7eb1994c..f18ba94a1c2 100644 --- a/docs/v3/source/includes/resources/audit_events/_header.md.erb +++ b/docs/v3/source/includes/resources/audit_events/_header.md.erb @@ -77,6 +77,11 @@ For more information, see the [Cloud Foundry docs](https://docs.cloudfoundry.org - `audit.route.unshare` - `audit.route.update` +##### Route_policy lifecycle +- `audit.route_policy.create` +- `audit.route_policy.delete` +- `audit.route_policy.update` + ##### Service lifecycle - `audit.service.create` - `audit.service.delete` diff --git a/spec/unit/repositories/event_types_spec.rb b/spec/unit/repositories/event_types_spec.rb index 1dcf6997c16..15c67f5389f 100644 --- a/spec/unit/repositories/event_types_spec.rb +++ b/spec/unit/repositories/event_types_spec.rb @@ -121,6 +121,9 @@ module Repositories 'audit.route.share', 'audit.route.unshare', 'audit.route.transfer-owner', + 'audit.route_policy.create', + 'audit.route_policy.update', + 'audit.route_policy.delete', 'audit.organization.create', 'audit.organization.update', 'audit.organization.delete-request', From 55426bd1a57201a482b8cf0ae52d5dd89cf0cdad Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 18 Jun 2026 15:15:39 +0200 Subject: [PATCH 57/64] Validate sources format in RoutePoliciesListMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move SOURCE_REGEX to RoutePolicy model — natural home for a format constant shared across messages. Update RoutePolicyCreateMessage to reference RoutePolicy::SOURCE_REGEX. Add sources_format_valid validator to RoutePoliciesListMessage that checks each element against RoutePolicy::SOURCE_REGEX, along with validates :sources, array: true, allow_nil: true. Add tests covering nil, all four valid formats, fully invalid, and mixed valid/invalid arrays. --- app/messages/route_policies_list_message.rb | 14 +++++++ app/messages/route_policy_create_message.rb | 4 +- app/models/runtime/route_policy.rb | 2 + .../route_policies_list_message_spec.rb | 37 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/app/messages/route_policies_list_message.rb b/app/messages/route_policies_list_message.rb index b1f2cfaca3e..40ba3ffc6f2 100644 --- a/app/messages/route_policies_list_message.rb +++ b/app/messages/route_policies_list_message.rb @@ -16,9 +16,23 @@ class RoutePoliciesListMessage < MetadataListMessage validates :space_guids, array: true, allow_nil: true validates :source_guids, array: true, allow_nil: true + validates :sources, array: true, allow_nil: true + + validate :sources_format_valid def self.from_params(params) super(params, %w[route_guids space_guids sources source_guids include]) end + + private + + def sources_format_valid + return unless sources.is_a?(Array) + + invalid = sources.reject { |s| s.is_a?(String) && RoutePolicy::SOURCE_REGEX.match?(s) } + return if invalid.empty? + + errors.add(:sources, "contains invalid source format: #{invalid.join(', ')}") + end end end diff --git a/app/messages/route_policy_create_message.rb b/app/messages/route_policy_create_message.rb index 5ec09bd8914..221942c5461 100644 --- a/app/messages/route_policy_create_message.rb +++ b/app/messages/route_policy_create_message.rb @@ -2,8 +2,6 @@ module VCAP::CloudController class RoutePolicyCreateMessage < MetadataBaseMessage - SOURCE_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/ - register_allowed_keys %i[ source relationships @@ -27,7 +25,7 @@ def relationships_message def source_format_valid return unless source.is_a?(String) - return if SOURCE_REGEX.match?(source) + return if RoutePolicy::SOURCE_REGEX.match?(source) errors.add(:source, "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'") end diff --git a/app/models/runtime/route_policy.rb b/app/models/runtime/route_policy.rb index 28a4878e59e..21f22803e2e 100644 --- a/app/models/runtime/route_policy.rb +++ b/app/models/runtime/route_policy.rb @@ -1,5 +1,7 @@ module VCAP::CloudController class RoutePolicy < Sequel::Model(:route_policies) + SOURCE_REGEX = /\A(cf:(app|space|org):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|cf:any)\z/ + many_to_one :route, class: 'VCAP::CloudController::Route', key: :route_id, diff --git a/spec/unit/messages/route_policies_list_message_spec.rb b/spec/unit/messages/route_policies_list_message_spec.rb index 1eb6c8fc07f..ac53922f1d7 100644 --- a/spec/unit/messages/route_policies_list_message_spec.rb +++ b/spec/unit/messages/route_policies_list_message_spec.rb @@ -174,6 +174,43 @@ module VCAP::CloudController expect(message).to be_valid expect(message.source_guids).to eq(%w[guid1 guid2]) end + + it 'validates sources is an array' do + message = RoutePoliciesListMessage.from_params sources: 'not array' + expect(message).not_to be_valid + expect(message.errors[:sources].length).to eq 1 + end + + it 'allows sources to be nil' do + message = RoutePoliciesListMessage.from_params({}) + expect(message).to be_valid + expect(message.sources).to be_nil + end + + it 'allows sources to be an array of valid source strings' do + valid_uuid = '11111111-2222-3333-4444-555555555555' + message = RoutePoliciesListMessage.from_params sources: [ + "cf:app:#{valid_uuid}", + "cf:space:#{valid_uuid}", + "cf:org:#{valid_uuid}", + 'cf:any' + ] + expect(message).to be_valid + end + + it 'rejects sources containing invalid format' do + message = RoutePoliciesListMessage.from_params sources: ['not-valid'] + expect(message).not_to be_valid + expect(message.errors[:sources].length).to eq 1 + expect(message.errors[:sources][0]).to include('invalid source format') + end + + it 'rejects sources where only some items are invalid' do + valid_uuid = '11111111-2222-3333-4444-555555555555' + message = RoutePoliciesListMessage.from_params sources: ["cf:app:#{valid_uuid}", 'bad-format'] + expect(message).not_to be_valid + expect(message.errors[:sources][0]).to include('bad-format') + end end end end From 6d6706cd918984cdc086d016d1abe62076302a8d Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 18 Jun 2026 15:26:15 +0200 Subject: [PATCH 58/64] Guard against route deletion race in RoutePolicyCreate If the route is deleted between the controller fetch and the transaction lock, Route.for_update.first returns nil. Add an explicit guard to raise Error with a clear message rather than letting RoutePolicy.create fail with a foreign key violation. Add a test covering the deleted-route scenario using the same route-relation spy pattern as the concurrent-creates test. --- app/actions/route_policy_create.rb | 2 +- spec/unit/actions/route_policy_create_spec.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/app/actions/route_policy_create.rb b/app/actions/route_policy_create.rb index 90e2c19f113..8540df42d63 100644 --- a/app/actions/route_policy_create.rb +++ b/app/actions/route_policy_create.rb @@ -12,7 +12,7 @@ def create(route:, message:) # so two concurrent transactions can both read [] and both pass cf:any # exclusivity validation. Locking the route row (which always exists) # ensures they serialize regardless of how many policies currently exist. - Route.where(id: route.id).for_update.first + Route.where(id: route.id).for_update.first or raise Error.new("Route '#{route.guid}' not found.") existing_policies = RoutePolicy.where(route_id: route.id).all validate_source_exclusivity(existing_policies, message.source) diff --git a/spec/unit/actions/route_policy_create_spec.rb b/spec/unit/actions/route_policy_create_spec.rb index 75a736caac1..bbc6d6f73d9 100644 --- a/spec/unit/actions/route_policy_create_spec.rb +++ b/spec/unit/actions/route_policy_create_spec.rb @@ -170,6 +170,18 @@ module VCAP::CloudController expect(route_relation).to have_received(:for_update) end end + + context 'when the route is deleted between controller fetch and transaction lock' do + it 'raises an error' do + route_relation = spy('route relation') + allow(route_relation).to receive_messages(for_update: route_relation, first: nil) + allow(Route).to receive(:where).with(id: route.id).and_return(route_relation) + + expect do + action.create(route:, message:) + end.to raise_error(RoutePolicyCreate::Error, /not found/) + end + end end end end From c88b4164a606da4a9537845460f55a42c95fb256 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 18 Jun 2026 16:13:17 +0200 Subject: [PATCH 59/64] Drop empty source_not_cf_any_with_others method Remove the registered validator and its empty method body. Move the explanatory comment inline next to validate :source_format_valid. --- app/messages/route_policy_create_message.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/messages/route_policy_create_message.rb b/app/messages/route_policy_create_message.rb index 221942c5461..fa7edb57c94 100644 --- a/app/messages/route_policy_create_message.rb +++ b/app/messages/route_policy_create_message.rb @@ -13,7 +13,7 @@ class RoutePolicyCreateMessage < MetadataBaseMessage validates :source, presence: true, string: true validate :source_format_valid - validate :source_not_cf_any_with_others + # cf:any exclusivity is enforced at the controller level when checking existing policies on the route delegate :route_guid, to: :relationships_message @@ -30,10 +30,6 @@ def source_format_valid errors.add(:source, "must be in format 'cf:app:', 'cf:space:', 'cf:org:', or 'cf:any'") end - def source_not_cf_any_with_others - # enforced at the controller level when checking existing policies on the route - end - class Relationships < BaseMessage register_allowed_keys [:route] From c28c42abc14d0e95862077f67c537f34a446d4ff Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 18 Jun 2026 16:31:37 +0200 Subject: [PATCH 60/64] Fix source_guids filter description in route policies list docs The old description referenced text-matching against the full source string. Now that source is stored as separate source_type + source_guid columns, source_guids filters by exact match on the GUID portion only. --- docs/v3/source/includes/resources/route_policies/_list.md.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/v3/source/includes/resources/route_policies/_list.md.erb b/docs/v3/source/includes/resources/route_policies/_list.md.erb index aa269b4d5c1..901abcfe938 100644 --- a/docs/v3/source/includes/resources/route_policies/_list.md.erb +++ b/docs/v3/source/includes/resources/route_policies/_list.md.erb @@ -52,7 +52,7 @@ curl "https://api.example.org/v3/route_policies?include=route,source" \ | **route_guids** | _list of strings_ | Comma-delimited list of route guids to filter by | **space_guids** | _list of strings_ | Comma-delimited list of space guids to filter by (filters by the route's space) | **sources** | _list of strings_ | Comma-delimited list of exact source strings to filter by (e.g., `cf:any`, `cf:app:guid`) -| **source_guids** | _list of strings_ | Comma-delimited list of GUIDs to text-match against source strings (useful for finding stale policies when resources are deleted) +| **source_guids** | _list of strings_ | Comma-delimited list of GUIDs to filter by; matches the GUID portion of the source (e.g. the app, space, or org GUID) | **page** | _integer_ | Page to display; valid values are integers >= 1 | **per_page** | _integer_ | Number of results per page; valid values are 1 through 5000 | **order_by** | _string_ | Value to sort by. Defaults to ascending; prepend with `-` to sort descending. Valid values are `created_at`, `updated_at` From c8ee32eb43b3b3db5e950e4c792e0d5d222dff88 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 19 Jun 2026 19:13:19 +0200 Subject: [PATCH 61/64] Fix destroy action: destroy before recording audit event Swap order so route_policy.destroy is called before record_route_policy_delete, ensuring no audit event is recorded if the destroy fails. Add request spec to verify no event is recorded when destroy raises. --- app/controllers/v3/route_policies_controller.rb | 4 ++-- spec/request/route_policies_spec.rb | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 122460e0eae..2c61c89026e 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -92,12 +92,12 @@ def destroy find_and_authorize_route_for_policy(route_policy) + route_policy.destroy + route_policy_event_repository.record_route_policy_delete( route_policy, user_audit_info ) - - route_policy.destroy head :no_content end diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index f0734d274dd..a987ad14770 100755 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -827,6 +827,19 @@ def expected_rule_json(rule) expect(event.actee_type).to eq('route_policy') end + context 'when destroy fails' do + it 'does not record an audit event' do + allow_any_instance_of(VCAP::CloudController::RoutePolicy).to receive(:destroy).and_raise(Sequel::Error.new('db error')) + allow_any_instance_of(ErrorPresenter).to receive(:raise_500?).and_return(false) + + expect do + delete "/v3/route_policies/#{route_policy.guid}", nil, admin_header + end.not_to change(VCAP::CloudController::Event, :count) + + expect(last_response.status).to eq(500) + end + end + context 'when the access rule does not exist' do it 'returns 404' do delete '/v3/route_policies/nonexistent-guid', nil, admin_header From 66a48a6f11e7b25bd1414c77f0aea995eff8cd2c Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 19 Jun 2026 19:18:34 +0200 Subject: [PATCH 62/64] Use can_read_route_policy_from_space? in find_and_authorize_route_for_policy Consistent with the show action (line 40) which already uses the dedicated permission check rather than the generic can_read_from_space?. --- app/controllers/v3/route_policies_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 2c61c89026e..5dc9beec26e 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -117,7 +117,7 @@ def find_and_authorize_route(route_guid) def find_and_authorize_route_for_policy(route_policy) route = route_policy.route - resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) + resource_not_found!(:route_policy) unless route && permission_queryer.can_read_route_policy_from_space?(route.space.id, route.space.organization_id) unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id) suspended! unless permission_queryer.is_space_active?(route.space.id) end From ab112db95ef3456542facebb0e218d439c4b3563 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 19 Jun 2026 19:23:25 +0200 Subject: [PATCH 63/64] Use add_unique_constraint for labels/annotations unique indexes Switch from inline index(..., unique: true) to alter_table + add_unique_constraint following the convention established in 20240102150000_add_annotation_label_uniqueness, with names route_policy_labels_unique and route_policy_annotations_unique. --- db/migrations/20260421074455_create_route_policies.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/db/migrations/20260421074455_create_route_policies.rb b/db/migrations/20260421074455_create_route_policies.rb index d83b2f3dccf..e1b5f71be04 100644 --- a/db/migrations/20260421074455_create_route_policies.rb +++ b/db/migrations/20260421074455_create_route_policies.rb @@ -21,9 +21,11 @@ String :value, null: false, size: 63 index :resource_guid, name: :route_policy_labels_resource_guid_index - index %i[resource_guid key_prefix key_name], unique: true, name: :route_policy_labels_compound_index foreign_key [:resource_guid], :route_policies, key: :guid, on_delete: :cascade, name: :fk_route_policy_labels_resource_guid end + alter_table :route_policy_labels do + add_unique_constraint %i[resource_guid key_prefix key_name], name: :route_policy_labels_unique + end end unless table_exists?(:route_policy_annotations) @@ -35,9 +37,11 @@ String :value, size: 5000 index :resource_guid, name: :route_policy_annotations_resource_guid_index - index %i[resource_guid key_prefix key_name], unique: true, name: :route_policy_annotations_key_index foreign_key [:resource_guid], :route_policies, key: :guid, on_delete: :cascade, name: :fk_route_policy_annotations_resource_guid end + alter_table :route_policy_annotations do + add_unique_constraint %i[resource_guid key_prefix key_name], name: :route_policy_annotations_unique + end end end From 6ae25754d07e8d65a52af49c510a6a3baec66190 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 25 Jun 2026 14:50:27 +0200 Subject: [PATCH 64/64] Mark route policies feature as experimental in docs --- docs/v3/source/includes/resources/route_policies/_header.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/v3/source/includes/resources/route_policies/_header.md b/docs/v3/source/includes/resources/route_policies/_header.md index 7c18cfcaa5d..859be05379d 100644 --- a/docs/v3/source/includes/resources/route_policies/_header.md +++ b/docs/v3/source/includes/resources/route_policies/_header.md @@ -9,3 +9,5 @@ Route policies are defined using a `source` selector that specifies who can acce - `cf:any` - Allow any caller (cannot be combined with other sources on the same route) **Note:** Route policies can only be created for routes on domains where `enforce_route_policies` is `true` and the domain is not internal (internal routes use container-to-container networking and bypass GoRouter). + +**This feature is experimental and is subject to change.**