Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
840a966
Add allowed_sources support for mTLS app-to-app routing
rkoster Mar 5, 2026
07c4ec3
Add unit tests for allowed_sources validation
rkoster Mar 5, 2026
a323763
Fix allowed_sources validation to handle symbol keys
rkoster Mar 5, 2026
17cb3df
Rename allowed_sources to mtls_allowed_sources for clarity
rkoster Mar 5, 2026
609e1b7
Refactor mTLS route options to RFC-0027 compliant flat format
rkoster Mar 5, 2026
9050ec9
Implement RFC domain-scoped mTLS routing with /v3/access_rules API
rkoster Apr 9, 2026
25210fe
Fix access_rules_controller permissions query
rkoster Apr 9, 2026
8cbee5a
Add automatic Diego sync callbacks to RouteAccessRule
rkoster Apr 9, 2026
5bcbb43
Implement include=selector_resource for /v3/access_rules endpoint
rkoster Apr 10, 2026
ec93b4d
Add space_guids filtering to /v3/access_rules endpoint
rkoster Apr 10, 2026
5e3f091
Implement include=route for /v3/access_rules endpoint
rkoster Apr 10, 2026
e59a2f6
Remove name field from access rules, add read-only relationships per …
rkoster Apr 15, 2026
b512e8f
Add metadata support to RouteAccessRule model
rkoster Apr 15, 2026
2769a08
Fix class loading for RouteAccessRule metadata models
rkoster Apr 15, 2026
a401085
Add validation to prevent access rules on internal domains per RFC
rkoster Apr 15, 2026
d50084e
Consolidate access rules migrations, fix RuboCop offenses, and clean …
rkoster Apr 15, 2026
026cbef
Fix race condition, double join, LIKE injection, N+1 queries, and dom…
rkoster Apr 15, 2026
5e1ca21
Fix incomplete LIKE metacharacter escaping (CodeQL rb/incomplete-sani…
rkoster Apr 15, 2026
10aa245
Add tests for LIKE metacharacter escaping (backslash, underscore)
rkoster Apr 15, 2026
3928be3
Fix MySQL key length limit in metadata table migration
rkoster Apr 15, 2026
75839c1
Fix routing_info_spec: remove nonexistent name field from RouteAccess…
rkoster Apr 15, 2026
e4d154e
Fix route presenter regression: include options: {} when empty
rkoster Apr 15, 2026
bc46b52
Fix CI failures: route presenter options logic and domain V2 serializ…
rkoster Apr 15, 2026
7bc622d
Rebrand: access rules → route policies, selector → source
rkoster Apr 21, 2026
7452e46
Fix test failures: complete terminology rebrand in specs
rkoster Apr 21, 2026
8954435
Fix routing_info_spec: use enforce_route_policies field names
rkoster Apr 21, 2026
0a61328
Fix domain_create_message_spec: use enforce_route_policies field names
rkoster Apr 21, 2026
f25162c
Fix route_policies_spec: use 'Source' terminology and 'sources' query…
rkoster Apr 21, 2026
7279201
Revert: restore label_selector (was incorrectly renamed to label_source)
rkoster Apr 21, 2026
ea18715
Add API documentation for Route Policies
rkoster May 7, 2026
bec99af
Add include support to route policies show endpoint
rkoster May 19, 2026
c80727b
Remove devbox configuration files from branch
rkoster May 19, 2026
d5029ca
Address philippthun's review feedback
rkoster May 19, 2026
99d0135
Rename add_mtls_options to add_route_policy_options and refactor stru…
rkoster May 21, 2026
765e923
Limit route policy includes to 'route' and 'source' only
rkoster May 21, 2026
3dad6e1
Prevent enforce_route_policies on internal and router_group domains
rkoster May 21, 2026
1c2e882
Fix RuboCop offenses in domain_create_message and route_policies_list…
rkoster May 21, 2026
c058620
Add include=route_policies support on routes endpoints
rkoster May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 1 addition & 55 deletions .envrc
Original file line number Diff line number Diff line change
@@ -1,26 +1,4 @@
# =============================================================================
# Cloud Controller NG - Development Environment
# =============================================================================
#
# QUICK START (local development):
# cc-containers start # Start DBs, UAA, nginx
# bundle install && cc-generate-config && cc-reset-db
# eval "$(cc-db-env psql ccdb)" # Set database env vars
# bin/cloud_controller -c tmp/.dev-generated/cloud_controller.local.yml
#
# SCRIPTS (all start with 'cc-'):
# cc-containers <cmd> # start/stop/logs/status (see --help)
# cc-db-env <db> <schema> # Database env vars (e.g. psql ccdb, mysql test)
# cc-generate-config [mode] # Generate cloud_controller.yml configs
# cc-reset-db # Drop and recreate all databases
# cc-setup-ide # Copy IDE configs (VS Code, IntelliJ)
#
# PERSONAL OVERRIDES (.envrc.local, gitignored):
# export PARALLEL_TEST_PROCESSORS=4
#
# =============================================================================

cores=$(/usr/sbin/sysctl -n hw.logicalcpu 2>/dev/null || nproc 2>/dev/null || echo 4)
cores=$(/usr/sbin/sysctl -n hw.logicalcpu)

if (( cores > 8 )); then
# This environment variable overrides the `parallel_test` gem's default behavior
Expand All @@ -30,35 +8,3 @@ if (( cores > 8 )); then

export PARALLEL_TEST_PROCESSORS=8
fi

# Set CC_CONFIG for local development (devcontainer sets CC_CONFIG=devcontainer)
# This is used by VS Code launch configs to select the right cloud_controller.yml
export CC_CONFIG="${CC_CONFIG:-local}"

# Database connection prefixes - used by IDE run configs and parallel tests
# Devcontainer overrides these in devcontainer.json with container hostnames (postgres, mysql)
export POSTGRES_CONNECTION_PREFIX="${POSTGRES_CONNECTION_PREFIX:-postgres://postgres:supersecret@localhost:5432}"
export MYSQL_CONNECTION_PREFIX="${MYSQL_CONNECTION_PREFIX:-mysql2://root:supersecret@127.0.0.1:3306}"

# Storage CLI path for S3 blobstore mode
export STORAGE_CLI_PATH="${PWD}/tmp/bin/storage-cli"

PATH_add bin
PATH_add .devcontainer/scripts

# =============================================================================
# Developer Overrides
# =============================================================================
if [ -f .envrc.local ]; then
source_env .envrc.local
fi

# Show quick hint on directory entry
if [ -n "$DEVCONTAINER" ]; then
log_status "Cloud Controller NG (devcontainer)"
log_status "Quick start: cc-generate-config && eval \"\$(cc-db-env psql ccdb)\""
else
log_status "Cloud Controller NG (local)"
log_status "Quick start: cc-containers start && cc-generate-config && cc-reset-db"
fi
log_status "See README.md for full setup instructions"
2 changes: 2 additions & 0 deletions app/actions/domain_create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ def create(message:, shared_organizations: [])
end

domain.router_group_guid = message.router_group_guid
domain.enforce_route_policies = message.enforce_route_policies || false
domain.route_policies_scope = message.route_policies_scope

Domain.db.transaction do
domain.save
Expand Down
34 changes: 34 additions & 0 deletions app/actions/route_policy_create.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
module VCAP::CloudController
class RoutePolicyCreate
class Error < StandardError
end

def create(route:, message:)
RoutePolicy.db.transaction do
# Lock existing route policies for this route to prevent concurrent inserts
# from violating cf:any exclusivity or uniqueness constraints
locked_policies = RoutePolicy.where(route_id: route.id).for_update.all

validate_source_exclusivity(locked_policies, message.source)

RoutePolicy.create(
source: message.source,
route_id: route.id
)
end
rescue Sequel::UniqueConstraintViolation
raise Error.new("A route policy with source '#{message.source}' already exists for this route.")
end

private

def validate_source_exclusivity(locked_policies, source)
existing_sources = locked_policies.map(&:source)

# Enforce cf:any exclusivity: if new policy is cf:any, reject if route already has any policies;
# if route already has a cf:any policy, reject new policies.
raise Error.new("Cannot add 'cf:any' source when other route policies already exist for this route.") if source == 'cf:any' && existing_sources.any?
raise Error.new("Cannot add source '#{source}': route already has a 'cf:any' policy.") if existing_sources.include?('cf:any')
end
end
end
150 changes: 150 additions & 0 deletions app/controllers/v3/route_policies_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
require 'messages/route_policy_create_message'
require 'messages/route_policy_update_message'
require 'messages/route_policies_list_message'
require 'messages/route_policy_show_message'
require 'presenters/v3/route_policy_presenter'
require 'decorators/include_route_policy_source_decorator'
require 'decorators/include_route_policy_route_decorator'
require 'actions/route_policy_create'

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)

Comment thread
rkoster marked this conversation as resolved.
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
message = RoutePolicyShowMessage.from_params(query_params)
unprocessable!(message.errors.full_messages) unless message.valid?

route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid])
resource_not_found!(:route_policy) unless route_policy

route = route_policy.route
resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)

Comment thread
rkoster marked this conversation as resolved.
decorators = []
decorators << IncludeRoutePolicySourceDecorator if IncludeRoutePolicySourceDecorator.match?(message.include)
decorators << IncludeRoutePolicyRouteDecorator if IncludeRoutePolicyRouteDecorator.match?(message.include)

render status: :ok, json: Presenters::V3::RoutePolicyPresenter.new(route_policy, decorators: decorators)
end

def create
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::RoutePolicyCreate.new.create(route: route, message: message)

render status: :created, json: Presenters::V3::RoutePolicyPresenter.new(route_policy)
rescue VCAP::CloudController::RoutePolicyCreate::Error => e
unprocessable!(e.message)
end

def update
route_policy = VCAP::CloudController::RoutePolicy.find(guid: hashed_params[:guid])
resource_not_found!(:route_policy) unless route_policy

find_and_authorize_route_for_policy(route_policy)

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

find_and_authorize_route_for_policy(route_policy)

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 find_and_authorize_route_for_policy(route_policy)
route = route_policy.route
resource_not_found!(:route_policy) unless route && permission_queryer.can_read_from_space?(route.space.id, route.space.organization_id)
unauthorized! unless permission_queryer.can_write_to_active_space?(route.space.id)
suspended! unless permission_queryer.is_space_active?(route.space.id)
end

def validate_route_domain(route)
if route.domain.internal?
unprocessable!('Cannot create route policies for routes on internal domains. Internal routes use container-to-container networking and bypass GoRouter.')
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 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:<guid>, cf:space:<guid>, cf:org:<guid>
# 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
3 changes: 3 additions & 0 deletions app/controllers/v3/routes_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require 'messages/route_update_destinations_message'
require 'actions/update_route_destinations'
require 'decorators/include_route_domain_decorator'
require 'decorators/include_route_policies_decorator'
require 'presenters/v3/route_presenter'
require 'presenters/v3/route_destinations_presenter'
require 'presenters/v3/paginated_list_presenter'
Expand Down Expand Up @@ -45,6 +46,7 @@ def index
decorators << IncludeRouteDomainDecorator if IncludeRouteDomainDecorator.match?(message.include)
decorators << IncludeSpaceDecorator if IncludeSpaceDecorator.match?(message.include)
decorators << IncludeOrganizationDecorator if IncludeOrganizationDecorator.match?(message.include)
decorators << IncludeRoutePoliciesDecorator if IncludeRoutePoliciesDecorator.match?(message.include)

render status: :ok, json: Presenters::V3::PaginatedListPresenter.new(
presenter: Presenters::V3::RoutePresenter,
Expand All @@ -63,6 +65,7 @@ def show
decorators << IncludeRouteDomainDecorator if IncludeRouteDomainDecorator.match?(message.include)
decorators << IncludeSpaceDecorator if IncludeSpaceDecorator.match?(message.include)
decorators << IncludeOrganizationDecorator if IncludeOrganizationDecorator.match?(message.include)
decorators << IncludeRoutePoliciesDecorator if IncludeRoutePoliciesDecorator.match?(message.include)

render status: :ok, json: Presenters::V3::RoutePresenter.new(
route,
Expand Down
19 changes: 19 additions & 0 deletions app/decorators/include_route_policies_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module VCAP::CloudController
class IncludeRoutePoliciesDecorator
class << self
def match?(include)
include&.any? { |i| %w[route_policies].include?(i) }
end

def decorate(hash, routes)
hash[:included] ||= {}
route_ids = routes.map(&:id).uniq
route_policies = RoutePolicy.where(route_id: route_ids).
eager(:route, :labels, :annotations).all

hash[:included][:route_policies] = route_policies.map { |rp| Presenters::V3::RoutePolicyPresenter.new(rp).to_hash }
hash
end
end
end
end
27 changes: 27 additions & 0 deletions app/decorators/include_route_policy_route_decorator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
module VCAP::CloudController
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, route_policies)
hash[:included] ||= {}

# 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).
order(:created_at, :guid).
eager(Presenters::V3::RoutePresenter.associated_resources).all

# Present routes
hash[:included][:routes] = routes.map { |route| Presenters::V3::RoutePresenter.new(route).to_hash }

hash
end
end
end
Loading
Loading