From 3bdaaed5b1aef29ca8e0a22d025f961d411813c3 Mon Sep 17 00:00:00 2001 From: Philipp Thun Date: Mon, 29 Jun 2026 15:58:58 +0200 Subject: [PATCH 1/2] Move cf:any exclusivity check into RoutePolicy#validate --- app/actions/route_policy_create.rb | 14 +------ app/models/runtime/route_policy.rb | 14 +++++++ spec/unit/actions/route_policy_create_spec.rb | 4 +- spec/unit/models/runtime/route_policy_spec.rb | 40 +++++++++++++++++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/app/actions/route_policy_create.rb b/app/actions/route_policy_create.rb index 8540df42d63..b94a2cd2015 100644 --- a/app/actions/route_policy_create.rb +++ b/app/actions/route_policy_create.rb @@ -14,9 +14,6 @@ def create(route:, message:) # ensures they serialize regardless of how many policies currently exist. Route.where(id: route.id).for_update.first or raise Error.new("Route '#{route.guid}' not found.") - existing_policies = RoutePolicy.where(route_id: route.id).all - validate_source_exclusivity(existing_policies, message.source) - RoutePolicy.create( source: message.source, route_id: route.id @@ -24,6 +21,8 @@ def create(route:, message:) end rescue Sequel::UniqueConstraintViolation raise Error.new("A route policy with source '#{message.source}' already exists for this route.") + rescue Sequel::ValidationFailed => e + raise Error.new(e.errors.full_messages.join(', ')) end private @@ -34,14 +33,5 @@ def validate_scope!(domain, source) raise Error.new("Source '#{source}' is not allowed: domain's route_policies_scope is 'space'.") end - - def validate_source_exclusivity(locked_policies, source) - existing_sources = locked_policies.map(&:source) - - # Enforce cf:any exclusivity: if new policy is cf:any, reject if route already has any policies; - # if route already has a cf:any policy, reject new policies. - raise Error.new("Cannot add 'cf:any' source when other route policies already exist for this route.") if source == 'cf:any' && existing_sources.any? - raise Error.new("Cannot add source '#{source}': route already has a 'cf:any' policy.") if existing_sources.include?('cf:any') - end end end diff --git a/app/models/runtime/route_policy.rb b/app/models/runtime/route_policy.rb index 21f22803e2e..1e5afcab9c7 100644 --- a/app/models/runtime/route_policy.rb +++ b/app/models/runtime/route_policy.rb @@ -33,6 +33,7 @@ def source=(val) def validate validates_presence :source_type validates_presence :route_id + validate_cf_any_exclusivity end def after_create @@ -47,6 +48,19 @@ def after_destroy private + def validate_cf_any_exclusivity + return unless route_id + + siblings = RoutePolicy.where(route_id: route_id).exclude(id: id) + return if siblings.empty? + + if source_type == 'any' + errors.add(:source, "'cf:any' cannot coexist with other route policies on the same route") + elsif siblings.where(source_type: 'any').any? + errors.add(:source, "cannot coexist with the existing 'cf:any' policy on this route") + end + end + def notify_processes_of_route_update return unless route diff --git a/spec/unit/actions/route_policy_create_spec.rb b/spec/unit/actions/route_policy_create_spec.rb index 6d5777490a5..d238caccc5b 100644 --- a/spec/unit/actions/route_policy_create_spec.rb +++ b/spec/unit/actions/route_policy_create_spec.rb @@ -55,7 +55,7 @@ module VCAP::CloudController it 'raises an error' do expect do action.create(route:, message:) - end.to raise_error(RoutePolicyCreate::Error, /cannot add 'cf:any'/i) + end.to raise_error(RoutePolicyCreate::Error, /'cf:any' cannot coexist with other route policies/) end end @@ -68,7 +68,7 @@ module VCAP::CloudController other_message = instance_double(RoutePolicyCreateMessage, source: "cf:app:#{SecureRandom.uuid}") expect do action.create(route: route, message: other_message) - end.to raise_error(RoutePolicyCreate::Error, /already has a 'cf:any' policy/) + end.to raise_error(RoutePolicyCreate::Error, /cannot coexist with the existing 'cf:any' policy/) end end diff --git a/spec/unit/models/runtime/route_policy_spec.rb b/spec/unit/models/runtime/route_policy_spec.rb index 2821d775a24..1a25ef68d85 100644 --- a/spec/unit/models/runtime/route_policy_spec.rb +++ b/spec/unit/models/runtime/route_policy_spec.rb @@ -103,6 +103,46 @@ module VCAP::CloudController expect(rule.valid?).to be false expect(rule.errors[:route_id]).to include(:presence) end + + describe 'cf:any exclusivity' do + it 'rejects cf:any when another policy already exists on the route' do + RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) + policy = RoutePolicy.new(source: 'cf:any', route: route) + + expect(policy.valid?).to be false + expect(policy.errors[:source]).to include("'cf:any' cannot coexist with other route policies on the same route") + end + + it 'rejects a non-cf:any policy when a cf:any policy already exists on the route' do + RoutePolicy.create(source: 'cf:any', route: route) + policy = RoutePolicy.new(source: "cf:app:#{app_guid}", route: route) + + expect(policy.valid?).to be false + expect(policy.errors[:source]).to include("cannot coexist with the existing 'cf:any' policy on this route") + end + + it 'raises Sequel::ValidationFailed when saving a conflicting cf:any policy' do + RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) + + expect do + RoutePolicy.create(source: 'cf:any', route: route) + end.to raise_error(Sequel::ValidationFailed) + end + + it 'allows multiple non-cf:any policies on the same route' do + RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) + + expect do + RoutePolicy.create(source: "cf:app:#{SecureRandom.uuid}", route: route) + end.not_to raise_error + end + + it 'allows cf:any when the route has no other policies' do + expect do + RoutePolicy.create(source: 'cf:any', route: route) + end.not_to raise_error + end + end end describe 'associations' do From a92962ef3c7f3b8ce243a532616929a8f2de3b48 Mon Sep 17 00:00:00 2001 From: Philipp Thun Date: Mon, 29 Jun 2026 16:55:14 +0200 Subject: [PATCH 2/2] Move RoutePolicy notify and audit events into actions --- app/actions/route_policy_create.rb | 18 ++++- app/actions/route_policy_destroy.rb | 21 ++++++ app/actions/route_policy_update.rb | 21 ++++++ .../v3/route_policies_controller.rb | 29 ++------ app/models/runtime/route_policy.rb | 22 ++---- spec/request/route_policies_spec.rb | 9 +-- spec/unit/actions/route_policy_create_spec.rb | 17 ++++- .../unit/actions/route_policy_destroy_spec.rb | 35 ++++++++++ spec/unit/actions/route_policy_update_spec.rb | 39 +++++++++++ spec/unit/models/runtime/route_policy_spec.rb | 69 +++++-------------- spec/unit/models/runtime/route_spec.rb | 27 ++++++++ 11 files changed, 209 insertions(+), 98 deletions(-) create mode 100644 app/actions/route_policy_destroy.rb create mode 100644 app/actions/route_policy_update.rb create mode 100644 spec/unit/actions/route_policy_destroy_spec.rb create mode 100644 spec/unit/actions/route_policy_update_spec.rb diff --git a/app/actions/route_policy_create.rb b/app/actions/route_policy_create.rb index b94a2cd2015..caf9b1d7d72 100644 --- a/app/actions/route_policy_create.rb +++ b/app/actions/route_policy_create.rb @@ -1,12 +1,18 @@ +require 'repositories/route_policy_event_repository' + module VCAP::CloudController class RoutePolicyCreate class Error < StandardError end + def initialize(user_audit_info) + @user_audit_info = user_audit_info + end + def create(route:, message:) validate_scope!(route.domain, message.source) - RoutePolicy.db.transaction do + route_policy = RoutePolicy.db.transaction do # Lock the parent route row to serialize concurrent creates. # SELECT ... FOR UPDATE on an empty policies table acquires no row locks, # so two concurrent transactions can both read [] and both pass cf:any @@ -19,6 +25,16 @@ def create(route:, message:) route_id: route.id ) end + + route_policy.notify_diego + + Repositories::RoutePolicyEventRepository.new.record_route_policy_create( + route_policy, + @user_audit_info, + { 'source' => message.source, 'route_guid' => route.guid } + ) + + route_policy rescue Sequel::UniqueConstraintViolation raise Error.new("A route policy with source '#{message.source}' already exists for this route.") rescue Sequel::ValidationFailed => e diff --git a/app/actions/route_policy_destroy.rb b/app/actions/route_policy_destroy.rb new file mode 100644 index 00000000000..5a5418af565 --- /dev/null +++ b/app/actions/route_policy_destroy.rb @@ -0,0 +1,21 @@ +require 'repositories/route_policy_event_repository' + +module VCAP::CloudController + class RoutePolicyDestroy + def initialize(user_audit_info) + @user_audit_info = user_audit_info + end + + def delete(route_policy) + route_policy.destroy + route_policy.notify_diego + + Repositories::RoutePolicyEventRepository.new.record_route_policy_delete( + route_policy, + @user_audit_info + ) + + nil + end + end +end diff --git a/app/actions/route_policy_update.rb b/app/actions/route_policy_update.rb new file mode 100644 index 00000000000..1979889c6a1 --- /dev/null +++ b/app/actions/route_policy_update.rb @@ -0,0 +1,21 @@ +require 'repositories/route_policy_event_repository' + +module VCAP::CloudController + class RoutePolicyUpdate + def initialize(user_audit_info) + @user_audit_info = user_audit_info + end + + def update(route_policy, message) + MetadataUpdate.update(route_policy, message) + + Repositories::RoutePolicyEventRepository.new.record_route_policy_update( + route_policy, + @user_audit_info, + message.audit_hash + ) + + route_policy + end + end +end diff --git a/app/controllers/v3/route_policies_controller.rb b/app/controllers/v3/route_policies_controller.rb index 5dc9beec26e..d9a8a7ccfbb 100755 --- a/app/controllers/v3/route_policies_controller.rb +++ b/app/controllers/v3/route_policies_controller.rb @@ -6,7 +6,8 @@ require 'decorators/include_route_policy_source_decorator' require 'decorators/include_route_policy_route_decorator' require 'actions/route_policy_create' -require 'repositories/route_policy_event_repository' +require 'actions/route_policy_update' +require 'actions/route_policy_destroy' require 'fetchers/label_selector_query_generator' class RoutePoliciesController < ApplicationController @@ -53,13 +54,7 @@ def create route = find_and_authorize_route(message.route_guid) validate_route_domain(route) - route_policy = VCAP::CloudController::RoutePolicyCreate.new.create(route: route, message: message) - - route_policy_event_repository.record_route_policy_create( - route_policy, - user_audit_info, - { 'source' => message.source, 'route_guid' => message.route_guid } - ) + route_policy = VCAP::CloudController::RoutePolicyCreate.new(user_audit_info).create(route: route, message: message) render status: :created, json: Presenters::V3::RoutePolicyPresenter.new(route_policy) rescue VCAP::CloudController::RoutePolicyCreate::Error => e @@ -75,13 +70,7 @@ def update message = RoutePolicyUpdateMessage.new(hashed_params[:body]) unprocessable!(message.errors.full_messages) unless message.valid? - VCAP::CloudController::MetadataUpdate.update(route_policy, message) - - route_policy_event_repository.record_route_policy_update( - route_policy.reload, - user_audit_info, - message.audit_hash - ) + VCAP::CloudController::RoutePolicyUpdate.new(user_audit_info).update(route_policy, message) render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy.reload) end @@ -92,21 +81,13 @@ def destroy find_and_authorize_route_for_policy(route_policy) - route_policy.destroy + VCAP::CloudController::RoutePolicyDestroy.new(user_audit_info).delete(route_policy) - route_policy_event_repository.record_route_policy_delete( - route_policy, - user_audit_info - ) head :no_content end private - def route_policy_event_repository - @route_policy_event_repository ||= Repositories::RoutePolicyEventRepository.new - end - def find_and_authorize_route(route_guid) route = VCAP::CloudController::Route.find(guid: route_guid) resource_not_found!(:route) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id) diff --git a/app/models/runtime/route_policy.rb b/app/models/runtime/route_policy.rb index 1e5afcab9c7..87f63a70ebb 100644 --- a/app/models/runtime/route_policy.rb +++ b/app/models/runtime/route_policy.rb @@ -36,14 +36,12 @@ def validate validate_cf_any_exclusivity end - def after_create - super - notify_processes_of_route_update - end + def notify_diego + return unless route - def after_destroy - super - notify_processes_of_route_update + route.apps.each do |process| + ProcessRouteHandler.new(process).notify_backend_of_route_update + end end private @@ -60,15 +58,5 @@ def validate_cf_any_exclusivity errors.add(:source, "cannot coexist with the existing 'cf:any' policy on this route") end end - - def notify_processes_of_route_update - return unless route - - db.after_commit do - route.apps.each do |process| - ProcessRouteHandler.new(process).notify_backend_of_route_update - end - end - end end end diff --git a/spec/request/route_policies_spec.rb b/spec/request/route_policies_spec.rb index 9fecad66a03..db1097fe3b8 100755 --- a/spec/request/route_policies_spec.rb +++ b/spec/request/route_policies_spec.rb @@ -580,14 +580,15 @@ def expected_rule_json(rule) end describe 'filtering by label_selector' do - let(:another_route) { create(:route, space: space, domain: mtls_domain) } + let(:second_route) { create(:route, space: space, domain: mtls_domain) } + let(:third_route) { create(:route, space: space, domain: mtls_domain) } let!(:labelled_policy) do - policy = VCAP::CloudController::RoutePolicy.create(source: 'cf:any', route_id: mtls_route.id) + policy = VCAP::CloudController::RoutePolicy.create(source: 'cf:any', route_id: second_route.id) VCAP::CloudController::RoutePolicyLabelModel.create(resource_guid: policy.guid, key_name: 'env', value: 'prod') policy end let!(:unlabelled_policy) do - VCAP::CloudController::RoutePolicy.create(source: 'cf:any', route_id: another_route.id) + VCAP::CloudController::RoutePolicy.create(source: 'cf:any', route_id: third_route.id) end it 'returns only policies matching the label selector' do @@ -715,7 +716,7 @@ def expected_rule_json(rule) let!(:rule_on_route1) do VCAP::CloudController::RoutePolicy.create( guid: SecureRandom.uuid, - source: 'cf:any', + source: "cf:app:#{SecureRandom.uuid}", route_id: mtls_route.id ) end diff --git a/spec/unit/actions/route_policy_create_spec.rb b/spec/unit/actions/route_policy_create_spec.rb index d238caccc5b..6569e871ca9 100644 --- a/spec/unit/actions/route_policy_create_spec.rb +++ b/spec/unit/actions/route_policy_create_spec.rb @@ -3,8 +3,9 @@ module VCAP::CloudController RSpec.describe RoutePolicyCreate do - subject(:action) { RoutePolicyCreate.new } + subject(:action) { RoutePolicyCreate.new(user_audit_info) } + let(:user_audit_info) { UserAuditInfo.new(user_email: 'user@example.com', user_guid: 'some-user-guid') } let(:space) { create(:space) } let(:domain) { create(:shared_domain, name: 'apps.identity', enforce_route_policies: true) } let(:route) { create(:route, space:, domain:) } @@ -33,6 +34,20 @@ module VCAP::CloudController expect(policy.source_guid).to eq('') end + it 'notifies diego after the transaction commits' do + expect_any_instance_of(RoutePolicy).to receive(:notify_diego).once + + action.create(route:, message:) + end + + it 'records a route_policy_create audit event' do + expect_any_instance_of(Repositories::RoutePolicyEventRepository). + to receive(:record_route_policy_create). + with(instance_of(RoutePolicy), user_audit_info, { 'source' => "cf:app:#{app_guid}", 'route_guid' => route.guid }) + + action.create(route:, message:) + end + context 'when the same source already exists for the route' do before do RoutePolicy.create(source: "cf:app:#{app_guid}", route_id: route.id) diff --git a/spec/unit/actions/route_policy_destroy_spec.rb b/spec/unit/actions/route_policy_destroy_spec.rb new file mode 100644 index 00000000000..7d2b47b5ed9 --- /dev/null +++ b/spec/unit/actions/route_policy_destroy_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' +require 'actions/route_policy_destroy' + +module VCAP::CloudController + RSpec.describe RoutePolicyDestroy do + subject(:action) { RoutePolicyDestroy.new(user_audit_info) } + + let(:user_audit_info) { UserAuditInfo.new(user_email: 'user@example.com', user_guid: 'some-user-guid') } + let(:space) { create(:space) } + let(:domain) { create(:shared_domain, name: 'apps.identity', enforce_route_policies: true) } + let(:route) { create(:route, space:, domain:) } + let(:app_guid) { SecureRandom.uuid } + let!(:route_policy) { RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) } + + describe '#delete' do + it 'destroys the route policy' do + expect { action.delete(route_policy) }.to change(RoutePolicy, :count).by(-1) + end + + it 'notifies diego after destroying' do + expect(route_policy).to receive(:notify_diego).once + + action.delete(route_policy) + end + + it 'records a route_policy_delete audit event' do + expect_any_instance_of(Repositories::RoutePolicyEventRepository). + to receive(:record_route_policy_delete). + with(route_policy, user_audit_info) + + action.delete(route_policy) + end + end + end +end diff --git a/spec/unit/actions/route_policy_update_spec.rb b/spec/unit/actions/route_policy_update_spec.rb new file mode 100644 index 00000000000..e0e830b9bd6 --- /dev/null +++ b/spec/unit/actions/route_policy_update_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' +require 'actions/route_policy_update' + +module VCAP::CloudController + RSpec.describe RoutePolicyUpdate do + subject(:action) { RoutePolicyUpdate.new(user_audit_info) } + + let(:user_audit_info) { UserAuditInfo.new(user_email: 'user@example.com', user_guid: 'some-user-guid') } + let(:space) { create(:space) } + let(:domain) { create(:shared_domain, name: 'apps.identity', enforce_route_policies: true) } + let(:route) { create(:route, space:, domain:) } + let(:app_guid) { SecureRandom.uuid } + let(:route_policy) { RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) } + let(:message) do + RoutePolicyUpdateMessage.new(metadata: { labels: { 'key' => 'value' }, annotations: { 'a' => 'note' } }) + end + + describe '#update' do + it 'applies the metadata update' do + action.update(route_policy, message) + + expect(route_policy.reload.labels.map { |l| [l.key_name, l.value] }).to include(%w[key value]) + expect(route_policy.annotations.map { |a| [a.key_name, a.value] }).to include(%w[a note]) + end + + it 'records a route_policy_update audit event' do + expect_any_instance_of(Repositories::RoutePolicyEventRepository). + to receive(:record_route_policy_update). + with(route_policy, user_audit_info, message.audit_hash) + + action.update(route_policy, message) + end + + it 'returns the route policy' do + expect(action.update(route_policy, message)).to eq(route_policy) + end + end + end +end diff --git a/spec/unit/models/runtime/route_policy_spec.rb b/spec/unit/models/runtime/route_policy_spec.rb index 1a25ef68d85..3744cb81adf 100644 --- a/spec/unit/models/runtime/route_policy_spec.rb +++ b/spec/unit/models/runtime/route_policy_spec.rb @@ -169,65 +169,32 @@ module VCAP::CloudController end end - describe 'callbacks' do - describe 'after_create' do - it 'calls notify_processes_of_route_update' do - expect_any_instance_of(RoutePolicy).to receive(:notify_processes_of_route_update).and_call_original + describe '#notify_diego' do + let(:rule) { RoutePolicy.create(source: "cf:app:#{app_guid}", route: route) } - RoutePolicy.create( - source: "cf:app:#{app_guid}", - route: route - ) - end - - it 'updates associated processes' do - process # force creation + it 'notifies the backend for each associated process' do + process # force creation - # Record the SQL update queries to verify the process row is updated - RoutePolicy.create( - source: "cf:app:#{app_guid}", - route: route - ) - - # Verify the route has linked processes - expect(route.apps).to include(process) - end + expect_any_instance_of(ProcessRouteHandler).to receive(:notify_backend_of_route_update).once - it 'does not fail if route has no associated processes' do - route_without_processes = create(:route, space:, domain:) - - expect do - RoutePolicy.create( - source: "cf:app:#{app_guid}", - route: route_without_processes - ) - end.not_to raise_error - end + rule.notify_diego end - describe 'after_destroy' do - it 'calls notify_processes_of_route_update' do - rule = RoutePolicy.create( - source: "cf:app:#{app_guid}", - route: route - ) - - expect_any_instance_of(RoutePolicy).to receive(:notify_processes_of_route_update).and_call_original + it 'does nothing when the route has no associated processes' do + route_without_processes = create(:route, space:, domain:) + rule_without_processes = RoutePolicy.create(source: "cf:app:#{app_guid}", route: route_without_processes) - rule.destroy - end + expect do + rule_without_processes.notify_diego + end.not_to raise_error + end - it 'does not fail if route has no associated processes' do - route_without_processes = create(:route, space:, domain:) - rule = RoutePolicy.create( - source: "cf:app:#{app_guid}", - route: route_without_processes - ) + it 'does nothing when the route is nil' do + rule_without_route = RoutePolicy.new(source: "cf:app:#{app_guid}") - expect do - rule.destroy - end.not_to raise_error - end + expect do + rule_without_route.notify_diego + end.not_to raise_error end end end diff --git a/spec/unit/models/runtime/route_spec.rb b/spec/unit/models/runtime/route_spec.rb index e76e2010d49..503e1818844 100644 --- a/spec/unit/models/runtime/route_spec.rb +++ b/spec/unit/models/runtime/route_spec.rb @@ -1767,6 +1767,33 @@ module VCAP::CloudController end end end + + context 'with route policies' do + let(:space) { create(:space) } + let(:domain) { create(:shared_domain, name: 'apps.identity', enforce_route_policies: true) } + let(:route) { create(:route, space:, domain:) } + let(:process) { ProcessModelFactory.make(space: space, state: 'STARTED', diego: false) } + let(:fake_route_handler) { instance_double(ProcessRouteHandler, notify_backend_of_route_update: nil) } + + before do + create(:route_mapping_model, app: process.app, route: route, process_type: process.type) + route.reload + allow(ProcessRouteHandler).to receive(:new).with(route.apps.first).and_return(fake_route_handler) + RoutePolicy.create(source: "cf:app:#{SecureRandom.uuid}", route: route) + RoutePolicy.create(source: "cf:app:#{SecureRandom.uuid}", route: route) + RoutePolicy.create(source: "cf:app:#{SecureRandom.uuid}", route: route) + end + + it 'deletes the associated route_policies' do + expect { route.destroy }.to change { RoutePolicy.where(route_id: route.id).count }.from(3).to(0) + end + + it 'notifies diego exactly once per associated process, regardless of the policy count' do + expect(fake_route_handler).to receive(:notify_backend_of_route_update).once + + route.destroy + end + end end def assert_valid_path(path)