From 8076e922113de686dc9a9e44cbef58b1e4273835 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:25:37 +0000 Subject: [PATCH 01/30] 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 1259e166ae347b5f1bd694b3d450734ee63f5ee8 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:26:35 +0000 Subject: [PATCH 02/30] 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 fae1fcbaf9bf63195044ae835df117b020040b2a Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 08:39:30 +0000 Subject: [PATCH 03/30] 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 0314a492157657c7a0d4ec0dd2ccf58ffbd103ac Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 10:01:09 +0000 Subject: [PATCH 04/30] 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 514cdb5f432b7b4442dea22e106cca2bfb5bf07a Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 5 Mar 2026 15:06:16 +0000 Subject: [PATCH 05/30] 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 f1c3791646a34a677baf656daff0955467d223c1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 9 Apr 2026 07:52:15 +0000 Subject: [PATCH 06/30] 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 cda2efb2917687cf8e01de8066b936ae576776e1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 9 Apr 2026 12:35:18 +0000 Subject: [PATCH 07/30] 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 e0fc36fb24cbb4cdddd2ed574a657900e24e1118 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 9 Apr 2026 13:44:57 +0000 Subject: [PATCH 08/30] 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 93e1594b38d..84ddeb4813e 100644 --- a/app/models.rb +++ b/app/models.rb @@ -69,6 +69,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 f90941d30412b0d26cea65dfdcf9a3efb943f7a9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 10 Apr 2026 06:38:05 +0000 Subject: [PATCH 09/30] 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 248d4f40ec82467e299d1956f3466cb674f7ba23 Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 10 Apr 2026 08:18:47 +0000 Subject: [PATCH 10/30] 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 059d7890d969f0355a0d89a47317d366a95b212f Mon Sep 17 00:00:00 2001 From: rkoster Date: Fri, 10 Apr 2026 08:35:29 +0000 Subject: [PATCH 11/30] 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 0388e608c491951450d07a6a4feeb9a1285938c2 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 07:41:12 +0000 Subject: [PATCH 12/30] 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 039cfe57f5ec8d6da3fc25bf54766b05cbb97941 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 08:24:18 +0000 Subject: [PATCH 13/30] 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 0ea1af553b74b78e327c9315223b14efd8405fbd Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 08:47:48 +0000 Subject: [PATCH 14/30] 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 84ddeb4813e..fad27a05c79 100644 --- a/app/models.rb +++ b/app/models.rb @@ -70,6 +70,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 f9441cfccc8f79320b1232e0abde162eaf3c0940 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 09:21:26 +0000 Subject: [PATCH 15/30] 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 31466c6239cbb743c06771ae6978e744496f646f Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 13:33:49 +0000 Subject: [PATCH 16/30] 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 dd0688f4bb28b813ea3843f85178ca6408467d00 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 13:57:26 +0000 Subject: [PATCH 17/30] 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 553d3332adc788971440ca17dc4d495af3d058a1 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 14:03:53 +0000 Subject: [PATCH 18/30] 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 aa5811cfd055a0046e374769cfdc9cca1bdb234f Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 14:07:39 +0000 Subject: [PATCH 19/30] 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 e2b537e9b9ce4d30eb1c4b1128ffe1742c51bb82 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 14:19:50 +0000 Subject: [PATCH 20/30] 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 f4a1f728b2f1c4a4c78a523b7f53ad07b5ba34bb Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 14:39:08 +0000 Subject: [PATCH 21/30] 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 42c5b2cdf99ef18df4a9c6f3b93c44982c13639c Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 15:09:10 +0000 Subject: [PATCH 22/30] 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 273a1dbb9c372345db43564f4a2f3008888ec7d9 Mon Sep 17 00:00:00 2001 From: rkoster Date: Wed, 15 Apr 2026 19:11:45 +0000 Subject: [PATCH 23/30] 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 c0b1b01c5dba3b7dc7500d0cb1b77b9d6204b3c0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 08:09:15 +0000 Subject: [PATCH 24/30] =?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 fad27a05c79..f85c9677dcc 100644 --- a/app/models.rb +++ b/app/models.rb @@ -69,9 +69,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 64fcef98a77..f256ffb548b 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 5a88e6b7795..da57aec32b2 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 fb60d1d9df6..bac1d9a59d4 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 fc5b009d716..836748866b1 100644 --- a/spec/request/organizations_spec.rb +++ b/spec/request/organizations_spec.rb @@ -194,7 +194,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 } } @@ -1462,7 +1462,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 } } @@ -1488,7 +1488,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 d3ace2cf840..701d4a057cb 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 863bd86b988..b8bfcaa0b9e 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 beee5e6ed5b..8fdb0a148ae 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 d5a34a08e2e..6b448ee0dd2 100644 --- a/spec/unit/fetchers/service_broker_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_broker_list_fetcher_spec.rb @@ -160,7 +160,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 @@ -189,7 +189,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 79f9b22d83c54bfd420031cfc539d7f1dadf50b7 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 08:30:34 +0000 Subject: [PATCH 25/30] 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 97c7e189882064cf888b23524cf0c2b100090fa0 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 09:13:34 +0000 Subject: [PATCH 26/30] 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 a41e9b0761663af7f9631aa365dabdf673742336 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 09:37:24 +0000 Subject: [PATCH 27/30] 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 2826e2c4e042fc6a04244f45fc0c5e0967641d0b Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 10:33:52 +0000 Subject: [PATCH 28/30] 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 ca18462e62f6ea50e8c9bdd30cc7a99e22104f53 Mon Sep 17 00:00:00 2001 From: rkoster Date: Tue, 21 Apr 2026 10:34:00 +0000 Subject: [PATCH 29/30] 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 f256ffb548b..64fcef98a77 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 da57aec32b2..5a88e6b7795 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 bac1d9a59d4..fb60d1d9df6 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 836748866b1..fc5b009d716 100644 --- a/spec/request/organizations_spec.rb +++ b/spec/request/organizations_spec.rb @@ -194,7 +194,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 } } @@ -1462,7 +1462,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 } } @@ -1488,7 +1488,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 701d4a057cb..d3ace2cf840 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 b8bfcaa0b9e..863bd86b988 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 8fdb0a148ae..beee5e6ed5b 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 6b448ee0dd2..d5a34a08e2e 100644 --- a/spec/unit/fetchers/service_broker_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_broker_list_fetcher_spec.rb @@ -160,7 +160,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 @@ -189,7 +189,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 1ba2f3f78feaeddbbdc1360adfd91199db7c7047 Mon Sep 17 00:00:00 2001 From: rkoster Date: Thu, 7 May 2026 09:35:18 +0000 Subject: [PATCH 30/30] 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 72ee53a16be..e5969843366 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 beb313d063e..6fa2e570c52 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